docker-devkit 0.3.0.dev116__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 (44) hide show
  1. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/AGENTS.md +12 -13
  2. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/PKG-INFO +1 -1
  3. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/pyproject.toml +1 -1
  4. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/release-devkit.yaml +1 -1
  5. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/build_docker.py +29 -23
  6. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/down.py +1 -1
  7. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/image_refs.py +6 -9
  8. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/lifecycle.py +10 -14
  9. docker_devkit-0.4.0/src/docker_devkit/mirror.py +72 -0
  10. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/up.py +4 -22
  11. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_image_refs.py +16 -26
  12. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_lifecycle.py +3 -35
  13. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_mirror.py +65 -110
  14. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_score_generate.py +3 -3
  15. docker_devkit-0.3.0.dev116/src/docker_devkit/mirror.py +0 -95
  16. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.gitattributes +0 -0
  17. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.github/workflows/integrate.yml +0 -0
  18. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.github/workflows/merge-gate.yml +0 -0
  19. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.github/workflows/release.yml +0 -0
  20. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.gitignore +0 -0
  21. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.python-version +0 -0
  22. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/CLAUDE.md +0 -0
  23. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/LICENSE +0 -0
  24. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/NOTICE +0 -0
  25. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/README.md +0 -0
  26. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/ruff.base.toml +0 -0
  27. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/ruff.toml +0 -0
  28. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/scripts/preflight.py +0 -0
  29. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/__init__.py +0 -0
  30. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/config.py +0 -0
  31. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/context_sha.py +0 -0
  32. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/detect_gpu.py +0 -0
  33. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/documents.py +0 -0
  34. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/matrix.py +0 -0
  35. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/modes.py +0 -0
  36. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/py.typed +0 -0
  37. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/score_generate.py +0 -0
  38. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_build_docker.py +0 -0
  39. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_config.py +0 -0
  40. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_context_sha.py +0 -0
  41. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_default_targets.py +0 -0
  42. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_documents.py +0 -0
  43. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_matrix.py +0 -0
  44. {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/uv.lock +0 -0
@@ -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.dev116
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.dev116"
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
@@ -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")
@@ -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)
@@ -60,15 +61,11 @@ def unpinned_references(root: Path) -> list[str]:
60
61
  ]
61
62
 
62
63
 
63
- def declared_references(
64
- root: Path, bake_globs: tuple[str, ...] = ("workloads/images.yml", "compose*.bake.yml")
65
- ) -> list[ImageReference]:
66
- return [
67
- reference
68
- for bake_glob in bake_globs
69
- for path in sorted(root.glob(bake_glob))
70
- for reference in bake_base_image_refs(parse_bake(path))
71
- ]
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))
72
69
 
73
70
 
74
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:
@@ -24,14 +24,14 @@ VERSION_COUPLINGS = [
24
24
  VersionCoupling(
25
25
  name="uv",
26
26
  pyproject_key="tool.uv.required-version",
27
- sites=(VersionSite("uv base tag", "compose*.bake.yml", r"uv:([^@]+?)-", "UV_BASE_IMAGE"),),
27
+ sites=(VersionSite("uv base tag", "workloads/images.yml", r"uv:([^@]+?)-", "UV_BASE_IMAGE"),),
28
28
  ),
29
29
  VersionCoupling(
30
30
  name="python",
31
31
  pyproject_key="project.requires-python",
32
32
  sites=(
33
- VersionSite("uv base python component", "compose*.bake.yml", r"python([0-9][0-9.]*)", "UV_BASE_IMAGE"),
34
- VersionSite("python base tag", "compose.zed.bake.yml", r"python:([0-9][0-9.]*)", "PYTHON_BASE_IMAGE"),
33
+ VersionSite("uv base python component", "workloads/images.yml", r"python([0-9][0-9.]*)", "UV_BASE_IMAGE"),
34
+ VersionSite("python base tag", "workloads/images.yml", r"python:([0-9][0-9.]*)", "PYTHON_BASE_IMAGE"),
35
35
  VersionSite("uv python install", "docker/zed-capture/Dockerfile", r"uv python install ([0-9][0-9.]*)"),
36
36
  ),
37
37
  ),
@@ -66,10 +66,6 @@ class TestDeclaredReferences:
66
66
  )
67
67
  assert declared_references(tmp_path) == [ImageReference("ALPINE_IMAGE", "alpine:3.20")]
68
68
 
69
- def test_should_yield_legacy_bake_declarations(self, tmp_path: Path):
70
- (tmp_path / "compose.bake.yml").write_text("x-base-images:\n ALPINE_IMAGE: alpine:3.20\n", encoding="utf-8")
71
- assert declared_references(tmp_path) == [ImageReference("ALPINE_IMAGE", "alpine:3.20")]
72
-
73
69
  def test_should_return_empty_for_empty_tree(self, tmp_path: Path):
74
70
  assert declared_references(tmp_path) == []
75
71
 
@@ -83,7 +79,8 @@ class TestDeclaredReferences:
83
79
  assert declared_references(tmp_path) == []
84
80
 
85
81
  def test_should_reject_unpinned_declaration(self, tmp_path: Path):
86
- (tmp_path / "compose.bake.yml").write_text("x-base-images:\n ALPINE_IMAGE: alpine\n", encoding="utf-8")
82
+ (tmp_path / "workloads").mkdir()
83
+ (tmp_path / "workloads" / "images.yml").write_text("x-base-images:\n ALPINE_IMAGE: alpine\n", encoding="utf-8")
87
84
  with pytest.raises(ValidationError):
88
85
  declared_references(tmp_path)
89
86
 
@@ -179,14 +176,11 @@ class TestVersionCouplingViolations:
179
176
  (tmp_path / "pyproject.toml").write_text(
180
177
  '[project]\nrequires-python = ">=3.13"\n\n[tool.uv]\nrequired-version = "==0.12.15"\n', encoding="utf-8"
181
178
  )
182
- (tmp_path / "compose.bake.yml").write_text(
183
- f"x-base-images:\n UV_BASE_IMAGE: ghcr.io/outernet-foundation/mirror/ghcr.io/astral-sh/uv:{uv_tag}\n",
184
- encoding="utf-8",
185
- )
186
- (tmp_path / "compose.zed.bake.yml").write_text(
179
+ (tmp_path / "workloads").mkdir()
180
+ (tmp_path / "workloads" / "images.yml").write_text(
187
181
  "x-base-images:\n"
188
- f" UV_BASE_IMAGE: ghcr.io/outernet-foundation/mirror/ghcr.io/astral-sh/uv:{uv_tag}\n"
189
- f" PYTHON_BASE_IMAGE: ghcr.io/outernet-foundation/mirror/docker.io/library/python:{python_tag}\n",
182
+ f" UV_BASE_IMAGE: ghcr.io/astral-sh/uv:{uv_tag}\n"
183
+ f" PYTHON_BASE_IMAGE: docker.io/library/python:{python_tag}\n",
190
184
  encoding="utf-8",
191
185
  )
192
186
  docker_directory = tmp_path / "docker" / "zed-capture"
@@ -200,40 +194,36 @@ class TestVersionCouplingViolations:
200
194
  def test_should_flag_drifted_uv_base_tag(self, tmp_path: Path):
201
195
  self._write_version_tree(tmp_path, uv_tag="0.12.14-python3.13-trixie-slim")
202
196
  assert version_coupling_violations(tmp_path, VERSION_COUPLINGS) == [
203
- f"uv: uv base tag at {tmp_path / 'compose.bake.yml'}: expected 0.12.15, found 0.12.14",
204
- f"uv: uv base tag at {tmp_path / 'compose.zed.bake.yml'}: expected 0.12.15, found 0.12.14",
197
+ f"uv: uv base tag at {tmp_path / 'workloads' / 'images.yml'}: expected 0.12.15, found 0.12.14",
205
198
  ]
206
199
 
207
200
  def test_should_flag_unversioned_uv_base_tag(self, tmp_path: Path):
208
201
  self._write_version_tree(tmp_path, uv_tag="python3.13-trixie-slim")
209
202
  assert version_coupling_violations(tmp_path, VERSION_COUPLINGS) == [
210
- f"uv: uv base tag at {tmp_path / 'compose.bake.yml'}: expected 0.12.15, found python3.13",
211
- f"uv: uv base tag at {tmp_path / 'compose.zed.bake.yml'}: expected 0.12.15, found python3.13",
203
+ f"uv: uv base tag at {tmp_path / 'workloads' / 'images.yml'}: expected 0.12.15, found python3.13",
212
204
  ]
213
205
 
214
206
  def test_should_flag_uv_base_without_python_component(self, tmp_path: Path):
215
207
  self._write_version_tree(tmp_path, uv_tag="0.12.15-trixie-slim")
216
208
  assert version_coupling_violations(tmp_path, VERSION_COUPLINGS) == [
217
- f"python: uv base python component at {tmp_path / 'compose.bake.yml'}: expected 3.13, found <missing>",
218
- f"python: uv base python component at {tmp_path / 'compose.zed.bake.yml'}: expected 3.13, found <missing>",
209
+ f"python: uv base python component at {tmp_path / 'workloads' / 'images.yml'}: expected 3.13, found <missing>",
219
210
  ]
220
211
 
221
212
  def test_should_flag_python_component_drift(self, tmp_path: Path):
222
213
  self._write_version_tree(tmp_path, python_tag="3.14-slim@sha256:" + "c" * 64, uv_python_install="3.14")
223
214
  assert version_coupling_violations(tmp_path, VERSION_COUPLINGS) == [
224
- f"python: python base tag at {tmp_path / 'compose.zed.bake.yml'}: expected 3.13, found 3.14",
215
+ f"python: python base tag at {tmp_path / 'workloads' / 'images.yml'}: expected 3.13, found 3.14",
225
216
  f"python: uv python install at {tmp_path / 'docker' / 'zed-capture' / 'Dockerfile'}: expected 3.13, found 3.14",
226
217
  ]
227
218
 
228
219
  def test_should_flag_missing_base_image_key(self, tmp_path: Path):
229
220
  self._write_version_tree(tmp_path)
230
- (tmp_path / "compose.zed.bake.yml").write_text(
231
- "x-base-images:\n"
232
- " UV_BASE_IMAGE: ghcr.io/outernet-foundation/mirror/ghcr.io/astral-sh/uv:0.12.15-python3.13-trixie-slim\n",
221
+ (tmp_path / "workloads" / "images.yml").write_text(
222
+ "x-base-images:\n UV_BASE_IMAGE: ghcr.io/astral-sh/uv:0.12.15-python3.13-trixie-slim\n",
233
223
  encoding="utf-8",
234
224
  )
235
225
  assert version_coupling_violations(tmp_path, VERSION_COUPLINGS) == [
236
- f"python: python base tag at {tmp_path / 'compose.zed.bake.yml'}: expected 3.13, found <missing>"
226
+ f"python: python base tag at {tmp_path / 'workloads' / 'images.yml'}: expected 3.13, found <missing>"
237
227
  ]
238
228
 
239
229
  def test_should_flag_site_whose_files_are_absent(self, tmp_path: Path):
@@ -114,40 +114,19 @@ class TestExpandComposeFiles:
114
114
 
115
115
 
116
116
  class TestResolveManifest:
117
- def test_prefers_images_manifest(self, tmp_path: Path):
117
+ def test_resolves_images_manifest(self, tmp_path: Path):
118
118
  (tmp_path / "workloads").mkdir()
119
119
  (tmp_path / "workloads" / "images.yml").write_text("", encoding="utf-8")
120
- (tmp_path / "compose.bake.yml").write_text("", encoding="utf-8")
121
120
 
122
121
  assert resolve_manifest(tmp_path) == tmp_path / "workloads" / "images.yml"
123
122
 
124
- def test_falls_back_to_legacy_bake_file(self, tmp_path: Path):
125
- (tmp_path / "compose.bake.yml").write_text("", encoding="utf-8")
126
-
127
- assert resolve_manifest(tmp_path) == tmp_path / "compose.bake.yml"
128
-
129
123
  def test_returns_none_without_manifest(self, tmp_path: Path):
130
124
  assert resolve_manifest(tmp_path) is None
131
125
 
132
126
 
133
127
  class TestResolveLock:
134
- def test_images_manifest_pairs_with_images_lock(self, tmp_path: Path):
135
- manifest = tmp_path / "workloads" / "images.yml"
136
-
137
- assert resolve_lock(tmp_path, manifest) == tmp_path / "workloads" / "images.lock"
138
-
139
- def test_legacy_manifest_pairs_with_legacy_lock(self, tmp_path: Path):
140
- manifest = tmp_path / "compose.bake.yml"
141
-
142
- assert resolve_lock(tmp_path, manifest) == tmp_path / ".env.lock"
143
-
144
- def test_without_manifest_prefers_existing_lock(self, tmp_path: Path):
145
- (tmp_path / ".env.lock").write_text("", encoding="utf-8")
146
-
147
- assert resolve_lock(tmp_path, None) == tmp_path / ".env.lock"
148
-
149
- def test_without_manifest_defaults_to_images_lock(self, tmp_path: Path):
150
- assert resolve_lock(tmp_path, None) == tmp_path / "workloads" / "images.lock"
128
+ def test_pairs_with_the_manifest(self, tmp_path: Path):
129
+ assert resolve_lock(tmp_path) == tmp_path / "workloads" / "images.lock"
151
130
 
152
131
 
153
132
  class TestEnforceBakeDeclaration:
@@ -157,11 +136,6 @@ class TestEnforceBakeDeclaration:
157
136
 
158
137
  enforce_bake_declaration(tmp_path, LifecycleConfig())
159
138
 
160
- def test_accepts_bake_with_declaration(self, tmp_path: Path):
161
- (tmp_path / "compose.bake.yml").write_text("", encoding="utf-8")
162
-
163
- enforce_bake_declaration(tmp_path, LifecycleConfig())
164
-
165
139
  def test_accepts_missing_manifest_without_declaration(self, tmp_path: Path):
166
140
  enforce_bake_declaration(tmp_path, None)
167
141
 
@@ -171,9 +145,3 @@ class TestEnforceBakeDeclaration:
171
145
 
172
146
  with pytest.raises(RuntimeError, match="lifecycle"):
173
147
  enforce_bake_declaration(tmp_path, None)
174
-
175
- def test_rejects_bake_without_declaration(self, tmp_path: Path):
176
- (tmp_path / "compose.bake.yml").write_text("", encoding="utf-8")
177
-
178
- with pytest.raises(RuntimeError, match="lifecycle"):
179
- enforce_bake_declaration(tmp_path, None)
@@ -1,5 +1,4 @@
1
1
  import os
2
- import tempfile
3
2
  from pathlib import Path
4
3
 
5
4
  import pytest
@@ -9,22 +8,14 @@ from typer.testing import CliRunner
9
8
 
10
9
  from docker_devkit import build_docker, mirror
11
10
  from docker_devkit.documents import BakeDocument
12
- from docker_devkit.image_refs import ImageReference
13
11
  from docker_devkit.mirror import MirrorConfig
14
12
 
15
13
  MIRROR_PREFIX = "ghcr.io/outernet-foundation/mirror"
16
14
  FAKE_DIGEST = "sha256:" + "a" * 64
17
15
 
18
- MIRROR_REFERENCES = [
19
- f"{MIRROR_PREFIX}/docker.io/library/python:3.13-slim@{FAKE_DIGEST}",
20
- f"{MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15-python3.13-trixie-slim",
21
- f"{MIRROR_PREFIX}/quay.io/keycloak/keycloak:26.3.5@{FAKE_DIGEST}",
22
- f"{MIRROR_PREFIX}/docker.io/livekit/livekit-server:v1.12.0@{FAKE_DIGEST}",
23
- ]
24
-
25
16
  MANIFEST = (
26
17
  "x-base-images:\n"
27
- f" UV_IMAGE: {MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15\n"
18
+ " UV_IMAGE: ghcr.io/astral-sh/uv:0.12.15\n"
28
19
  " API_IMAGE: ghcr.io/outernet-foundation/placeframe/api:1.2.3\n"
29
20
  "services:\n"
30
21
  " api:\n"
@@ -35,9 +26,8 @@ MANIFEST = (
35
26
  )
36
27
 
37
28
  LOCKED_ERROR = (
38
- f"Image {MIRROR_PREFIX}/docker.io/library/alpine:3.20 (digest {FAKE_DIGEST}) is not mirrored yet. "
39
- "If this is a new upstream dependency that has not been through CI, pass --allow-upstream-fallback "
40
- "to pull the same digest from upstream for this run."
29
+ f"Image {MIRROR_PREFIX}/docker.io/library/alpine:3.20 (digest {FAKE_DIGEST}) is not mirrored yet; "
30
+ "the CI mirror job populates it when the declaring change runs"
41
31
  )
42
32
 
43
33
  runner = CliRunner()
@@ -108,10 +98,6 @@ def _bash_check_false(command: str) -> bool:
108
98
  return False
109
99
 
110
100
 
111
- def _must_not_be_probed(reference: str) -> bool:
112
- raise AssertionError(f"unexpected existence probe: {reference}")
113
-
114
-
115
101
  def _no_service_shas(root: Path, bake: BakeDocument) -> dict[str, str]:
116
102
  return {}
117
103
 
@@ -124,29 +110,6 @@ def _fixed_digest(reference: str) -> str:
124
110
  return FAKE_DIGEST
125
111
 
126
112
 
127
- class TestInversion:
128
- @pytest.mark.parametrize("reference", MIRROR_REFERENCES)
129
- def test_round_trips_through_the_prefix(self, reference: str):
130
- assert mirror.is_mirrored(reference, MIRROR_PREFIX)
131
- assert f"{MIRROR_PREFIX}/{mirror.upstream_ref(reference, MIRROR_PREFIX)}" == reference
132
-
133
- @pytest.mark.parametrize("reference", MIRROR_REFERENCES)
134
- def test_preserves_tag_and_digest_tail(self, reference: str):
135
- assert mirror.upstream_ref(reference, MIRROR_PREFIX).rpartition("/")[2] == reference.rpartition("/")[2]
136
-
137
- def test_dockerio_path_maps_to_fully_qualified_upstream(self):
138
- reference = f"{MIRROR_PREFIX}/docker.io/livekit/livekit-server:v1.12.0@{FAKE_DIGEST}"
139
- assert (
140
- mirror.upstream_ref(reference, MIRROR_PREFIX) == f"docker.io/livekit/livekit-server:v1.12.0@{FAKE_DIGEST}"
141
- )
142
-
143
- def test_prefix_boundary_requires_slash(self):
144
- assert not mirror.is_mirrored(f"{MIRROR_PREFIX}-evil/docker.io/library/alpine:3.20", MIRROR_PREFIX)
145
-
146
- def test_publish_entries_are_not_mirrored(self):
147
- assert not mirror.is_mirrored("ghcr.io/outernet-foundation/placeframe/api:1.2.3", MIRROR_PREFIX)
148
-
149
-
150
113
  class TestLoadMirrorConfig:
151
114
  def test_loads_declared_prefix(self, tmp_path: Path):
152
115
  _write_config(tmp_path, {"mirror": {"prefix": MIRROR_PREFIX}})
@@ -174,14 +137,15 @@ class TestLoadMirrorConfig:
174
137
  mirror.load_mirror_config(tmp_path)
175
138
 
176
139
 
177
- class TestMirrorTargets:
178
- def test_filters_declared_references_by_prefix_and_sorts(self, tmp_path: Path):
179
- (tmp_path / "workloads").mkdir()
180
- (tmp_path / "workloads" / "images.yml").write_text(MANIFEST, encoding="utf-8")
140
+ class TestMirroredRef:
141
+ def test_prefixes_upstream_native_references(self):
142
+ assert mirror.mirrored_ref("ghcr.io/astral-sh/uv:0.12.15", MIRROR_PREFIX) == (
143
+ f"{MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15"
144
+ )
181
145
 
182
- assert mirror.mirror_targets(tmp_path, MIRROR_PREFIX) == [
183
- ImageReference("UV_IMAGE", f"{MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15")
184
- ]
146
+ def test_rejects_already_prefixed_references(self):
147
+ with pytest.raises(RuntimeError, match="upstream-native"):
148
+ mirror.mirrored_ref(f"{MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15", MIRROR_PREFIX)
185
149
 
186
150
 
187
151
  class TestReferenceExists:
@@ -198,59 +162,33 @@ class TestReferenceExists:
198
162
  assert not mirror.reference_exists("docker.io/library/alpine:3.20")
199
163
 
200
164
 
201
- class TestUpstreamFallback:
165
+ class TestEnforceMirrored:
202
166
  def test_fails_closed_with_the_locked_message(self, monkeypatch: pytest.MonkeyPatch):
203
167
  monkeypatch.setattr(mirror, "reference_exists", _bash_check_false)
204
168
  lock_data = {"ALPINE_IMAGE": f"{MIRROR_PREFIX}/docker.io/library/alpine:3.20@{FAKE_DIGEST}"}
205
169
 
206
170
  with pytest.raises(RuntimeError) as excinfo:
207
- mirror.upstream_fallback(lock_data, MIRROR_PREFIX, allow_upstream_fallback=False)
171
+ mirror.enforce_mirrored(lock_data)
208
172
 
209
173
  assert str(excinfo.value) == LOCKED_ERROR
210
174
 
211
- def test_flag_substitutes_exactly_the_missing_entries(self, monkeypatch: pytest.MonkeyPatch):
212
- present = f"{MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15"
213
- absent = f"{MIRROR_PREFIX}/docker.io/library/alpine:3.20@{FAKE_DIGEST}"
214
- existence = SelectiveExistence(present)
215
- monkeypatch.setattr(mirror, "reference_exists", existence.exists)
216
- lock_data = {
217
- "UV_IMAGE": present,
218
- "ALPINE_IMAGE": absent,
219
- "API_IMAGE": "ghcr.io/outernet-foundation/placeframe/api:1.2.3",
220
- }
221
-
222
- substitutions = mirror.upstream_fallback(lock_data, MIRROR_PREFIX, allow_upstream_fallback=True)
223
-
224
- assert substitutions == {"ALPINE_IMAGE": f"docker.io/library/alpine:3.20@{FAKE_DIGEST}"}
225
- assert existence.probed == [present, absent]
226
-
227
- def test_returns_empty_when_everything_is_mirrored(self, monkeypatch: pytest.MonkeyPatch):
175
+ def test_passes_when_every_entry_exists(self, monkeypatch: pytest.MonkeyPatch):
228
176
  monkeypatch.setattr(mirror, "reference_exists", _always_true)
229
177
  lock_data = {"ALPINE_IMAGE": f"{MIRROR_PREFIX}/docker.io/library/alpine:3.20@{FAKE_DIGEST}"}
230
178
 
231
- assert mirror.upstream_fallback(lock_data, MIRROR_PREFIX, allow_upstream_fallback=False) == {}
179
+ mirror.enforce_mirrored(lock_data)
232
180
 
233
- def test_non_mirror_entries_are_never_probed(self, monkeypatch: pytest.MonkeyPatch):
234
- monkeypatch.setattr(mirror, "reference_exists", _must_not_be_probed)
235
- lock_data = {"API_IMAGE": "ghcr.io/outernet-foundation/placeframe/api:1.2.3"}
236
-
237
- assert mirror.upstream_fallback(lock_data, MIRROR_PREFIX, allow_upstream_fallback=True) == {}
238
-
239
-
240
- class TestWriteFallbackOverlay:
241
- def test_writes_sorted_entries_under_a_pid_named_tempfile(self):
242
- substitutions = {
243
- "UV_IMAGE": "ghcr.io/astral-sh/uv:0.12.15",
244
- "ALPINE_IMAGE": f"docker.io/library/alpine:3.20@{FAKE_DIGEST}",
245
- }
181
+ def test_reports_the_first_missing_entry_deterministically(self, monkeypatch: pytest.MonkeyPatch):
182
+ present = f"{MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15@{FAKE_DIGEST}"
183
+ absent = f"{MIRROR_PREFIX}/docker.io/library/alpine:3.20@{FAKE_DIGEST}"
184
+ existence = SelectiveExistence(present)
185
+ monkeypatch.setattr(mirror, "reference_exists", existence.exists)
186
+ lock_data = {"UV_IMAGE": present, "ALPINE_IMAGE": absent}
246
187
 
247
- overlay = mirror.write_fallback_overlay(substitutions)
188
+ with pytest.raises(RuntimeError, match="alpine"):
189
+ mirror.enforce_mirrored(lock_data)
248
190
 
249
- assert overlay.parent == Path(tempfile.gettempdir())
250
- assert overlay.name == f"docker-devkit-upstream-fallback-{os.getpid()}.env"
251
- assert overlay.read_text(encoding="utf-8") == (
252
- f"ALPINE_IMAGE=docker.io/library/alpine:3.20@{FAKE_DIGEST}\nUV_IMAGE=ghcr.io/astral-sh/uv:0.12.15\n"
253
- )
191
+ assert existence.probed == [absent]
254
192
 
255
193
 
256
194
  class TestMirrorVerb:
@@ -259,7 +197,7 @@ class TestMirrorVerb:
259
197
  (tmp_path / "workloads").mkdir()
260
198
  (tmp_path / "workloads" / "images.yml").write_text(MANIFEST, encoding="utf-8")
261
199
 
262
- def test_copies_each_mirror_target_from_upstream(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
200
+ def test_copies_every_declared_reference(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
263
201
  recorder = CommandRecorder()
264
202
  monkeypatch.setattr(mirror, "bash", recorder.run)
265
203
  monkeypatch.chdir(tmp_path)
@@ -268,10 +206,13 @@ class TestMirrorVerb:
268
206
  result = runner.invoke(mirror.app, [])
269
207
 
270
208
  assert result.exit_code == 0
209
+ upstream_uv = "ghcr.io/astral-sh/uv:0.12.15"
210
+ upstream_api = "ghcr.io/outernet-foundation/placeframe/api:1.2.3"
271
211
  assert [command for command in recorder.commands if command.startswith("crane copy")] == [
272
- f"crane copy ghcr.io/astral-sh/uv:0.12.15 {MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15"
212
+ f"crane copy {upstream_uv} {MIRROR_PREFIX}/{upstream_uv}",
213
+ f"crane copy {upstream_api} {MIRROR_PREFIX}/{upstream_api}",
273
214
  ]
274
- assert "Mirrored images: 1" in result.stdout
215
+ assert "Mirrored images: 2" in result.stdout
275
216
 
276
217
  def test_errors_without_mirror_table(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
277
218
  monkeypatch.chdir(tmp_path)
@@ -283,13 +224,13 @@ class TestMirrorVerb:
283
224
 
284
225
 
285
226
  class TestUpstreamAwareLockResolution:
286
- def _write_tree(self, tmp_path: Path, *, mirror: bool) -> None:
227
+ def _write_tree(self, tmp_path: Path, *, mirror: bool, manifest: str = MANIFEST) -> None:
287
228
  if mirror:
288
229
  _write_config(tmp_path, {"mirror": {"prefix": MIRROR_PREFIX}})
289
230
  (tmp_path / "workloads").mkdir()
290
- (tmp_path / "workloads" / "images.yml").write_text(MANIFEST, encoding="utf-8")
231
+ (tmp_path / "workloads" / "images.yml").write_text(manifest, encoding="utf-8")
291
232
 
292
- def test_resolves_mirror_declarations_against_upstream(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
233
+ def test_records_declarations_under_the_mirror_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
293
234
  self._write_tree(tmp_path, mirror=True)
294
235
  monkeypatch.chdir(tmp_path)
295
236
  recorder = DigestRecorder()
@@ -303,25 +244,49 @@ class TestUpstreamAwareLockResolution:
303
244
  "ghcr.io/outernet-foundation/placeframe/api:1.2.3",
304
245
  ]
305
246
  lock = (tmp_path / "workloads" / "images.lock").read_text(encoding="utf-8")
306
- assert f"API_IMAGE=ghcr.io/outernet-foundation/placeframe/api:1.2.3@{FAKE_DIGEST}" in lock
247
+ assert f"API_IMAGE={MIRROR_PREFIX}/ghcr.io/outernet-foundation/placeframe/api:1.2.3@{FAKE_DIGEST}" in lock
307
248
  assert f"UV_IMAGE={MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15@{FAKE_DIGEST}" in lock
308
249
 
309
- def test_without_table_inspects_the_declared_reference(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
250
+ def test_requires_a_mirror_section_for_base_images(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
310
251
  self._write_tree(tmp_path, mirror=False)
311
252
  monkeypatch.chdir(tmp_path)
253
+ monkeypatch.setattr(build_docker, "compute_service_shas", _no_service_shas)
254
+
255
+ with pytest.raises(RuntimeError, match="mirror section"):
256
+ build_docker.run_build(lock_only=True)
257
+
258
+ def test_digest_pinned_declarations_pass_through_prefixed(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
259
+ manifest = f"x-base-images:\n ALPINE_IMAGE: docker.io/library/alpine@{FAKE_DIGEST}\n"
260
+ self._write_tree(tmp_path, mirror=True, manifest=manifest)
261
+ monkeypatch.chdir(tmp_path)
312
262
  recorder = DigestRecorder()
313
263
  monkeypatch.setattr(build_docker, "resolve_remote_digest", recorder.resolve)
314
264
  monkeypatch.setattr(build_docker, "compute_service_shas", _no_service_shas)
315
265
 
316
266
  build_docker.run_build(lock_only=True)
317
267
 
318
- assert recorder.inspected == [
319
- f"{MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15",
320
- "ghcr.io/outernet-foundation/placeframe/api:1.2.3",
321
- ]
268
+ assert recorder.inspected == []
269
+ lock = (tmp_path / "workloads" / "images.lock").read_text(encoding="utf-8")
270
+ assert f"ALPINE_IMAGE={MIRROR_PREFIX}/docker.io/library/alpine@{FAKE_DIGEST}" in lock
271
+
272
+ def test_cached_entries_resolve_again_only_on_upgrade(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
273
+ self._write_tree(tmp_path, mirror=True)
274
+ (tmp_path / "workloads" / "images.lock").write_text(
275
+ f"API_IMAGE={MIRROR_PREFIX}/ghcr.io/outernet-foundation/placeframe/api:1.2.3@{FAKE_DIGEST}\n"
276
+ f"UV_IMAGE={MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15@{FAKE_DIGEST}\n",
277
+ encoding="utf-8",
278
+ )
279
+ monkeypatch.chdir(tmp_path)
280
+ recorder = DigestRecorder()
281
+ monkeypatch.setattr(build_docker, "resolve_remote_digest", recorder.resolve)
282
+ monkeypatch.setattr(build_docker, "compute_service_shas", _no_service_shas)
283
+
284
+ build_docker.run_build(lock_only=True)
285
+
286
+ assert recorder.inspected == []
322
287
 
323
288
 
324
- class TestBakeConsumesEnvOverlay:
289
+ class TestBakeSeesLockValues:
325
290
  def _write_tree(self, tmp_path: Path) -> None:
326
291
  _write_config(tmp_path, {"mirror": {"prefix": MIRROR_PREFIX}})
327
292
  (tmp_path / "workloads").mkdir()
@@ -335,17 +300,7 @@ class TestBakeConsumesEnvOverlay:
335
300
  monkeypatch.setattr(build_docker, "compute_service_shas", _no_service_shas)
336
301
  return probe
337
302
 
338
- def test_overlay_wins_over_lock_values(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
339
- self._write_tree(tmp_path)
340
- monkeypatch.chdir(tmp_path)
341
- probe = self._fake_bake(monkeypatch)
342
- upstream = f"ghcr.io/astral-sh/uv:0.12.15@{FAKE_DIGEST}"
343
-
344
- build_docker.run_build(gpu="none", env_overlay={"UV_IMAGE": upstream})
345
-
346
- assert probe.uv_image == upstream
347
-
348
- def test_without_overlay_bake_sees_mirror_values(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
303
+ def test_bake_sees_mirror_prefixed_lock_values(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
349
304
  self._write_tree(tmp_path)
350
305
  monkeypatch.chdir(tmp_path)
351
306
  probe = self._fake_bake(monkeypatch)
@@ -35,20 +35,20 @@ def make_config(**overrides: str | list[str]) -> ScoreConfig:
35
35
 
36
36
  def test_expand_substitutes_uppercase_placeholders(monkeypatch: pytest.MonkeyPatch) -> None:
37
37
  monkeypatch.setenv("API_SHA", "tree-abc")
38
- expanded = expand_placeholders("image: registry/app:${API_SHA}", "api.yaml", Path("compose.bake.yml"))
38
+ expanded = expand_placeholders("image: registry/app:${API_SHA}", "api.yaml", Path("workloads/images.yml"))
39
39
  assert expanded == "image: registry/app:tree-abc"
40
40
 
41
41
 
42
42
  def test_expand_leaves_score_resource_references(monkeypatch: pytest.MonkeyPatch) -> None:
43
43
  monkeypatch.setenv("API_SHA", "tree-abc")
44
44
  text = "host: ${resources.db.host}"
45
- assert expand_placeholders(text, "api.yaml", Path("compose.bake.yml")) == text
45
+ assert expand_placeholders(text, "api.yaml", Path("workloads/images.yml")) == text
46
46
 
47
47
 
48
48
  def test_expand_exits_on_missing_placeholder(monkeypatch: pytest.MonkeyPatch) -> None:
49
49
  monkeypatch.delenv("API_SHA", raising=False)
50
50
  with pytest.raises(SystemExit, match="API_SHA"):
51
- expand_placeholders("image: registry/app:${API_SHA}", "api.yaml", Path("compose.bake.yml"))
51
+ expand_placeholders("image: registry/app:${API_SHA}", "api.yaml", Path("workloads/images.yml"))
52
52
 
53
53
 
54
54
  def test_sort_documents_orders_by_kind_then_namespace(tmp_path: Path) -> None:
@@ -1,95 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import shlex
5
- import tempfile
6
- from pathlib import Path
7
-
8
- import typer
9
- from bashrun.bash import bash, bash_check
10
-
11
- from .config import MirrorConfig, load_config
12
- from .image_refs import ImageReference, declared_references
13
-
14
- app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
15
-
16
- CRANE_VERSION = "v0.22.1"
17
-
18
-
19
- @app.command()
20
- def mirror() -> None:
21
- root = Path.cwd()
22
- config = load_mirror_config(root)
23
- if config is None:
24
- raise RuntimeError("a mirror section with 'prefix' is required in docker-devkit.yaml to mirror")
25
-
26
- targets = mirror_targets(root, config.prefix)
27
- print(f"Install crane {CRANE_VERSION}")
28
- sudo = "sudo " if os.geteuid() != 0 else ""
29
- archive = "go-containerregistry_Linux_x86_64.tar.gz"
30
- bash(f"curl -fsSLO https://github.com/google/go-containerregistry/releases/download/{CRANE_VERSION}/{archive}")
31
- bash(f"{sudo}tar -xzf {archive} -C /usr/local/bin crane")
32
- Path(archive).unlink()
33
- for target in targets:
34
- upstream = upstream_ref(target.reference, config.prefix)
35
- print(f"Mirror {upstream} -> {target.reference}")
36
- bash(f"crane copy {upstream} {target.reference}")
37
- print(f"Mirrored images: {len(targets)}")
38
-
39
-
40
- def load_mirror_config(root: Path) -> MirrorConfig | None:
41
- config = load_config(root)
42
- return config.mirror if config is not None else None
43
-
44
-
45
- def is_mirrored(reference: str, prefix: str) -> bool:
46
- return reference.startswith(f"{prefix}/")
47
-
48
-
49
- def upstream_ref(reference: str, prefix: str) -> str:
50
- return reference[len(prefix) + 1 :]
51
-
52
-
53
- def reference_exists(reference: str) -> bool:
54
- return bash_check(f"docker buildx imagetools inspect {shlex.quote(reference)}")
55
-
56
-
57
- def mirror_targets(root: Path, prefix: str) -> list[ImageReference]:
58
- return sorted(
59
- (occurrence for occurrence in declared_references(root) if is_mirrored(occurrence.reference, prefix)),
60
- key=lambda occurrence: occurrence.reference,
61
- )
62
-
63
-
64
- def upstream_fallback(lock_data: dict[str, str], prefix: str, *, allow_upstream_fallback: bool) -> dict[str, str]:
65
- missing = {
66
- name: value for name, value in lock_data.items() if is_mirrored(value, prefix) and not reference_exists(value)
67
- }
68
- if not missing:
69
- return {}
70
- if not allow_upstream_fallback:
71
- value = next(iter(missing.values()))
72
- reference, _, digest = value.rpartition("@")
73
- raise RuntimeError(
74
- f"Image {reference or value} (digest {digest or 'unknown'}) is not mirrored yet. "
75
- "If this is a new upstream dependency that has not been through CI, pass --allow-upstream-fallback "
76
- "to pull the same digest from upstream for this run."
77
- )
78
- return {name: upstream_ref(value, prefix) for name, value in missing.items()}
79
-
80
-
81
- def write_fallback_overlay(substitutions: dict[str, str]) -> Path:
82
- overlay = Path(tempfile.gettempdir()) / f"docker-devkit-upstream-fallback-{os.getpid()}.env"
83
- overlay.write_text(
84
- "\n".join(f"{name}={value}" for name, value in sorted(substitutions.items())) + "\n",
85
- encoding="utf-8",
86
- )
87
- return overlay
88
-
89
-
90
- def main() -> None:
91
- app()
92
-
93
-
94
- if __name__ == "__main__":
95
- main()