From ce45a4a4964ff2191686082dc6701fbc6e2265f3 Mon Sep 17 00:00:00 2001 From: Dragos Gheorghioiu Date: Tue, 20 Oct 2026 19:47:38 +0300 Subject: [PATCH] feat(resources): Add the build client The SDK had no client for the build API, so a Python caller built images through the CLI and could not follow a build's progress or read what it pushed. Generate the plumbing from build.yaml, skipping the progress stream the templates cannot return, and add ukc.builds on top: it follows the stream across cuts, skipping what it has seen, and takes its sources from constructors that carry only what was given. Signed-off-by: Dragos Gheorghioiu --- .gitignore | 2 +- CHANGELOG.md | 7 + Makefile | 23 +- README.md | 46 +- examples/detached.py | 97 +++ examples/parallel.py | 113 +++ src/unikraft_cloud/__init__.py | 22 +- src/unikraft_cloud/api/__init__.py | 38 +- src/unikraft_cloud/api/build/__init__.py | 35 + src/unikraft_cloud/api/build/builds_gen.py | 112 +++ src/unikraft_cloud/api/build/models_gen.py | 756 +++++++++++++++++++++ src/unikraft_cloud/client.py | 35 +- src/unikraft_cloud/core/errors.py | 36 +- src/unikraft_cloud/core/http.py | 30 +- src/unikraft_cloud/core/metro.py | 4 + src/unikraft_cloud/core/session.py | 6 + src/unikraft_cloud/resources/builds.py | 394 +++++++++++ templates/resources.tmpl | 13 +- tests/conftest.py | 1 + tests/test_builds.py | 453 ++++++++++++ tests/test_transport.py | 39 +- 21 files changed, 2202 insertions(+), 60 deletions(-) create mode 100644 examples/detached.py create mode 100644 examples/parallel.py create mode 100644 src/unikraft_cloud/api/build/__init__.py create mode 100644 src/unikraft_cloud/api/build/builds_gen.py create mode 100644 src/unikraft_cloud/api/build/models_gen.py create mode 100644 src/unikraft_cloud/resources/builds.py create mode 100644 tests/test_builds.py diff --git a/.gitignore b/.gitignore index 79a2c3d..a9f81f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ __pycache__/ -build/ +/build/ dist/ *.egg-info/ .pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index eef53f8..92e039c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ### Added +- The build API client: `ukc.builds` creates, lists, cancels and deletes + builds on the remote build service, follows their progress with `logs()` + (reconnecting and skipping what it has seen) and `plain_logs()`, and takes + its sources from `git_source`, `url_source`, `kraftfile_from` and + `image_output`. The service's refusals carry their code on + `err.errors[0].code`. The plumbing is `ukc.api.build`, generated from + `build.yaml`; `build_url` (or `UKC_BUILD_SERVER`) names the service. - 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`. diff --git a/Makefile b/Makefile index c4ef5a9..3c32c77 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,15 @@ CHANNEL ?= prod-staging SPEC_BASE ?= https://raw.githubusercontent.com/unikraft-cloud/openapi/refs/heads/$(CHANNEL) PLATFORM_SPEC ?= $(SPEC_BASE)/platform.json CONTROLPLANE_SPEC ?= $(SPEC_BASE)/controlplane.json +BUILD_SPEC ?= $(SPEC_BASE)/build.yaml -# Both specs also live in the proto repository, which is where the changes land -# first. Generate against a local checkout of it with: +# The platform and control-plane specs also live in the proto repository, which +# is where the changes land first, and the build spec in the build service's. +# Generate against a local checkout with: # make generate \ # PLATFORM_SPEC=../proto/gen/openapi/platform/openapi.yaml \ -# CONTROLPLANE_SPEC=../proto/gen/openapi/controlplane/openapi.yaml +# CONTROLPLANE_SPEC=../proto/gen/openapi/controlplane/openapi.yaml \ +# BUILD_SPEC=../blacksmith/openapi.yaml # The openapi-gen code generator, pinned so a regeneration is reproducible. # Raise OPENAPI_GEN_VERSION to take a newer one. @@ -29,7 +32,7 @@ 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: generate-platform generate-controlplane ## Regenerate the platform and control-plane plumbing from the OpenAPI specs. +generate: generate-platform generate-controlplane generate-build ## Regenerate the platform, control-plane and build plumbing from the OpenAPI specs. $(MAKE) fmt # Namespaced schema names (`Instances.Instance`) are not Python identifiers, so @@ -52,6 +55,18 @@ generate-controlplane: ## Regenerate the control-plane API plumbing. -t $(TEMPLATES) \ --namespace-flatten strip +# The progress stream of a build is newline-delimited JSON or plain text, +# which the templates cannot return, so its operation is left to the +# hand-written client. +.PHONY: generate-build +generate-build: ## Regenerate the build API plumbing. + rm -f $(OUTPUT)/build/*_gen.py + $(OPENAPI_GEN) \ + -i $(BUILD_SPEC) \ + -o $(OUTPUT)/build \ + -t $(TEMPLATES) \ + -v skip=GetBuildLogs + # A plugin's client is generated by plugin-sdk from the plugin's own # specification, and installed as a package of its own. diff --git a/README.md b/README.md index e947368..1376fe0 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ ukc = UnikraftCloud( | `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. | | `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. | | `control_plane_url` | Override the control-plane API base URL. | +| `build_url` | Override the build API base URL, an `http(s)://` URL. Falls back to `UKC_BUILD_SERVER`. | | `headers` | Extra headers sent with every request. | | `user_agent` | Override the default User-Agent. | | `http` | An `httpx.AsyncClient` to send through. Supplying one makes its lifetime yours. | @@ -229,7 +230,8 @@ except UnikraftCloudError as err: `RateLimitError` (429) and `ServerError` (5xx) are raised for the statuses they name, and all subclass `UnikraftCloudError`. The API reports some failures inside an otherwise-200 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. +with the status that says the same thing. The build service refuses with a word instead, +such as `not_found` or `too_many_builds`, on the same field. 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 @@ -286,7 +288,8 @@ except MetroFanoutError as err: ## Resources `instances`, `templates`, `images`, `volumes`, `services`, `certificates` and `users` -hang off any scope — `ukc`, `ukc.metro("fra")` or `ukc.metros([...])`. +hang off any scope — `ukc`, `ukc.metro("fra")` or `ukc.metros([...])`. `builds` hangs +off `ukc` alone: the build service is global. 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. @@ -362,6 +365,33 @@ job = await fra.templates.get(name="worker-v3").clone( ) ``` +### Builds + +A build runs on the remote build service, in a builder of your own account, from +sources the builder fetches itself: a git repository or a tarball, never a directory on +this machine. A create takes the properties the API describes; `kraftfile_from` reads +the Kraftfile out of a source, and `image_output` pushes the image with your token. +`logs()` follows the progress as buildkit events, every event once, asking again and +skipping what it has seen when the stream is cut, and ends with the event that carries +the `result` or the `error`: a failed build is that last event, not an exception. +`plain_logs()` is the text `docker buildx build --progress=plain` prints, asked for +once. The service is named by `build_url` or `UKC_BUILD_SERVER`, an `http(s)://` URL. + +```python +from unikraft_cloud import git_source, image_output, kraftfile_from + +src = git_source("https://github.com/acme/app.git", "main", subdir="app") +build = await ukc.builds.create( + kraftfile=kraftfile_from(src), + contexts={"context": src}, + output=[image_output("index.unikraft.io/acme/app:1.2.3")], +) +async for event in build.logs(): + if event.error: + raise SystemExit(f"build failed: {event.error.message}") +build = await build.refresh() +``` + ## The sandbox plugin A plugin is a helper loaded into an instance beside its workload, and reached through the @@ -469,12 +499,16 @@ async with PlatformApi(config) as api: - [`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 +- [`examples/detached.py`](examples/detached.py) — build a git repository on the build service and push the image +- [`examples/parallel.py`](examples/parallel.py) — several builds at once, followed side by side ## Development -The `api/platform` and `api/controlplane` packages are generated from the OpenAPI -specification by `openapi-gen`, pinned in the Makefile, using the templates in -[`templates/`](templates). The sandbox plugin's plumbing +The `api/platform`, `api/controlplane` and `api/build` packages are generated from +the OpenAPI specifications by `openapi-gen`, pinned in the Makefile, using the +templates in [`templates/`](templates). The progress stream of a build is the one +operation the templates skip (`-v skip=...`): it is newline-delimited JSON or plain +text, which `ukc.builds` reads by hand. 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. @@ -490,7 +524,7 @@ and a `Request`-named schema that a response also carries fails to parse when th leaves a required field out. ```sh -make generate # regenerate the platform and control-plane plumbing from the specs +make generate # regenerate the platform, control-plane and build plumbing from the specs make lint # ruff check + format --check make typecheck # mypy make test # pytest diff --git a/examples/detached.py b/examples/detached.py new file mode 100644 index 0000000..58e88ac --- /dev/null +++ b/examples/detached.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# A detached build on the remote build service: the source, the Kraftfile and +# the output all go in the request, and the build runs entirely server side. +# Every input but the token and the image to push has a default, which builds +# the nginx example of github.com/unikraft-cloud/examples: +# UKC_TOKEN=... uv run examples/detached.py index.unikraft.io//nginx:latest +# +# Progress goes to stderr and the pushed ref@digest to stdout, so that +# IMAGE=$(uv run examples/detached.py ...) captures the image. + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import sys + +from unikraft_cloud import ( + Build, + UnikraftCloud, + UnikraftCloudError, + git_source, + image_output, + kraftfile_from, +) + + +async def main(args: argparse.Namespace) -> None: + async with UnikraftCloud(build_url=args.server) as ukc: # token from UKC_TOKEN + src = git_source(args.git, args.ref or None, subdir=args.subdir or None) + build = await ukc.builds.create( + kraftfile=kraftfile_from(src), + contexts={"context": src}, + output=[image_output(args.image)], + ) + print(f"build {build.id}", file=sys.stderr) + + try: + build = await follow(build) + except asyncio.CancelledError: + # Interrupted: the build would go on without us, so cancel it. + await build.cancel() + raise + + if build.result is None: + # Failed or cancelled: the error is the build's, not the API's. + print(f"build {build.id} {build.status}: {build.error}", file=sys.stderr) + raise SystemExit(1) + for img in build.result.images or []: + print(f"{img.ref}@{img.digest}") + + +async def follow(build: Build) -> Build: + """Print the build's progress as the service renders it, until it has ended.""" + while True: + async with contextlib.aclosing(build.plain_logs()) as lines: + async for text in lines: + sys.stderr.write(text) + sys.stderr.flush() + build = await build.refresh() + # The text ends when the build does, or when the service's side of the + # stream broke off first; then it says so, and asking again replays it. + if build.status not in ("waiting", "running"): + return build + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Build a git repository on the remote build service and push it to ." + ) + parser.add_argument("image", help="the image reference to push, including its tag") + parser.add_argument("--server", default=None, help="build service URL, or $UKC_BUILD_SERVER") + parser.add_argument( + "--git", + default="https://github.com/unikraft-cloud/examples.git", + help="git repository to build", + ) + parser.add_argument( + "--ref", + default="main", + help="branch, tag or commit to build, or empty for the default branch", + ) + parser.add_argument( + "--subdir", + default="nginx", + help="directory of the repository that holds the Kraftfile and is the build context, " + "or empty for the root", + ) + try: + asyncio.run(main(parser.parse_args())) + except KeyboardInterrupt: + sys.exit(130) + except UnikraftCloudError as err: + print(f"API error ({err.status or '?'}): {err}", file=sys.stderr) + raise SystemExit(1) from err diff --git a/examples/parallel.py b/examples/parallel.py new file mode 100644 index 0000000..f710930 --- /dev/null +++ b/examples/parallel.py @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Several builds on the remote build service at once, followed side by side. +# Each argument after the image namespace is a directory of +# github.com/unikraft-cloud/examples, pushed to /:latest: +# UKC_TOKEN=... uv run examples/parallel.py index.unikraft.io/ nginx redis +# +# Progress goes to stderr with each line prefixed by its directory, and the +# pushed ref@digest of every build to stdout. + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import sys + +from unikraft_cloud import ( + UnikraftCloud, + UnikraftCloudError, + git_source, + image_output, + kraftfile_from, +) + +# The ANSI colours of the prefixes, in docker compose's order. +COLOURS = ["36", "33", "32", "35", "34", "36;1", "33;1", "32;1", "35;1", "34;1"] + + +async def main(args: argparse.Namespace) -> None: + dirs = args.dirs or ["mariadb", "neo4j", "visual-studio-code-server"] + colour = sys.stderr.isatty() + width = max(len(d) for d in dirs) + async with UnikraftCloud(build_url=args.server) as ukc: # token from UKC_TOKEN + builds = [] + for index, subdir in enumerate(dirs): + prefix = f"{subdir:<{width}} | " + if colour: + prefix = f"\x1b[{COLOURS[index % len(COLOURS)]}m{prefix}\x1b[0m" + builds.append(run(ukc, args, subdir, prefix)) + # Every build runs to its end; the failures are reported together. + failures = [ + f"{subdir}: {outcome}" + for subdir, outcome in zip( + dirs, await asyncio.gather(*builds, return_exceptions=True), strict=True + ) + if outcome is not None + ] + if failures: + print("parallel: " + "; ".join(failures), file=sys.stderr) + raise SystemExit(1) + + +async def run(ukc: UnikraftCloud, args: argparse.Namespace, subdir: str, prefix: str) -> str | None: + """Run one build to its end, printing its progress behind `prefix`; None once it pushed.""" + src = git_source(args.git, args.ref or None, subdir=subdir) + build = await ukc.builds.create( + kraftfile=kraftfile_from(src), + contexts={"context": src}, + output=[image_output(f"{args.namespace}/{subdir}:latest")], + ) + print(f"{prefix}build {build.id}", file=sys.stderr) + + try: + while True: + async with contextlib.aclosing(build.plain_logs()) as lines: + tail = "" + async for text in lines: + *whole, tail = (tail + text).split("\n") + for line in whole: + print(f"{prefix}{line}", file=sys.stderr) + if tail: + print(f"{prefix}{tail}", file=sys.stderr) + build = await build.refresh() + if build.status not in ("waiting", "running"): + break + except asyncio.CancelledError: + await build.cancel() + raise + + if build.result is None: + return f"build {build.id} {build.status}: {build.error}" + for img in build.result.images or []: + print(f"{img.ref}@{img.digest}") + return None + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Build directories of a git repository at once on the remote build service " + "and push each to /:latest." + ) + parser.add_argument("namespace", help="where to push, e.g. index.unikraft.io/") + parser.add_argument("dirs", nargs="*", help="directories of the repository to build") + parser.add_argument("--server", default=None, help="build service URL, or $UKC_BUILD_SERVER") + parser.add_argument( + "--git", + default="https://github.com/unikraft-cloud/examples.git", + help="git repository to build", + ) + parser.add_argument( + "--ref", + default="main", + help="branch, tag or commit to build, or empty for the default branch", + ) + try: + asyncio.run(main(parser.parse_args())) + except KeyboardInterrupt: + sys.exit(130) + except UnikraftCloudError as err: + print(f"API error ({err.status or '?'}): {err}", file=sys.stderr) + raise SystemExit(1) from err diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index 5d9ecc4..c9d127b 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -5,12 +5,13 @@ The idiomatic client is :class:`UnikraftCloud`. The raw, spec-shaped API stays available on ``ukc.api`` and from ``unikraft_cloud.api.platform``, -``unikraft_cloud.api.controlplane`` and ``unikraft_cloud.api.plugins``. +``unikraft_cloud.api.controlplane``, ``unikraft_cloud.api.build`` and +``unikraft_cloud.api.plugins``. """ from __future__ import annotations -from .api import Api, ControlPlaneApi, PlatformApi, PluginsApi, SandboxApi +from .api import Api, BuildApi, ControlPlaneApi, PlatformApi, PluginsApi, SandboxApi from .client import USER_AGENT, MetroClient, Scope, UnikraftCloud from .core.errors import ( AlreadyExistsError, @@ -44,6 +45,7 @@ Unset, ) from .core.metro import ( + BUILD_BASE_URL, CONTROLPLANE_BASE_URL, DEFAULT_METRO, KNOWN_METROS, @@ -71,6 +73,14 @@ Sandbox, SandboxFiles, ) +from .resources.builds import ( + Build, + Builds, + git_source, + image_output, + kraftfile_from, + url_source, +) from .resources.certificates import ( Certificate, CertificateHandle, @@ -129,6 +139,7 @@ __version__ = "0.2.0" __all__ = [ + "BUILD_BASE_URL", "CONTROLPLANE_BASE_URL", "DEFAULT_METRO", "KNOWN_METROS", @@ -142,6 +153,9 @@ "ApiClientConfig", "AttachedVolume", "AuthenticationError", + "Build", + "BuildApi", + "Builds", "CallOptions", "Certificate", "CertificateHandle", @@ -249,9 +263,13 @@ "WaitedInstance", "__version__", "collect", + "git_source", + "image_output", + "kraftfile_from", "metro_base_url", "metro_endpoint", "or_absent", "paginate", + "url_source", "with_metro", ] diff --git a/src/unikraft_cloud/api/__init__.py b/src/unikraft_cloud/api/__init__.py index 61ca3af..db33589 100644 --- a/src/unikraft_cloud/api/__init__.py +++ b/src/unikraft_cloud/api/__init__.py @@ -13,12 +13,14 @@ from dataclasses import replace from ..core.http import ApiClientConfig, ApiClientGroup +from ..core.metro import BUILD_BASE_URL +from .build import BuildApi from .controlplane import ControlPlaneApi from .platform import PlatformApi from .plugins import PluginsApi from .plugins.sandbox import SandboxApi -__all__ = ["Api", "ControlPlaneApi", "PlatformApi", "PluginsApi", "SandboxApi"] +__all__ = ["Api", "BuildApi", "ControlPlaneApi", "PlatformApi", "PluginsApi", "SandboxApi"] class Api(ApiClientGroup): @@ -32,28 +34,40 @@ class Api(ApiClientGroup): await ukc.api.platform.instances.get_instances(count=10) await ukc.api.controlplane.metros.list_metros() await ukc.api.plugins.sandbox.for_instance(uuid).commands.list_commands() + await ukc.api.build.builds.list_builds() """ - def __init__(self, platform: ApiClientConfig, control_plane: ApiClientConfig) -> None: + def __init__( + self, + platform: ApiClientConfig, + control_plane: ApiClientConfig, + build: ApiClientConfig | None = None, + ) -> None: super().__init__(platform) #: The metro-scoped platform API. self.platform = PlatformApi(self.config) - # The control plane shares the pool unless it was configured to connect - # differently, which is its to honour rather than to override. - shared = (control_plane.http, control_plane.transport, control_plane.trust_env) == ( - platform.http, - platform.transport, - platform.trust_env, - ) #: The global control-plane API. - self.controlplane = ControlPlaneApi( - replace(control_plane, http=self.http) if shared else control_plane + self.controlplane = ControlPlaneApi(self._shared(platform, control_plane)) + #: The global build API. + self.build = BuildApi( + self._shared(platform, build or platform.with_base_url(BUILD_BASE_URL)) ) #: The official plugin APIs, reached through an instance on the platform #: API's metro. self.plugins = PluginsApi(self.config) + def _shared(self, platform: ApiClientConfig, config: ApiClientConfig) -> ApiClientConfig: + # A global API shares the pool unless it was configured to connect + # differently, which is its to honour rather than to override. + shared = (config.http, config.transport, config.trust_env) == ( + platform.http, + platform.transport, + platform.trust_env, + ) + return replace(config, http=self.http) if shared else config + async def aclose(self) -> None: - """Release the pool, and the control plane's own if it made one.""" + """Release the pool, and the global APIs' own if they made one.""" await self.controlplane.aclose() + await self.build.aclose() await super().aclose() diff --git a/src/unikraft_cloud/api/build/__init__.py b/src/unikraft_cloud/api/build/__init__.py new file mode 100644 index 0000000..3689f35 --- /dev/null +++ b/src/unikraft_cloud/api/build/__init__.py @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# The build API's "plumbing" layer: the generated client, plus a container +# that holds it behind one transport config. Everything here returns the +# response exactly as the OpenAPI specification describes it; the build API +# has no envelope. The progress stream of a build is not generated: it is +# `Builds.logs` and `Builds.plain_logs` in the idiomatic layer. + +from __future__ import annotations + +from ...core.http import ApiClient, ApiClientConfig, ApiClientGroup +from . import models_gen as models +from .builds_gen import BuildsApi + +__all__ = ["ApiClient", "ApiClientConfig", "BuildApi", "BuildsApi", "models"] + + +class BuildApi(ApiClientGroup): + """The build API, raw. + + Unless ``config.http`` supplied the connection pool, the container owns it + and releases it on ``async with`` exit or :meth:`aclose`. + + .. code-block:: python + + from unikraft_cloud.api.build import BuildApi + + async with BuildApi(config) as api: + res = await api.builds.list_builds() + """ + + def __init__(self, config: ApiClientConfig) -> None: + super().__init__(config) + self.builds = BuildsApi(self.config) diff --git a/src/unikraft_cloud/api/build/builds_gen.py b/src/unikraft_cloud/api/build/builds_gen.py new file mode 100644 index 0000000..50116e9 --- /dev/null +++ b/src/unikraft_cloud/api/build/builds_gen.py @@ -0,0 +1,112 @@ +# Code generated by openapi-gen; DO NOT EDIT. + +from __future__ import annotations + +from collections.abc import Mapping +from urllib.parse import quote + +from ...core.http import UNSET, ApiClient, TimeoutOption +from . import models_gen as models + + +class BuildsApi(ApiClient): + """Low-level "plumbing" client for the `builds` resource. + + Methods mirror the OpenAPI specification and return the raw response + envelope. The idiomatic clients are built on top of this. + """ + + async def cancel_build( + self, + id: str, + *, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> models.Build: + """Cancel Build""" + return await self.request( + models.Build, + method="POST", + path=f"/v1/builds/{quote(id, safe='')}/cancel", + headers=headers, + base_url=base_url, + timeout=timeout, + ) + + async def create_build( + self, + *, + body: models.CreateBuildRequest, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> models.Build: + """Create Build""" + return await self.request( + models.Build, + method="POST", + path="/v1/builds", + body=body, + headers=headers, + base_url=base_url, + timeout=timeout, + ) + + async def delete_build( + self, + id: str, + *, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> None: + """Delete Build""" + return await self.request_no_content( + method="DELETE", + path=f"/v1/builds/{quote(id, safe='')}", + headers=headers, + base_url=base_url, + timeout=timeout, + ) + + async def get_build( + self, + id: str, + *, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> models.Build: + """Get Build""" + return await self.request( + models.Build, + method="GET", + path=f"/v1/builds/{quote(id, safe='')}", + headers=headers, + base_url=base_url, + timeout=timeout, + ) + + async def list_builds( + self, + *, + status: models.BuildStatus | None = None, + limit: int | None = None, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> models.ListBuildsResponse: + """List Builds""" + return await self.request( + models.ListBuildsResponse, + method="GET", + path="/v1/builds", + query={ + "status": status, + "limit": limit, + }, + headers=headers, + base_url=base_url, + timeout=timeout, + ) diff --git a/src/unikraft_cloud/api/build/models_gen.py b/src/unikraft_cloud/api/build/models_gen.py new file mode 100644 index 0000000..b551ca0 --- /dev/null +++ b/src/unikraft_cloud/api/build/models_gen.py @@ -0,0 +1,756 @@ +# Code generated by openapi-gen; DO NOT EDIT. +# +# Type definitions for the Unikraft Cloud API, generated from the OpenAPI +# specification. These mirror the wire format exactly. + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +# Enumerations, as Literal aliases rather than enum classes: the values stay +# plain strings, so `state == "running"` needs no import, and a value the server +# adds is one line to widen. + + +BuildStatus = Literal["waiting", "running", "succeeded", "failed", "cancelled"] +""" +The state of a build. A build starts as `waiting` or `running` and ends as +`succeeded`, `failed` or `cancelled`; it never leaves a final state. + +| State | Description | +|-------------|-------------| +| `waiting` | The build waits for a client to attach its buildkit session, +and then becomes `running`. | +| `running` | The build waits for a build slot or for its builder, or is +being built. | +| `succeeded` | The build finished and every output was pushed; see +`result`. | +| `failed` | The build failed; see `error`. | +| `cancelled` | The build was cancelled before it finished; `error` says so. +| +""" + +ErrorCode = Literal[ + "invalid", + "unsupported", + "unauthenticated", + "auth_unavailable", + "not_found", + "conflict", + "too_many_builds", + "internal", + "failed", + "cancelled", + "interrupted", + "builder_quota", +] +""" +The reason a request, or a build, failed. + +The first eight codes are answered by the API together with the HTTP status +code given. The last four are the `code` of the `error` of a build whose +`status` is `failed` or `cancelled`, both in the build itself and in the +last +event of its progress stream. + +| Code | Description | +|--------------------|-------------| +| `invalid` | `400`: the request is malformed, or a value in it is +not valid: the body is not JSON, a required field is missing, a source has +more than one of `session`, `git` and `url`, an image reference does not +parse, an inline Kraftfile does not parse or cannot be built, ... | +| `unsupported` | `400`: the request uses a part of the full build API, +or of the Kraftfile, that this service does not support yet. Nothing is +ignored silently. | +| `unauthenticated` | `401`: the request carries no bearer token, or one +that the platform does not accept. | +| `auth_unavailable` | `502`: the token could not be validated because the +platform did not give a usable answer. Retrying later is the right response. +| +| `not_found` | `404`: no build with this ID belongs to the caller's +tenant. The build of another tenant is indistinguishable from a build that +does not exist. | +| `conflict` | `409`: the build is still waiting or running, so its +record cannot be deleted yet; cancel it first. Or the build does not wait +for a session, or already has one. Or the `builder` of the request conflicts +with the existing builder of that name. | +| `too_many_builds` | `429`: the caller's tenant already has as many builds +running or waiting as it may. Submit the build again once one of them has +finished. | +| `internal` | `500`: the service could not handle the request, +through no fault of the request; the details are in the service's log. `503` +while the service is shutting down. Retrying later is the right response. | +| `failed` | The build itself failed: a source could not be +fetched, a Kraftfile read from a source did not parse, a build step exited +non-zero, the push was refused. The message is the error buildkit or the +pipeline reported. | +| `cancelled` | The build was cancelled by a request to the cancel +endpoint. | +| `interrupted` | The service restarted while the build was running, so +the build was lost. It can be submitted again. | +| `builder_quota` | The builder of the build could not be created or +started because the tenant is over its quota. Free resources or raise the +quota, then submit the build again. | +""" + + +class Build(BaseModel): + """ + A build. `result` and `error` are exclusive: a build that succeeded has the + one, a build that failed or was cancelled has the other, and a build that is + still waiting or running has neither. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + """The UUID of the build.""" + status: BuildStatus | None = None + """The state of the build.""" + builder: str | None = None + """The name of the builder the build runs on.""" + session: str | None = None + """ + The path to attach the build's session at. Only present while the build is + `waiting` for it. + """ + created_at: datetime | None = None + """The time the build was created.""" + started_at: datetime | None = None + """The time the build started on its builder. Absent until it has.""" + finished_at: datetime | None = None + """The time the build reached a final state. Absent until it has.""" + result: BuildResult | None = None + """What the build produced. Only present on a `succeeded` build.""" + error: Error | None = None + """ + Why the build did not succeed. Only present on a `failed` or `cancelled` + build. + """ + + +class BuildContexts(BaseModel): + """ + The contexts a build is built from, by the names of buildkit's Dockerfile + frontend. Any other key is a named context, for `FROM ` and + `COPY --from=`, like `docker buildx build --build-context`, for every + Dockerfile the build runs. Named contexts are only valid when the rootfs or + a rom is built from a Dockerfile. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + context: Source + """ + The build context: the directory the Dockerfile is built in, and the + directory relative paths in the Kraftfile resolve in. + """ + dockerfile: Source | None = None + """ + The directory holding the Dockerfile of the rootfs. Defaults to + `context`. The Dockerfile in it is its `filename`, which defaults to the + name of the Dockerfile the Kraftfile builds its rootfs with. Only valid + when the rootfs is built from a Dockerfile. + """ + + +class BuildEvent(BaseModel): + """ + One line of the progress stream of a build. Every line is a buildkit + `SolveStatus` in the shape of `docker buildx build --progress=rawjson`, so + anything that renders buildkit progress renders this stream as-is. Steps + that buildkit does not run itself, such as pulling the kernel, packing the + image and pushing it, are reported as vertices of their own. The last line + of the stream is always exactly one of `result` or `error`, and no other + line carries either. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + vertexes: list[Vertex] | None = None + """The vertices that started or completed.""" + statuses: list[VertexStatus] | None = None + """The statuses that progressed.""" + logs: list[VertexLog] | None = None + """The output that vertices produced.""" + warnings: list[VertexWarning] | None = None + """The warnings that were raised.""" + result: BuildResult | None = None + """What the build produced. Only on the last line, of a succeeded build.""" + error: Error | None = None + """ + Why the build did not succeed. Only on the last line, of a failed or + cancelled build. + """ + + +class BuildOutput(BaseModel): + """ + Where the result of a build goes: exactly one of an image, named by `image`, + or a volume, named by `volume`. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + image: str | None = None + """ + The reference to push the built image to, including the tag. The caller + picks the tag, and checks whether it already exists. The push is done with + the caller's token, unless `registries` has credentials for its registry. + """ + volume: str | None = None + """ + The name or UUID of an existing volume to import the built filesystem + into. Only valid for a plain image of a single platform. Not supported + yet. + """ + metro: str | None = None + """ + The metro of `volume`, only needed when its name is ambiguous. Only valid + with `volume`. Not supported yet. + """ + + +class BuildResult(BaseModel): + """What a succeeded build produced.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + images: list[BuildResultImage] | None = None + """The pushed images, one per `image` entry of `output`.""" + volumes: list[BuildResultVolume] | None = None + """The imported volumes, one per `volume` entry of `output`.""" + + +class BuildResultImage(BaseModel): + """An image a build pushed.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + ref: str | None = None + """The reference the image was pushed to, as requested in `output`.""" + digest: str | None = None + """The digest of the pushed image.""" + platforms: list[str] | None = None + """The platforms the image was built for, e.g. `kraftcloud/x86_64`.""" + + +class BuildResultVolume(BaseModel): + """A volume a build imported its filesystem into.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the volume.""" + name: str | None = None + """The name of the volume.""" + metro: str | None = None + """The metro of the volume.""" + + +class Builder(BaseModel): + """ + The builder a build runs on: a buildkitd instance and its cache volume in + the + caller's account. Builds on the same builder share its cache. A builder that + does not exist yet is created from these fields; for one that does, every + field but `name` only has to match it, and one that conflicts is a `409`. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str | None = None + """The name of the builder. Empty means `default`.""" + image: str | None = None + """The buildkit image the builder boots. Defaults to the service's.""" + vcpus: int | None = None + """The vCPUs of the builder.""" + memory_mb: int | None = None + """The memory of the builder, in MiB.""" + cache_mb: int | None = None + """The size of the builder's cache volume, in MiB.""" + autokill: BuilderAutokill | None = None + """Delete the builder once it has been idle for a while.""" + + +class BuilderAutokill(BaseModel): + """When to delete an idle builder.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + time_ms: int | None = None + """ + Delete the builder and its cache once it has had no running build for this + many milliseconds. `0` (the default) never does. + """ + + +class CreateBuildRequest(BaseModel): + """The request message for creating a build.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + kraftfile: Kraftfile | None = None + """ + The Kraftfile to build, which makes the build a Unikraft image. Without + one, the build is a plain image of `context`, built with the Dockerfile in + `dockerfile`. + """ + contexts: BuildContexts + """The contexts to build from.""" + platforms: list[str] | None = None + """ + The platforms to build for. Only the architecture selects, so `x86_64` is + `linux/amd64`. Defaults to the targets of the Kraftfile or of its runtime, + or to the builder's own platform for a plain image. + """ + build_args: dict[str, str] | None = None + """The values of the Dockerfile's `ARG`s, by name.""" + target: str | None = None + """The Dockerfile stage to build. Defaults to the last one.""" + no_cache: bool | None = None + """Ignore the builder's cache, like `--no-cache`.""" + builder: Builder | None = None + """ + The builder to run on. Defaults to the one named `default`. Not supported + yet. + """ + secrets: dict[str, Secret] | None = None + """The secrets the build can mount, by ID.""" + ssh: dict[str, SSHAgent] | None = None + """The SSH agents the build can mount, by ID. Not supported yet.""" + registries: dict[str, Registry] | None = None + """ + The credentials for registries, by host, with `*` for any other host. + `docker.io` names Docker Hub. The caller's token is the credential for + a registry that has none, and so is only used when there is no `*`. + """ + output: list[BuildOutput] + """ + Where the result goes. At least one output is required. The outputs are + pushed one after the other, so a build that fails or is cancelled can + leave the first outputs pushed. + """ + + +class Error(BaseModel): + """Why a request, or a build, failed.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + code: ErrorCode | None = None + """The code of the error, for a program to act on.""" + message: str | None = None + """The reason for the error, for a person to read.""" + + +class ErrorBody(BaseModel): + """ + The response message for a failed request. The HTTP status code says that + the request failed and `error` says why. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + error: Error | None = None + + +class ListBuildsResponse(BaseModel): + """The response message for listing the builds of the caller's tenant.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + builds: list[Build] | None = None + """The builds, newest first.""" + + +class Position(BaseModel): + """A position in a source file.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + line: int | None = None + """The line, counted from one.""" + character: int | None = None + """The character within the line, counted from zero.""" + + +class ProgressGroup(BaseModel): + """A group of vertices that progress renderers fold into one line.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + """The identifier of the group.""" + name: str | None = None + """The name the group is rendered under.""" + weak: bool | None = None + """Whether the group only exists while its vertices are running.""" + + +class Range(BaseModel): + """A range of a source file.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + start: Position | None = None + """Where the range starts.""" + end: Position | None = None + """Where the range ends.""" + + +class Registry(BaseModel): + """ + The credentials for a registry: exactly one of a `username` and `password`, + or `session` to ask the client's session for them. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + username: str | None = None + """The username to log in with.""" + password: str | None = None + """The password to log in with, which is never stored.""" + session: bool | None = None + """ + Must be `true`: the client's session answers for the registry. Not + supported yet. + """ + + +class SSHAgent(BaseModel): + """ + An SSH agent the build can mount with `RUN --mount=type=ssh,id=`, which + only the client's session can serve. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + session: str + """The ID the client's session serves the agent under.""" + + +class Secret(BaseModel): + """ + A secret the build can mount with `RUN --mount=type=secret,id=`: + exactly + one of a value in the request or a secret the client's session serves. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + value: str | None = None + """The value of the secret, at most 500 KiB, which is never stored.""" + session: str | None = None + """The ID the client's session serves the secret under. Not supported yet.""" + + +class Source(BaseModel): + """ + Where the files of a build come from. A source is exactly one of a + directory the client's buildkit session serves, named by `session`, a git + repository, named by `git`, or an https tarball, named by `url`; `ref` + qualifies a repository, and `subdir` and `filename` qualify any. A git or + https source is fetched by the builder itself, not by the service, so it is + cached on the builder like any other build step. A session source is read + from the client as the build needs it, so the build waits for the session. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + session: str | None = None + """ + The name the client's buildkit session serves the directory under. + Mutually exclusive with `git` and `url`. Not supported yet. + """ + git: str | None = None + """ + The HTTPS URL of a git repository, cloned anonymously. Mutually exclusive + with `session` and `url`. + """ + url: str | None = None + """ + The HTTPS URL of a tarball, which is fetched and unpacked. Mutually + exclusive with `session` and `git`. + """ + ref: str | None = None + """ + The branch, tag or commit of a git repository to check out. Defaults to + the default branch of the repository. Only valid with `git`. + """ + subdir: str | None = None + """ + A directory inside the source; the source is that directory rather than + the whole tree. Defaults to the root. + """ + filename: str | None = None + """ + The file to read out of the source, relative to `subdir`. Only valid where + the source names a single file: `kraftfile`, where it defaults to + `Kraftfile`, and `contexts.dockerfile`, where it defaults to the name of + the Dockerfile the Kraftfile builds with. A `filename` on any other + context is a `400`. + """ + + +class SourceInfo(BaseModel): + """A source file a warning points into.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + filename: str | None = None + """The name of the file.""" + data: str | None = None + """The contents of the file, base64-encoded.""" + definition: Any | None = None + """The LLB definition the file was lowered to.""" + language: str | None = None + """The language of the file, e.g. `dockerfile`.""" + + +class Vertex(BaseModel): + """ + A step of the build, which buildkit calls a vertex: a pull, a Dockerfile + instruction, the packing of the image, the push, ... A vertex is reported + when it starts and again when it completes, and is identified by its digest. + """ + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + digest: str | None = None + """The digest identifying the vertex.""" + inputs: list[str] | None = None + """The digests of the vertices this one depends on.""" + name: str | None = None + """The name of the step, e.g. `[rootfs 2/4] RUN npm ci`.""" + started: datetime | None = None + """The time the step started.""" + completed: datetime | None = None + """The time the step completed.""" + cached: bool | None = None + """Whether the step was served from the builder's cache.""" + error: str | None = None + """The error of the step, when it failed.""" + progressGroup: ProgressGroup | None = None + """The group of steps this one is rendered as part of.""" + + +class VertexLog(BaseModel): + """A chunk of the output of a vertex.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + vertex: str | None = None + """The digest of the vertex that produced the output.""" + stream: int | None = None + """The stream the output was written to: `1` is stdout, `2` is stderr.""" + data: str | None = None + """The output itself, base64-encoded.""" + timestamp: datetime | None = None + """The time the output was produced.""" + + +class VertexStatus(BaseModel): + """The progress of a long-running part of a vertex, such as a download.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + """The identifier of the status within its vertex.""" + vertex: str | None = None + """The digest of the vertex this status belongs to.""" + name: str | None = None + """The name of what is progressing.""" + total: int | None = None + """The total amount of work, when known.""" + current: int | None = None + """The amount of work done so far.""" + timestamp: datetime | None = None + """The time this status was reported.""" + started: datetime | None = None + """The time the work started.""" + completed: datetime | None = None + """The time the work completed.""" + + +class VertexWarning(BaseModel): + """A warning buildkit raised while building a vertex.""" + + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + vertex: str | None = None + """The digest of the vertex the warning belongs to.""" + level: int | None = None + """The severity of the warning.""" + short: str | None = None + """The one-line summary of the warning, base64-encoded.""" + detail: list[str] | None = None + """The lines of the full explanation of the warning, base64-encoded.""" + url: str | None = None + """A page documenting the warning.""" + sourceInfo: SourceInfo | None = None + """The source file the warning points into.""" + range: list[Range] | None = None + """The ranges of the source file the warning points at.""" + + +KraftfileInline = str +""" +A whole Kraftfile, in YAML, as a string. The service parses it, so the +client does not need a Kraftfile parser of its own. +""" + +KraftfileObject = dict[str, Any] +""" +An already-parsed Kraftfile, as an object in the JSON form of +`unikraft.com/x/kraftfile`: the same keys as the YAML file (`runtime`, +`rootfs`, `roms`, `cmd`, `env`, `labels`, ...), with `spec` optional. See +https://unikraft.com/docs/cli/reference/kraftfile for the keys. None of +`session`, `git` and `url` is one of them, which is what tells the object +apart from a source. +""" + +Kraftfile = KraftfileInline | Source | KraftfileObject +""" +The Kraftfile of a build, in one of three forms: the whole file as a string, +a source to read it from, or the parsed file as an object. A source has +`session`, `git` or `url`, and none is a Kraftfile key, so the two object +forms never collide. Relative paths in the Kraftfile resolve inside the +build context. +""" + + +# Annotations are deferred, so a model may name one defined further down. This +# resolves them all now: a reference that cannot be resolved becomes an error on +# import rather than a surprise at the first request. +for _model in ( + Build, + BuildContexts, + BuildEvent, + BuildOutput, + BuildResult, + BuildResultImage, + BuildResultVolume, + Builder, + BuilderAutokill, + CreateBuildRequest, + Error, + ErrorBody, + ListBuildsResponse, + Position, + ProgressGroup, + Range, + Registry, + SSHAgent, + Secret, + Source, + SourceInfo, + Vertex, + VertexLog, + VertexStatus, + VertexWarning, +): + _model.model_rebuild() diff --git a/src/unikraft_cloud/client.py b/src/unikraft_cloud/client.py index be09179..0f81eec 100644 --- a/src/unikraft_cloud/client.py +++ b/src/unikraft_cloud/client.py @@ -11,8 +11,16 @@ from typing_extensions import Self from .api import Api -from .core.http import UNSET, ApiClientConfig, TimeoutOption, Unset, default_timeout +from .core.http import ( + UNSET, + ApiClientConfig, + TimeoutOption, + Unset, + default_timeout, + normalise_base_url, +) from .core.metro import ( + BUILD_BASE_URL, CONTROLPLANE_BASE_URL, DEFAULT_METRO, Metro, @@ -22,6 +30,7 @@ metro_base_url, ) from .core.session import Session, SessionConfig +from .resources.builds import Builds from .resources.certificates import Certificates from .resources.images import Images from .resources.instances import Instances @@ -80,7 +89,9 @@ def __init__(self, session: Session, endpoint: MetroEndpoint) -> None: #: The metro these clients are pinned to. self.endpoint = endpoint #: The raw API surfaces, with the platform API pinned to this metro. - self.api = Api(session.platform.with_base_url(endpoint.base_url), session.control_plane) + self.api = Api( + session.platform.with_base_url(endpoint.base_url), session.control_plane, session.build + ) class UnikraftCloud(Scope): @@ -93,8 +104,8 @@ class UnikraftCloud(Scope): discovery. The raw, spec-shaped API stays available on :attr:`api`, and from - ``unikraft_cloud.api.platform``, ``unikraft_cloud.api.controlplane`` and - ``unikraft_cloud.api.plugins``. + ``unikraft_cloud.api.platform``, ``unikraft_cloud.api.controlplane``, + ``unikraft_cloud.api.build`` and ``unikraft_cloud.api.plugins``. The client owns a connection pool, so close it when you are done -- either with ``async with`` or by awaiting :meth:`aclose`. @@ -120,6 +131,7 @@ def __init__( metros: MetroScope | None = None, base_url: str | None = None, control_plane_url: str | None = None, + build_url: str | None = None, headers: Mapping[str, str] | None = None, user_agent: str | None = None, http: httpx.AsyncClient | None = None, @@ -140,6 +152,8 @@ def __init__( remains the target for operations that must pick a single metro. :param base_url: Explicit platform API base URL; overrides `metro`. :param control_plane_url: Override the control-plane API base URL. + :param build_url: Override the build API base URL, an ``http(s)://`` + URL. Falls back to ``UKC_BUILD_SERVER``. :param headers: Extra headers sent with every request. :param user_agent: Override the default User-Agent. :param http: An httpx client to send through. Supplying one makes its @@ -195,11 +209,18 @@ def __init__( control_plane = platform.with_base_url( metro_base_url(control_plane_url or CONTROLPLANE_BASE_URL) ) + build_url = build_url or os.environ.get("UKC_BUILD_SERVER") or BUILD_BASE_URL + # The build service is named by its URL alone, never by a metro code, + # so a bare host is a mistake rather than a metro to expand. + if not build_url.lower().startswith(("http://", "https://")): + raise ValueError(f"build_url must be an http(s):// URL; got {build_url!r}.") + build = platform.with_base_url(normalise_base_url(build_url)) session = Session( SessionConfig( platform=platform, control_plane=control_plane, + build=build, default_metro=default_metro, named_metro=named, pinned=( @@ -211,8 +232,10 @@ def __init__( #: The raw ("plumbing") API surfaces: ``api.platform`` (metro-scoped, #: pointing at the default metro unless a call passes ``base_url``), - #: ``api.controlplane`` and ``api.plugins``. - self.api = Api(platform, control_plane) + #: ``api.controlplane``, ``api.build`` and ``api.plugins``. + self.api = Api(platform, control_plane, build) + #: Builds on the build service, which is global: a scope does not apply. + self.builds = Builds(session) self._metro_clients: dict[str, MetroClient] = {} async def aclose(self) -> None: diff --git a/src/unikraft_cloud/core/errors.py b/src/unikraft_cloud/core/errors.py index 3b25f3f..e50b531 100644 --- a/src/unikraft_cloud/core/errors.py +++ b/src/unikraft_cloud/core/errors.py @@ -41,8 +41,9 @@ class ResponseError: status: int | None = None #: Optional human-readable detail. message: str | None = None - #: The API's own error code, for an item of a request that failed. - code: int | None = None + #: The API's own error code: a number for an item of a platform request + #: that failed, a word such as ``"not_found"`` for a build service refusal. + code: int | str | None = None #: The UUID of the item that failed, when the API names it that way. uuid: str | None = None #: The name of the item that failed, when the API names it that way. @@ -123,7 +124,11 @@ def __init__( class AlreadyExistsError(UnikraftCloudError): - """A resource of that name is already there (409).""" + """The request conflicts with what is there (409). + + A resource of that name already exists; or, on the build service, a + build still waiting or running cannot be deleted yet. + """ class WaitTimeoutError(UnikraftCloudError, TimeoutError): @@ -158,6 +163,9 @@ def error_for_status( if status in (401, 403): cls = AuthenticationError elif status == 404: + # The platform settles whether the resource or the route is missing + # where its envelope is read. The build service says `not_found` for + # both, so its 404 cannot claim the build is absent. cls = NotFoundError elif status == 409: cls = AlreadyExistsError @@ -202,6 +210,17 @@ def parse_response_errors(body: Any) -> tuple[ResponseError, ...] | None: return None raw = body.get("errors") if not isinstance(raw, list) or not raw: + # The build API has no envelope: its failure is one `{"error": + # {"code", "message"}}`, with a word for its code. + error = body.get("error") + if isinstance(error, dict) and isinstance(error.get("code"), str): + message = error.get("message") + return ( + ResponseError( + message=message if isinstance(message, str) else None, + code=error["code"], + ), + ) return None errors: list[ResponseError] = [] for entry in raw: @@ -218,9 +237,18 @@ def parse_response_errors(body: Any) -> tuple[ResponseError, ...] | None: def envelope_message(body: Any) -> str | None: - """Read the envelope's ``message``, if it has a usable one.""" + """Read the envelope's ``message``, if it has a usable one. + + The build API has no envelope: its failure is ``{"error": {"code", + "message"}}``, so the message is read from there too. + """ if isinstance(body, dict): message = body.get("message") if isinstance(message, str) and message: return message + error = body.get("error") + if isinstance(error, dict): + message = error.get("message") + if isinstance(message, str) and message: + return message return None diff --git a/src/unikraft_cloud/core/http.py b/src/unikraft_cloud/core/http.py index 149283c..f9825e1 100644 --- a/src/unikraft_cloud/core/http.py +++ b/src/unikraft_cloud/core/http.py @@ -450,11 +450,17 @@ async def _send(self, request: httpx.Request, *, stream: bool) -> httpx.Response except httpx.HTTPError as cause: raise self._network_error(request, cause) from cause - def _network_error(self, request: httpx.Request, cause: httpx.HTTPError) -> UnikraftCloudError: - return UnikraftCloudError( - f"Request to {request.url} failed: {describe_cause(cause)}", - kind="network", - ) + def _network_error( + self, request: httpx.Request, cause: httpx.HTTPError, *, status: int | None = None + ) -> UnikraftCloudError: + # A failure once the headers had arrived keeps their status, so that a + # stream that broke off can be told from a service that never answered. + message = f"Request to {request.url} failed: {describe_cause(cause)}" + if status is not None and not httpx.codes.is_success(status): + # A refusal whose body broke off is still the refusal its status + # says: `except NotFoundError` and `err.status == 404` must agree. + return error_for_status(status, message) + return UnikraftCloudError(message, kind="network", status=status) async def request( self, @@ -562,6 +568,7 @@ async def stream_bytes( base_url: str | None = None, timeout: TimeoutOption = UNSET, chunk_size: int | None = None, + media_type: str | None = None, ) -> AsyncGenerator[bytes, None]: """Yield a raw byte payload as it arrives, without holding it all in memory. @@ -569,6 +576,8 @@ async def stream_bytes( and release the connection at once, with ``contextlib.aclosing`` or its ``aclose()``; a ``break`` alone leaves that to garbage collection. A failure is raised before the first chunk, exactly as :meth:`request_bytes` raises it. + A success whose ``Content-Type`` is not `media_type`, when one is given, is + a parse error rather than a payload: it is not what the caller reads. """ request = self._build_request( method=method, @@ -585,12 +594,19 @@ async def stream_bytes( if not response.is_success: await response.aread() self._raise_for_status(response, self._parse_json(response, request.url)) + served = response.headers.get("content-type", "").split(";")[0].strip().lower() + if media_type is not None and served != media_type: + raise UnikraftCloudError( + f"Expected {media_type} from {request.url}, got {served or 'no media type'}", + kind="parse", + status=response.status_code, + ) async for chunk in response.aiter_bytes(chunk_size): yield chunk except httpx.HTTPError as cause: # The headers arrived, so `_send` let this through; the body can still # fail mid-stream, and that is a network error like any other. - raise self._network_error(request, cause) from cause + raise self._network_error(request, cause, status=response.status_code) from cause finally: # Close on early return or raise so the connection is not left # dangling mid-stream. @@ -646,7 +662,7 @@ async def stream( except httpx.HTTPError as cause: # The headers arrived, so `_send` let this through; the body can still # fail mid-stream, and that is a network error like any other. - raise self._network_error(request, cause) from cause + raise self._network_error(request, cause, status=response.status_code) from cause finally: # Close on early return or raise so the connection is not left # dangling mid-stream. diff --git a/src/unikraft_cloud/core/metro.py b/src/unikraft_cloud/core/metro.py index cb1719b..8619bfc 100644 --- a/src/unikraft_cloud/core/metro.py +++ b/src/unikraft_cloud/core/metro.py @@ -13,6 +13,7 @@ from .http import normalise_base_url __all__ = [ + "BUILD_BASE_URL", "CONTROLPLANE_BASE_URL", "DEFAULT_METRO", "KNOWN_METROS", @@ -57,6 +58,9 @@ #: global (not metro-scoped). CONTROLPLANE_BASE_URL = "https://controlplane.unikraft.cloud" +#: The build API base URL, global like the control plane's. +BUILD_BASE_URL = "https://build.unikraft.cloud" + _URL_SCHEME = re.compile(r"^https?://", re.IGNORECASE) diff --git a/src/unikraft_cloud/core/session.py b/src/unikraft_cloud/core/session.py index 327ad53..146f559 100644 --- a/src/unikraft_cloud/core/session.py +++ b/src/unikraft_cloud/core/session.py @@ -15,6 +15,7 @@ from .errors import UnikraftCloudError from .http import ApiClientConfig from .metro import ( + BUILD_BASE_URL, Metro, MetroEndpoint, MetroScope, @@ -46,6 +47,9 @@ class SessionConfig: #: there is nothing to discover, and fanning out would invent hostnames the #: caller never mentioned. pinned: MetroEndpoint | None = None + #: Transport config for the build API, which is global too. Omitted, the + #: platform's config pointed at the default build endpoint. + build: ApiClientConfig | None = None class Session: @@ -60,6 +64,8 @@ def __init__(self, config: SessionConfig) -> None: self.platform = config.platform #: Control-plane transport config (the control plane is global). self.control_plane = config.control_plane + #: Build API transport config (the build service is global). + self.build = config.build or config.platform.with_base_url(BUILD_BASE_URL) #: The single endpoint every scope collapses to, when one was named. self.pinned = config.pinned #: The metro named for this client, the target of an operation that must diff --git a/src/unikraft_cloud/resources/builds.py b/src/unikraft_cloud/resources/builds.py new file mode 100644 index 0000000..387738d --- /dev/null +++ b/src/unikraft_cloud/resources/builds.py @@ -0,0 +1,394 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Builds on the remote build service: a request names the sources, the +# Kraftfile and the outputs, the build runs on a builder in the caller's own +# account, and its progress can be followed from the start until it ends. The +# service is global, so no metro scope applies. Every source is a git +# repository or a tarball the builder fetches itself: a directory on this +# machine would need a buildkit session, which the service does not take yet. + +from __future__ import annotations + +import asyncio +import codecs +import contextlib +import json +from collections.abc import AsyncGenerator +from typing import Any +from urllib.parse import quote + +from pydantic import PrivateAttr, TypeAdapter, ValidationError + +from ..api.build import BuildsApi, models +from ..core.errors import UnikraftCloudError +from ..core.http import UNSET, TimeoutOption +from ..core.resource import check_spec +from ..core.session import Session + +__all__ = [ + "LOGS_RETRY_BUDGET", + "LOGS_RETRY_MAX", + "LOGS_RETRY_MIN", + "Build", + "Builds", + "git_source", + "image_output", + "kraftfile_from", + "url_source", +] + +#: The wait before the progress stream is asked for again, in seconds, which +#: doubles on every attempt in a row up to the cap and starts over once an +#: attempt brings a new event. +LOGS_RETRY_MIN = 0.25 +LOGS_RETRY_MAX = 5.0 +#: How many more times in a row the stream is asked for after an attempt got +#: no stream at all, because the service could not be reached or answered +#: with a 5xx, before :meth:`Builds.logs` gives up. +LOGS_RETRY_BUDGET = 5 + +# Named so tests can stand in for it and assert the backoff without waiting. +_sleep = asyncio.sleep + +_EVENT = TypeAdapter(models.BuildEvent) +_NDJSON = "application/x-ndjson" + + +def git_source( + url: str, ref: str | None = None, *, subdir: str | None = None, filename: str | None = None +) -> models.Source: + """A source that is the git repository at `url`, checked out at `ref`. + + `ref` is a branch, a tag or a commit; omitted, it is the default branch. + `subdir` narrows the source to a directory of the repository, and + `filename` points the Kraftfile's source at a file, which defaults to + ``Kraftfile``, or the ``dockerfile`` context at its Dockerfile. The + builder clones the repository anonymously. + """ + return models.Source(**_given(git=url, ref=ref, subdir=subdir, filename=filename)) + + +def url_source( + url: str, *, subdir: str | None = None, filename: str | None = None +) -> models.Source: + """A source that is the tarball at `url`, which the builder fetches and unpacks.""" + return models.Source(**_given(url=url, subdir=subdir, filename=filename)) + + +def kraftfile_from(source: models.Source) -> models.Kraftfile: + """A Kraftfile the service reads out of `source`, from its `filename`. + + Relative paths in the file resolve inside the build context, not inside + `source`. A `filename` is only valid on the Kraftfile's source and on the + ``dockerfile`` context; any other context that names one is refused. To + give the file inline instead, pass ``kraftfile=`` its YAML as a ``str``, + or the parsed file as a ``dict``. + """ + return source + + +def image_output(ref: str) -> models.BuildOutput: + """An output that pushes the built image to `ref`, tag included, with the caller's token.""" + return models.BuildOutput(image=ref) + + +def _given(**fields: str | None) -> dict[str, str]: + """The fields that were given, so a request carries no explicit nulls.""" + return {key: value for key, value in fields.items() if value is not None} + + +class Build(models.Build): + """A build, with the operations that take its ID.""" + + _builds: Builds = PrivateAttr() + + @classmethod + def _of(cls, builds: Builds, build: models.Build) -> Build: + found = cls.model_validate(build.model_dump(by_alias=True, exclude_unset=True)) + found._builds = builds + return found + + @property + def _ref(self) -> str: + if not self.id: + raise ValueError("This build has no ID, so nothing can be asked about it.") + return self.id + + async def refresh(self, *, timeout: TimeoutOption = UNSET) -> Build: + """The build as the service has it now.""" + return await self._builds.get(self._ref, timeout=timeout) + + def logs(self, *, timeout: TimeoutOption = UNSET) -> AsyncGenerator[models.BuildEvent, None]: + """Follow the build's progress, see :meth:`Builds.logs`.""" + return self._builds.logs(self._ref, timeout=timeout) + + def plain_logs(self, *, timeout: TimeoutOption = UNSET) -> AsyncGenerator[str, None]: + """Follow the build's progress as text, see :meth:`Builds.plain_logs`.""" + return self._builds.plain_logs(self._ref, timeout=timeout) + + async def cancel(self, *, timeout: TimeoutOption = UNSET) -> Build: + """Cancel the build, see :meth:`Builds.cancel`.""" + return await self._builds.cancel(self._ref, timeout=timeout) + + async def delete(self, *, timeout: TimeoutOption = UNSET) -> None: + """Delete the finished build, see :meth:`Builds.delete`.""" + await self._builds.delete(self._ref, timeout=timeout) + + +class Builds: + """Builds on the build service, as exposed by ``ukc.builds``. + + The service is global, so ``builds`` hangs off ``ukc`` alone and not off + a metro scope. Every source is a git repository or a tarball the builder + fetches itself; a directory on this machine cannot be sent. + + .. code-block:: python + + from unikraft_cloud import git_source, image_output, kraftfile_from + + src = git_source("https://github.com/acme/app.git", "main", subdir="app") + build = await ukc.builds.create( + kraftfile=kraftfile_from(src), + contexts={"context": src}, + output=[image_output("index.unikraft.io/acme/app:1.2.3")], + ) + async for line in build.plain_logs(): + print(line, end="") + build = await build.refresh() + """ + + def __init__(self, session: Session) -> None: + #: The generated plumbing client every operation goes through. + self.api = BuildsApi(session.build) + + async def create(self, *, timeout: TimeoutOption = UNSET, **spec: Any) -> Build: + """Create a build from the properties the API describes; it runs whether or not followed. + + A property the API does not describe is a ``TypeError``. The service + refuses a request that uses a part of the API it does not support yet + with the code ``unsupported``, and a tenant with as many builds + running or waiting as it may with ``too_many_builds``; the code is + ``err.errors[0].code``. + """ + check_spec(spec, models.CreateBuildRequest, "build") + request = models.CreateBuildRequest.model_validate(spec) + return Build._of(self, await self.api.create_build(body=request, timeout=timeout)) + + async def get(self, id: str, *, timeout: TimeoutOption = UNSET) -> Build: + """A build by ID: its state, and its result or error once it has ended.""" + _check(id) + return Build._of(self, await self.api.get_build(id, timeout=timeout)) + + async def list( + self, + *, + status: models.BuildStatus | None = None, + limit: int | None = None, + timeout: TimeoutOption = UNSET, + ) -> list[Build]: + """The caller's builds, newest first, only those in `status` if given.""" + listed = await self.api.list_builds(status=status, limit=limit, timeout=timeout) + return [Build._of(self, build) for build in listed.builds or []] + + async def cancel(self, id: str, *, timeout: TimeoutOption = UNSET) -> Build: + """Cancel a build by ID. + + A waiting or running build becomes ``cancelled`` once buildkit has + unwound, with its progress ending in an error of that code; a build + that has already ended is left as it is. + """ + _check(id) + return Build._of(self, await self.api.cancel_build(id, timeout=timeout)) + + async def delete(self, id: str, *, timeout: TimeoutOption = UNSET) -> None: + """Delete an ended build by ID: its record and its progress. + + The images it pushed are left alone. A build still waiting or running + is refused with a 409, the code ``conflict``: cancel it first. + """ + _check(id) + await self.api.delete_build(id, timeout=timeout) + + async def logs( + self, id: str, *, timeout: TimeoutOption = UNSET + ) -> AsyncGenerator[models.BuildEvent, None]: + """Follow the progress of a build by ID, every event once, in order. + + The stream ends with the event that says how the build went: its + ``result``, or its ``error``. The service replays the stream from the + start on every request, so a stream cut before that event -- by the + connection, by something between the service and the caller, or by + `timeout` running out between two events -- is asked for again after + a short and growing wait, and the events already yielded are skipped. + A request that gets no stream from the service -- it answers with a + 5xx, or cannot be reached once it has answered before -- is made + again up to five more times in a row, then given up on with the last + error. A request the service refuses (a 4xx, such as a build that + does not exist), an answer that is not a progress stream, an event + that does not parse, or a service that has not answered at all yet, + such as a wrong host, is raised at once. A failed build is not an + error here but the last event, whose ``error`` is set. + + `timeout` bounds each wait for more of the stream, not the whole + follow: a build quiet for longer is asked for again, which replays it. + + Close the iterator to stop early and release the connection at once, + with ``contextlib.aclosing`` or its ``aclose()``. + """ + _check(id) + seen = 0 # the lines yielded so far, which a replay skips + answered = False # the service has answered once, so it is there + failed = 0 # the attempts in a row that got no stream at all + idle = 0 # the attempts in a row that brought no new event + while True: + yielded = 0 + try: + async with contextlib.aclosing(self._follow(id, seen, timeout)) as events: + async for event in events: + yielded += 1 + yield event + if _done(event): + return + # The stream ended before its last event. + answered, no_stream = True, False + except _CutError: + answered, no_stream = True, False + except UnikraftCloudError as err: + if err.status is not None and err.status >= 500: + answered = True # The service is there, and could not answer. + elif err.kind == "network" and answered and err.__cause__ is not None: + pass # No answer from a service that answered before. + else: + # Refused, even by an answer that broke off; not a stream; + # or a service that never answered. A closed client has no + # cause, and is not coming back. + raise + no_stream = True + failed += 1 + if failed > LOGS_RETRY_BUDGET: + raise type(err)( + f"Giving up on the progress stream after {failed} attempts: {err}", + kind=err.kind, + status=err.status, + errors=err.errors, + body=err.body, + ) from err + seen += yielded + if not no_stream: + failed = 0 + if yielded > 0: + idle = 0 + wait = min(LOGS_RETRY_MIN * 2**idle, LOGS_RETRY_MAX) + if wait < LOGS_RETRY_MAX: + idle += 1 + await _sleep(wait) + + async def _follow( + self, id: str, seen: int, timeout: TimeoutOption + ) -> AsyncGenerator[models.BuildEvent, None]: + """Request the stream once, skipping its first `seen` lines. + + A last event is never skipped: a service that restarted replays a + shorter stream that ends in an error. A stream that breaks off or + ends mid-line once the service has answered was cut, and raises + :class:`_CutError`. + """ + chunks = self.api.stream_bytes( + method="GET", + path=f"/v1/builds/{quote(id, safe='')}/logs", + headers={"accept": _NDJSON}, + timeout=timeout, + media_type=_NDJSON, + ) + buffer = b"" + line = 0 + try: + async with contextlib.aclosing(chunks): + async for chunk in chunks: + buffer += chunk + while (end := buffer.find(b"\n")) >= 0: + raw, buffer = buffer[:end].strip(), buffer[end + 1 :] + if not raw: + continue + line += 1 + event = _parse(raw, line) + if line <= seen and not _done(event): + continue + yield event + if _done(event): + return + except UnikraftCloudError as err: + # A network error that carries a status failed in the body of a + # stream the service had started; one without never got that far. + if err.kind == "network" and err.status is not None and err.status < 300: + raise _CutError from err + raise + if buffer.strip(): + raise _CutError + + async def plain_logs( + self, id: str, *, timeout: TimeoutOption = UNSET + ) -> AsyncGenerator[str, None]: + """Follow the progress of a build by ID as text, as the service renders it. + + The text is the one ``docker buildx build --progress=plain`` prints, + then a ``pushed @`` line per image the build pushed, or + an ``error: ...`` line when it failed. The service renders the text + again from the start on every request, so unlike :meth:`logs` this + asks only once: a stream the service breaks off on its side ends the + text with ``error: the stream ended before the build did: ask for it + again``, and one cut on the way, or by `timeout` running out while + waiting for more, is raised. Either way the build goes on, and :meth:`get` + says whether it still runs. + """ + _check(id) + chunks = self.api.stream_bytes( + method="GET", + path=f"/v1/builds/{quote(id, safe='')}/logs", + headers={"accept": "text/plain"}, + timeout=timeout, + media_type="text/plain", + ) + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + async with contextlib.aclosing(chunks): + async for chunk in chunks: + if text := decoder.decode(chunk): + yield text + if text := decoder.decode(b"", final=True): + yield text + + +class _CutError(Exception): + """The progress stream broke off or ended mid-line: it was cut.""" + + +def _check(id: str) -> None: + """Refuse an ID the request path would resolve away, or that would add a segment.""" + if id in ("", ".", "..") or "/" in id: + raise ValueError(f"A build ID is one segment of a path; got {id!r}.") + + +def _done(event: models.BuildEvent) -> bool: + return event.result is not None or event.error is not None + + +def _parse(raw: bytes, line: int) -> models.BuildEvent: + """Decode one line of the stream, dropping the nulls buildkit leaves in its lists.""" + try: + return _EVENT.validate_python(_without_nulls(json.loads(raw))) + except (ValueError, ValidationError) as cause: + raise UnikraftCloudError( + f"Line {line} of the progress stream did not match the specification: {cause}", + kind="parse", + body=raw, + ) from cause + + +def _without_nulls(data: Any) -> Any: + """`data` with the nulls dropped from every list in it, however deep.""" + if isinstance(data, list): + return [_without_nulls(item) for item in data if item is not None] + if isinstance(data, dict): + return {key: _without_nulls(value) for key, value in data.items()} + return data diff --git a/templates/resources.tmpl b/templates/resources.tmpl index c663f34..f34c6cc 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -3,9 +3,12 @@ platform and control-plane packages sit at api//, which the default fits; a package elsewhere passes `-v core=`. */ -}} {{- $core := .Var "core" "...core" -}} +{{- /* The operations named in `skip`, separated by colons, are left to + hand-written code: a stream the templates cannot return. */ -}} +{{- $skip := splitList ":" (.Var "skip" "") -}} {{- /* A class is rendered per tag, so an operation without one would be dropped from every class in silence. */ -}} -{{- range $op := $ops }}{{ if not $op.Operation.Tags }} +{{- range $op := $ops }}{{ if has $op.Operation.OperationID $skip }}{{ continue }}{{ end }}{{ if not $op.Operation.Tags }} {{- fail (printf "%s %s has no tag, so no generated class can expose it" $op.Method $op.Path) }} {{- end }}{{ end -}} {{- /* A tag names a module and a class, so two tags that normalise alike would @@ -34,7 +37,7 @@ {{- $names = append $names $key -}} {{- end -}} {{- $methods := list -}} -{{- range $op := $ops -}} +{{- range $op := $ops -}}{{ if has $op.Operation.OperationID $skip }}{{ continue }}{{ end }} {{- if has $tag $op.Operation.Tags -}} {{- $method := pySafeName (snakecase $op.Operation.OperationID) -}} {{- if not (regexMatch "^[A-Za-z_][A-Za-z0-9_]*$" $method) -}} @@ -67,7 +70,7 @@ {{- $text := false }} {{- /* Every type the methods render, to find Any and Literal in. */ -}} {{- $types := "" }} -{{- range $op := $ops }} +{{- range $op := $ops }}{{ if has $op.Operation.OperationID $skip }}{{ continue }}{{ end }} {{- if has $tag $op.Operation.Tags }} {{- if pathParameters $op }}{{ $quoted = true }}{{ end }} {{- range pathParameters $op }}{{ if and .Schema (not (.Schema.Value.Type.Is "string")) }}{{ $text = true }}{{ end }}{{ $types = printf "%s %s" $types (paramToPyType .) }}{{ end }} @@ -89,7 +92,7 @@ {{- $literal := regexMatch "\\bLiteral\\b" $types }} {{- /* Likewise the marker for lists sent as one comma-separated parameter. */ -}} {{- $csv := false }} -{{- range $op := $ops }} +{{- range $op := $ops }}{{ if has $op.Operation.OperationID $skip }}{{ continue }}{{ end }} {{- if has $tag $op.Operation.Tags }} {{- range queryParameters $op }} {{- if not .Schema }} @@ -161,7 +164,7 @@ class {{ pascalcase $tag }}Api(ApiClient): Methods mirror the OpenAPI specification and return the raw response envelope. The idiomatic clients are built on top of this. """ -{{- range $op := $ops }} +{{- range $op := $ops }}{{ if has $op.Operation.OperationID $skip }}{{ continue }}{{ end }} {{- if has $tag $op.Operation.Tags }} {{ template "method" $op }} {{- end }} diff --git a/tests/conftest.py b/tests/conftest.py index ea49e3d..cc2859e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,6 +39,7 @@ def _no_ambient_config(monkeypatch: pytest.MonkeyPatch) -> None: """ monkeypatch.delenv("UKC_TOKEN", raising=False) monkeypatch.delenv("UKC_METRO", raising=False) + monkeypatch.delenv("UKC_BUILD_SERVER", raising=False) #: A handler answering one request, at once or after awaiting something. diff --git a/tests/test_builds.py b/tests/test_builds.py new file mode 100644 index 0000000..f8193f4 --- /dev/null +++ b/tests/test_builds.py @@ -0,0 +1,453 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# The build client: what a build request carries, how the service's refusals +# read, and how the progress stream is followed across cuts and replays. + +from __future__ import annotations + +import contextlib +import json +from collections.abc import AsyncIterator, Sequence +from dataclasses import dataclass, field +from typing import Any + +import httpx +import pytest + +from unikraft_cloud import ( + NotFoundError, + ServerError, + UnikraftCloud, + UnikraftCloudError, + git_source, + image_output, + kraftfile_from, +) +from unikraft_cloud.api.build import models +from unikraft_cloud.resources import builds as builds_module + +from .conftest import Recorder, queued + +BUILD_URL = "https://b.example.com" +ID = "0b9c6f1e-6a52-4d1f-9a43-2f0d9b7f5c11" + +LINE_KERNEL = '{"vertexes":[null,{"digest":"sha256:1","name":"[kernel] pull"}]}' +LINE_ROOTFS = '{"vertexes":[{"digest":"sha256:2","name":"[rootfs 1/1] RUN npm ci"}]}' +LINE_LOG = ( + '{"logs":[{"vertex":"sha256:2","stream":2,"data":"aGk=","timestamp":"2026-10-20T12:00:00Z"}]}' +) +LINE_WARNING = '{"warnings":[{"vertex":"sha256:2","level":1,"detail":[null,"ZGV0YWls"]}]}' +LINE_RESULT = ( + '{"result":{"images":[{"ref":"index.unikraft.io/acme/app:1","digest":"sha256:4c1a",' + '"platforms":["linux/amd64"]}]}}' +) +LINE_ERROR = '{"error":{"code":"interrupted","message":"the service restarted"}}' + +SRC = git_source("https://github.com/acme/app.git", "main", subdir="app") +UNAVAILABLE = (503, {"error": {"code": "internal", "message": "shutting down"}}) + + +def client(recorder: Recorder) -> UnikraftCloud: + return UnikraftCloud(token="tok", transport=recorder.transport, build_url=BUILD_URL) + + +def build(status: str = "running") -> dict[str, Any]: + return { + "id": ID, + "status": status, + "builder": "default", + "created_at": "2026-10-20T12:00:00Z", + } + + +def spec(**overrides: Any) -> dict[str, Any]: + """The properties of a build of the example app, as `create` takes them.""" + return { + "kraftfile": kraftfile_from(SRC), + "contexts": {"context": SRC}, + "output": [image_output("index.unikraft.io/acme/app:1.2.3")], + **overrides, + } + + +@dataclass +class Stream: + """One answer to a request for the progress stream: its lines, cut short or not.""" + + lines: Sequence[str] + cut: bool = False + media: str = "application/x-ndjson" + status: int = 200 + chunks: list[bytes] = field(init=False) + closed: bool = field(default=False, init=False) + + def __post_init__(self) -> None: + self.chunks = [f"{line}\n".encode() for line in self.lines] + + +class _Body(httpx.AsyncByteStream): + def __init__(self, answer: Stream) -> None: + self.answer = answer + + async def __aiter__(self) -> AsyncIterator[bytes]: + for chunk in self.answer.chunks: + yield chunk + if self.answer.cut: + raise httpx.ReadError("connection reset mid-body") + + async def aclose(self) -> None: + self.answer.closed = True + + +def progress(answers: Sequence[Any]) -> Recorder: + """A transport answering each request in turn: a stream, a (status, body) pair, or an error. + + A request past the last answer fails the test, so a follower that asks once + too often is caught rather than left looping while the waits cost nothing. + """ + remaining = list(answers) + + def handler(request: httpx.Request) -> httpx.Response: + assert remaining, "the progress stream was asked for once too often" + answer = remaining.pop(0) + if isinstance(answer, Exception): + raise answer + if isinstance(answer, tuple): + return httpx.Response(answer[0], json=answer[1]) + return httpx.Response( + answer.status, stream=_Body(answer), headers={"content-type": answer.media} + ) + + return Recorder(handler) + + +@pytest.fixture +def sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]: + """The waits between attempts, recorded instead of slept.""" + waited: list[float] = [] + + async def record(seconds: float) -> None: + waited.append(seconds) + + monkeypatch.setattr(builds_module, "_sleep", record) + return waited + + +async def events(ukc: UnikraftCloud) -> list[models.BuildEvent]: + async with contextlib.aclosing(ukc.builds.logs(ID)) as logs: + return [event async for event in logs] + + +class TestBuilds: + async def test_create_sends_the_request_and_returns_the_build(self) -> None: + recorder = queued([(201, build())]) + async with client(recorder) as ukc: + created = await ukc.builds.create(**spec()) + + sent = recorder.calls[0] + assert (sent.method, str(sent.url)) == ("POST", f"{BUILD_URL}/v1/builds") + # Only what was given goes on the wire: no nulls for the fields left out. + assert json.loads(sent.content) == { + "kraftfile": {"git": "https://github.com/acme/app.git", "ref": "main", "subdir": "app"}, + "contexts": { + "context": { + "git": "https://github.com/acme/app.git", + "ref": "main", + "subdir": "app", + } + }, + "output": [{"image": "index.unikraft.io/acme/app:1.2.3"}], + } + assert (created.id, created.status) == (ID, "running") + + async def test_create_refuses_a_property_the_api_does_not_describe(self) -> None: + recorder = queued([(201, build())]) + async with client(recorder) as ukc: + with pytest.raises(TypeError, match="'kraftfil' is not one of the properties"): + await ukc.builds.create(**spec(kraftfil="runtime: nginx\n")) + assert recorder.calls == [] + + async def test_an_inline_kraftfile_goes_as_given(self) -> None: + recorder = queued([(201, build()), (201, build())]) + async with client(recorder) as ukc: + await ukc.builds.create(**spec(kraftfile="spec: v0.6\nruntime: nginx\n")) + await ukc.builds.create(**spec(kraftfile={"spec": "v0.6", "runtime": "nginx"})) + assert json.loads(recorder.calls[0].content)["kraftfile"] == "spec: v0.6\nruntime: nginx\n" + assert json.loads(recorder.calls[1].content)["kraftfile"] == { + "spec": "v0.6", + "runtime": "nginx", + } + + async def test_the_build_url_falls_back_to_the_environment( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UKC_BUILD_SERVER", "https://staging.example.com/v1") + # No builds is a null, the service's nil slice, as well as an empty list. + recorder = queued([(200, {"builds": None})]) + async with UnikraftCloud(token="tok", transport=recorder.transport) as ukc: + assert await ukc.builds.list() == [] + await ukc.api.build.builds.list_builds() + await ukc.metro("fra").api.build.builds.list_builds() + assert recorder.urls == ["https://staging.example.com/v1/builds"] * 3 + + async def test_an_argument_beats_the_environment_and_the_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UKC_BUILD_SERVER", "https://staging.example.com") + recorder = queued([(200, {"builds": []})]) + async with client(recorder) as ukc: + await ukc.builds.list() + monkeypatch.delenv("UKC_BUILD_SERVER") + async with UnikraftCloud(token="tok", transport=recorder.transport) as ukc: + await ukc.builds.list() + assert recorder.urls == [f"{BUILD_URL}/v1/builds", "https://build.unikraft.cloud/v1/builds"] + + async def test_the_build_url_must_be_a_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + # A bare host is not a metro code to expand, as the platform's would be. + with pytest.raises(ValueError, match="build_url must be an http"): + UnikraftCloud(token="tok", build_url="build.staging.example.com") + monkeypatch.setenv("UKC_BUILD_SERVER", "localhost:8080") + with pytest.raises(ValueError, match="'localhost:8080'"): + UnikraftCloud(token="tok") + + async def test_a_refusal_carries_the_services_message_and_code(self) -> None: + recorder = queued( + [(400, {"error": {"code": "unsupported", "message": "unsupported field: builder"}})] + ) + async with client(recorder) as ukc: + with pytest.raises(UnikraftCloudError) as refused: + await ukc.builds.create(**spec()) + + assert str(refused.value) == "unsupported field: builder" + assert refused.value.errors is not None + assert refused.value.errors[0].code == "unsupported" + + async def test_a_missing_build_is_not_known_to_be_absent(self) -> None: + # The service says `not_found` for a route that is not there too, so + # a 404 cannot vouch that the build is absent. + recorder = queued([(404, {"error": {"code": "not_found", "message": f"no build {ID}"}})]) + async with client(recorder) as ukc: + with pytest.raises(NotFoundError) as missing: + await ukc.builds.get(ID) + assert missing.value.absent is False + + async def test_the_operations_take_the_builds_id(self) -> None: + recorder = queued( + [(200, {"builds": [build()]}), (202, build()), (200, build("cancelled")), (204, None)] + ) + async with client(recorder) as ukc: + [found] = await ukc.builds.list(status="running", limit=1) + await found.cancel() + refreshed = await found.refresh() + await refreshed.delete() + + assert refreshed.status == "cancelled" + assert recorder.urls[0] == f"{BUILD_URL}/v1/builds?status=running&limit=1" + assert [(c.method, c.url.path) for c in recorder.calls[1:]] == [ + ("POST", f"/v1/builds/{ID}/cancel"), + ("GET", f"/v1/builds/{ID}"), + ("DELETE", f"/v1/builds/{ID}"), + ] + + async def test_an_id_that_is_not_one_path_segment_is_refused_before_sending(self) -> None: + recorder = queued([(200, {"builds": [{"status": "running"}]})]) + async with client(recorder) as ukc: + [unnamed] = await ukc.builds.list() + with pytest.raises(ValueError, match="no ID"): + await unnamed.refresh() + for bad in ("", ".", "..", f"{ID}/logs"): + for op in (ukc.builds.get, ukc.builds.cancel, ukc.builds.delete): + with pytest.raises(ValueError, match="one segment of a path"): + await op(bad) + for follow in (ukc.builds.logs, ukc.builds.plain_logs): + with pytest.raises(ValueError, match="one segment of a path"): + await anext(follow(bad)) + assert len(recorder.calls) == 1 + + +class TestLogs: + async def test_yields_every_event_once_and_stops_at_the_last(self) -> None: + recorder = progress( + [Stream([LINE_KERNEL, "", LINE_LOG, LINE_WARNING, LINE_RESULT, LINE_ROOTFS])] + ) + async with client(recorder) as ukc: + got = await events(ukc) + + assert recorder.calls[0].headers["accept"] == "application/x-ndjson" + assert recorder.paths == [f"/v1/builds/{ID}/logs"] + # Four events: the empty line is not one, and nothing follows the last. + assert len(got) == 4 + # buildkit's nulls are dropped rather than refused, however deep. + assert got[2].warnings is not None and got[2].warnings[0].detail == ["ZGV0YWls"] + + async def test_a_cut_stream_is_asked_again_and_the_replay_skipped( + self, sleeps: list[float] + ) -> None: + cut_mid_line = Stream([LINE_KERNEL, LINE_ROOTFS, LINE_LOG]) + cut_mid_line.chunks.append(b'{"vertexes":[{"dig') + recorder = progress( + [ + Stream([LINE_KERNEL, LINE_ROOTFS], cut=True), + # The replay has it all, plus a line cut mid-way through. + cut_mid_line, + Stream([LINE_KERNEL, LINE_ROOTFS, LINE_LOG, LINE_RESULT]), + ] + ) + async with client(recorder) as ukc: + got = await events(ukc) + + assert [bool(e.vertexes) for e in got] == [True, True, False, False] + # A wait before each replay, at the shortest: every attempt brought an event. + assert sleeps == [0.25, 0.25] + + async def test_a_shorter_replay_that_ends_in_an_error_is_not_skipped( + self, sleeps: list[float] + ) -> None: + recorder = progress( + [ + Stream([LINE_KERNEL, LINE_ROOTFS, LINE_LOG]), + Stream([LINE_ERROR]), + ] + ) + async with client(recorder) as ukc: + got = await events(ukc) + + assert len(got) == 4 + assert got[3].error is not None and got[3].error.code == "interrupted" + + async def test_the_wait_between_idle_replays_grows_to_a_cap(self, sleeps: list[float]) -> None: + recorder = progress([Stream([LINE_KERNEL])] * 8 + [Stream([LINE_KERNEL, LINE_RESULT])]) + async with client(recorder) as ukc: + await events(ukc) + + assert sleeps == [0.25, 0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0] + + async def test_a_service_that_cannot_answer_is_given_up_on(self, sleeps: list[float]) -> None: + recorder = progress([UNAVAILABLE] * 6) + async with client(recorder) as ukc: + with pytest.raises(ServerError) as given_up: + await events(ukc) + + assert "shutting down" in str(given_up.value) + assert isinstance(given_up.value.__cause__, ServerError) + assert sleeps == [0.25, 0.5, 1.0, 2.0, 4.0] + + async def test_the_budget_is_for_attempts_in_a_row(self, sleeps: list[float]) -> None: + recorder = progress( + [ + # A 5xx whose body broke off is as much an answer the service could not give, + # and a service that answered before and cannot be reached now is asked again. + Stream([], cut=True, status=503, media="application/json"), + httpx.ConnectError("connection refused"), + *[UNAVAILABLE] * 3, + # A stream, cut, starts the count over. + Stream([LINE_KERNEL], cut=True), + *[UNAVAILABLE] * 5, + Stream([LINE_KERNEL, LINE_ROOTFS, LINE_RESULT]), + ] + ) + async with client(recorder) as ukc: + await events(ukc) + + async def test_not_reaching_a_service_that_answered_spends_the_budget( + self, sleeps: list[float] + ) -> None: + unreachable = httpx.ConnectError("connection refused") + recorder = progress([Stream([LINE_KERNEL], cut=True), *[unreachable] * 6]) + async with client(recorder) as ukc: + with pytest.raises(UnikraftCloudError) as given_up: + await events(ukc) + assert given_up.value.kind == "network" + assert len(recorder.calls) == 7 + + async def test_a_service_that_never_answered_is_raised_at_once(self) -> None: + recorder = progress([httpx.ConnectError("connection refused")]) + async with client(recorder) as ukc: + with pytest.raises(UnikraftCloudError) as unreachable: + await events(ukc) + assert unreachable.value.kind == "network" + + async def test_a_refusal_is_raised_at_once(self) -> None: + recorder = progress([(404, {"error": {"code": "not_found", "message": f"no build {ID}"}})]) + async with client(recorder) as ukc: + with pytest.raises(NotFoundError): + await events(ukc) + + async def test_a_refusal_that_broke_off_is_raised_at_once(self, sleeps: list[float]) -> None: + # A refusal whose body was cut is still a refusal, not a stream cut + # short: even once the service has answered, it is not asked again. + recorder = progress( + [ + Stream([LINE_KERNEL], cut=True), + Stream([], cut=True, status=404, media="application/json"), + ] + ) + async with client(recorder) as ukc: + with pytest.raises(NotFoundError): + await events(ukc) + + async def test_a_closed_client_is_not_asked_again( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + recorder = progress([Stream([LINE_KERNEL], cut=True)]) + ukc = client(recorder) + # The wait before the replay closes the client, as a shutdown would. + monkeypatch.setattr(builds_module, "_sleep", lambda _: ukc.aclose()) + with pytest.raises(UnikraftCloudError, match=r"^This client is closed"): + await events(ukc) + + async def test_an_answer_that_is_not_a_stream_is_raised_at_once(self) -> None: + recorder = progress([(200, {"builds": []})]) + async with client(recorder) as ukc: + with pytest.raises(UnikraftCloudError) as wrong: + await events(ukc) + assert wrong.value.kind == "parse" + assert "got application/json" in str(wrong.value) + + async def test_a_line_that_does_not_parse_is_a_parse_error(self) -> None: + recorder = progress([Stream([LINE_KERNEL, '{"vertexes": "not a list"}'])]) + async with client(recorder) as ukc: + with pytest.raises(UnikraftCloudError) as bad: + await events(ukc) + assert bad.value.kind == "parse" + assert "Line 2" in str(bad.value) + + async def test_closing_early_releases_the_connection(self) -> None: + answer = Stream([LINE_KERNEL, LINE_ROOTFS, LINE_LOG]) + recorder = progress([answer]) + async with client(recorder) as ukc: + logs = ukc.builds.logs(ID, timeout=1.5) + async for _ in logs: + break + await logs.aclose() + assert answer.closed is True + assert recorder.calls[0].extensions["timeout"]["read"] == 1.5 + + +class TestPlainLogs: + async def test_yields_the_text_as_it_arrives(self) -> None: + answer = Stream([], media="text/plain; charset=utf-8") + # A character split across two chunks still comes out whole. + answer.chunks = [b"#1 [kernel] pull\n#1 DONE 0.3s \xe2", b"\x9c\x93\n"] + recorder = progress([answer]) + async with ( + client(recorder) as ukc, + contextlib.aclosing(ukc.builds.plain_logs(ID, timeout=1.5)) as lines, + ): + got = [text async for text in lines] + + assert recorder.calls[0].headers["accept"] == "text/plain" + assert recorder.calls[0].extensions["timeout"]["read"] == 1.5 + assert "".join(got) == "#1 [kernel] pull\n#1 DONE 0.3s ✓\n" + + async def test_a_cut_is_raised_and_not_asked_again(self) -> None: + recorder = progress([Stream(["#1 [kernel] pull"], cut=True, media="text/plain")]) + got: list[str] = [] + async with client(recorder) as ukc: + with pytest.raises(UnikraftCloudError): + async with contextlib.aclosing(ukc.builds.plain_logs(ID)) as lines: + async for text in lines: + got.append(text) + + assert got == ["#1 [kernel] pull\n"] diff --git a/tests/test_transport.py b/tests/test_transport.py index 9e9786e..bfe771c 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -49,6 +49,17 @@ class Page(BaseModel): name: str | None = None +class Broken(httpx.AsyncByteStream): + """A response body the connection resets after its first bytes.""" + + def __init__(self, head: bytes) -> None: + self.head = head + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield self.head + raise httpx.ReadError("connection reset") + + class Client(ApiClient): """A stand-in for a generated client, exercising the transport directly.""" @@ -411,14 +422,9 @@ async def test_a_streamed_failure_raises_before_the_first_chunk(self) -> None: [chunk async for chunk in api.chunks()] async def test_a_stream_that_breaks_mid_body_is_a_network_error(self) -> None: - class Breaks(httpx.AsyncByteStream): - async def __aiter__(self) -> AsyncIterator[bytes]: - yield b"he" - raise httpx.ReadError("connection reset mid-body") - recorder = Recorder( lambda request: httpx.Response( - 200, stream=Breaks(), headers={"content-type": "application/octet-stream"} + 200, stream=Broken(b"he"), headers={"content-type": "application/octet-stream"} ) ) async with client(recorder) as api: @@ -513,14 +519,11 @@ async def test_a_redirected_stream_raises_before_yielding(self) -> None: assert caught.value.status == 302 async def test_a_stream_that_breaks_mid_body_is_a_network_error(self) -> None: - class Broken(httpx.AsyncByteStream): - async def __aiter__(self) -> Any: - yield b'data: {"n": 1}\n\n' - raise httpx.ReadError("connection reset") - recorder = Recorder( lambda request: httpx.Response( - 200, stream=Broken(), headers={"content-type": "text/event-stream"} + 200, + stream=Broken(b'data: {"n": 1}\n\n'), + headers={"content-type": "text/event-stream"}, ) ) async with client(recorder) as api: @@ -529,7 +532,16 @@ async def __aiter__(self) -> Any: async for event in api.events(): seen.append(event) assert seen == [{"n": 1}] - assert caught.value.kind == "network" + # The headers had arrived, so the status says the stream itself broke. + assert (caught.value.kind, caught.value.status) == ("network", 200) + assert "connection reset" in str(caught.value) + + async def test_a_refusal_whose_body_breaks_off_is_still_the_refusal(self) -> None: + recorder = Recorder(lambda request: httpx.Response(403, stream=Broken(b'{"status": "err'))) + async with client(recorder) as api: + with pytest.raises(AuthenticationError) as caught: + [event async for event in api.events()] + assert caught.value.status == 403 assert "connection reset" in str(caught.value) async def test_breaking_early_releases_the_stream(self) -> None: @@ -601,6 +613,7 @@ async def test_both_surfaces_share_one_pool_by_default(self) -> None: ) api = Api(config, config.with_base_url("https://cp.example")) assert api.platform.instances.http is api.controlplane.metros.http + assert api.platform.instances.http is api.build.builds.http await api.aclose() assert api.platform.instances.http.is_closed