|
3 | 3 | The official Python SDK for the [Unikraft Cloud](https://unikraft.com) Platform and |
4 | 4 | control-plane APIs. |
5 | 5 |
|
| 6 | +It has two layers. The **idiomatic** layer is what you reach for: envelope-free results, |
| 7 | +automatic pagination, chainable references, and multi-metro fan-out. The **plumbing** |
| 8 | +layer underneath mirrors the OpenAPI specification exactly, and stays available for |
| 9 | +anything the idiomatic layer does not cover yet. |
| 10 | + |
| 11 | +The SDK is async-only. |
| 12 | + |
6 | 13 | ## Installation |
7 | 14 |
|
8 | 15 | ```sh |
9 | 16 | pip install unikraft-cloud |
10 | 17 | ``` |
11 | 18 |
|
12 | | -## Usage |
| 19 | +Requires Python 3.10 or newer. |
| 20 | + |
| 21 | +## Quickstart |
13 | 22 |
|
14 | 23 | ```python |
15 | 24 | import asyncio |
16 | | -import os |
17 | 25 |
|
18 | 26 | from unikraft_cloud import UnikraftCloud |
19 | 27 |
|
20 | 28 |
|
21 | 29 | async def main() -> None: |
22 | | - async with UnikraftCloud(token=os.environ["UKC_TOKEN"]) as ukc: |
23 | | - async for instance in ukc.instances.list(details=True): |
24 | | - print(instance.metro, instance.name, instance.state) |
| 30 | + async with UnikraftCloud() as ukc: # token from UKC_TOKEN |
| 31 | + instance = await ukc.metro("fra").instances.create( |
| 32 | + image="nginx:latest", memory_mb=256, autostart=True |
| 33 | + ) |
| 34 | + print(instance.name, instance.uuid, instance.metro) |
25 | 35 |
|
26 | 36 |
|
27 | 37 | asyncio.run(main()) |
28 | 38 | ``` |
29 | 39 |
|
| 40 | +The client owns a connection pool, so close it when you are done — either with |
| 41 | +`async with`, or by awaiting `ukc.aclose()`. |
| 42 | + |
| 43 | +## Configuration |
| 44 | + |
| 45 | +```python |
| 46 | +ukc = UnikraftCloud( |
| 47 | + token="...", # falls back to UKC_TOKEN |
| 48 | + metro="fra", # falls back to UKC_METRO; omit to cover every metro |
| 49 | +) |
| 50 | +``` |
| 51 | + |
| 52 | +| Argument | Purpose | |
| 53 | +| --- | --- | |
| 54 | +| `token` | Bearer token. Falls back to `UKC_TOKEN`. | |
| 55 | +| `metro` | The metro operations default to, or a full `http(s)://` URL for a staging or self-hosted deployment. Falls back to `UKC_METRO`. A code leaves the other metros reachable with `ukc.metro(...)`; a URL pins the client to that endpoint, and naming another metro then raises. | |
| 56 | +| `metros` | The metros operations cover by default: `"all"`, one metro, or a list. Creating a resource needs exactly one, so name a metro somewhere when you create. | |
| 57 | +| `base_url` | Explicit platform API base URL. It settles where requests go, so it overrides `metro` and `UKC_METRO` alike, and pins the client to that one endpoint. | |
| 58 | +| `control_plane_url` | Override the control-plane API base URL. | |
| 59 | +| `headers` | Extra headers sent with every request. | |
| 60 | +| `user_agent` | Override the default User-Agent. | |
| 61 | +| `http` | An `httpx.AsyncClient` to send through. Supplying one makes its lifetime yours. | |
| 62 | +| `transport` | An `httpx.AsyncBaseTransport`, chiefly for testing with `httpx.MockTransport`. | |
| 63 | +| `trust_env` | Honour `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`. Defaults to `True`. | |
| 64 | +| `timeout` | Timeout for every request. Omitted, an injected `http` client keeps its own; otherwise the default bounds connecting but not reading, because `wait` operations block for as long as you asked. | |
| 65 | + |
| 66 | +## Metros |
| 67 | + |
| 68 | +The platform API is metro-scoped. By default the client is **account-wide**: reads ask |
| 69 | +every metro the account can reach and merge the answers as they arrive, and each result |
| 70 | +carries the metro it came from. |
| 71 | + |
| 72 | +```python |
| 73 | +# Every metro, merged as the pages arrive. Await the listing instead for a list. |
| 74 | +async for inst in ukc.instances.list(details=True): |
| 75 | + print(inst.metro, inst.name, inst.state) |
| 76 | +every = await ukc.instances.list(details=True) # one pass each: call list() again for more |
| 77 | + |
| 78 | +# One metro. Because it is known, no lookup is needed. |
| 79 | +await ukc.metro("fra").instances.get(name="web").suspend() |
| 80 | + |
| 81 | +# Several metros, for one call or for a whole client. |
| 82 | +async for inst in ukc.instances.list(metros=["fra", "dal"]): |
| 83 | + ... |
| 84 | +scoped = ukc.metros(["fra", "dal"]) |
| 85 | + |
| 86 | +# A listing you stop reading holds a page of every metro, so close it. |
| 87 | +async with ukc.instances.list() as listing: |
| 88 | + async for inst in listing: |
| 89 | + break |
| 90 | + |
| 91 | +# What the account can reach, as the control plane reports it. |
| 92 | +for endpoint in await ukc.available_metros(): |
| 93 | + print(endpoint.metro, endpoint.base_url) |
| 94 | +``` |
| 95 | + |
| 96 | +Naming metros is also how you skip metro discovery, which is otherwise one extra request |
| 97 | +per client. |
| 98 | + |
| 99 | +## References |
| 100 | + |
| 101 | +A resource is addressed by `name` or `uuid` — one or the other, because the API validates |
| 102 | +whichever field it is given. |
| 103 | + |
| 104 | +```python |
| 105 | +await ukc.instances.get(name="web") |
| 106 | +await ukc.instances.get(uuid="550e8400-e29b-41d4-a716-446655440000") |
| 107 | +``` |
| 108 | + |
| 109 | +A name is only unique **within** a metro, so the same name can exist in several. Add |
| 110 | +`metro=` to say which you mean, which also saves a lookup: |
| 111 | + |
| 112 | +```python |
| 113 | +await ukc.instances.get(name="web", metro="fra") |
| 114 | +``` |
| 115 | + |
| 116 | +Without it, and with more than one metro in scope, the SDK asks every metro. If the name |
| 117 | +matches in several it raises `AmbiguousRefError` rather than picking one — with the |
| 118 | +matches attached, so recovering costs no further requests: |
| 119 | + |
| 120 | +```python |
| 121 | +from unikraft_cloud import AmbiguousRefError |
| 122 | + |
| 123 | +try: |
| 124 | + await ukc.instances.get(name="web") |
| 125 | +except AmbiguousRefError as err: |
| 126 | + print(err.metros) # ("fra", "dal") |
| 127 | + print([m.uuid for m in err.matches]) |
| 128 | +``` |
| 129 | + |
| 130 | +To act on all of them deliberately, use `each()`: |
| 131 | + |
| 132 | +```python |
| 133 | +await ukc.instances.each(name="web").suspend() # in every metro that has one |
| 134 | +``` |
| 135 | + |
| 136 | +Bulk operations take a sequence of references, as `Ref` objects, plain dicts, or names: |
| 137 | + |
| 138 | +```python |
| 139 | +from unikraft_cloud import Ref |
| 140 | + |
| 141 | +await ukc.instances.delete([Ref(uuid="a"), {"name": "b"}, "web"]) |
| 142 | +``` |
| 143 | + |
| 144 | +An operation the API could only carry out in part raises, naming what failed. What did |
| 145 | +succeed is on `err.results`, so a partial failure costs nothing already done: |
| 146 | + |
| 147 | +```python |
| 148 | +from unikraft_cloud import NotFoundError |
| 149 | + |
| 150 | +try: |
| 151 | + await ukc.instances.delete(["web", "gone"]) |
| 152 | +except NotFoundError as err: |
| 153 | + print([deleted.name for deleted in err.results]) # ["web"] |
| 154 | +``` |
| 155 | + |
| 156 | +## Chainable handles |
| 157 | + |
| 158 | +Single-resource operations return a **handle** rather than a coroutine, so they compose. |
| 159 | +A handle is awaitable too, so awaiting one gives you the resource: |
| 160 | + |
| 161 | +```python |
| 162 | +inst = await ukc.instances.get(name="web") # the instance |
| 163 | +await ukc.instances.get(name="web").suspend() # the suspend |
| 164 | + |
| 165 | +logs = await ( |
| 166 | + ukc.metro("fra") |
| 167 | + .instances.create(image="nginx:latest") |
| 168 | + .wait(state="running", timeout_seconds=30) |
| 169 | + .logs(offset=-4096) |
| 170 | +) |
| 171 | +``` |
| 172 | + |
| 173 | +Nothing is sent until a handle is awaited or an operation is chained onto it. With one |
| 174 | +metro in scope, `get(name=...).suspend()` is a single request; when the scope spans |
| 175 | +metros, the instance is located first so the operation reaches the metro that holds it. |
| 176 | + |
| 177 | +A handle that is dropped without ever being awaited emits a `RuntimeWarning`: unlike a |
| 178 | +forgotten `await` on a coroutine, nothing else would tell you no request was sent. |
| 179 | + |
| 180 | +A handle is awaitable but is not a coroutine, so `asyncio.gather(...)` takes one while |
| 181 | +`asyncio.create_task(...)` does not; wrap it in `asyncio.ensure_future(...)` for a task. |
| 182 | + |
| 183 | +## Updating |
| 184 | + |
| 185 | +Properties are keyword arguments. A value sets the property, `REMOVE` clears it out, and |
| 186 | +anything omitted is left alone — all in one request. |
| 187 | + |
| 188 | +```python |
| 189 | +from unikraft_cloud import REMOVE |
| 190 | + |
| 191 | +await ukc.instances.get(name="web").update(memory_mb=512, vcpus=2, autokill=REMOVE) |
| 192 | +``` |
| 193 | + |
| 194 | +When `set` is not what you mean — merging into a property, or removing individual members |
| 195 | +— stage the operations and apply them together: |
| 196 | + |
| 197 | +```python |
| 198 | +await ( |
| 199 | + ukc.instances.get(name="web") |
| 200 | + .edit() |
| 201 | + .set(memory_mb=512) |
| 202 | + .add(env={"LOG_LEVEL": "debug"}, tags=["prod"]) |
| 203 | + .delete(env=["OLD_FLAG"]) |
| 204 | + .apply() |
| 205 | +) |
| 206 | +``` |
| 207 | + |
| 208 | +`apply()` returns a handle, so the chain continues. For anything keyword arguments cannot |
| 209 | +express, `patch()` takes the raw triples. |
| 210 | + |
| 211 | +## Errors |
| 212 | + |
| 213 | +Every failure is an `UnikraftCloudError`, so one `except` catches the lot. Its `kind` |
| 214 | +says which layer failed (`"http"`, `"network"`, `"parse"` or `"fanout"`) and `status` |
| 215 | +carries the HTTP status where there was one. |
| 216 | + |
| 217 | +```python |
| 218 | +from unikraft_cloud import NotFoundError, UnikraftCloudError |
| 219 | + |
| 220 | +try: |
| 221 | + await ukc.instances.get(name="web") |
| 222 | +except NotFoundError: |
| 223 | + ... |
| 224 | +except UnikraftCloudError as err: |
| 225 | + print(err.kind, err.status, err.errors) |
| 226 | +``` |
| 227 | + |
| 228 | +`AuthenticationError` (401/403), `NotFoundError` (404), `AlreadyExistsError` (409), |
| 229 | +`RateLimitError` (429) and `ServerError` (5xx) are raised for the statuses they name, and |
| 230 | +all subclass `UnikraftCloudError`. The API reports some failures inside an otherwise-200 |
| 231 | +envelope, per item; those carry the API's own code on `err.errors[n].code` and are raised |
| 232 | +with the status that says the same thing. |
| 233 | + |
| 234 | +A `wait()` that runs out of time raises `WaitTimeoutError`, which is also a builtin |
| 235 | +`TimeoutError`, and carries the state the API last saw: |
| 236 | + |
| 237 | +```python |
| 238 | +try: |
| 239 | + await ukc.instances.get(name="web").wait(state="running", timeout_seconds=30) |
| 240 | +except TimeoutError as err: |
| 241 | + print(err.state) # e.g. "starting" |
| 242 | +``` |
| 243 | + |
| 244 | +When the API attaches a warning to an answer -- a deprecated field, say -- the SDK |
| 245 | +issues it as a Python `UnikraftCloudWarning`, so the standard `warnings` filters |
| 246 | +apply. |
| 247 | + |
| 248 | +A multi-metro operation that only partly succeeded raises `MetroFanoutError`. An |
| 249 | +iteration yields everything the healthy metros returned *before* raising, so a partial |
| 250 | +failure never costs you the whole answer; operations that cannot yield as they go attach |
| 251 | +what did arrive to `err.results`. |
| 252 | + |
| 253 | +```python |
| 254 | +from unikraft_cloud import MetroFanoutError |
| 255 | + |
| 256 | +try: |
| 257 | + async for inst in ukc.instances.list(): |
| 258 | + ... |
| 259 | +except MetroFanoutError as err: |
| 260 | + print([failure.metro for failure in err.failures]) |
| 261 | +``` |
| 262 | + |
| 263 | +## Resources |
| 264 | + |
| 265 | +`instances`, `volumes`, `services`, `certificates` and `users` hang off any scope — |
| 266 | +`ukc`, `ukc.metro("fra")` or `ukc.metros([...])`. |
| 267 | + |
| 268 | +Creating one takes the properties the API describes as keyword arguments, and a property |
| 269 | +it does not have is a `TypeError` rather than a field the server quietly ignores. |
| 270 | + |
| 271 | +```python |
| 272 | +await ukc.volumes.get(name="data").attach(to="web", at="/data") |
| 273 | +await ukc.services.get(name="web").update(hard_limit=10) |
| 274 | +await ukc.certificates.get(name="tls").update(chain=chain_pem, pkey=key_pem) |
| 275 | + |
| 276 | +for quota in await ukc.users.quotas(): |
| 277 | + print(quota.metro, quota.used, quota.hard) |
| 278 | +``` |
| 279 | + |
| 280 | +## The plumbing layer |
| 281 | + |
| 282 | +Every operation in the specification is available raw, returning the response envelope |
| 283 | +untouched. Each client talks to exactly one metro, and a single call can be redirected |
| 284 | +with `base_url=`. |
| 285 | + |
| 286 | +```python |
| 287 | +res = await ukc.api.platform.instances.get_instances(count=10) |
| 288 | +print(res.status, res.op_time_us, res.data.instances) |
| 289 | + |
| 290 | +await ukc.api.controlplane.metros.list_metros() |
| 291 | + |
| 292 | +# Or per resource, alongside its idiomatic client. |
| 293 | +await ukc.instances.api.get_instance_metrics(uuid=["..."]) |
| 294 | +``` |
| 295 | + |
| 296 | +It can also be used on its own, without the idiomatic layer: |
| 297 | + |
| 298 | +```python |
| 299 | +from unikraft_cloud import ApiClientConfig |
| 300 | +from unikraft_cloud.api.platform import PlatformApi |
| 301 | + |
| 302 | +config = ApiClientConfig(base_url="https://api.fra.unikraft.cloud", token=token) |
| 303 | +async with PlatformApi(config) as api: |
| 304 | + res = await api.instances.get_instances(count=10) |
| 305 | +``` |
| 306 | + |
| 307 | +## Examples |
| 308 | + |
| 309 | +- [`examples/quickstart.py`](examples/quickstart.py) — create, wait, read logs, list, suspend, delete |
| 310 | +- [`examples/update.py`](examples/update.py) — patch objects and the staged editor |
| 311 | +- [`examples/plumbing.py`](examples/plumbing.py) — the raw API on its own |
| 312 | + |
| 313 | +## Development |
| 314 | + |
| 315 | +The `api/platform` and `api/controlplane` packages are generated from the OpenAPI |
| 316 | +specification by [`openapi-gen`](https://github.com/unikraft-cloud) using the templates in |
| 317 | +[`templates/`](templates). Everything else is hand-written. Files ending in `_gen.py` are |
| 318 | +never edited by hand. |
| 319 | + |
| 320 | +```sh |
| 321 | +make generate # regenerate both plumbing clients from the specs |
| 322 | +make lint # ruff check + format --check |
| 323 | +make typecheck # mypy |
| 324 | +make test # pytest |
| 325 | +``` |
| 326 | + |
| 327 | +The test suite runs entirely offline through `httpx.MockTransport`. |
| 328 | + |
30 | 329 | ## Licence |
31 | 330 |
|
32 | 331 | BSD-3-Clause. See [`LICENSE.md`](LICENSE.md). |
0 commit comments