docker-devkit 0.1.14__tar.gz → 0.1.16__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/AGENTS.md +6 -5
  2. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/PKG-INFO +1 -1
  3. docker_devkit-0.1.16/plan.md +145 -0
  4. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/pyproject.toml +7 -2
  5. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/build_docker.py +31 -48
  6. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/context_sha.py +6 -9
  7. docker_devkit-0.1.16/src/docker_devkit/documents.py +64 -0
  8. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/down.py +2 -1
  9. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/image_refs.py +26 -71
  10. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/score_generate.py +2 -1
  11. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/up.py +2 -1
  12. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/tests/test_context_sha.py +13 -12
  13. docker_devkit-0.1.16/tests/test_default_targets.py +86 -0
  14. docker_devkit-0.1.16/tests/test_documents.py +63 -0
  15. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/tests/test_image_refs.py +38 -57
  16. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/uv.lock +11 -0
  17. docker_devkit-0.1.14/plan.md +0 -122
  18. docker_devkit-0.1.14/tests/test_default_targets.py +0 -60
  19. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/.gitattributes +0 -0
  20. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/.github/workflows/ci.yml +0 -0
  21. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/.github/workflows/release.yml +0 -0
  22. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/.gitignore +0 -0
  23. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/.python-version +0 -0
  24. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/CLAUDE.md +0 -0
  25. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/LICENSE +0 -0
  26. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/NOTICE +0 -0
  27. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/README.md +0 -0
  28. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/publish-config.json +0 -0
  29. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/ruff.base.toml +0 -0
  30. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/ruff.toml +0 -0
  31. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/__init__.py +0 -0
  32. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/detect_gpu.py +0 -0
  33. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/lifecycle.py +0 -0
  34. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/modes.py +0 -0
  35. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/src/docker_devkit/py.typed +0 -0
  36. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/tests/test_lifecycle.py +0 -0
  37. {docker_devkit-0.1.14 → docker_devkit-0.1.16}/tests/test_score_generate.py +0 -0
@@ -18,13 +18,14 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
18
18
  |---|---|
19
19
  | `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--dev`. |
20
20
  | `down.py` | `docker compose down`. Flags: `--volumes`/`-v` (also removes named volumes), `--gpu`. |
21
- | `build_docker.py` | `run_build()` + the `build` command — cross-builds all images per `compose.bake.yml`, writes `.env.lock` (third-party pulled digests) and `.env.shas` (locally-built `tree-<hash>` tags). |
21
+ | `build_docker.py` | `run_build()` + the `build` command — cross-builds all images per `compose.bake.yml`, writes `.env.lock` (one `NAME=path:tag@sha256:…` entry per `x-base-images` declaration; `@digest`-pinned declarations pass through unresolved) and `.env.shas` (locally-built `tree-<hash>` tags). `--mode ci` hard-requires `x-registry-cache` in the bake file. |
22
22
  | `score_generate.py` | `generate-score` — deterministic Score-manifest generation from a consumer's `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 `pyproject.toml` under `[tool.docker-devkit.generate-score]` (`ScoreConfig`); `load_score_config()` reads it, `ensure_score_tools()` fetches the pinned score-compose/score-k8s release binaries (for CI staleness gates). `--local` writes the k8s artifact to the configured gitignored local path with the local storage class. |
23
23
  | `lifecycle.py` | `LifecycleConfig` — the `[tool.docker-devkit.lifecycle]` declaration (`files`: compose files in layering order with `{gpu}` templating; `dev_file`: optional dev overlay); `load_lifecycle_config()` (walks `tool → docker-devkit → lifecycle`, `None` when absent), `enforce_bake_declaration()` (a canonical `compose.bake.yml` 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). |
24
24
  | `detect_gpu.py` | `detect_gpu()` → `cuda`/`rocm`/`none` from host devices; the `Gpu` literal. |
25
25
  | `modes.py` | `resolve_auth_mode()` — validates `PUBLIC_URL` + `AUTH_MODE` from `.env`, rejecting `keycloak` over cleartext `http://`. |
26
- | `context_sha.py` | `compute_service_shas()` — per-service `tree-<hash>` image tags over the `.dockerignore`-allowlisted git tree. |
27
- | `image_refs.py` | Image-reference collection — `collect_repo_references(root, …globs)` enumerates every reference across compose `x-image-ref` services, bake `x-base-images` (pydantic-validated), Dockerfile `FROM`, Dockerfile `COPY --from=` (slash-bearing captures only, so build-stage names are skipped), and yaml `image:` line scans; the Dockerfile and yaml globs are `None`-able to skip those sources (consumer mirroring scans declarations only, policing scans everything); plus build-arg stripping, `unpinned_references(root)` (every slash-bearing reference must carry a tag, a digest, or a trailing `${build-arg}` suffix — consumer CIs fail on its result), `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), and remote digest resolution via `imagetools inspect`. `run_build()` consumes the two structural collectors; consumer repos' mirroring tooling consumes the repo scan. |
26
+ | `context_sha.py` | `compute_service_shas(repo_root, bake: BakeDocument)` — per-service `tree-<hash>` image tags over the `.dockerignore`-allowlisted git tree. |
27
+ | `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. |
28
+ | `image_refs.py` | Image-reference collection, split by source. `declared_references(root)` enumerates bake `x-base-images` declarations (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; `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. |
28
29
 
29
30
  ## Constraints
30
31
 
@@ -32,14 +33,14 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
32
33
 
33
34
  **Context.** A repo that authors its own stack builds its own images and assembles a multi-file compose graph (e.g. `compose.yml` + `compose.postgres.yml` + a GPU layer + `compose.dev.yml`), injecting per-service `${*_SHA}` image tags and a `.env.lock` of third-party digests. A consumer repo has none of that: it ships a single self-contained compose file that OCI-includes the upstream artifact already baked (image digests and internal vars frozen as literals) or includes a sibling checkout (which supplies its own `.env.lock`/`.env.shas` through the include's `env_file`).
34
35
 
35
- **Constraint.** `up`/`down` assemble the graph from `[tool.docker-devkit.lifecycle]` in the consumer's root pyproject — one code path, declared data. Keys: `files` (compose files in layering order, `{gpu}` templating, a `{gpu}` entry dropped when the GPU resolves to `none`; defaults to empty), `dev_file` (dev overlay, appended by `up --dev` and always by `down` so teardown removes containers from prior dev bring-ups). No table → single-file default (`-f compose.yml`). `--compose-file` does not exist; raw `docker compose` is the pressure valve. `--dev` is opt-in — production-shaped bring-up is the default. `.env.lock` is required unconditionally, no knob; consumers pin stock images via `${…_DIGEST:?err}` holes filled by a committed lock. A canonical `compose.bake.yml` present without a lifecycle declaration is a hard error (declare the table, or the file doesn't belong under that name) — guards are budgeted for silent failures only, and a missing lock already fails loudly. The bake file remains the build/up contract: SHA injection and `--build` acceptance key on `compose.bake.yml` existing, and `build` always reads exactly that name. An empty assembly (`files` empty/omitted, no dev overlay) is a loud `up`/`down` error — the honest state for a builds-only repo.
36
+ **Constraint.** `up`/`down` assemble the graph from `[tool.docker-devkit.lifecycle]` in the consumer's root pyproject — one code path, declared data. Keys: `files` (compose files in layering order, `{gpu}` templating, a `{gpu}` entry dropped when the GPU resolves to `none`; defaults to empty), `dev_file` (dev overlay, appended by `up --dev` and always by `down` so teardown removes containers from prior dev bring-ups). No table → single-file default (`-f compose.yml`). `--compose-file` does not exist; raw `docker compose` is the pressure valve. `--dev` is opt-in — production-shaped bring-up is the default. `.env.lock` is required unconditionally, no knob; consumers pin stock images via `${…_IMAGE:?err}` holes filled by a committed lock. A canonical `compose.bake.yml` present without a lifecycle declaration is a hard error (declare the table, or the file doesn't belong under that name) — guards are budgeted for silent failures only, and a missing lock already fails loudly. The bake file remains the build/up contract: SHA injection and `--build` acceptance key on `compose.bake.yml` existing, and `build` always reads exactly that name. An empty assembly (`files` empty/omitted, no dev overlay) is a loud `up`/`down` error — the honest state for a builds-only repo.
36
37
 
37
38
  **Consequences.** placeframe's shape lives in placeframe's pyproject, not in this package: `files = ["compose.yml", "compose.postgres.yml", "compose.{gpu}.yml"]`, `dev_file = "compose.dev.yml"`. A consumer repo declares a bare table (builds images, no repo-local runtime graph) and runs `uv run up` against its own `.env` + committed `.env.lock`.
38
39
 
39
40
  ### File and env conventions the commands assume
40
41
 
41
42
  - `.env` (required) — carries `PUBLIC_URL` and `AUTH_MODE`; `modes.py` reads it and rejects `keycloak` over cleartext `http://`.
42
- - `.env.lock` (required in every repo, checked in) — third-party and base-image digests, written by `build`. `up`/`down` refuse without it; consumers satisfy it with a committed lock of `${…_DIGEST:?err}` hole-fills.
43
+ - `.env.lock` (required in every repo, checked in) — third-party and base-image pins, written by `build`: one `NAME=path:tag@sha256:…` entry per bake declaration. `up`/`down` refuse without it; consumers satisfy it with a committed lock filling the `${…_IMAGE:?err}` holes.
43
44
  - `.env.shas` (where images are built locally, gitignored, per-build) — `tree-<hash>` tags of locally-built images, written by `build`. `up` injects the `${*_SHA}` values into the environment directly and reads neither file; `.env.shas` exists so a consumer running the compose files through raw `docker compose` can resolve those holes via `--env-file .env.shas`.
44
45
  - `compose.bake.yml` (where a local build graph exists, required) — the 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`. One bake file name, ever: `build` reads exactly this name.
45
46
  - the compose layers declared in `[tool.docker-devkit.lifecycle]` — e.g. placeframe's `compose.yml` / `compose.postgres.yml` / `compose.<gpu>.yml` / `compose.dev.yml`; the GPU one is picked by `detect_gpu()` (or the `--gpu` override).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docker-devkit
3
- Version: 0.1.14
3
+ Version: 0.1.16
4
4
  Summary: Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly
5
5
  License-File: LICENSE
6
6
  License-File: NOTICE
@@ -0,0 +1,145 @@
1
+ # One home for third-party image declarations; docker-devkit stops parsing compose
2
+
3
+ Status note for pickup: COMPLETE. Shipped: docker-devkit 0.1.15, release-devkit
4
+ 0.1.11 (both on PyPI). placeframe `dev`: `f2c94d16` (declarations conversion +
5
+ generate-score port completion), `33ddfb26` (score artifacts), `e0bcd9f8`
6
+ (prose) — preflight green. capture-tool `dev`: `ef7660d` (renames + repin),
7
+ `6025c5e` (prose) — lock round-trip stable, rig render green. All four repos
8
+ need operator pushes. Resolved during execution: the fluent-bit declaration key
9
+ is `SEAWEEDFS_AUDIT_IMAGE` (the plan's `FLUENT_BIT_IMAGE` never matched the
10
+ actual compose hole — holes stayed textually unchanged); stale `DOTNET_*`
11
+ declarations and `PYTHON_BASE`/`ZED_BASE` lock entries were pruned in placeframe
12
+ (zero consumers); `INITIALIZE_S3_IMAGE` (consumed by scripts/sweep_postprocess)
13
+ was promoted to a declaration; the generate-score console-script collision was
14
+ resolved by completing the port (config table in placeframe pyproject,
15
+ duplicate module deleted); capture-tool's bake file is `compose.bake.yml`, not
16
+ the `compose.zed.bake.yml` this plan named. NEXT: make-it-sing's own plan.md
17
+ resumes — flip its committed Stage 3 checkpoint (`6b4120c`) from
18
+ `ARG UV_BASE_DIGEST` + `FROM mirror/…${UV_BASE_DIGEST}` to
19
+ `ARG UV_BASE_IMAGE` + `FROM ${UV_BASE_IMAGE}`, repin docker-devkit >=0.1.15.
20
+
21
+ ## Why this initiative exists
22
+
23
+ docker-devkit parses consumer compose files for exactly one purpose: discovering
24
+ `x-image-ref` labels (declarations of externally-pulled images) so `run_build`
25
+ can digest-lock them and `mirror-images` can mirror them. That single need dragged
26
+ in a pile of wrong-shaped machinery:
27
+
28
+ - Compose's YAML dialect tags (`!reset`) require PyYAML constructor registration,
29
+ and PyYAML's constructor API is untyped — a type-checker fight with no clean
30
+ resolution (the only surviving form was a suppression-boundary debate).
31
+ - The include-following logic in `run_build` serves zero org repos (the only
32
+ compose include anywhere is make-it-sing's `oci://` one, which contributes
33
+ nothing and was skipped anyway).
34
+ - Two declaration mechanisms exist for one need: `x-image-ref` labels in compose
35
+ (placeframe: fluent-bit, cloudbeaver, keycloak, ngrok) and `x-base-images` in
36
+ the bake file (placeframe: loki, alloy, grafana, and every Dockerfile base).
37
+ - The `_DIGEST` suffix lock shape (`image: path${X_DIGEST:?err}` in compose,
38
+ lock `X_DIGEST=@sha256:…`) authors the mirror path TWICE — bake declaration +
39
+ compose image line — a drift exposure (path drift fails loud at pull; tags
40
+ cannot drift, but the duplication is authored).
41
+ - The Dockerfile suffix form (`FROM path${X_DIGEST}`) has a silent-unpinned
42
+ failure mode: an empty arg yields a tagless `FROM path`, and Docker silently
43
+ pulls `:latest`.
44
+
45
+ ## The design
46
+
47
+ - **Single declaration home**: bake `x-base-images`. Keys use the `_IMAGE`
48
+ convention (`UV_BASE_IMAGE`, `KEYCLOAK_IMAGE`, …). One authored string
49
+ (`path:tag`) per third-party image, anywhere in the org.
50
+ - **run_build single-emits** one lock entry per declaration:
51
+ `NAME=path:tag@sha256:…`. No dual shapes, no suffix entries.
52
+ - **Compose image lines** are pure holes: `image: "${KEYCLOAK_IMAGE:?err}"`.
53
+ - **Dockerfiles** use `ARG NAME` + `FROM ${NAME}`. An empty arg is a hard parse
54
+ error — this fixes the silent-`:latest` foot-gun.
55
+ - **BakeDocument gains a field validator**: every `base_images` value must be
56
+ `path:tag` or `path@digest`. The declarations half of `unpinned_references`
57
+ moves to parse time (enforced on every command run, not a CI-only scan).
58
+ - **`collect_repo_references` splits**: `declared_references(root)` (bake
59
+ declarations only — what mirror-images consumes) and a stray-ref scan
60
+ (Dockerfile `FROM`/`COPY --from` literals + score yaml `image:` lines — what
61
+ `unpinned_references` polices; it survives with that narrow job of catching
62
+ hand-written literal refs that bypass the declaration system).
63
+ - **`version_coupling_violations` is unchanged** — it guards a genuinely
64
+ non-derivable agreement (pyproject `tool.uv.required-version` ↔ image tag; the
65
+ tag carries suffixes like `-python3.13-trixie-slim` that pyproject doesn't
66
+ hold). Two authored truths that must agree is a different problem from one
67
+ truth scattered; the check is correct, keep it.
68
+
69
+ ## Changes
70
+
71
+ ### docker-devkit (this repo) — release 0.1.15
72
+
73
+ - `image_refs.py`: delete `compose_image_refs`, `ComposeDialectLoader`, both
74
+ `add_constructor` registrations, and the compose branch of
75
+ `collect_repo_references`; implement the `declared_references` / stray-scan
76
+ split. `bake_base_image_refs` and `version_coupling_violations` stay.
77
+ - `documents.py`: add the `base_images` field validator.
78
+ - `build_docker.py`: delete the third-party `x-image-ref` resolution block;
79
+ single-emit lock entries from `x-base-images`; wire all bake consumption
80
+ through `parse_bake`/`BakeDocument` (typed access replaces dict plumbing —
81
+ including `compute_service_shas(repo_root, bake: BakeDocument)` and
82
+ `compute_default_targets(bake: BakeDocument, …)` signatures, with `up.py`,
83
+ `down.py`, `score_generate.py` parsing at their call sites); loud guard:
84
+ `--mode ci` requires `x-registry-cache`.
85
+ - Tests: delete the compose-dialect tests; add validator + split-function tests;
86
+ existing fixtures via `BakeDocument.model_validate`.
87
+ - `types-pyyaml` stays as a dev dependency (it types `parse_bake`'s PyYAML
88
+ surface; with the dialect code gone, zero suppressions are needed anywhere).
89
+ - AGENTS.md: update the `image_refs.py` row and any compose-parsing references
90
+ (separate prose commit from the code).
91
+ - Release via the normal tag-ledger flow; operator push (App token cannot push).
92
+
93
+ ### release-devkit — next patch (0.1.11)
94
+
95
+ - `mirror_images.py`: switch from
96
+ `collect_repo_references(Path.cwd(), dockerfile_glob=None)` to
97
+ `declared_references(root)` — same refs, declarations-only source.
98
+
99
+ ### placeframe — one PR
100
+
101
+ - `compose.bake.yml`: add declarations for the 4 `x-image-ref` services
102
+ (`CLOUDBEAVER_IMAGE`, `KEYCLOAK_IMAGE`, `FLUENT_BIT_IMAGE`, `NGROK_IMAGE`,
103
+ values = today's label strings); rename every existing `_DIGEST` declaration
104
+ key to the `_IMAGE` convention; `x-base-args` keys follow the renames.
105
+ - `compose.yml`: delete the 4 labels (their image lines are textually unchanged —
106
+ they were already `${X_IMAGE:?err}` holes); flip loki×2 / alloy / grafana from
107
+ `path${X_DIGEST:?err}` to `${X_IMAGE:?err}`; inventory every `${…_IMAGE}` hole
108
+ (at minimum `SEAWEEDFS_AUDIT_IMAGE`) and ensure a declaration exists for each.
109
+ - Dockerfiles: convert every base `FROM` to `ARG NAME` + `FROM ${NAME}`.
110
+ - Version-coupling site registry (consumer preflight): rename `base_image` site
111
+ names to the new declaration keys — locate the registry during execution.
112
+ - Regenerate `.env.lock` (`uv run build --lock-only`); preflight green; repin
113
+ docker-devkit `>=0.1.15`.
114
+ - AGENTS.md mentions of `x-image-ref` updated (separate prose commit).
115
+
116
+ ### capture-tool
117
+
118
+ - 3 Dockerfiles + `compose.zed.bake.yml` `x-base-args` key renames; regen
119
+ `.env.lock`; repin docker-devkit.
120
+
121
+ ### make-it-sing — NOT this initiative
122
+
123
+ Its own `plan.md` resumes AFTER this one lands (its Stage 3/5 consume the new
124
+ pattern). Its committed Stage 3 checkpoint (`6b4120c`) still carries the suffix
125
+ form (`ARG UV_BASE_DIGEST` + `FROM mirror/…${UV_BASE_DIGEST}`) — flip to
126
+ `ARG UV_BASE_IMAGE` + `FROM ${UV_BASE_IMAGE}` when resuming that plan.
127
+
128
+ ## Ordering
129
+
130
+ docker-devkit 0.1.15 → release-devkit 0.1.11 → placeframe PR → capture-tool.
131
+ All releases are operator pushes; the sandbox App token has Contents:read only.
132
+
133
+ ## Verification
134
+
135
+ - docker-devkit: pytest + ruff + basedpyright all green (the checkpoint's known
136
+ failures must be gone — their absence is the proof the deletion was complete).
137
+ - placeframe / capture-tool: `uv run build --lock-only` round-trip, preflight
138
+ battery, `docker compose config` render.
139
+
140
+ ## Deliberately out of scope
141
+
142
+ - Score yaml scanning changes.
143
+ - Renaming the `x-base-images` key itself (it now covers runtime images too —
144
+ placeframe's loki already made that de facto; note it in a bake-file comment).
145
+ - make-it-sing CI stages (owned by its own plan).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-devkit"
3
- version = "0.1.14"
3
+ version = "0.1.16"
4
4
  description = "Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly"
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
@@ -13,7 +13,12 @@ dependencies = [
13
13
  ]
14
14
 
15
15
  [dependency-groups]
16
- dev = ["basedpyright>=1.39.10", "ruff>=0.14.11", "pytest>=8.0.0"]
16
+ dev = [
17
+ "basedpyright>=1.39.10",
18
+ "ruff>=0.14.11",
19
+ "pytest>=8.0.0",
20
+ "types-pyyaml>=6.0.12.20260906",
21
+ ]
17
22
 
18
23
  [build-system]
19
24
  requires = ["hatchling"]
@@ -9,13 +9,13 @@ from subprocess import CalledProcessError
9
9
  from typing import Annotated, Any, Literal
10
10
 
11
11
  import typer
12
- import yaml
13
12
  from bashrun.bash import bash, bash_output
14
13
  from .detect_gpu import GPU_TYPES, Gpu, detect_gpu
14
+ from .documents import BakeDocument, digest_pinned, parse_bake
15
15
  from pydantic_settings import BaseSettings
16
16
 
17
17
  from .context_sha import compute_service_shas
18
- from .image_refs import bake_base_image_refs, compose_service_refs, resolve_remote_digest
18
+ from .image_refs import bake_base_image_refs, resolve_remote_digest
19
19
  from .modes import parse_env_file
20
20
 
21
21
 
@@ -27,7 +27,6 @@ settings = Settings.model_validate({})
27
27
 
28
28
  LOCK_FILE = Path(".env.lock")
29
29
  ENV_SHAS_FILE = Path(".env.shas")
30
- COMPOSE_FILE = Path("compose.yml")
31
30
  DEFAULT_BAKE_FILE = Path("compose.bake.yml")
32
31
  METADATA_PATH = Path("metadata.json")
33
32
 
@@ -75,29 +74,26 @@ def run_build(
75
74
  no_cache: bool = False,
76
75
  targets_opt: list[str] | None = None,
77
76
  ) -> None:
78
- service_shas = compute_service_shas(Path.cwd(), DEFAULT_BAKE_FILE)
77
+ bake = parse_bake(DEFAULT_BAKE_FILE)
78
+
79
+ service_shas = compute_service_shas(Path.cwd(), bake)
79
80
  os.environ.update(service_shas)
80
81
 
81
- # Tags of the images built this run — the local analog of .env.lock's pulled digests
82
+ # Write .env.shas with the locally-built image tags
82
83
  ENV_SHAS_FILE.write_text(
83
84
  "".join(f"{key}={value}\n" for key, value in sorted(service_shas.items())), encoding="utf-8"
84
85
  )
85
86
 
86
- # A bake-only repo has no compose.yml; every consumer of compose_data is empty-safe
87
- bake_data: dict[str, Any] = yaml.safe_load(DEFAULT_BAKE_FILE.read_text(encoding="utf-8"))
88
- compose_data: dict[str, Any] = (
89
- yaml.safe_load(COMPOSE_FILE.read_text(encoding="utf-8")) if COMPOSE_FILE.exists() else {}
90
- )
91
- for include in compose_data.pop("include", []):
92
- include_path = COMPOSE_FILE.parent / (include if isinstance(include, str) else include["path"])
93
- included: dict[str, Any] = yaml.safe_load(include_path.read_text(encoding="utf-8"))
94
- compose_data.setdefault("services", {}).update(included.get("services", {}))
87
+ # Load any existing lock
95
88
  lock_data = parse_env_file(LOCK_FILE) if LOCK_FILE.exists() else {}
96
89
 
97
90
  # TOOD: Create separate commands for ci and local modes so typer can do this validation instead of us
98
91
  if mode == "ci" and gpu == "auto":
99
92
  raise typer.BadParameter("In CI mode, --gpu cannot be 'auto'; specify 'cuda' or 'rocm'.")
100
93
 
94
+ if mode == "ci" and bake.registry_cache is None:
95
+ raise typer.BadParameter("In CI mode, the bake file must declare x-registry-cache.")
96
+
101
97
  if gpu_only and gpu not in GPU_TYPES:
102
98
  raise typer.BadParameter("--gpu-only requires a concrete gpu (cuda or rocm), not 'auto' or 'none'.")
103
99
 
@@ -109,28 +105,18 @@ def run_build(
109
105
  if gpu == "auto" and not lock_only:
110
106
  gpu = detect_gpu()
111
107
 
112
- # Resolve base image external dependencies
113
- for occurrence in [ref for ref in bake_base_image_refs(bake_data) if upgrade or ref.name not in lock_data]:
114
- lock_data[occurrence.name] = (
115
- f"@{'' if '$' in occurrence.reference else resolve_remote_digest(occurrence.reference)}"
116
- )
117
-
118
- # Resolve third-party image external dependencies. x-image-ref marks services whose image
119
- # is sourced externally (vs. built from a bake file), so it's the right discriminator
120
- # regardless of how many bake files exist.
121
- third_party_images: dict[str, str] = {
122
- occurrence.name.upper().replace("-", "_") + "_IMAGE": occurrence.reference
123
- for occurrence in compose_service_refs(compose_data)
124
- }
125
-
126
- # Re-resolve when explicitly requested, when unseen, or when the image name in compose.yml
127
- # changed from what's in the lock file (e.g. postgres:16-alpine → postgis/postgres:16-3.4-alpine)
128
- for image, ref in {
129
- image: ref
130
- for image, ref in third_party_images.items()
131
- if upgrade or image not in lock_data or not lock_data[image].startswith(ref + "@")
132
- }.items():
133
- lock_data[image] = f"{ref}@{'' if '$' in ref else resolve_remote_digest(ref)}"
108
+ # One lock entry per declared third-party image: NAME=path:tag@sha256:…
109
+ for occurrence in bake_base_image_refs(bake):
110
+ if digest_pinned(occurrence.reference):
111
+ lock_data[occurrence.name] = occurrence.reference
112
+ continue
113
+ if (
114
+ not upgrade
115
+ and occurrence.name in lock_data
116
+ and lock_data[occurrence.name].startswith(occurrence.reference + "@")
117
+ ):
118
+ continue
119
+ lock_data[occurrence.name] = f"{occurrence.reference}@{resolve_remote_digest(occurrence.reference)}"
134
120
 
135
121
  # Update main lock file
136
122
  LOCK_FILE.write_text(
@@ -149,17 +135,17 @@ def run_build(
149
135
 
150
136
  # Determine bake targets
151
137
  if targets_opt:
152
- unknown = set(targets_opt) - set(bake_data["services"])
138
+ unknown = set(targets_opt) - set(bake.services)
153
139
  if unknown:
154
- raise typer.BadParameter(f"Unknown targets: {unknown}. Available: {sorted(bake_data['services'])}")
155
- targets = [t for t in bake_data["services"] if t in set(targets_opt)]
140
+ raise typer.BadParameter(f"Unknown targets: {unknown}. Available: {sorted(bake.services)}")
141
+ targets = [t for t in bake.services if t in set(targets_opt)]
156
142
  else:
157
- targets = compute_default_targets(bake_data, gpu, gpu_only)
143
+ targets = compute_default_targets(bake, gpu, gpu_only)
158
144
 
159
145
  # Configure registry caches in CI mode
160
146
  if mode == "ci":
161
147
  for target in targets:
162
- target_cache = f"{bake_data['x-registry-cache']}:{target}"
148
+ target_cache = f"{bake.registry_cache}:{target}"
163
149
  command_arguments.append(
164
150
  f"--set {target}.cache-to+=type=registry,ref={target_cache},mode=max,image-manifest=true,oci-mediatypes=true"
165
151
  )
@@ -178,8 +164,7 @@ def run_build(
178
164
  else:
179
165
  raise RuntimeError(f"Unsupported host architecture: {machine}")
180
166
  for target in targets:
181
- target_platforms = bake_data["services"][target].get("build", {}).get("platforms", [])
182
- if len(target_platforms) > 1:
167
+ if len(bake.services[target].build.platforms) > 1:
183
168
  command_arguments.append(f"--set {target}.platform={host_platform}")
184
169
 
185
170
  # Load or push images based on mode
@@ -253,11 +238,9 @@ def _check_gc_limits(min_gb: int = 60):
253
238
  # Services without build.tags (e.g. neural-networks-base-*) stay out too: they are
254
239
  # build-only dependencies pulled in via additional_contexts, and bake rejects a
255
240
  # tagless target under --push.
256
- def compute_default_targets(bake_data: dict[str, Any], gpu: Gpu, gpu_only: bool = False) -> list[str]:
257
- cross_compile_targets: set[str] = set(bake_data.get("x-cross-compile-targets", []))
258
- tagged_services = [
259
- service for service, config in bake_data["services"].items() if config.get("build", {}).get("tags")
260
- ]
241
+ def compute_default_targets(bake: BakeDocument, gpu: Gpu, gpu_only: bool = False) -> list[str]:
242
+ cross_compile_targets: set[str] = set(bake.cross_compile_targets)
243
+ tagged_services = [service for service, config in bake.services.items() if config.build.tags]
261
244
  if gpu_only:
262
245
  return [
263
246
  service
@@ -3,23 +3,20 @@ from __future__ import annotations
3
3
  from pathlib import Path, PurePosixPath
4
4
  from tempfile import TemporaryDirectory
5
5
 
6
- from typing import Any
7
-
8
6
  import pathspec
9
- import yaml
10
7
  from bashrun.bash import bash, bash_output
11
8
 
9
+ from .documents import BakeDocument
10
+
12
11
 
13
- def compute_service_shas(repo_root: Path, bake_file: Path) -> dict[str, str]:
12
+ def compute_service_shas(repo_root: Path, bake: BakeDocument) -> dict[str, str]:
14
13
  repo_root = repo_root.resolve()
15
14
 
16
- bake_data: dict[str, Any] = yaml.safe_load(bake_file.read_text(encoding="utf-8"))
17
15
  service_dirs: set[str] = set()
18
- for config in bake_data["services"].values():
19
- build: dict[str, Any] = config.get("build", {})
20
- if not build.get("tags"):
16
+ for config in bake.services.values():
17
+ if not config.build.tags:
21
18
  continue
22
- service_dirs.add(str(PurePosixPath(build.get("dockerfile", "")).parent))
19
+ service_dirs.add(str(PurePosixPath(config.build.dockerfile).parent))
23
20
 
24
21
  tree_entries = bash_output("git ls-tree -r HEAD", cwd=repo_root).splitlines()
25
22
 
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import TypeVar
6
+
7
+ import yaml
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
9
+
10
+ ModelT = TypeVar("ModelT", bound=BaseModel)
11
+
12
+ DECLARED_REFERENCE_PATTERN = re.compile(
13
+ r"^(?P<path>[^:@\s$]+)(?::(?P<tag>[^@\s$]+))?(?:@(?P<digest>sha256:[a-f0-9]{64}))?$"
14
+ )
15
+ DIGEST_TAIL_PATTERN = re.compile(r"@sha256:[a-f0-9]{64}$")
16
+
17
+
18
+ class BakeBuild(BaseModel):
19
+ model_config = ConfigDict(extra="forbid")
20
+
21
+ context: str = "."
22
+ dockerfile: str
23
+ target: str | None = None
24
+ platforms: list[str] = Field(default_factory=list)
25
+ args: dict[str, str] = Field(default_factory=dict)
26
+ tags: list[str] = Field(default_factory=list)
27
+ additional_contexts: dict[str, str] = Field(default_factory=dict)
28
+
29
+
30
+ class BakeService(BaseModel):
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+ build: BakeBuild
34
+
35
+
36
+ class BakeDocument(BaseModel):
37
+ model_config = ConfigDict(extra="forbid")
38
+
39
+ base_images: dict[str, str] = Field(default_factory=dict, alias="x-base-images")
40
+ base_args: dict[str, str] = Field(default_factory=dict, alias="x-base-args")
41
+ registry_cache: str | None = Field(default=None, alias="x-registry-cache")
42
+ cross_compile_targets: list[str] = Field(default_factory=list, alias="x-cross-compile-targets")
43
+ services: dict[str, BakeService] = Field(default_factory=dict)
44
+
45
+ @field_validator("base_images")
46
+ @classmethod
47
+ def _require_tag_or_digest(cls, base_images: dict[str, str]) -> dict[str, str]:
48
+ for name, reference in base_images.items():
49
+ match = DECLARED_REFERENCE_PATTERN.fullmatch(reference)
50
+ if match is None or not (match.group("tag") or match.group("digest")):
51
+ raise ValueError(f"x-base-images.{name}: {reference!r} must be 'path:tag' or 'path@digest'")
52
+ return base_images
53
+
54
+
55
+ def digest_pinned(reference: str) -> bool:
56
+ return DIGEST_TAIL_PATTERN.search(reference) is not None
57
+
58
+
59
+ def parse_bake(path: Path) -> BakeDocument:
60
+ loader = yaml.SafeLoader(path.read_text(encoding="utf-8"))
61
+ try:
62
+ return BakeDocument.model_validate(loader.get_single_data())
63
+ finally:
64
+ loader.dispose()
@@ -7,6 +7,7 @@ from bashrun.bash import bash_handoff
7
7
  from .detect_gpu import Gpu, detect_gpu
8
8
 
9
9
  from .context_sha import compute_service_shas
10
+ from .documents import parse_bake
10
11
  from .lifecycle import BAKE_FILE, enforce_bake_declaration, expand_compose_files, load_lifecycle_config
11
12
  from .modes import resolve_auth_mode
12
13
 
@@ -36,7 +37,7 @@ def down(
36
37
  resolve_auth_mode(ENV_FILE)
37
38
 
38
39
  if BAKE_FILE.exists():
39
- os.environ.update(compute_service_shas(Path.cwd(), BAKE_FILE))
40
+ os.environ.update(compute_service_shas(Path.cwd(), parse_bake(BAKE_FILE)))
40
41
 
41
42
  # Always layer the dev overlay so containers from a prior --dev bring-up get torn down too
42
43
  compose_files = expand_compose_files(config, gpu, include_dev=True)
@@ -8,9 +8,10 @@ from dataclasses import dataclass
8
8
  from itertools import starmap
9
9
  from pathlib import Path
10
10
 
11
- import yaml
12
11
  from bashrun.bash import bash_output
13
- from pydantic import BaseModel, ConfigDict, Field, RootModel
12
+ from pydantic import RootModel
13
+
14
+ from .documents import BakeDocument, parse_bake
14
15
 
15
16
  BUILD_ARG_PATTERN = re.compile(r"\$\{[A-Za-z0-9_]+\}")
16
17
  FROM_PATTERN = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE)
@@ -20,24 +21,6 @@ DIGEST_PATTERN = re.compile(r"^Digest:\s+(sha256:[a-f0-9]+)", re.MULTILINE)
20
21
  MISSING_VERSION = "<missing>"
21
22
 
22
23
 
23
- class ComposeService(BaseModel):
24
- model_config = ConfigDict(extra="ignore")
25
-
26
- image_ref: str | None = Field(default=None, alias="x-image-ref")
27
-
28
-
29
- class ComposeDocument(BaseModel):
30
- model_config = ConfigDict(extra="ignore")
31
-
32
- services: dict[str, ComposeService] = Field(default_factory=dict)
33
-
34
-
35
- class BakeDocument(BaseModel):
36
- model_config = ConfigDict(extra="ignore")
37
-
38
- base_images: dict[str, str] = Field(default_factory=dict, alias="x-base-images")
39
-
40
-
41
24
  type TomlValue = (
42
25
  str
43
26
  | int
@@ -79,7 +62,7 @@ class VersionCoupling:
79
62
  def unpinned_references(root: Path) -> list[str]:
80
63
  return [
81
64
  occurrence.reference
82
- for occurrence in collect_repo_references(root)
65
+ for occurrence in stray_references(root)
83
66
  if "/" in (reference := strip_build_args(occurrence.reference))
84
67
  and not BUILD_ARG_PATTERN.search(occurrence.reference)
85
68
  and ":" not in (tail := reference[reference.rfind("/") + 1 :])
@@ -87,48 +70,32 @@ def unpinned_references(root: Path) -> list[str]:
87
70
  ]
88
71
 
89
72
 
90
- def collect_repo_references(
73
+ def declared_references(root: Path, bake_glob: str = "compose*.bake.yml") -> list[ImageReference]:
74
+ return [reference for path in sorted(root.glob(bake_glob)) for reference in bake_base_image_refs(parse_bake(path))]
75
+
76
+
77
+ def stray_references(
91
78
  root: Path,
92
- compose_glob: str = "compose*.yml",
93
- bake_glob: str = "compose*.bake.yml",
94
- dockerfile_glob: str | None = "docker/*/Dockerfile*",
95
- image_glob: str | None = "score/*.yaml",
79
+ dockerfile_glob: str = "docker/*/Dockerfile*",
80
+ image_glob: str = "score/*.yaml",
96
81
  ) -> list[ImageReference]:
97
82
  return (
98
83
  [
99
- reference
100
- for path in sorted(root.glob(compose_glob))
101
- for reference in compose_service_refs(_load_yaml_document(path))
84
+ ImageReference("", match.group(1))
85
+ for path in sorted(root.glob(dockerfile_glob))
86
+ for match in FROM_PATTERN.finditer(path.read_text(encoding="utf-8"))
102
87
  ]
103
88
  + [
104
- reference
105
- for path in sorted(root.glob(bake_glob))
106
- for reference in bake_base_image_refs(_load_yaml_document(path))
89
+ ImageReference("", match.group(1))
90
+ for path in sorted(root.glob(dockerfile_glob))
91
+ for match in COPY_FROM_PATTERN.finditer(path.read_text(encoding="utf-8"))
92
+ if "/" in match.group(1)
93
+ ]
94
+ + [
95
+ ImageReference("", match.group(1))
96
+ for path in sorted(root.glob(image_glob))
97
+ for match in IMAGE_LINE_PATTERN.finditer(path.read_text(encoding="utf-8"))
107
98
  ]
108
- + (
109
- [
110
- ImageReference("", match.group(1))
111
- for path in sorted(root.glob(dockerfile_glob))
112
- for match in FROM_PATTERN.finditer(path.read_text(encoding="utf-8"))
113
- ]
114
- + [
115
- ImageReference("", match.group(1))
116
- for path in sorted(root.glob(dockerfile_glob))
117
- for match in COPY_FROM_PATTERN.finditer(path.read_text(encoding="utf-8"))
118
- if "/" in match.group(1)
119
- ]
120
- if dockerfile_glob
121
- else []
122
- )
123
- + (
124
- [
125
- ImageReference("", match.group(1))
126
- for path in sorted(root.glob(image_glob))
127
- for match in IMAGE_LINE_PATTERN.finditer(path.read_text(encoding="utf-8"))
128
- ]
129
- if image_glob
130
- else []
131
- )
132
99
  )
133
100
 
134
101
 
@@ -153,14 +120,6 @@ def resolve_remote_digest(reference: str) -> str:
153
120
  return match.group(1)
154
121
 
155
122
 
156
- def compose_service_refs(document: object) -> list[ImageReference]:
157
- return [
158
- ImageReference(service_name, service.image_ref)
159
- for service_name, service in ComposeDocument.model_validate(document).services.items()
160
- if service.image_ref is not None
161
- ]
162
-
163
-
164
123
  def strip_build_args(reference: str) -> str:
165
124
  return BUILD_ARG_PATTERN.sub("", reference).strip()
166
125
 
@@ -183,7 +142,7 @@ def _site_version(path: Path, site: VersionSite) -> str:
183
142
  haystack = next(
184
143
  (
185
144
  reference.reference
186
- for reference in bake_base_image_refs(_load_yaml_document(path))
145
+ for reference in bake_base_image_refs(parse_bake(path))
187
146
  if reference.name == site.base_image
188
147
  ),
189
148
  "",
@@ -194,9 +153,5 @@ def _site_version(path: Path, site: VersionSite) -> str:
194
153
  return match.group(1) if match else MISSING_VERSION
195
154
 
196
155
 
197
- def bake_base_image_refs(document: object) -> list[ImageReference]:
198
- return list(starmap(ImageReference, BakeDocument.model_validate(document).base_images.items()))
199
-
200
-
201
- def _load_yaml_document(path: Path) -> object:
202
- return yaml.safe_load(path.read_text(encoding="utf-8"))
156
+ def bake_base_image_refs(document: BakeDocument) -> list[ImageReference]:
157
+ return list(starmap(ImageReference, document.base_images.items()))