docker-devkit 0.3.0.dev113__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docker_devkit-0.4.0/.github/workflows/integrate.yml +24 -0
- docker_devkit-0.4.0/.github/workflows/merge-gate.yml +18 -0
- docker_devkit-0.4.0/.github/workflows/release.yml +34 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/AGENTS.md +13 -14
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/PKG-INFO +1 -1
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/pyproject.toml +1 -1
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/release-devkit.yaml +1 -1
- docker_devkit-0.4.0/scripts/preflight.py +3 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/build_docker.py +29 -23
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/down.py +1 -1
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/image_refs.py +8 -21
- {docker_devkit-0.3.0.dev113 → 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.dev113 → docker_devkit-0.4.0}/src/docker_devkit/up.py +4 -22
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_image_refs.py +16 -26
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_lifecycle.py +3 -35
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_mirror.py +65 -110
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_score_generate.py +3 -3
- docker_devkit-0.4.0/uv.lock +524 -0
- docker_devkit-0.3.0.dev113/.github/actions/setup-release-devkit/action.yml +0 -11
- docker_devkit-0.3.0.dev113/.github/workflows/integrate.yml +0 -77
- docker_devkit-0.3.0.dev113/.github/workflows/merge-gate.yml +0 -40
- docker_devkit-0.3.0.dev113/.github/workflows/release.yml +0 -57
- docker_devkit-0.3.0.dev113/src/docker_devkit/mirror.py +0 -95
- docker_devkit-0.3.0.dev113/uv.lock +0 -493
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/.gitattributes +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/.gitignore +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/.python-version +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/CLAUDE.md +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/LICENSE +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/NOTICE +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/README.md +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/ruff.base.toml +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/ruff.toml +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/__init__.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/config.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/context_sha.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/detect_gpu.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/documents.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/matrix.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/modes.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/py.typed +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/src/docker_devkit/score_generate.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_build_docker.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_config.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_context_sha.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_default_targets.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_documents.py +0 -0
- {docker_devkit-0.3.0.dev113 → docker_devkit-0.4.0}/tests/test_matrix.py +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Integrate
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
pull_request:
|
|
6
|
+
branches: [dev]
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: integrate-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
preflight:
|
|
17
|
+
uses: outernet-foundation/github-actions-toolkit/.github/workflows/preflight.yml@66df38b08b8209b587de1c140b720d835ed8a3c3
|
|
18
|
+
|
|
19
|
+
update-pr-draft-release:
|
|
20
|
+
needs: [preflight]
|
|
21
|
+
permissions:
|
|
22
|
+
contents: write
|
|
23
|
+
packages: read
|
|
24
|
+
uses: outernet-foundation/github-actions-toolkit/.github/workflows/update-pr-draft-release.yml@66df38b08b8209b587de1c140b720d835ed8a3c3
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Merge gate
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [dev]
|
|
6
|
+
types: [labeled]
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: merge-gate-${{ github.event.pull_request.number }}
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
merge-gate:
|
|
16
|
+
uses: outernet-foundation/github-actions-toolkit/.github/workflows/merge-gate.yml@66df38b08b8209b587de1c140b720d835ed8a3c3
|
|
17
|
+
secrets:
|
|
18
|
+
merge-bot-private-key: ${{ secrets.MERGE_BOT_APP_PRIVATE_KEY }}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, dev]
|
|
6
|
+
|
|
7
|
+
concurrency:
|
|
8
|
+
group: release-${{ github.ref }}
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
prerelease:
|
|
15
|
+
if: github.ref == 'refs/heads/dev'
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
contents: write
|
|
19
|
+
id-token: write
|
|
20
|
+
steps:
|
|
21
|
+
- uses: outernet-foundation/github-actions-toolkit/.github/actions/release@66df38b08b8209b587de1c140b720d835ed8a3c3
|
|
22
|
+
with:
|
|
23
|
+
channel: dev
|
|
24
|
+
|
|
25
|
+
release:
|
|
26
|
+
if: github.ref == 'refs/heads/main'
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
permissions:
|
|
29
|
+
contents: write
|
|
30
|
+
id-token: write
|
|
31
|
+
steps:
|
|
32
|
+
- uses: outernet-foundation/github-actions-toolkit/.github/actions/release@66df38b08b8209b587de1c140b720d835ed8a3c3
|
|
33
|
+
with:
|
|
34
|
+
channel: stable
|
|
@@ -10,7 +10,7 @@ Config follows the org law (home: github-actions-toolkit's `AGENTS.md`): this is
|
|
|
10
10
|
|
|
11
11
|
## Release flow
|
|
12
12
|
|
|
13
|
-
github-actions-toolkit's `AGENTS.md` owns the
|
|
13
|
+
github-actions-toolkit's `AGENTS.md` owns the consumer workflow contract; this repo follows it unchanged. Repo-specific facts: github-actions-toolkit is never a project dependency (docker-devkit sits inside its own dependency graph; a project-level edge is a resolver cycle), and from 1.0 on API-breaking changes ship with a manually bumped `major_minor` (pre-1.0, breaking changes ride the patch flow; patch-auto assumes additive changes).
|
|
14
14
|
|
|
15
15
|
## Mirror consumption
|
|
16
16
|
|
|
@@ -22,19 +22,19 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
|
|
|
22
22
|
|
|
23
23
|
| Module | Role |
|
|
24
24
|
|---|---|
|
|
25
|
-
| `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--dev
|
|
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")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import datetime
|
|
3
|
+
import datetime as dt
|
|
4
4
|
import re
|
|
5
5
|
import shlex
|
|
6
6
|
import tomllib
|
|
@@ -12,6 +12,7 @@ from bashrun.bash import bash_output
|
|
|
12
12
|
from pydantic import RootModel
|
|
13
13
|
|
|
14
14
|
from .documents import BakeDocument, parse_bake
|
|
15
|
+
from .lifecycle import MANIFEST_PATH
|
|
15
16
|
|
|
16
17
|
BUILD_ARG_PATTERN = re.compile(r"\$\{[A-Za-z0-9_]+\}")
|
|
17
18
|
FROM_PATTERN = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE)
|
|
@@ -21,17 +22,7 @@ DIGEST_PATTERN = re.compile(r"^Digest:\s+(sha256:[a-f0-9]+)", re.MULTILINE)
|
|
|
21
22
|
MISSING_VERSION = "<missing>"
|
|
22
23
|
|
|
23
24
|
|
|
24
|
-
type TomlValue =
|
|
25
|
-
str
|
|
26
|
-
| int
|
|
27
|
-
| float
|
|
28
|
-
| bool
|
|
29
|
-
| datetime.datetime
|
|
30
|
-
| datetime.date
|
|
31
|
-
| datetime.time
|
|
32
|
-
| list[TomlValue]
|
|
33
|
-
| dict[str, TomlValue]
|
|
34
|
-
)
|
|
25
|
+
type TomlValue = str | int | float | bool | dt.datetime | dt.date | dt.time | list[TomlValue] | dict[str, TomlValue]
|
|
35
26
|
|
|
36
27
|
|
|
37
28
|
class TomlDocument(RootModel[TomlValue]):
|
|
@@ -70,15 +61,11 @@ def unpinned_references(root: Path) -> list[str]:
|
|
|
70
61
|
]
|
|
71
62
|
|
|
72
63
|
|
|
73
|
-
def declared_references(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
for bake_glob in bake_globs
|
|
79
|
-
for path in sorted(root.glob(bake_glob))
|
|
80
|
-
for reference in bake_base_image_refs(parse_bake(path))
|
|
81
|
-
]
|
|
64
|
+
def declared_references(root: Path) -> list[ImageReference]:
|
|
65
|
+
manifest = root / MANIFEST_PATH
|
|
66
|
+
if not manifest.exists():
|
|
67
|
+
return []
|
|
68
|
+
return bake_base_image_refs(parse_bake(manifest))
|
|
82
69
|
|
|
83
70
|
|
|
84
71
|
def stray_references(
|
|
@@ -8,11 +8,9 @@ from .detect_gpu import Gpu
|
|
|
8
8
|
DEFAULT_COMPOSE_FILE = Path("compose.yml")
|
|
9
9
|
GPU_PLACEHOLDER = "{gpu}"
|
|
10
10
|
|
|
11
|
-
# The image manifest and its lock are a named pair living in the same directory.
|
|
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:
|