Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
4b2d981
feat(core): Return a raw response unenveloped
danielvallance Sep 15, 2026
b054a7e
feat(templates): Render raw payloads and headers
danielvallance Sep 15, 2026
27357b7
feat(core): Expose the transport surface
danielvallance Oct 4, 2026
16e536d
feat(templates): Alias a scalar or array schema
danielvallance Sep 27, 2026
a86b27d
feat(templates): Honour required request fields
danielvallance Oct 4, 2026
4edfa9a
fix(templates): Alias maps and unsafe field names
danielvallance Oct 2, 2026
fa31a42
feat(templates): Refuse what cannot be rendered
danielvallance Oct 2, 2026
d7ece10
fix(templates): Order aliases by what they name
danielvallance Oct 2, 2026
531f2f8
fix(templates): Fix content queries and _ fields
danielvallance Oct 2, 2026
6526976
fix(templates): Fix pydantic name clashes
danielvallance Oct 2, 2026
45b6dde
fix(templates): Check schema names and nullables
danielvallance Oct 2, 2026
5ebe68a
fix(templates): Keep generated names distinct
danielvallance Oct 4, 2026
bd70305
docs(templates): Explain request model detection
danielvallance Oct 2, 2026
97c0b32
fix(templates): Keep a nullable enum nullable
danielvallance Oct 2, 2026
2de4619
fix(templates): Refuse styles the transport lacks
danielvallance Oct 2, 2026
39ad276
fix(templates): Refuse combined union keywords
danielvallance Oct 2, 2026
794db24
fix(templates): Check a tag's characters
danielvallance Oct 3, 2026
df3568f
fix(templates): Import only what a module uses
danielvallance Oct 4, 2026
5bc4fb4
feat(templates): Detect request models by use
danielvallance Oct 4, 2026
093823d
fix(templates): Refuse a null the wire lacks
danielvallance Oct 4, 2026
83ed1dd
feat(api): Regenerate the plumbing from the specs
danielvallance Oct 4, 2026
a31b9f7
feat(api): Add the sandbox plugin plumbing
danielvallance Oct 4, 2026
fa67f86
fix(core): Repeat a lookup that failed
danielvallance Oct 4, 2026
9b3a57e
feat(plugins): Add the sandbox client
danielvallance Sep 15, 2026
d251f94
feat(plugins): Stream output and forget a command
danielvallance Oct 4, 2026
9114eda
feat(plugins): Move files in pieces and streams
danielvallance Oct 4, 2026
5b85469
feat(plugins): Slice a wait under the proxy limit
danielvallance Oct 4, 2026
75a4418
feat(resources): Add the idiomatic images client
danielvallance Sep 15, 2026
1275dc6
feat(resources): Look images up in the registry
danielvallance Sep 23, 2026
efb2928
feat(resources): Decode why an instance stopped
danielvallance Oct 4, 2026
b940ad2
feat(resources): Report an instance that stopped
danielvallance Oct 4, 2026
19e84bb
feat(resources): Wait, forgive and retry a delete
danielvallance Oct 4, 2026
77282b5
feat(resources): Address instances by their tags
danielvallance Sep 23, 2026
ffb7cd9
feat(resources): Add the templates client
danielvallance Oct 4, 2026
7930e97
chore: Bump the version to 0.2.0
danielvallance Oct 3, 2026
c1ee6e5
docs: Fix stale comments and help texts
danielvallance Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Changelog

## 0.2.0 (unreleased)

### Added

- The sandbox plugin client: `sandbox()` on an instance handle runs commands,
follows their output, feeds standard input and moves files, and `plugin(name)`
on one addresses any other plugin. The plumbing is `ukc.api.plugins.sandbox`.
- `ukc.images`: what each metro's nodes cache, and `find`/`exists` against the
registry itself through the control plane.
- `ukc.templates`: `prepare` a template from an instance specification, `clone`
instances out of it, and list, read and delete them, by tag too.
- Instances: `Instance.stop` decodes why an instance stopped, `create` raises
`InstanceStoppedError` when the instance it made stopped instead of running,
`delete` waits, forgives a missing instance and retries a busy one, and
`each(tags=...)` addresses every instance that carries the given tags.
- A `create` whose wait lapsed raises `WaitTimeoutError` with the instance it
made on `err.errors`; a wait of `-1` on `wait`, `delete` or `create` leaves
the read timeout unbounded; `Instances.delete` takes `timeout_seconds` too;
and `ResponseError.state` says what state a failed item was in.
- The transport is public: `request`, `request_no_content`, `request_bytes`,
`stream_bytes` and `stream` on `ApiClient`, and `RawResponse` for payloads
that are bytes.
- The platform and control-plane plumbing regenerated from the current
specification: the audit event stream, the `services` module, and request
models whose required fields are required.

### Changed

- `unikraft-cloud-plugin-sandbox-api` is a dependency: the sandbox plumbing is
generated by plugin-sdk and packaged on its own.
- A request model's required fields are required at construction, so a request
missing one raises pydantic's `ValidationError` before anything is sent.
- The plumbing's `ServiceGroupsApi` is `ServicesApi`, after the specification's
tag, and the `body` of `get_instance_logs` and `wait_instances` is required.
- The private transport methods (`_request`, `_stream`, ...) are gone; the
public names above replace them.
- `or_absent` resolves to `None` only for a resource reported missing
(`NotFoundError.absent`); a 404 from a route that is not there is raised.

### Removed

- `UsersApi.add_users` and its models, which the specification no longer has.

### Fixed

- A handle or set whose lookup failed looks the resource up again on the next
await, instead of raising the first failure again; an operation chained onto
it does the same, while its own outcome still stands.
34 changes: 21 additions & 13 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,29 +26,37 @@ OUTPUT ?= ./src/unikraft_cloud/api
.PHONY: all
all: generate test

# Each per-spec target clears its generated modules first, so a tag the spec
# drops leaves no stale client behind. The hand-written `__init__.py` files stay.
.PHONY: generate
generate: ## Regenerate both plumbing clients from the OpenAPI specs.
# Generated modules are cleared first, so a tag the spec drops leaves no
# stale client behind. The hand-written `__init__.py` files stay.
rm -f $(OUTPUT)/platform/*_gen.py $(OUTPUT)/controlplane/*_gen.py
# Namespaced schema names (`Instances.Instance`) are not Python identifiers,
# so the namespace is stripped from them.
generate: generate-platform generate-controlplane ## Regenerate the platform and control-plane plumbing from the OpenAPI specs.
$(MAKE) fmt

# Namespaced schema names (`Instances.Instance`) are not Python identifiers, so
# `--namespace-flatten strip` removes the namespace.
.PHONY: generate-platform
generate-platform: ## Regenerate the platform API plumbing.
rm -f $(OUTPUT)/platform/*_gen.py
$(OPENAPI_GEN) \
-i $(PLATFORM_SPEC) \
-o $(OUTPUT)/platform \
-t $(TEMPLATES) \
--namespace-flatten strip \
-v package=api
--namespace-flatten strip

.PHONY: generate-controlplane
generate-controlplane: ## Regenerate the control-plane API plumbing.
rm -f $(OUTPUT)/controlplane/*_gen.py
$(OPENAPI_GEN) \
-i $(CONTROLPLANE_SPEC) \
-o $(OUTPUT)/controlplane \
-t $(TEMPLATES) \
--namespace-flatten strip \
-v package=api
$(MAKE) fmt
--namespace-flatten strip

# A plugin's client is generated by plugin-sdk from the plugin's own
# specification, and installed as a package of its own.

.PHONY: fmt
fmt: ## Format the generated (and all) sources.
fmt: ## Sort the imports of the generated plumbing and format it.
$(UV) run ruff check --select I --fix $(OUTPUT)
$(UV) run ruff format $(OUTPUT)

Expand Down Expand Up @@ -76,4 +84,4 @@ clean: ## Remove build output.
.PHONY: help
help: ## Show this help.
@grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'
186 changes: 179 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,14 @@ all subclass `UnikraftCloudError`. The API reports some failures inside an other
envelope, per item; those carry the API's own code on `err.errors[n].code` and are raised
with the status that says the same thing.

Two failures are caught before anything is sent, and are not `UnikraftCloudError`: a
property the resource does not have is a `TypeError`, and a request missing a field the
specification requires -- a service group without its `services`, an instance's `volumes`
entry without its `at` -- is pydantic's `ValidationError`.

A `wait()` that runs out of time raises `WaitTimeoutError`, which is also a builtin
`TimeoutError`, and carries the state the API last saw:
`TimeoutError`, and carries the state the API last saw, with the instances it named on
`err.errors` -- so a `create()` whose wait lapsed still hands over the instance it made:

```python
try:
Expand All @@ -241,6 +247,23 @@ except TimeoutError as err:
print(err.state) # e.g. "starting"
```

A `create()` whose instance stopped instead of running -- a node could not pull its
image, say -- raises `InstanceStoppedError`. The instance is read back so the error
carries the decoded reason, and every instance read back carries the same decoding as
its `stop`:

```python
from unikraft_cloud import InstanceStoppedError, PlatformStopCode

fra = ukc.metro("fra")
try:
await fra.instances.create(image="org/app:latest", autostart=True, timeout_s=30)
except InstanceStoppedError as err:
print(err.stop) # e.g. "platform stop: image pull failed"
if err.stop and err.stop.platform_code == PlatformStopCode.IMAGE_PULL_FAILED:
await fra.instances.get(uuid=err.instance.uuid).delete()
```

When the API attaches a warning to an answer -- a deprecated field, say -- the SDK
issues it as a Python `UnikraftCloudWarning`, so the standard `warnings` filters
apply.
Expand All @@ -262,8 +285,8 @@ except MetroFanoutError as err:

## Resources

`instances`, `volumes`, `services`, `certificates` and `users` hang off any scope —
`ukc`, `ukc.metro("fra")` or `ukc.metros([...])`.
`instances`, `templates`, `images`, `volumes`, `services`, `certificates` and `users`
hang off any scope — `ukc`, `ukc.metro("fra")` or `ukc.metros([...])`.

Creating one takes the properties the API describes as keyword arguments, and a property
it does not have is a `TypeError` rather than a field the server quietly ignores.
Expand All @@ -277,6 +300,131 @@ for quota in await ukc.users.quotas():
print(quota.metro, quota.used, quota.hard)
```

### Instances

Beyond the operations above, instances can be addressed by their tags, deleted with a wait
and a retry while busy, and asked why they stopped:

```python
# Every instance in scope carrying all of the tags, however many; nothing matched is an
# empty set, so a cleanup can run again.
await ukc.instances.each(tags=["batch", "job=42"]).delete(missing_ok=True)
async for inst in ukc.instances.list(tags=["batch"], details=True):
...

# Wait for the deletion (-1 for as long as the platform allows), forgive an instance
# that is already gone, and keep trying while something still holds on to it. The read
# timeout is stretched to outlast the wait only when no `timeout` was given.
await fra.instances.get(name="relay").delete(timeout_seconds=60, missing_ok=True, retry_busy=20)

from unikraft_cloud import StopReason

inst = await fra.instances.get(name="web")
if inst.state == "stopped":
print(inst.describe_stop()) # e.g. "kernel crash: out of memory (ENOMEM)"
if inst.stop and inst.stop.reason & StopReason.KERNEL:
print(inst.stop.kernel_code)
```

### Images

The metros report what their nodes have cached; the registry says what a node can pull.
`find` and `exists` ask the control plane, which answers from the registry itself, and
read a reference as the CLI does: a registry host in front is ignored, a bare name is in
the `official` namespace, and no tag means `latest`.

```python
for image in await ukc.images.list():
print(image.metro, image.url, image.size_in_bytes)

if not await ukc.images.exists("org/app:1.2.3"):
build_and_push("org/app:1.2.3")
found = await ukc.images.find("org/app@sha256:...") # a tag of that digest, or None
```

### Templates

A template is a snapshotted instance that new instances are cloned from.
`prepare` makes sure one exists, building it from an instance specification when it does
not; `clone` stamps instances out of it. The API types that specification as an instance,
so its image and its plugins' images are plain references, not objects with a pull policy
or credentials, and its `gpus` is the list an instance reports, not the count a create
takes. The instance that becomes the template decides the moment it is snapshotted by
writing `1` to `/uk/libukp/template_instance`.

```python
fra = ukc.metro("fra")
await fra.templates.prepare("worker-v3", create_args={"image": "org/worker:v3", "memory_mb": 1024})
job = await fra.templates.get(name="worker-v3").clone(
roms=[{"name": "job", "at": "/mnt/job", "files": [{"path": "job.json", "data": "{}"}]}],
volumes=[{"size_mb": 512, "at": "/tmp"}],
features=["delete-on-stop"],
)
```

## The sandbox plugin

A plugin is a helper loaded into an instance beside its workload, and reached through the
instance on the metro that runs it. The sandbox plugin runs commands and moves files
inside an instance; `sandbox()` on an instance handle is its client, and nothing is sent
until it is used.

```python
from unikraft_cloud import ExecTimeoutError

fra = ukc.metro("fra")
inst = await fra.instances.create(
image="org/app:latest",
memory_mb=1024,
plugins=[{"name": "sandbox", "image": "plugins/sandbox:latest", "config": {}}],
autostart=True,
timeout_s=60,
)
sb = fra.instances.get(uuid=inst.uuid).sandbox()
await sb.wait_ready(timeout=60) # until the plugin answers; PluginNotReadyError otherwise

# A shell line, or an argument vector run without a shell. The exit code is reported, not
# raised; output is collected, and handed over as it arrives if you ask.
result = await sb.exec("make test", cwd="/app", on_output=lambda chunk: print(chunk.data))
result = await sb.exec(["python", "-c", "print('no quoting needed')"])
print(result.exit_code, result.stdout, result.stderr)

# A timeout interrupts the command and waits for it to end; with a grace period a command
# that ignores the interrupt is given up on, and the error carries it for you to signal.
try:
await sb.exec("./long-build", timeout=600, wait_delay=10, forget=True)
except ExecTimeoutError as err:
await err.command.signal("KILL")

# Start, feed, follow and finish a command yourself.
cmd = await sb.run("sort")
await cmd.feed_stdin(b"b\na\n")
await cmd.close_stdin()
async for chunk in cmd.stream(): # until the command ends
...
code = await cmd.wait(timeout=30) # None while it still runs
await cmd.delete() # once it has ended

# Files move in chunks, so no request size limit applies; a download lands whole or not
# at all.
await sb.fs.write("/app/config.json", data, parents=True)
await sb.fs.upload_file("./bundle.tar", "/app/")
async for piece in sb.fs.stream("/app/out.bin"):
...
await sb.fs.read_to("/app/out.bin", "./out.bin")
```

`ExecResult.exit_code` is negative for a command a signal ended: `-2` after the
interrupt a timeout sends. A command left behind when `exec` fails or is cancelled keeps
running in the sandbox, for you to signal or forget. `PluginNotReadyError` and
`ExecTimeoutError` are `UnikraftCloudError` and builtin `TimeoutError` both, like
`WaitTimeoutError`.

Any other plugin is addressed the same way: `plugin("name")` on an instance handle resolves
its route once, and `.client(SomeApi)` builds a client there. The sandbox plumbing is
`ukc.api.plugins.sandbox.for_instance(uuid)`, and the generated clients it wraps come from
the `unikraft-cloud-plugin-sandbox-api` package.

## The plumbing layer

Every operation in the specification is available raw, returning the response envelope
Expand All @@ -291,6 +439,16 @@ await ukc.api.controlplane.metros.list_metros()

# Or per resource, alongside its idiomatic client.
await ukc.instances.api.get_instance_metrics(uuid=["..."])

# An operation that streams events yields each as it arrives.
async for event in ukc.api.platform.audit.subscribe_audit_events(tags=["prod"]):
print(event.type, event.object.uuid if event.object else None)

# An operation that answers with bytes returns them with their status and headers.
raw = await ukc.api.plugins.sandbox.for_instance(instance_uuid).commands.get_raw_command_log(
command_uuid, "stdout", range="bytes=0-1023"
)
print(raw.status, raw.byte_range, raw.total_size, raw.content)
```

It can also be used on its own, without the idiomatic layer:
Expand All @@ -308,17 +466,31 @@ async with PlatformApi(config) as api:

- [`examples/quickstart.py`](examples/quickstart.py) — create, wait, read logs, list, suspend, delete
- [`examples/update.py`](examples/update.py) — patch objects and the staged editor
- [`examples/sandbox.py`](examples/sandbox.py) — run commands and move files through the sandbox plugin
- [`examples/templates.py`](examples/templates.py) — look an image up, prepare a template, clone it
- [`examples/plumbing.py`](examples/plumbing.py) — the raw API on its own

## Development

The `api/platform` and `api/controlplane` packages are generated from the OpenAPI
specification by [`openapi-gen`](https://github.com/unikraft-cloud) using the templates in
[`templates/`](templates). Everything else is hand-written. Files ending in `_gen.py` are
never edited by hand.
specification by `openapi-gen`, pinned in the Makefile, using the templates in
[`templates/`](templates). The sandbox plugin's plumbing
is generated elsewhere, by
[plugin-sdk](https://github.com/unikraft-cloud/plugin-sdk) from the plugin's own
specification, and installed as the `unikraft-cloud-plugin-sandbox-api` package.
Everything else is hand-written. Files ending in `_gen.py` are never edited by hand.

The templates tell request models from response models by name, after the TypeSpec
convention `<Verb><Resource>Request*`, and by use: a schema whose name contains
`Request` and not `Response` keeps the fields the specification requires, and so does a
schema that only requests reach -- through request bodies and the models they name,
however deep -- so a request missing a required field fails at construction. A schema
that any response reaches has every field optional, so a partial response always parses,
and a `Request`-named schema that a response also carries fails to parse when the server
leaves a required field out.

```sh
make generate # regenerate both plumbing clients from the specs
make generate # regenerate the platform and control-plane plumbing from the specs
make lint # ruff check + format --check
make typecheck # mypy
make test # pytest
Expand Down
Loading
Loading