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.
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/AGENTS.md +12 -13
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/PKG-INFO +1 -1
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/pyproject.toml +1 -1
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/release-devkit.yaml +1 -1
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/build_docker.py +29 -23
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/down.py +1 -1
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/image_refs.py +6 -9
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/lifecycle.py +10 -14
- docker_devkit-0.4.0/src/docker_devkit/mirror.py +72 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/up.py +4 -22
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_image_refs.py +16 -26
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_lifecycle.py +3 -35
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_mirror.py +65 -110
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_score_generate.py +3 -3
- docker_devkit-0.3.0.dev116/src/docker_devkit/mirror.py +0 -95
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.gitattributes +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.github/workflows/integrate.yml +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.github/workflows/merge-gate.yml +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.github/workflows/release.yml +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.gitignore +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/.python-version +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/CLAUDE.md +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/LICENSE +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/NOTICE +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/README.md +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/ruff.base.toml +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/ruff.toml +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/scripts/preflight.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/__init__.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/config.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/context_sha.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/detect_gpu.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/documents.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/matrix.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/modes.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/py.typed +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/src/docker_devkit/score_generate.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_build_docker.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_config.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_context_sha.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_default_targets.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_documents.py +0 -0
- {docker_devkit-0.3.0.dev116 → docker_devkit-0.4.0}/tests/test_matrix.py +0 -0
- {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
|
|
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=
|
|
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
|
|
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`
|
|
37
|
-
| `mirror.py` | The mirror namespace's config,
|
|
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=
|
|
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`.
|
|
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
|
|
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
|
-
|
|
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
|
|
64
|
-
- `up` fails closed:
|
|
65
|
-
- The `mirror` verb (crane install from a pinned release,
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
29
|
+
raise RuntimeError(f"No image manifest at {MANIFEST_PATH}")
|
|
31
30
|
return manifest
|
|
32
31
|
|
|
33
32
|
|
|
34
|
-
def resolve_lock(root: Path
|
|
35
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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", "
|
|
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", "
|
|
34
|
-
VersionSite("python base tag", "
|
|
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 / "
|
|
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 / "
|
|
183
|
-
|
|
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/
|
|
189
|
-
f" PYTHON_BASE_IMAGE:
|
|
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 / '
|
|
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 / '
|
|
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 / '
|
|
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 / '
|
|
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 / "
|
|
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 / '
|
|
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
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
178
|
-
def
|
|
179
|
-
(
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
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.
|
|
171
|
+
mirror.enforce_mirrored(lock_data)
|
|
208
172
|
|
|
209
173
|
assert str(excinfo.value) == LOCKED_ERROR
|
|
210
174
|
|
|
211
|
-
def
|
|
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
|
-
|
|
179
|
+
mirror.enforce_mirrored(lock_data)
|
|
232
180
|
|
|
233
|
-
def
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
188
|
+
with pytest.raises(RuntimeError, match="alpine"):
|
|
189
|
+
mirror.enforce_mirrored(lock_data)
|
|
248
190
|
|
|
249
|
-
assert
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
231
|
+
(tmp_path / "workloads" / "images.yml").write_text(manifest, encoding="utf-8")
|
|
291
232
|
|
|
292
|
-
def
|
|
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
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
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
|
|
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("
|
|
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("
|
|
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("
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|