docker-devkit 0.3.0.dev113__tar.gz → 0.4.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.
Files changed (49) hide show
  1. docker_devkit-0.4.0/.github/workflows/integrate.yml +24 -0
  2. docker_devkit-0.4.0/.github/workflows/merge-gate.yml +18 -0
  3. docker_devkit-0.4.0/.github/workflows/release.yml +34 -0
  4. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/AGENTS.md +13 -14
  5. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/PKG-INFO +1 -1
  6. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/pyproject.toml +1 -1
  7. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/release-devkit.yaml +1 -1
  8. docker_devkit-0.4.0/scripts/preflight.py +3 -0
  9. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/build_docker.py +29 -23
  10. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/down.py +1 -1
  11. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/image_refs.py +8 -21
  12. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/lifecycle.py +10 -14
  13. docker_devkit-0.4.0/src/docker_devkit/mirror.py +72 -0
  14. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/up.py +4 -22
  15. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_image_refs.py +16 -26
  16. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_lifecycle.py +3 -35
  17. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_mirror.py +65 -110
  18. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_score_generate.py +3 -3
  19. docker_devkit-0.4.0/uv.lock +524 -0
  20. docker_devkit-0.3.0.dev113/.github/actions/setup-release-devkit/action.yml +0 -11
  21. docker_devkit-0.3.0.dev113/.github/workflows/integrate.yml +0 -77
  22. docker_devkit-0.3.0.dev113/.github/workflows/merge-gate.yml +0 -40
  23. docker_devkit-0.3.0.dev113/.github/workflows/release.yml +0 -57
  24. docker_devkit-0.3.0.dev113/src/docker_devkit/mirror.py +0 -95
  25. docker_devkit-0.3.0.dev113/uv.lock +0 -493
  26. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/.gitattributes +0 -0
  27. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/.gitignore +0 -0
  28. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/.python-version +0 -0
  29. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/CLAUDE.md +0 -0
  30. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/LICENSE +0 -0
  31. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/NOTICE +0 -0
  32. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/README.md +0 -0
  33. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/ruff.base.toml +0 -0
  34. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/ruff.toml +0 -0
  35. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/__init__.py +0 -0
  36. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/config.py +0 -0
  37. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/context_sha.py +0 -0
  38. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/detect_gpu.py +0 -0
  39. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/documents.py +0 -0
  40. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/matrix.py +0 -0
  41. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/modes.py +0 -0
  42. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/py.typed +0 -0
  43. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/score_generate.py +0 -0
  44. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_build_docker.py +0 -0
  45. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_config.py +0 -0
  46. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_context_sha.py +0 -0
  47. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_default_targets.py +0 -0
  48. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_documents.py +0 -0
  49. {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_matrix.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
@@ -10,7 +10,7 @@ Config follows the org law (home: github-actions-toolkit's `AGENTS.md`): this is
10
10
 
11
11
  ## Release flow
12
12
 
13
- github-actions-toolkit's `AGENTS.md` owns the three-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).
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
 
@@ -22,19 +22,19 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
22
22
 
23
23
  | Module | Role |
24
24
  |---|---|
25
- | `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--dev`, `--allow-upstream-fallback`. |
25
+ | `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--dev`. |
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). `--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. |
27
+ | `build_docker.py` | `run_build()` + the `build` command — cross-builds all images per the resolved image manifest, writing the paired lock (one `NAME={prefix}/{ref}@sha256:…` entry per upstream-native `x-base-images` declaration, the mirror-prefixed form derived from `mirror.prefix`; `@digest`-pinned declarations pass through unresolved; digests resolve 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
- | `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). |
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`), `enforce_bake_declaration()` (a manifest 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
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. |
36
- | `image_refs.py` | Image-reference collection, split by source. `declared_references(root)` enumerates bake `x-base-images` declarations from `workloads/images.yml` and the legacy `compose*.bake.yml` names (parsed and validated via `BakeDocument`) — the single source consumer mirroring consumes. `stray_references(root, …globs)` scans Dockerfile `FROM`, Dockerfile `COPY --from=` (slash-bearing captures only, so build-stage names are skipped), and yaml `image:` lines — hand-written literal refs that bypass the declaration system — across the `workloads/` and `docker/` Dockerfile trees and the `stack/score/` + `score/` source trees; `unpinned_references(root)` polices exactly those strays (every slash-bearing reference must carry a tag, a digest, or a trailing `${build-arg}` suffix — consumer CIs fail on its result). `bake_base_image_refs(bake: BakeDocument)` adapts declarations to `ImageReference` pairs; `version_coupling_violations(root, couplings)` (each `VersionCoupling` names a pyproject-declared version and the `VersionSite` regexes that must restate it — bake `x-base-images` values or raw file text — so non-derivable couplings like uv `required-version` ↔ uv-base-image tag can't drift silently; the mechanism lives here, the registry in consumer repos' preflight) is unchanged. Remote digest resolution goes through `imagetools inspect`. Compose files are parsed by nothing in this package. |
37
- | `mirror.py` | The mirror namespace's config, inversion, and verbs. `MirrorConfig` (model in `config.py`, the `mirror:` section, key `prefix`), `load_mirror_config()` (reads the `mirror:` section via `config.load_config`, `None` when the section or file is absent), `is_mirrored`/`upstream_ref` (prefix inversion preserving the `:tag@sha256:…` tail), `reference_exists` (imagetools existence probe honoring docker config creds), `mirror_targets` (declared refs filtered by prefix, sorted), the `mirror` verb (crane install + per-target `crane copy`), and `up`'s fallback helpers (`upstream_fallback`, `write_fallback_overlay`). Constraints in "Mirror namespace and upstream fallback". |
36
+ | `image_refs.py` | Image-reference collection, split by source. `declared_references(root)` enumerates bake `x-base-images` declarations from `workloads/images.yml` (parsed and validated via `BakeDocument`) — the single source consumer mirroring consumes. `stray_references(root, …globs)` scans Dockerfile `FROM`, Dockerfile `COPY --from=` (slash-bearing captures only, so build-stage names are skipped), and yaml `image:` lines — hand-written literal refs that bypass the declaration system — across the `workloads/` and `docker/` Dockerfile trees and the `stack/score/` + `score/` source trees; `unpinned_references(root)` polices exactly those strays (every slash-bearing reference must carry a tag, a digest, or a trailing `${build-arg}` suffix — consumer CIs fail on its result). `bake_base_image_refs(bake: BakeDocument)` adapts declarations to `ImageReference` pairs; `version_coupling_violations(root, couplings)` (each `VersionCoupling` names a pyproject-declared version and the `VersionSite` regexes that must restate it — bake `x-base-images` values or raw file text — so non-derivable couplings like uv `required-version` ↔ uv-base-image tag can't drift silently; the mechanism lives here, the registry in consumer repos' preflight) is unchanged. Remote digest resolution goes through `imagetools inspect`. Compose files are parsed by nothing in this package. |
37
+ | `mirror.py` | The mirror namespace's config, injection, and verbs. `MirrorConfig` (model in `config.py`, the `mirror:` section, key `prefix`), `load_mirror_config()` (reads the `mirror:` section via `config.load_config`, `None` when the section or file is absent), `mirrored_ref` (prefix injection with the double-prefix guard), `reference_exists` (imagetools existence probe honoring docker config creds), `enforce_mirrored` (`up`'s fail-closed probe over lock values), and the `mirror` verb (crane install + one `crane copy {ref} {prefix}/{ref}` per declared reference). Constraints in "Mirror namespace". |
38
38
 
39
39
  ## Lifecycle declaration contract
40
40
 
@@ -47,23 +47,22 @@ placeframe's shape lives in placeframe's `docker-devkit.yaml`, not in this packa
47
47
  ## File and env conventions the commands assume
48
48
 
49
49
  - `.env` (required) — carries `PUBLIC_URL` and `AUTH_MODE`; `modes.py` reads it and rejects `keycloak` over cleartext `http://`.
50
- - `workloads/images.lock` (required in every repo, checked in) — third-party and base-image pins, written by `build`: one `NAME=path:tag@sha256:…` entry per manifest declaration. `up`/`down` refuse without it; consumers satisfy it with a committed lock filling the `${…_IMAGE:?err}` holes. The legacy name `.env.lock` resolves while the manifest still carries its legacy name; the lock always sits beside its manifest as the resolved pair.
51
- - `workloads/images.yml` (where a local build graph exists, required) — the image manifest and buildx-bake description; its top-level `x-cross-compile-targets` list (optional) is the set of services excluded from the default target list because they need per-arch treatment, opt-in via `--targets`. The legacy root name `compose.bake.yml` resolves identically. `build` reads exactly one manifest: the first of `workloads/images.yml`, `compose.bake.yml` that exists. `buildx bake` resolves the manifest's relative paths against its invocation cwd, so `build` always invokes from the repo root.
50
+ - `workloads/images.lock` (required in every repo, checked in) — third-party and base-image pins, written by `build`: one `NAME={prefix}/{ref}@sha256:…` entry per manifest declaration. `up`/`down` refuse without it; consumers satisfy it with a committed lock filling the `${…_IMAGE:?err}` holes. The lock sits beside its manifest as the resolved pair.
51
+ - `workloads/images.yml` (where a local build graph exists, required) — the image manifest and buildx-bake description; its top-level `x-cross-compile-targets` list (optional) is the set of services excluded from the default target list because they need per-arch treatment, opt-in via `--targets`. `build` reads exactly one manifest: `workloads/images.yml`. `buildx bake` resolves the manifest's relative paths against its invocation cwd, so `build` always invokes from the repo root.
52
52
  - the compose layers declared in the `lifecycle:` section of `docker-devkit.yaml`; the GPU one is picked by `detect_gpu()` (or the `--gpu` override).
53
53
  - `.dockerignore` (where images are built locally) — allowlist that `context_sha.py` hashes over to compute per-service `tree-<hash>` tags. See "Docker build context" in `AGENTS-SHARED.md` for the allowlist rationale.
54
54
 
55
55
  `build` injects the `tree-<hash>` tags of locally-built images into the environment rather than writing them anywhere; `up`/`down` do the same via `compute_service_shas`. The lock stays a pure third-party pin file — a CI invariant forbids built-image digests in the committed, reviewed lock.
56
56
 
57
- ## Mirror namespace and upstream fallback
57
+ ## Mirror namespace
58
58
 
59
59
  Third-party images are consumed through an org-owned mirror namespace (`ghcr.io/outernet-foundation/mirror/<upstream-host>/<path>…`) instead of pulling upstream registries directly: manifests at upstream get GC'd, and an upstream yank once broke external cloners — digest pins alone don't save you when the manifest is gone. Durability for strangers beats upstream egress: strangers, CI, and prod always pull the mirror. Populating it is CI's job, single writer — engineers never push to the org namespace, and error text must never teach them to (no "run this populate command" hints).
60
60
 
61
- The mirror is declared, not inferred: the `mirror:` section with `prefix` in the consumer's root `docker-devkit.yaml`. Absent section → all mirror behavior off. Under the section:
61
+ Declarations are upstream-native and the mirror-prefixed form is derived: `x-base-images` values spell the upstream reference, and the `mirror:` section's `prefix` in the consumer's root `docker-devkit.yaml` is the single place the namespace is named. `mirrored_ref(reference, prefix)` is the only join — it injects the prefix and rejects an already-prefixed reference (the double-prefix guard: drift toward the namespace is a loud error, never a silent skip). Declaring `x-base-images` with no `mirror:` section is a hard `build` error; the `mirror` verb requires the section outright. Under the section:
62
62
 
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
- - `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 — it needs nothing from any runner floor.
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).
63
+ - `build` resolves lock digests against the upstream reference and records each entry under the derived mirror name (`NAME={prefix}/{ref}@sha256:…`). 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
+ - `up` fails closed: it probes every lock entry (each is mirror-prefixed by construction); a missing mirror image is a hard `RuntimeError`, not a warning — people ignore warnings, and warnings are errors someone was too lazy to fix. There is no escape flag: a missing entry means the declaring change has not been through CI's mirror job yet, and the fix is to let it run. CI ordering never wants the escape — the mirror job runs before anything needs the images.
65
+ - The `mirror` verb (crane install from a pinned release, one `crane copy {ref} {prefix}/{ref}` per declared reference, count footer) uses bashrun + plain prints — it needs nothing from any runner floor. Every declared reference is a target; nothing is filtered by prefix, so a drifted declaration cannot silently mirror zero.
67
66
 
68
67
  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
68
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docker-devkit
3
- Version: 0.3.0.dev113
3
+ Version: 0.4.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-devkit"
3
- version = "0.3.0.dev113"
3
+ version = "0.4.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 = [
@@ -1,6 +1,6 @@
1
1
  packages:
2
2
  docker-devkit:
3
3
  path: .
4
- major_minor: "0.3"
4
+ major_minor: "0.4"
5
5
  registry: pypi
6
6
  identity: docker-devkit
@@ -0,0 +1,3 @@
1
+ from bashrun.bash import bash
2
+
3
+ bash("uv run --locked preflight-python")
@@ -14,6 +14,7 @@ import typer
14
14
  from bashrun.bash import bash, bash_output
15
15
  from build_artifact_registry.builds import push_build
16
16
  from build_artifact_registry.setup_oras import install_oras
17
+ from .config import MirrorConfig
17
18
  from .detect_gpu import GPU_TYPES, Gpu, detect_gpu
18
19
  from .documents import BakeDocument, digest_pinned, parse_bake
19
20
  from pydantic_settings import BaseSettings
@@ -21,7 +22,7 @@ from .context_sha import compute_service_shas
21
22
 
22
23
  from .image_refs import bake_base_image_refs, resolve_remote_digest
23
24
  from .lifecycle import require_manifest, resolve_lock
24
- from .mirror import is_mirrored, load_mirror_config, upstream_ref
25
+ from .mirror import load_mirror_config, mirrored_ref
25
26
  from .modes import parse_env_file
26
27
 
27
28
 
@@ -85,11 +86,10 @@ def run_build(
85
86
  lock_only: bool = False,
86
87
  gpu_only: bool = False,
87
88
  no_cache: bool = False,
88
- env_overlay: dict[str, str] | None = None,
89
89
  ) -> None:
90
90
  root = Path.cwd()
91
91
  manifest = require_manifest(root)
92
- lock_file = resolve_lock(root, manifest)
92
+ lock_file = resolve_lock(root)
93
93
  mirror_config = load_mirror_config(root)
94
94
 
95
95
  bake = parse_bake(manifest)
@@ -117,24 +117,7 @@ def run_build(
117
117
  if gpu == "auto" and not lock_only:
118
118
  gpu = detect_gpu()
119
119
 
120
- # One lock entry per declared third-party image: NAME=path:tag@sha256:…
121
- # Mirror-prefixed declarations resolve their digest against upstream (crane copies
122
- # are byte-identical, so the digest is valid under both names) and record it under
123
- # the mirror name — declaring and re-locking works before CI has mirrored anything.
124
- for occurrence in bake_base_image_refs(bake):
125
- if digest_pinned(occurrence.reference):
126
- lock_data[occurrence.name] = occurrence.reference
127
- continue
128
- if (
129
- not upgrade
130
- and occurrence.name in lock_data
131
- and lock_data[occurrence.name].startswith(occurrence.reference + "@")
132
- ):
133
- continue
134
- inspect_target = occurrence.reference
135
- if mirror_config is not None and is_mirrored(occurrence.reference, mirror_config.prefix):
136
- inspect_target = upstream_ref(occurrence.reference, mirror_config.prefix)
137
- lock_data[occurrence.name] = f"{occurrence.reference}@{resolve_remote_digest(inspect_target)}"
120
+ update_lock_entries(bake, mirror_config, lock_data, upgrade=upgrade)
138
121
 
139
122
  # Update main lock file
140
123
  lock_file.write_text(
@@ -147,8 +130,6 @@ def run_build(
147
130
 
148
131
  # Update environment with external dependency image digests
149
132
  os.environ.update(lock_data)
150
- if env_overlay:
151
- os.environ.update(env_overlay)
152
133
 
153
134
  # Build command arguments
154
135
  command_arguments: list[str] = []
@@ -226,6 +207,31 @@ def run_build(
226
207
  push_image_digests(shelf, sha, baked_images, targets)
227
208
 
228
209
 
210
+ # One lock entry per declared third-party image: NAME={prefix}/{ref}@sha256:… — the
211
+ # mirror-prefixed form is derived from the mirror prefix, the digest resolves against
212
+ # the upstream reference (crane copies are byte-identical, so the digest is valid
213
+ # under both names) and records under the mirror name.
214
+ def update_lock_entries(
215
+ bake: BakeDocument,
216
+ mirror_config: MirrorConfig | None,
217
+ lock_data: dict[str, str],
218
+ *,
219
+ upgrade: bool,
220
+ ) -> None:
221
+ if not bake.base_images:
222
+ return
223
+ if mirror_config is None:
224
+ raise RuntimeError("x-base-images declared without a mirror section in docker-devkit.yaml")
225
+ for occurrence in bake_base_image_refs(bake):
226
+ lock_base = mirrored_ref(occurrence.reference, mirror_config.prefix)
227
+ if digest_pinned(occurrence.reference):
228
+ lock_data[occurrence.name] = lock_base
229
+ continue
230
+ if not upgrade and occurrence.name in lock_data and lock_data[occurrence.name].startswith(lock_base + "@"):
231
+ continue
232
+ lock_data[occurrence.name] = f"{lock_base}@{resolve_remote_digest(occurrence.reference)}"
233
+
234
+
229
235
  def leg_key(targets: list[str]) -> str:
230
236
  return hashlib.sha256(",".join(sorted(targets)).encode("utf-8")).hexdigest()[:8]
231
237
 
@@ -31,7 +31,7 @@ def down(
31
31
  config = load_lifecycle_config(root)
32
32
  enforce_bake_declaration(root, config)
33
33
  manifest = resolve_manifest(root)
34
- lock_file = resolve_lock(root, manifest)
34
+ lock_file = resolve_lock(root)
35
35
 
36
36
  if not ENV_FILE.exists():
37
37
  raise RuntimeError("No .env file found")
@@ -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
@@ -12,6 +12,7 @@ from bashrun.bash import bash_output
12
12
  from pydantic import RootModel
13
13
 
14
14
  from .documents import BakeDocument, parse_bake
15
+ from .lifecycle import MANIFEST_PATH
15
16
 
16
17
  BUILD_ARG_PATTERN = re.compile(r"\$\{[A-Za-z0-9_]+\}")
17
18
  FROM_PATTERN = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE)
@@ -21,17 +22,7 @@ DIGEST_PATTERN = re.compile(r"^Digest:\s+(sha256:[a-f0-9]+)", re.MULTILINE)
21
22
  MISSING_VERSION = "<missing>"
22
23
 
23
24
 
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
- )
25
+ type TomlValue = str | int | float | bool | dt.datetime | dt.date | dt.time | list[TomlValue] | dict[str, TomlValue]
35
26
 
36
27
 
37
28
  class TomlDocument(RootModel[TomlValue]):
@@ -70,15 +61,11 @@ def unpinned_references(root: Path) -> list[str]:
70
61
  ]
71
62
 
72
63
 
73
- def declared_references(
74
- root: Path, bake_globs: tuple[str, ...] = ("workloads/images.yml", "compose*.bake.yml")
75
- ) -> list[ImageReference]:
76
- return [
77
- reference
78
- for bake_glob in bake_globs
79
- for path in sorted(root.glob(bake_glob))
80
- for reference in bake_base_image_refs(parse_bake(path))
81
- ]
64
+ def declared_references(root: Path) -> list[ImageReference]:
65
+ manifest = root / MANIFEST_PATH
66
+ if not manifest.exists():
67
+ return []
68
+ return bake_base_image_refs(parse_bake(manifest))
82
69
 
83
70
 
84
71
  def stray_references(
@@ -8,11 +8,9 @@ from .detect_gpu import Gpu
8
8
  DEFAULT_COMPOSE_FILE = Path("compose.yml")
9
9
  GPU_PLACEHOLDER = "{gpu}"
10
10
 
11
- # The image manifest and its lock are a named pair living in the same directory. The
12
- # legacy names keep resolving so a repo can upgrade the devkit before moving its files.
13
- MANIFEST_CANDIDATES = (Path("workloads/images.yml"), Path("compose.bake.yml"))
14
- MANIFEST_LOCK_PAIR = {"images.yml": "images.lock", "compose.bake.yml": ".env.lock"}
15
- LOCK_CANDIDATES = (Path("workloads/images.lock"), Path(".env.lock"))
11
+ # The image manifest and its lock are a named pair living in the same directory.
12
+ MANIFEST_PATH = Path("workloads/images.yml")
13
+ LOCK_PATH = Path("workloads/images.lock")
16
14
 
17
15
 
18
16
  def load_lifecycle_config(root: Path) -> LifecycleConfig | None:
@@ -21,27 +19,25 @@ def load_lifecycle_config(root: Path) -> LifecycleConfig | None:
21
19
 
22
20
 
23
21
  def resolve_manifest(root: Path) -> Path | None:
24
- return next((root / candidate for candidate in MANIFEST_CANDIDATES if (root / candidate).exists()), None)
22
+ manifest = root / MANIFEST_PATH
23
+ return manifest if manifest.exists() else None
25
24
 
26
25
 
27
26
  def require_manifest(root: Path) -> Path:
28
27
  manifest = resolve_manifest(root)
29
28
  if manifest is None:
30
- raise RuntimeError(f"No image manifest found; {MANIFEST_CANDIDATES[0]} is the canonical name")
29
+ raise RuntimeError(f"No image manifest at {MANIFEST_PATH}")
31
30
  return manifest
32
31
 
33
32
 
34
- def resolve_lock(root: Path, manifest: Path | None) -> Path:
35
- if manifest is not None:
36
- return manifest.parent / MANIFEST_LOCK_PAIR[manifest.name]
37
- existing = next((root / candidate for candidate in LOCK_CANDIDATES if (root / candidate).exists()), None)
38
- return existing if existing is not None else root / LOCK_CANDIDATES[0]
33
+ def resolve_lock(root: Path) -> Path:
34
+ return root / LOCK_PATH
39
35
 
40
36
 
41
37
  def enforce_bake_declaration(root: Path, config: LifecycleConfig | None) -> None:
42
- if config is None and any((root / candidate).exists() for candidate in MANIFEST_CANDIDATES):
38
+ if config is None and (root / MANIFEST_PATH).exists():
43
39
  raise RuntimeError(
44
- "image manifest present without a lifecycle section in docker-devkit.yaml; declare the section or rename the file"
40
+ "image manifest present without a lifecycle section in docker-devkit.yaml; declare the section"
45
41
  )
46
42
 
47
43
 
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shlex
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from bashrun.bash import bash, bash_check
9
+
10
+ from .config import MirrorConfig, load_config
11
+ from .image_refs import declared_references
12
+
13
+ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
14
+
15
+ CRANE_VERSION = "v0.22.1"
16
+
17
+
18
+ @app.command()
19
+ def mirror() -> None:
20
+ root = Path.cwd()
21
+ config = load_mirror_config(root)
22
+ if config is None:
23
+ raise RuntimeError("a mirror section with 'prefix' is required in docker-devkit.yaml to mirror")
24
+
25
+ targets = sorted(declared_references(root), key=lambda occurrence: occurrence.reference)
26
+ print(f"Install crane {CRANE_VERSION}")
27
+ sudo = "sudo " if os.geteuid() != 0 else ""
28
+ archive = "go-containerregistry_Linux_x86_64.tar.gz"
29
+ bash(f"curl -fsSLO https://github.com/google/go-containerregistry/releases/download/{CRANE_VERSION}/{archive}")
30
+ bash(f"{sudo}tar -xzf {archive} -C /usr/local/bin crane")
31
+ Path(archive).unlink()
32
+ for target in targets:
33
+ destination = mirrored_ref(target.reference, config.prefix)
34
+ print(f"Mirror {target.reference} -> {destination}")
35
+ bash(f"crane copy {target.reference} {destination}")
36
+ print(f"Mirrored images: {len(targets)}")
37
+
38
+
39
+ def load_mirror_config(root: Path) -> MirrorConfig | None:
40
+ config = load_config(root)
41
+ return config.mirror if config is not None else None
42
+
43
+
44
+ def mirrored_ref(reference: str, prefix: str) -> str:
45
+ if reference.startswith(f"{prefix}/"):
46
+ raise RuntimeError(
47
+ f"x-base-images values are upstream-native; {reference} already carries the mirror prefix {prefix}"
48
+ )
49
+ return f"{prefix}/{reference}"
50
+
51
+
52
+ def reference_exists(reference: str) -> bool:
53
+ return bash_check(f"docker buildx imagetools inspect {shlex.quote(reference)}")
54
+
55
+
56
+ def enforce_mirrored(lock_data: dict[str, str]) -> None:
57
+ for value in sorted(lock_data.values()):
58
+ if reference_exists(value):
59
+ continue
60
+ reference, _, digest = value.rpartition("@")
61
+ raise RuntimeError(
62
+ f"Image {reference or value} (digest {digest or 'unknown'}) is not mirrored yet; "
63
+ "the CI mirror job populates it when the declaring change runs"
64
+ )
65
+
66
+
67
+ def main() -> None:
68
+ app()
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
@@ -16,7 +16,7 @@ from .lifecycle import (
16
16
  resolve_lock,
17
17
  resolve_manifest,
18
18
  )
19
- from .mirror import load_mirror_config, upstream_fallback, write_fallback_overlay
19
+ from .mirror import enforce_mirrored, load_mirror_config
20
20
  from .modes import parse_env_file, resolve_auth_mode
21
21
 
22
22
  ENV_FILE = Path(".env")
@@ -42,19 +42,12 @@ def up(
42
42
  "--dev",
43
43
  help="Layer the declared dev overlay (compose.dev.yml shape) over the production stack for bind-mount/debug bring-up.",
44
44
  ),
45
- allow_upstream_fallback: Annotated[
46
- bool,
47
- typer.Option(
48
- "--allow-upstream-fallback",
49
- help="Pull unmirrored lock entries from upstream at the same digest instead of failing closed.",
50
- ),
51
- ] = False,
52
45
  ) -> None:
53
46
  root = Path.cwd()
54
47
  config = load_lifecycle_config(root)
55
48
  enforce_bake_declaration(root, config)
56
49
  manifest = resolve_manifest(root)
57
- lock_file = resolve_lock(root, manifest)
50
+ lock_file = resolve_lock(root)
58
51
 
59
52
  if dev and (config is None or config.dev_file is None):
60
53
  raise RuntimeError("--dev requires a dev_file in the lifecycle section of docker-devkit.yaml")
@@ -75,21 +68,12 @@ def up(
75
68
 
76
69
  auth_mode = resolve_auth_mode(ENV_FILE)
77
70
 
78
- substitutions: dict[str, str] = {}
79
- fallback_overlay: Path | None = None
80
71
  mirror_config = load_mirror_config(root)
81
72
  if mirror_config is not None:
82
- substitutions = upstream_fallback(
83
- parse_env_file(lock_file), mirror_config.prefix, allow_upstream_fallback=allow_upstream_fallback
84
- )
85
- if substitutions:
86
- for name, upstream in sorted(substitutions.items()):
87
- print(f"Falling back to upstream for {name}: {upstream}", flush=True)
88
- os.environ.update(substitutions)
89
- fallback_overlay = write_fallback_overlay(substitutions)
73
+ enforce_mirrored(parse_env_file(lock_file))
90
74
 
91
75
  if build:
92
- run_build(gpu=gpu, env_overlay=substitutions)
76
+ run_build(gpu=gpu)
93
77
 
94
78
  if manifest is not None:
95
79
  os.environ.update(compute_service_shas(root, parse_bake(manifest)))
@@ -101,8 +85,6 @@ def up(
101
85
  files_args = " ".join(f"-f {compose_file}" for compose_file in compose_files)
102
86
  profile_flag = "--profile keycloak " if auth_mode == "keycloak" else ""
103
87
  compose_args = f"{files_args} {profile_flag}--project-directory {root} --env-file .env --env-file {lock_file}"
104
- if fallback_overlay is not None:
105
- compose_args += f" --env-file {fallback_overlay}"
106
88
 
107
89
  up_command = f"docker compose {compose_args} up"
108
90
  if not build: