docker-devkit 0.2.1.dev37485576216__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docker_devkit-0.3.0/.github/workflows/integrate.yml +24 -0
- docker_devkit-0.3.0/.github/workflows/merge-gate.yml +18 -0
- docker_devkit-0.3.0/.github/workflows/release.yml +34 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/AGENTS.md +7 -7
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/PKG-INFO +2 -2
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/pyproject.toml +3 -4
- docker_devkit-0.3.0/release-devkit.yaml +6 -0
- docker_devkit-0.3.0/scripts/preflight.py +3 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/build_docker.py +52 -52
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/image_refs.py +2 -12
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/matrix.py +31 -14
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/tests/test_build_docker.py +16 -18
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/tests/test_matrix.py +57 -6
- docker_devkit-0.3.0/uv.lock +524 -0
- docker_devkit-0.2.1.dev37485576216/.github/actions/setup-release-devkit/action.yml +0 -11
- docker_devkit-0.2.1.dev37485576216/.github/workflows/integrate.yml +0 -56
- docker_devkit-0.2.1.dev37485576216/.github/workflows/merge-gate.yml +0 -41
- docker_devkit-0.2.1.dev37485576216/.github/workflows/release.yml +0 -58
- docker_devkit-0.2.1.dev37485576216/release-devkit.yaml +0 -7
- docker_devkit-0.2.1.dev37485576216/uv.lock +0 -494
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/.gitattributes +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/.gitignore +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/.python-version +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/CLAUDE.md +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/LICENSE +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/NOTICE +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/README.md +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/ruff.base.toml +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/ruff.toml +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/__init__.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/config.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/context_sha.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/detect_gpu.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/documents.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/down.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/lifecycle.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/mirror.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/modes.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/py.typed +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/score_generate.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/up.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/tests/test_config.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/tests/test_context_sha.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/tests/test_default_targets.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/tests/test_documents.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/tests/test_image_refs.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/tests/test_lifecycle.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/tests/test_mirror.py +0 -0
- {docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/tests/test_score_generate.py +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Integrate
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
pull_request:
|
|
6
|
+
branches: [dev]
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: integrate-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
preflight:
|
|
17
|
+
uses: outernet-foundation/github-actions-toolkit/.github/workflows/preflight.yml@66df38b08b8209b587de1c140b720d835ed8a3c3
|
|
18
|
+
|
|
19
|
+
update-pr-draft-release:
|
|
20
|
+
needs: [preflight]
|
|
21
|
+
permissions:
|
|
22
|
+
contents: write
|
|
23
|
+
packages: read
|
|
24
|
+
uses: outernet-foundation/github-actions-toolkit/.github/workflows/update-pr-draft-release.yml@66df38b08b8209b587de1c140b720d835ed8a3c3
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Merge gate
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [dev]
|
|
6
|
+
types: [labeled]
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: merge-gate-${{ github.event.pull_request.number }}
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
merge-gate:
|
|
16
|
+
uses: outernet-foundation/github-actions-toolkit/.github/workflows/merge-gate.yml@66df38b08b8209b587de1c140b720d835ed8a3c3
|
|
17
|
+
secrets:
|
|
18
|
+
merge-bot-private-key: ${{ secrets.MERGE_BOT_APP_PRIVATE_KEY }}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, dev]
|
|
6
|
+
|
|
7
|
+
concurrency:
|
|
8
|
+
group: release-${{ github.ref }}
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
prerelease:
|
|
15
|
+
if: github.ref == 'refs/heads/dev'
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
contents: write
|
|
19
|
+
id-token: write
|
|
20
|
+
steps:
|
|
21
|
+
- uses: outernet-foundation/github-actions-toolkit/.github/actions/release@66df38b08b8209b587de1c140b720d835ed8a3c3
|
|
22
|
+
with:
|
|
23
|
+
channel: dev
|
|
24
|
+
|
|
25
|
+
release:
|
|
26
|
+
if: github.ref == 'refs/heads/main'
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
permissions:
|
|
29
|
+
contents: write
|
|
30
|
+
id-token: write
|
|
31
|
+
steps:
|
|
32
|
+
- uses: outernet-foundation/github-actions-toolkit/.github/actions/release@66df38b08b8209b587de1c140b720d835ed8a3c3
|
|
33
|
+
with:
|
|
34
|
+
channel: stable
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
`docker-devkit` owns the Docker-stack lifecycle commands — `up`, `down`, `build`, `mirror`, `generate-score`, `docker-build-matrix` — plus the helpers they share (`detect_gpu`, `modes`, `context_sha`). A small, generic package that any repo shipping a compose graph can depend on to get the same bring-up/tear-down / cross-build flow, with the compose assembly declared per consumer in the `lifecycle:` section of `docker-devkit.yaml`. The PyPI identity starts its own version tags at `0.1.0`; the terminal `stack-toolkit` distributions (≤0.1.1) are deprecation signposts pointing here, not this package's history.
|
|
6
6
|
|
|
7
|
-
The package is `docker_devkit` (src-layout under `src/docker_devkit/`); all dependencies resolve from PyPI — `bashrun` (shell-outs), `
|
|
7
|
+
The package is `docker_devkit` (src-layout under `src/docker_devkit/`); all dependencies resolve from PyPI — `bashrun` (shell-outs), `build-artifact-registry` (builds-shelf push and ORAS provisioning for CI artifacts), `packaging` (`requires` specifier parsing), `pathspec`, `pydantic`/`pydantic-settings`, `pyyaml` (score-document parsing), `strictyaml` (config loading), `typer` (CLIs) — git-source pins only in scratch branches testing unreleased changes.
|
|
8
8
|
|
|
9
|
-
Config follows the org law (home:
|
|
9
|
+
Config follows the org law (home: github-actions-toolkit's `AGENTS.md`): this is a root-`docker-devkit.yaml` devkit. The schema (`config.DockerDevkitConfig`, strictyaml-loaded, `extra="forbid"` on every model) carries a required `requires` version stamp plus optional `lifecycle` / `mirror` / `generate-score` sections; `config.load_config` self-checks the installed docker-devkit version against `requires` and refuses loudly outside the range (the `0.0.0.dev0` dev sentinel bypasses the check, not the field).
|
|
10
10
|
|
|
11
11
|
## Release flow
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
github-actions-toolkit's `AGENTS.md` owns the consumer workflow contract; this repo follows it unchanged. Repo-specific facts: github-actions-toolkit is never a project dependency (docker-devkit sits inside its own dependency graph; a project-level edge is a resolver cycle), and from 1.0 on API-breaking changes ship with a manually bumped `major_minor` (pre-1.0, breaking changes ride the patch flow; patch-auto assumes additive changes).
|
|
14
14
|
|
|
15
15
|
## Mirror consumption
|
|
16
16
|
|
|
@@ -24,12 +24,12 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
|
|
|
24
24
|
|---|---|
|
|
25
25
|
| `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--dev`, `--allow-upstream-fallback`. |
|
|
26
26
|
| `down.py` | `docker compose down`. Flags: `--volumes`/`-v` (also removes named volumes), `--gpu`. |
|
|
27
|
-
| `build_docker.py` | `run_build()` + the `build` command — cross-builds all images per the resolved image manifest, writing the paired lock (one `NAME=path:tag@sha256:…` entry per `x-base-images` declaration; `@digest`-pinned declarations pass through unresolved; mirror-prefixed declarations resolve their digest against the upstream reference). `--
|
|
27
|
+
| `build_docker.py` | `run_build()` + the `build` command — cross-builds all images per the resolved image manifest, writing the paired lock (one `NAME=path:tag@sha256:…` entry per `x-base-images` declaration; `@digest`-pinned declarations pass through unresolved; mirror-prefixed declarations resolve their digest against the upstream reference). `--push` is the CI shape (push images + registry caches + digest-manifest shelving) and hard-requires `x-registry-cache`; the flag order mirrors the binding's run line (`--push`, `--targets`, `--gpu`), and `--targets` accepts both repeated flags and one space-joined value. Push mode reads its registry facts from the neutral `REGISTRY`/`REGISTRY_TOKEN`/`REGISTRY_USERNAME` environment — no `GITHUB_*` reads. |
|
|
28
28
|
| `score_generate.py` | `generate-score` — deterministic Score-manifest generation from a consumer's `stack/score/` sources: `${ALL_CAPS}` placeholder expansion into tempdir-rendered workload copies, idempotent `score-compose`/`score-k8s` init (state preserved across runs), and a determinism suite (document sort, state-path normalisation, 0644 modes, LF newlines). Consumer policy — workloads, provisioners, patch templates, output paths, storage classes, publishes, tool versions — is declared in the consumer's root `docker-devkit.yaml` under `generate-score:` (`ScoreConfig`, model in `config.py`); `load_score_config()` reads it, `ensure_score_tools()` fetches the pinned score-compose/score-k8s release binaries (for CI staleness gates). Output and state paths resolve against the repo root: the compose half runs in `score_dir` (its `.score-compose` state stays there, gitignored), the k8s half runs in the configured `k8s_state` parent so the committed `.score-k8s` state lands in the generated tier. `--local` writes the k8s artifact to the configured gitignored local path with the local storage class. |
|
|
29
29
|
| `config.py` | `DockerDevkitConfig` — the root `docker-devkit.yaml` schema: a required `requires` PEP 440 specifier plus optional `lifecycle` / `mirror` / `generate-score` sections, `extra="forbid"` on every model. `load_config(root)` is the single strictyaml entry point: parses the file (returns `None` when absent — the no-config → single-file-default path for `up`/`down`), then self-checks the installed docker-devkit version against `requires` (refuses loudly outside range; the `0.0.0.dev0` dev sentinel bypasses the check, not the field). The section models (`LifecycleConfig`, `MirrorConfig`, `ScoreConfig`) live here; `load_lifecycle_config` / `load_mirror_config` / `load_score_config` in their modules are thin wrappers over it. |
|
|
30
30
|
| `lifecycle.py` | `LifecycleConfig` (model in `config.py`) — the `lifecycle:` section declaration (`files`: compose files in layering order with `{gpu}` templating; `dev_file`: optional dev overlay); `load_lifecycle_config()` (reads the `lifecycle:` section via `config.load_config`, `None` when the section or file is absent), `resolve_manifest()` / `require_manifest()` (the raising form) / `resolve_lock()` (the image manifest and its lock are a named pair — `workloads/images.yml` + `workloads/images.lock`, with the legacy root `compose.bake.yml` + `.env.lock` pair still resolved so repos can upgrade before moving files), `enforce_bake_declaration()` (a manifest under either name requires a declaration), `expand_compose_files()` (assembles the `-f` list; a `{gpu}` entry is dropped when the GPU resolves to `none`; `dev_file` appended when a dev overlay applies). |
|
|
31
31
|
| `detect_gpu.py` | `detect_gpu()` → `cuda`/`rocm`/`none` from host devices; the `Gpu` literal. |
|
|
32
|
-
| `matrix.py` | `docker-build-matrix` — emit the CI build
|
|
32
|
+
| `matrix.py` | `docker-build-matrix` — emit the CI build leg list as a bare JSON array on stdout (the binding's getter pipes it through its `matrix` verb, which composes the envelope and guards the empty case): one entry per variant × cohort cell, `targets` = that cell's explicitly-buildable services with dependents grouped with the base their `additional_contexts: target:` references pull in (one leg builds a whole dependency-closure group in a single bake invocation). The leg schema — `targets` (space-joined), `variant` (`common`/`cuda`/`rocm`, from the service-name suffix) — is named contract with the binding; renaming a key breaks its fanout YAML. The cross-compile cohort (services in `x-cross-compile-targets` or declaring `platforms:`) gets its own legs — per-arch builds are the QEMU-slow ones and parallelize away from native builds; a group spanning cohort membership is a loud error. Tagless services never become explicit targets (bake rejects a tagless target under `--push`) — they ride as in-invocation dependencies; a group spanning gpu variants is a loud error. |
|
|
33
33
|
| `modes.py` | `resolve_auth_mode()` — validates `PUBLIC_URL` + `AUTH_MODE` from `.env`, rejecting `keycloak` over cleartext `http://`. |
|
|
34
34
|
| `context_sha.py` | `compute_service_shas(repo_root, bake: BakeDocument)` — per-service `tree-<hash>` image tags over the `.dockerignore`-allowlisted git tree. The workload-tree root is derived as the single top-level directory containing every tagged service's Dockerfile (a bake graph spanning several top-level directories is a loud error); files under it that belong to no tagged service are excluded from every SHA rather than shared into all of them. |
|
|
35
35
|
| `documents.py` | `BakeDocument` + `parse_bake(path)` — the typed bake-file model every consumer of bake data goes through (build, up, down, generate-score, the reference collectors). A field validator enforces at parse time that every `x-base-images` value is `path:tag` or `path@digest` (a `${…}` hole or a bare path is a validation error, killing the silent-unpinned `FROM` → `:latest` failure mode). `digest_pinned(reference)` is the shared tail test. |
|
|
@@ -62,10 +62,10 @@ The mirror is declared, not inferred: the `mirror:` section with `prefix` in the
|
|
|
62
62
|
|
|
63
63
|
- `build` resolves lock digests for mirror-prefixed declarations against the upstream reference (prefix stripped, tag/digest tail preserved) and records it under the mirror name. Valid because `crane copy` produces byte-identical manifests — upstream and mirror digests are the same digest; a manifest re-wrap (`docker buildx imagetools`-shaped tooling) would change the top-level digest and break lock equality. This makes declare → re-lock walkable before CI has mirrored anything; lock lines are never hand-authored.
|
|
64
64
|
- `up` fails closed: after lock resolution it probes each mirror-prefixed lock entry; a missing mirror image is a hard `RuntimeError`, not a warning — people ignore warnings, and warnings are errors someone was too lazy to fix. The only escape is the per-run `--allow-upstream-fallback` flag. Under the flag, missing entries become digest-preserving address substitutions (same bytes from the upstream host), printed as plain statements — the operator asked for fallback, the run reports what it did. The rewrite is authoritative on both consumption paths: an appended `--env-file` overlay (compose's later env files win) plus `os.environ` before `build`'s bake (shell env wins over env files; `run_build` re-applies the overlay after its own lock update so bake is never clobbered). CI never passes the flag — the mirror job runs before anything needs the images.
|
|
65
|
-
- The `mirror` verb (crane install from a pinned release, per-target `crane copy`, count footer) uses bashrun + plain prints
|
|
65
|
+
- The `mirror` verb (crane install from a pinned release, per-target `crane copy`, count footer) uses bashrun + plain prints — it needs nothing from any runner floor.
|
|
66
66
|
- Non-mirror lock entries (e.g. `placeframe/*` publishes) are not probed and keep failing naturally at pull. There is no config-level ban on the flag: an air-gap fails closed anyway (no route to upstream — the pull dies with or without it).
|
|
67
67
|
|
|
68
|
-
The
|
|
68
|
+
The build-artifact-registry edge exists for the builds shelf: a `build --push` run distills bake's `--metadata-file` into a digest manifest (`target → {ref, digest, tags[]}` JSON) and pushes it through build-artifact-registry's `push_build` — one **shard** per matrix leg, tagged `sha-{full}-{key}` where the key is the sha256-8 of the leg's sorted targets (legs run in parallel, so a shared tag would be last-writer-wins; the pull side enumerates a SHA's shards and merges, and consumption only ever runs after every leg finished). The shelf root comes from the neutral `REGISTRY` environment and the SHA from the checkout HEAD (`git rev-parse HEAD`), stamping the certified PR head the release channels query back at; the keying law is stated in github-actions-toolkit's `AGENTS.md`. `workloads/images.lock` stays in source control (the stapler that consumed the shelf copy is gone). ORAS CLI knowledge lives only in build-artifact-registry (org law); local builds (no `--push`) never touch the shelf.
|
|
69
69
|
|
|
70
70
|
Lifecycle stays a separate package from codegen (client generation, datamodel generation, workspace locking) because their dependency surfaces are disjoint — bundling them would force a consumer that only brings a stack up to install `sqlacodegen`, `psycopg`, `datamodel-code-generator`.
|
|
71
71
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: docker-devkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
License-File: NOTICE
|
|
7
7
|
Requires-Python: >=3.13
|
|
8
8
|
Requires-Dist: bashrun>=0.1.0
|
|
9
|
-
Requires-Dist:
|
|
9
|
+
Requires-Dist: build-artifact-registry>=0.2.0.dev0
|
|
10
10
|
Requires-Dist: packaging>=24.1
|
|
11
11
|
Requires-Dist: pathspec>=1.0.4
|
|
12
12
|
Requires-Dist: pydantic-settings>=2.9.1
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "docker-devkit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly"
|
|
5
5
|
requires-python = ">=3.13"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"bashrun>=0.1.0",
|
|
8
|
-
"
|
|
8
|
+
"build-artifact-registry>=0.2.0.dev0",
|
|
9
9
|
"packaging>=24.1",
|
|
10
10
|
"pathspec>=1.0.4",
|
|
11
11
|
"pydantic>=2.11.7",
|
|
@@ -20,7 +20,7 @@ dev = [
|
|
|
20
20
|
"basedpyright>=1.39.10",
|
|
21
21
|
"ruff>=0.14.11",
|
|
22
22
|
"pytest>=8.0.0",
|
|
23
|
-
"python-devkit>=0.
|
|
23
|
+
"python-devkit>=0.3.0.dev0",
|
|
24
24
|
"types-pyyaml>=6.0.12.20260906",
|
|
25
25
|
"types-strictyaml>=1.7.3.3",
|
|
26
26
|
]
|
|
@@ -39,7 +39,6 @@ docker-build-matrix = "docker_devkit.matrix:app"
|
|
|
39
39
|
|
|
40
40
|
[tool.hatch.build.targets.wheel]
|
|
41
41
|
packages = ["src/docker_devkit"]
|
|
42
|
-
include = ["src/docker_devkit/py.typed"]
|
|
43
42
|
|
|
44
43
|
[tool.uv.sources]
|
|
45
44
|
|
{docker_devkit-0.2.1.dev37485576216 → docker_devkit-0.3.0}/src/docker_devkit/build_docker.py
RENAMED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import platform
|
|
@@ -7,11 +8,12 @@ import re
|
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from subprocess import CalledProcessError
|
|
9
10
|
from tempfile import TemporaryDirectory
|
|
10
|
-
from typing import Annotated, Any,
|
|
11
|
+
from typing import Annotated, Any, TypedDict
|
|
11
12
|
|
|
12
13
|
import typer
|
|
13
14
|
from bashrun.bash import bash, bash_output
|
|
14
|
-
from
|
|
15
|
+
from build_artifact_registry.builds import push_build
|
|
16
|
+
from build_artifact_registry.setup_oras import install_oras
|
|
15
17
|
from .detect_gpu import GPU_TYPES, Gpu, detect_gpu
|
|
16
18
|
from .documents import BakeDocument, digest_pinned, parse_bake
|
|
17
19
|
from pydantic_settings import BaseSettings
|
|
@@ -31,8 +33,6 @@ settings = Settings.model_validate({})
|
|
|
31
33
|
|
|
32
34
|
METADATA_PATH = Path("metadata.json")
|
|
33
35
|
|
|
34
|
-
Mode = Literal["local", "ci"]
|
|
35
|
-
|
|
36
36
|
DIGEST_PROJECT = "images-digests"
|
|
37
37
|
DIGEST_PLATFORM = "all"
|
|
38
38
|
DIGEST_FILE_NAME = "images-digests.json"
|
|
@@ -49,55 +49,43 @@ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
|
|
|
49
49
|
|
|
50
50
|
@app.command()
|
|
51
51
|
def build(
|
|
52
|
+
push: Annotated[
|
|
53
|
+
bool, typer.Option("--push", help="Push images (+ registry caches) and shelve the digest manifest.")
|
|
54
|
+
] = False,
|
|
55
|
+
targets_opt: Annotated[
|
|
56
|
+
list[str] | None,
|
|
57
|
+
typer.Option("--targets", "-t", help="Build only these services (from the image manifest)."),
|
|
58
|
+
] = None,
|
|
59
|
+
gpu: Annotated[Gpu, typer.Option("--gpu", help="auto|cuda|rocm|none")] = "auto",
|
|
52
60
|
upgrade: bool = typer.Option(False, "--upgrade", "-u", help="Re-resolve and rewrite base digests."),
|
|
53
61
|
lock_only: bool = typer.Option(False, "--lock-only", help="Update lock file without building images."),
|
|
54
|
-
mode: Annotated[
|
|
55
|
-
Mode, typer.Option("--mode", help="local: --load images; ci: --push images + registry caches.")
|
|
56
|
-
] = "local",
|
|
57
|
-
gpu: Annotated[Gpu, typer.Option("--gpu", help="auto|cuda|rocm|none")] = "auto",
|
|
58
62
|
gpu_only: bool = typer.Option(
|
|
59
63
|
False, "--gpu-only", help="Build only the services suffixed for this gpu (requires a concrete --gpu)."
|
|
60
64
|
),
|
|
61
65
|
no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild by disabling cache usage."),
|
|
62
|
-
targets_opt: Annotated[
|
|
63
|
-
list[str] | None,
|
|
64
|
-
typer.Option("--targets", "-t", help="Build only these services (from the image manifest)."),
|
|
65
|
-
] = None,
|
|
66
|
-
builds_registry: Annotated[
|
|
67
|
-
str | None,
|
|
68
|
-
typer.Option(
|
|
69
|
-
"--builds-registry", help="OCI registry path to push the digest manifest to (CI build artifacts)."
|
|
70
|
-
),
|
|
71
|
-
] = None,
|
|
72
|
-
run_number: Annotated[
|
|
73
|
-
int, typer.Option("--run-number", help="CI run number baked into the digest manifest's builds-shelf tag.")
|
|
74
|
-
] = 0,
|
|
75
66
|
) -> None:
|
|
67
|
+
targets = [service for value in targets_opt or [] for service in value.split()] or None
|
|
76
68
|
run_build(
|
|
69
|
+
push=push,
|
|
70
|
+
targets_opt=targets,
|
|
71
|
+
gpu=gpu,
|
|
77
72
|
upgrade=upgrade,
|
|
78
73
|
lock_only=lock_only,
|
|
79
|
-
mode=mode,
|
|
80
|
-
gpu=gpu,
|
|
81
74
|
gpu_only=gpu_only,
|
|
82
75
|
no_cache=no_cache,
|
|
83
|
-
targets_opt=targets_opt,
|
|
84
|
-
builds_registry=builds_registry,
|
|
85
|
-
run_number=run_number,
|
|
86
76
|
)
|
|
87
77
|
|
|
88
78
|
|
|
89
79
|
def run_build(
|
|
90
80
|
*,
|
|
81
|
+
push: bool = False,
|
|
82
|
+
targets_opt: list[str] | None = None,
|
|
83
|
+
gpu: Gpu = "auto",
|
|
91
84
|
upgrade: bool = False,
|
|
92
85
|
lock_only: bool = False,
|
|
93
|
-
mode: Mode = "local",
|
|
94
|
-
gpu: Gpu = "auto",
|
|
95
86
|
gpu_only: bool = False,
|
|
96
87
|
no_cache: bool = False,
|
|
97
|
-
targets_opt: list[str] | None = None,
|
|
98
88
|
env_overlay: dict[str, str] | None = None,
|
|
99
|
-
builds_registry: str | None = None,
|
|
100
|
-
run_number: int = 0,
|
|
101
89
|
) -> None:
|
|
102
90
|
root = Path.cwd()
|
|
103
91
|
manifest = require_manifest(root)
|
|
@@ -112,18 +100,17 @@ def run_build(
|
|
|
112
100
|
# Load any existing lock
|
|
113
101
|
lock_data = parse_env_file(lock_file) if lock_file.exists() else {}
|
|
114
102
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
raise typer.BadParameter("In CI mode, --gpu cannot be 'auto'; specify 'cuda' or 'rocm'.")
|
|
103
|
+
if push and gpu == "auto":
|
|
104
|
+
raise typer.BadParameter("With --push, --gpu cannot be 'auto'; specify 'cuda' or 'rocm'.")
|
|
118
105
|
|
|
119
|
-
if
|
|
120
|
-
raise typer.BadParameter("
|
|
106
|
+
if push and bake.registry_cache is None:
|
|
107
|
+
raise typer.BadParameter("With --push, the bake file must declare x-registry-cache.")
|
|
121
108
|
|
|
122
109
|
if gpu_only and gpu not in GPU_TYPES:
|
|
123
110
|
raise typer.BadParameter("--gpu-only requires a concrete gpu (cuda or rocm), not 'auto' or 'none'.")
|
|
124
111
|
|
|
125
112
|
# For local builds, ensure Docker GC limits are high enough that GPU builds don't cause cache evictions
|
|
126
|
-
if
|
|
113
|
+
if not push and not lock_only and not targets_opt:
|
|
127
114
|
_check_gc_limits(min_gb=60)
|
|
128
115
|
|
|
129
116
|
# Resolve gpu
|
|
@@ -175,8 +162,8 @@ def run_build(
|
|
|
175
162
|
else:
|
|
176
163
|
targets = compute_default_targets(bake, gpu, gpu_only)
|
|
177
164
|
|
|
178
|
-
# Configure registry caches
|
|
179
|
-
if
|
|
165
|
+
# Configure registry caches for push builds
|
|
166
|
+
if push:
|
|
180
167
|
for target in targets:
|
|
181
168
|
target_cache = f"{bake.registry_cache}:{target}"
|
|
182
169
|
command_arguments.append(
|
|
@@ -186,9 +173,9 @@ def run_build(
|
|
|
186
173
|
|
|
187
174
|
# `docker buildx --load` only writes a single arch to the host image store,
|
|
188
175
|
# so bake targets declared multi-platform get overridden to host arch in
|
|
189
|
-
# local builds.
|
|
176
|
+
# local builds. --push produces the full manifest list.
|
|
190
177
|
# Override key is `platform` (singular) even though the bake field is plural.
|
|
191
|
-
if
|
|
178
|
+
if not push:
|
|
192
179
|
machine = platform.machine().lower()
|
|
193
180
|
if machine in ("x86_64", "amd64"):
|
|
194
181
|
host_platform = "linux/amd64"
|
|
@@ -200,8 +187,8 @@ def run_build(
|
|
|
200
187
|
if len(bake.services[target].build.platforms) > 1:
|
|
201
188
|
command_arguments.append(f"--set {target}.platform={host_platform}")
|
|
202
189
|
|
|
203
|
-
# Load or push images
|
|
204
|
-
command_arguments.append("--
|
|
190
|
+
# Load or push images
|
|
191
|
+
command_arguments.append("--push" if push else "--load")
|
|
205
192
|
|
|
206
193
|
# Handle no-cache option
|
|
207
194
|
if no_cache:
|
|
@@ -229,28 +216,39 @@ def run_build(
|
|
|
229
216
|
if not set(targets) <= baked_images.keys():
|
|
230
217
|
raise RuntimeError("Bake output does not cover the requested targets")
|
|
231
218
|
|
|
232
|
-
|
|
219
|
+
# Push the digest manifest to the repo's builds shelf (registry supplied explicitly, SHA from the checkout)
|
|
220
|
+
if push:
|
|
221
|
+
registry = os.environ.get("REGISTRY", "")
|
|
222
|
+
if not registry:
|
|
223
|
+
raise SystemExit("REGISTRY not set — --push shelves the digest manifest under it")
|
|
224
|
+
shelf = f"{registry}/builds"
|
|
225
|
+
sha = bash_output("git rev-parse HEAD").strip()
|
|
226
|
+
push_image_digests(shelf, sha, baked_images, targets)
|
|
227
|
+
|
|
233
228
|
|
|
229
|
+
def leg_key(targets: list[str]) -> str:
|
|
230
|
+
return hashlib.sha256(",".join(sorted(targets)).encode("utf-8")).hexdigest()[:8]
|
|
234
231
|
|
|
232
|
+
|
|
233
|
+
# Each matrix leg pushes its own manifest shard under sha-{sha}-{key}: legs run in
|
|
234
|
+
# parallel, so a shared tag would be last-writer-wins. The pull side enumerates a
|
|
235
|
+
# SHA's shards and merges — consumption happens only after every leg finished.
|
|
235
236
|
def push_image_digests(
|
|
236
|
-
|
|
237
|
-
|
|
237
|
+
shelf: str,
|
|
238
|
+
sha: str,
|
|
238
239
|
baked_images: dict[str, Any],
|
|
239
240
|
targets: list[str],
|
|
240
241
|
) -> None:
|
|
241
|
-
|
|
242
|
-
return
|
|
243
|
-
if run_number <= 0:
|
|
244
|
-
raise typer.BadParameter("--builds-registry requires a positive --run-number")
|
|
242
|
+
install_oras()
|
|
245
243
|
manifest = distill_digest_manifest(baked_images, targets)
|
|
246
244
|
with TemporaryDirectory(prefix="digest-manifest-") as staging:
|
|
247
245
|
manifest_path = Path(staging) / DIGEST_FILE_NAME
|
|
248
246
|
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
249
247
|
push_build(
|
|
250
|
-
|
|
248
|
+
shelf,
|
|
251
249
|
DIGEST_PROJECT,
|
|
252
250
|
DIGEST_PLATFORM,
|
|
253
|
-
f"
|
|
251
|
+
f"sha-{sha}-{leg_key(targets)}",
|
|
254
252
|
Path(staging),
|
|
255
253
|
[DIGEST_FILE_NAME],
|
|
256
254
|
)
|
|
@@ -271,6 +269,8 @@ def distill_digest_manifest(baked_images: dict[str, Any], targets: list[str]) ->
|
|
|
271
269
|
if separator:
|
|
272
270
|
image_ref = repository
|
|
273
271
|
tags.append(tag)
|
|
272
|
+
if not tags:
|
|
273
|
+
raise ValueError(f"target '{target}' produced no tagged image references from '{image_name}'")
|
|
274
274
|
manifest[target] = {"ref": image_ref, "digest": digest, "tags": tags}
|
|
275
275
|
return manifest
|
|
276
276
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import datetime
|
|
3
|
+
import datetime as dt
|
|
4
4
|
import re
|
|
5
5
|
import shlex
|
|
6
6
|
import tomllib
|
|
@@ -21,17 +21,7 @@ DIGEST_PATTERN = re.compile(r"^Digest:\s+(sha256:[a-f0-9]+)", re.MULTILINE)
|
|
|
21
21
|
MISSING_VERSION = "<missing>"
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
type TomlValue =
|
|
25
|
-
str
|
|
26
|
-
| int
|
|
27
|
-
| float
|
|
28
|
-
| bool
|
|
29
|
-
| datetime.datetime
|
|
30
|
-
| datetime.date
|
|
31
|
-
| datetime.time
|
|
32
|
-
| list[TomlValue]
|
|
33
|
-
| dict[str, TomlValue]
|
|
34
|
-
)
|
|
24
|
+
type TomlValue = str | int | float | bool | dt.datetime | dt.date | dt.time | list[TomlValue] | dict[str, TomlValue]
|
|
35
25
|
|
|
36
26
|
|
|
37
27
|
class TomlDocument(RootModel[TomlValue]):
|
|
@@ -18,32 +18,49 @@ PLAIN_VARIANT = "common"
|
|
|
18
18
|
@app.command()
|
|
19
19
|
def matrix_main() -> None:
|
|
20
20
|
bake = parse_bake(require_manifest(Path.cwd()))
|
|
21
|
-
print(
|
|
21
|
+
print(json.dumps(compute_matrix_entries(bake)))
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
# One entry per variant: targets are the dependency-closure groups
|
|
25
|
-
# explicitly-buildable services, coalesced into a single leg — the
|
|
26
|
-
# gpu-resolved build invocation would produce, with dependents
|
|
27
|
-
# their additional_contexts `target:` references pull in.
|
|
24
|
+
# One entry per variant and cohort cell: targets are the dependency-closure groups
|
|
25
|
+
# of that cell's explicitly-buildable services, coalesced into a single leg — the
|
|
26
|
+
# shape a single gpu-resolved build invocation would produce, with dependents
|
|
27
|
+
# grouped with the base their additional_contexts `target:` references pull in.
|
|
28
|
+
# The cross-compile cohort gets its own legs: per-arch builds are the QEMU-slow
|
|
29
|
+
# ones, so they parallelize away from native builds instead of serializing them
|
|
30
|
+
# into one invocation.
|
|
28
31
|
def compute_matrix_entries(bake: BakeDocument) -> list[dict[str, str]]:
|
|
29
|
-
|
|
32
|
+
cohort = cross_compile_cohort(bake)
|
|
33
|
+
entries_by_cell: dict[tuple[str, bool], list[str]] = {}
|
|
30
34
|
for group in dependency_groups(bake):
|
|
31
|
-
explicit = sorted(
|
|
32
|
-
service
|
|
33
|
-
for service in group
|
|
34
|
-
if bake.services[service].build.tags and service not in set(bake.cross_compile_targets)
|
|
35
|
-
)
|
|
35
|
+
explicit = sorted(service for service in group if bake.services[service].build.tags)
|
|
36
36
|
if not explicit:
|
|
37
37
|
continue
|
|
38
38
|
variant = group_variant(bake, explicit)
|
|
39
|
-
|
|
39
|
+
memberships = {service in cohort for service in explicit}
|
|
40
|
+
if len(memberships) > 1:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
f"dependency group spans cross-compile cohort membership ({', '.join(explicit)}) — "
|
|
43
|
+
"additional_contexts must stay within one cohort"
|
|
44
|
+
)
|
|
45
|
+
entries_by_cell.setdefault((variant, memberships.pop()), []).extend(explicit)
|
|
40
46
|
|
|
41
47
|
return [
|
|
42
|
-
{"targets": " ".join(sorted(
|
|
43
|
-
for variant in sorted(
|
|
48
|
+
{"targets": " ".join(sorted(targets)), "variant": variant}
|
|
49
|
+
for (variant, _), targets in sorted(entries_by_cell.items())
|
|
44
50
|
]
|
|
45
51
|
|
|
46
52
|
|
|
53
|
+
# The cross-compile cohort: every service declaring `platforms:` (per-arch
|
|
54
|
+
# treatment is a manifest fact, not an operator judgment) plus the hand-listed
|
|
55
|
+
# x-cross-compile-targets members.
|
|
56
|
+
def cross_compile_cohort(bake: BakeDocument) -> set[str]:
|
|
57
|
+
unknown = set(bake.cross_compile_targets) - set(bake.services)
|
|
58
|
+
if unknown:
|
|
59
|
+
raise RuntimeError(f"x-cross-compile-targets names unknown services: {', '.join(sorted(unknown))}")
|
|
60
|
+
pinned = {service for service, config in bake.services.items() if config.build.platforms}
|
|
61
|
+
return pinned | set(bake.cross_compile_targets)
|
|
62
|
+
|
|
63
|
+
|
|
47
64
|
def dependency_groups(bake: BakeDocument) -> list[list[str]]:
|
|
48
65
|
parent = {service: service for service in bake.services}
|
|
49
66
|
|
|
@@ -3,13 +3,13 @@ from pathlib import Path
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
|
-
import typer
|
|
7
6
|
|
|
8
7
|
from docker_devkit import build_docker
|
|
9
8
|
from docker_devkit.build_docker import DIGEST_FILE_NAME, distill_digest_manifest, push_image_digests
|
|
10
9
|
|
|
11
10
|
DIGEST_A = "sha256:" + "a" * 64
|
|
12
11
|
DIGEST_B = "sha256:" + "b" * 64
|
|
12
|
+
BUILD_SHA = "c" * 40
|
|
13
13
|
REPO_ZED = "ghcr.io/outernet-foundation/placeframe-capture-tool/zed-capture"
|
|
14
14
|
REPO_AOA = "ghcr.io/outernet-foundation/placeframe-capture-tool/aoa-bridge"
|
|
15
15
|
|
|
@@ -45,9 +45,9 @@ def test_distills_single_target_single_tag() -> None:
|
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
def test_distills_multiple_tags_from_comma_separated_refs() -> None:
|
|
48
|
-
baked = _baked("zed-capture", refs=f"{REPO_ZED}:tree-abc,{REPO_ZED}:
|
|
48
|
+
baked = _baked("zed-capture", refs=f"{REPO_ZED}:tree-abc,{REPO_ZED}:latest", digest=DIGEST_A)
|
|
49
49
|
assert distill_digest_manifest(baked, ["zed-capture"]) == {
|
|
50
|
-
"zed-capture": {"ref": REPO_ZED, "digest": DIGEST_A, "tags": ["tree-abc", "
|
|
50
|
+
"zed-capture": {"ref": REPO_ZED, "digest": DIGEST_A, "tags": ["tree-abc", "latest"]}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
|
|
@@ -69,31 +69,29 @@ def test_strips_digest_suffix_from_refs() -> None:
|
|
|
69
69
|
assert entry["tags"] == ["tree-abc"]
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
push_image_digests(None, 42, baked, ["zed-capture"])
|
|
77
|
-
assert recorder.calls == []
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def test_push_image_digests_rejects_zero_run_number() -> None:
|
|
81
|
-
with pytest.raises(typer.BadParameter):
|
|
82
|
-
push_image_digests("ghcr.io/owner/builds", 0, {}, [])
|
|
72
|
+
def test_refuses_target_with_no_tagged_references() -> None:
|
|
73
|
+
baked = _baked("zed-capture", refs=f"{REPO_ZED}@{DIGEST_A}", digest=DIGEST_A)
|
|
74
|
+
with pytest.raises(ValueError, match="zed-capture"):
|
|
75
|
+
distill_digest_manifest(baked, ["zed-capture"])
|
|
83
76
|
|
|
84
77
|
|
|
85
78
|
def test_push_image_digests_pushes_manifest_to_images_digests_all(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
86
79
|
recorder = PushBuildRecorder()
|
|
87
80
|
monkeypatch.setattr(build_docker, "push_build", recorder)
|
|
88
|
-
baked = _baked("zed-capture", refs=f"{REPO_ZED}:tree-abc,{REPO_ZED}:
|
|
89
|
-
push_image_digests("ghcr.io/owner/builds",
|
|
81
|
+
baked = _baked("zed-capture", refs=f"{REPO_ZED}:tree-abc,{REPO_ZED}:latest", digest=DIGEST_A)
|
|
82
|
+
push_image_digests("ghcr.io/owner/builds", BUILD_SHA, baked, ["zed-capture"])
|
|
90
83
|
assert len(recorder.calls) == 1
|
|
91
84
|
registry, project, platform, tag, _source_directory, paths = recorder.calls[0]
|
|
92
85
|
assert registry == "ghcr.io/owner/builds"
|
|
93
86
|
assert project == "images-digests"
|
|
94
87
|
assert platform == "all"
|
|
95
|
-
assert tag == "
|
|
88
|
+
assert tag == f"sha-{BUILD_SHA}-{build_docker.leg_key(['zed-capture'])}"
|
|
96
89
|
assert paths == [DIGEST_FILE_NAME]
|
|
97
90
|
assert recorder.manifests == [
|
|
98
|
-
{"zed-capture": {"ref": REPO_ZED, "digest": DIGEST_A, "tags": ["tree-abc", "
|
|
91
|
+
{"zed-capture": {"ref": REPO_ZED, "digest": DIGEST_A, "tags": ["tree-abc", "latest"]}}
|
|
99
92
|
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_leg_key_is_order_insensitive() -> None:
|
|
96
|
+
assert build_docker.leg_key(["zed-capture", "aoa-bridge"]) == build_docker.leg_key(["aoa-bridge", "zed-capture"])
|
|
97
|
+
assert build_docker.leg_key(["zed-capture"]) != build_docker.leg_key(["aoa-bridge"])
|