docker-devkit 0.1.13__tar.gz → 0.1.15__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 (39) hide show
  1. docker_devkit-0.1.15/AGENTS.md +62 -0
  2. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/PKG-INFO +2 -2
  3. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/README.md +5 -4
  4. docker_devkit-0.1.15/plan.md +141 -0
  5. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/pyproject.toml +8 -3
  6. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/build_docker.py +36 -67
  7. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/context_sha.py +6 -9
  8. docker_devkit-0.1.15/src/docker_devkit/documents.py +64 -0
  9. docker_devkit-0.1.15/src/docker_devkit/down.py +68 -0
  10. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/image_refs.py +26 -71
  11. docker_devkit-0.1.15/src/docker_devkit/lifecycle.py +46 -0
  12. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/score_generate.py +2 -1
  13. docker_devkit-0.1.15/src/docker_devkit/up.py +91 -0
  14. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/tests/test_context_sha.py +13 -12
  15. docker_devkit-0.1.15/tests/test_default_targets.py +86 -0
  16. docker_devkit-0.1.15/tests/test_documents.py +63 -0
  17. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/tests/test_image_refs.py +38 -57
  18. docker_devkit-0.1.15/tests/test_lifecycle.py +117 -0
  19. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/uv.lock +11 -0
  20. docker_devkit-0.1.13/AGENTS.md +0 -60
  21. docker_devkit-0.1.13/src/docker_devkit/down.py +0 -86
  22. docker_devkit-0.1.13/src/docker_devkit/up.py +0 -109
  23. docker_devkit-0.1.13/tests/test_default_targets.py +0 -60
  24. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/.gitattributes +0 -0
  25. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/.github/workflows/ci.yml +0 -0
  26. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/.github/workflows/release.yml +0 -0
  27. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/.gitignore +0 -0
  28. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/.python-version +0 -0
  29. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/CLAUDE.md +0 -0
  30. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/LICENSE +0 -0
  31. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/NOTICE +0 -0
  32. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/publish-config.json +0 -0
  33. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/ruff.base.toml +0 -0
  34. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/ruff.toml +0 -0
  35. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/__init__.py +0 -0
  36. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/detect_gpu.py +0 -0
  37. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/modes.py +0 -0
  38. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/py.typed +0 -0
  39. {docker_devkit-0.1.13 → docker_devkit-0.1.15}/tests/test_score_generate.py +0 -0
@@ -0,0 +1,62 @@
1
+ # docker-devkit
2
+
3
+ ## What this is
4
+
5
+ `docker-devkit` owns the Docker-stack lifecycle commands — `up`, `down`, `build` — plus the helpers they share (`detect_gpu`, `modes`, `context_sha`). It is a small, generic package that any repo shipping a compose graph can depend on to get the same bring-up/tear-down / cross-build flow, with the compose assembly declared per consumer in `[tool.docker-devkit.lifecycle]` so a repo that authors its own images and a repo that only OCI-includes an upstream artifact both work through the same commands.
6
+
7
+ The package is `docker_devkit` (src-layout under `src/docker_devkit/`); all dependencies resolve from PyPI (`bashrun>=0.1.0`; git-source pins only in scratch branches testing unreleased changes). The repo and package renamed twice on the way here — `stack-lifecycle` → `stack-toolkit` (2026-09-20, before any publish) and `stack-toolkit` → `docker-devkit` (2026-09-21, member of the `-devkit` family). The PyPI identity is fresh: `docker-devkit` starts its own tag ledger at `0.1.0`; the terminal `stack-toolkit` distributions (≤0.1.1) are deprecation signposts pointing here, not this package's history.
8
+
9
+ ## Release flow
10
+
11
+ Publishing rides `release.yml`, triggered by a successful CI run on a `main` push: the machinery — release-devkit's `publish-stable` (an inlined, version-pinned `uvx` step in the publish job, keeping OIDC identity local), never a project dependency (docker-devkit sits inside its own dependency graph; a project-level release-devkit edge is a resolver cycle) — computes the plan from the tag ledger and path-diff, patches the version ephemerally, and publishes to PyPI under OIDC trusted publishing (publisher bound to `release.yml`, no environment). The committed `pyproject.toml` version is permanently the `0.0.0.dev0` sentinel; the `docker-devkit-v*` tags are the version ledger (declared `major_minor` line in `publish-config.json`, patch-auto within the line). While pre-1.0, breaking changes ride the normal patch flow without a bump; from 1.0 on, API-breaking changes ship with a manually bumped `major_minor` — patch-auto assumes additive changes.
12
+
13
+ ## Shape
14
+
15
+ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:app`, `build` → `build_docker.py:app`, `generate-score` → `score_generate.py:app`. All accept `--help`.
16
+
17
+ | Module | Role |
18
+ |---|---|
19
+ | `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--dev`. |
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` (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
+ | `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
+ | `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
+ | `detect_gpu.py` | `detect_gpu()` → `cuda`/`rocm`/`none` from host devices; the `Gpu` literal. |
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(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. |
29
+
30
+ ## Constraints
31
+
32
+ ### Lifecycle declaration contract
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`).
35
+
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.
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`.
39
+
40
+ ### File and env conventions the commands assume
41
+
42
+ - `.env` (required) — carries `PUBLIC_URL` and `AUTH_MODE`; `modes.py` reads it and rejects `keycloak` over cleartext `http://`.
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.
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`.
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.
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).
47
+ - `.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.
48
+
49
+ The build command writes to two files rather than one because a CI invariant forbids built-image digests in `.env.lock` (which is committed and reviewed) and because `.env.shas` changes on every source edit, so it stays out of version control.
50
+
51
+ ### Package split rationale
52
+
53
+ Lifecycle (`up`/`down`/`build`) and codegen (client generation, datamodel generation, workspace locking) have disjoint dependency surfaces. Bundling them would force a consumer that only brings a stack up to install `sqlacodegen`, `psycopg`, `datamodel-code-generator`. Keeping the lifecycle in its own small package lets consumers git-reference just this without dragging the codegen surface behind it.
54
+
55
+ ### `--quiet-pull` logging and GPU auto-detection
56
+
57
+ `--quiet-pull` suppresses compose's per-layer progress (CUDA images carry hundreds of layers) while still surfacing pull totals. `--gpu auto` resolves to `cuda`/`rocm`/`none` from host devices and selects the matching `compose.<gpu>.yml`; override only to reproduce a CI environment or test the CPU-only path.
58
+
59
+ ## See also
60
+
61
+ - `README.md` — human-facing setup and usage.
62
+ - [`bashrun`](https://github.com/outernet-foundation/bashrun) — the guardrailed subprocess wrapper the lifecycle commands shell out through.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docker-devkit
3
- Version: 0.1.13
4
- Summary: Docker-stack lifecycle commands (up/down/build) with a native/consumer split
3
+ Version: 0.1.15
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
7
7
  Requires-Python: >=3.13
@@ -1,8 +1,8 @@
1
1
  # docker-devkit
2
2
 
3
- Docker-stack lifecycle commands — `up`, `down`, `build` — with a native/consumer split. A repo that authors its own stack (Dockerfiles + `compose.bake.yml`) uses the native mode: multi-file compose assembly, per-service SHA injection, `.env.lock` resolution. A consumer repo that OCI-includes an already-baked upstream stack uses the same commands as a thin wrapper: single-graph, `.env`-only, no build path.
3
+ Docker-stack lifecycle commands — `up`, `down`, `build` — with the compose assembly declared per repo in `[tool.docker-devkit.lifecycle]`. A repo that authors its own stack (Dockerfiles + `compose.bake.yml`) declares its file layers and gets multi-file assembly, per-service SHA injection, and `.env.lock` resolution; a consumer repo that OCI-includes an already-baked upstream stack declares a bare table and runs the same commands as a thin wrapper over its own `.env` + committed `.env.lock`.
4
4
 
5
- See [`AGENTS.md`](./AGENTS.md) for the file/layout conventions the commands assume (`compose.bake.yml`, `.env.lock`, `.env.shas`, `PUBLIC_URL` / `AUTH_MODE` in `.env`) and the mode-detection contract.
5
+ See [`AGENTS.md`](./AGENTS.md) for the file/layout conventions the commands assume (`compose.bake.yml`, `.env.lock`, `.env.shas`, `PUBLIC_URL` / `AUTH_MODE` in `.env`) and the declaration contract.
6
6
 
7
7
  ## Setup
8
8
 
@@ -17,8 +17,9 @@ uv sync
17
17
  From a stack repo's root:
18
18
 
19
19
  ```bash
20
- uv run up # docker compose up (auto-detects GPU; native or consumer per compose.bake.yml)
21
- uv run up --build # build images locally first (native only)
20
+ uv run up # docker compose up (auto-detects GPU; assembles the declared lifecycle files)
21
+ uv run up --dev # layer the declared dev overlay (bind-mount/debug bring-up)
22
+ uv run up --build # build images locally first (requires compose.bake.yml)
22
23
  uv run up --gpu none # override GPU auto-detection
23
24
  uv run down # docker compose down
24
25
  uv run down -v # also remove named volumes
@@ -0,0 +1,141 @@
1
+ # One home for third-party image declarations; docker-devkit stops parsing compose
2
+
3
+ Status note for pickup: the docker-devkit half is DONE — redesign executed in
4
+ `2a1fe90` (compose parsing deleted, `declared_references`/`stray_references`
5
+ split, `BakeDocument` base-images validator, typed bake plumbing through
6
+ build/up/down/generate-score, ci-mode registry-cache guard). pytest 77 green,
7
+ ruff + basedpyright clean — the checkpoint's intentional failures are gone.
8
+ Still pending, in order: operator push + tag-ledger release of this repo;
9
+ release-devkit 0.1.11 (`mirror_images.py` → `declared_references`); the
10
+ placeframe PR; capture-tool renames. No consumer repo has been touched yet.
11
+
12
+ Note on release numbering: the plan projects 0.1.15, but the local tag ledger
13
+ sits at docker-devkit-v0.1.11 with the lifecycle rewrite also unreleased —
14
+ patch-auto will compute the real number at publish time, and the placeframe
15
+ repin must use whatever ships, not the projection.
16
+
17
+ ## Why this initiative exists
18
+
19
+ docker-devkit parses consumer compose files for exactly one purpose: discovering
20
+ `x-image-ref` labels (declarations of externally-pulled images) so `run_build`
21
+ can digest-lock them and `mirror-images` can mirror them. That single need dragged
22
+ in a pile of wrong-shaped machinery:
23
+
24
+ - Compose's YAML dialect tags (`!reset`) require PyYAML constructor registration,
25
+ and PyYAML's constructor API is untyped — a type-checker fight with no clean
26
+ resolution (the only surviving form was a suppression-boundary debate).
27
+ - The include-following logic in `run_build` serves zero org repos (the only
28
+ compose include anywhere is make-it-sing's `oci://` one, which contributes
29
+ nothing and was skipped anyway).
30
+ - Two declaration mechanisms exist for one need: `x-image-ref` labels in compose
31
+ (placeframe: fluent-bit, cloudbeaver, keycloak, ngrok) and `x-base-images` in
32
+ the bake file (placeframe: loki, alloy, grafana, and every Dockerfile base).
33
+ - The `_DIGEST` suffix lock shape (`image: path${X_DIGEST:?err}` in compose,
34
+ lock `X_DIGEST=@sha256:…`) authors the mirror path TWICE — bake declaration +
35
+ compose image line — a drift exposure (path drift fails loud at pull; tags
36
+ cannot drift, but the duplication is authored).
37
+ - The Dockerfile suffix form (`FROM path${X_DIGEST}`) has a silent-unpinned
38
+ failure mode: an empty arg yields a tagless `FROM path`, and Docker silently
39
+ pulls `:latest`.
40
+
41
+ ## The design
42
+
43
+ - **Single declaration home**: bake `x-base-images`. Keys use the `_IMAGE`
44
+ convention (`UV_BASE_IMAGE`, `KEYCLOAK_IMAGE`, …). One authored string
45
+ (`path:tag`) per third-party image, anywhere in the org.
46
+ - **run_build single-emits** one lock entry per declaration:
47
+ `NAME=path:tag@sha256:…`. No dual shapes, no suffix entries.
48
+ - **Compose image lines** are pure holes: `image: "${KEYCLOAK_IMAGE:?err}"`.
49
+ - **Dockerfiles** use `ARG NAME` + `FROM ${NAME}`. An empty arg is a hard parse
50
+ error — this fixes the silent-`:latest` foot-gun.
51
+ - **BakeDocument gains a field validator**: every `base_images` value must be
52
+ `path:tag` or `path@digest`. The declarations half of `unpinned_references`
53
+ moves to parse time (enforced on every command run, not a CI-only scan).
54
+ - **`collect_repo_references` splits**: `declared_references(root)` (bake
55
+ declarations only — what mirror-images consumes) and a stray-ref scan
56
+ (Dockerfile `FROM`/`COPY --from` literals + score yaml `image:` lines — what
57
+ `unpinned_references` polices; it survives with that narrow job of catching
58
+ hand-written literal refs that bypass the declaration system).
59
+ - **`version_coupling_violations` is unchanged** — it guards a genuinely
60
+ non-derivable agreement (pyproject `tool.uv.required-version` ↔ image tag; the
61
+ tag carries suffixes like `-python3.13-trixie-slim` that pyproject doesn't
62
+ hold). Two authored truths that must agree is a different problem from one
63
+ truth scattered; the check is correct, keep it.
64
+
65
+ ## Changes
66
+
67
+ ### docker-devkit (this repo) — release 0.1.15
68
+
69
+ - `image_refs.py`: delete `compose_image_refs`, `ComposeDialectLoader`, both
70
+ `add_constructor` registrations, and the compose branch of
71
+ `collect_repo_references`; implement the `declared_references` / stray-scan
72
+ split. `bake_base_image_refs` and `version_coupling_violations` stay.
73
+ - `documents.py`: add the `base_images` field validator.
74
+ - `build_docker.py`: delete the third-party `x-image-ref` resolution block;
75
+ single-emit lock entries from `x-base-images`; wire all bake consumption
76
+ through `parse_bake`/`BakeDocument` (typed access replaces dict plumbing —
77
+ including `compute_service_shas(repo_root, bake: BakeDocument)` and
78
+ `compute_default_targets(bake: BakeDocument, …)` signatures, with `up.py`,
79
+ `down.py`, `score_generate.py` parsing at their call sites); loud guard:
80
+ `--mode ci` requires `x-registry-cache`.
81
+ - Tests: delete the compose-dialect tests; add validator + split-function tests;
82
+ existing fixtures via `BakeDocument.model_validate`.
83
+ - `types-pyyaml` stays as a dev dependency (it types `parse_bake`'s PyYAML
84
+ surface; with the dialect code gone, zero suppressions are needed anywhere).
85
+ - AGENTS.md: update the `image_refs.py` row and any compose-parsing references
86
+ (separate prose commit from the code).
87
+ - Release via the normal tag-ledger flow; operator push (App token cannot push).
88
+
89
+ ### release-devkit — next patch (0.1.11)
90
+
91
+ - `mirror_images.py`: switch from
92
+ `collect_repo_references(Path.cwd(), dockerfile_glob=None)` to
93
+ `declared_references(root)` — same refs, declarations-only source.
94
+
95
+ ### placeframe — one PR
96
+
97
+ - `compose.bake.yml`: add declarations for the 4 `x-image-ref` services
98
+ (`CLOUDBEAVER_IMAGE`, `KEYCLOAK_IMAGE`, `FLUENT_BIT_IMAGE`, `NGROK_IMAGE`,
99
+ values = today's label strings); rename every existing `_DIGEST` declaration
100
+ key to the `_IMAGE` convention; `x-base-args` keys follow the renames.
101
+ - `compose.yml`: delete the 4 labels (their image lines are textually unchanged —
102
+ they were already `${X_IMAGE:?err}` holes); flip loki×2 / alloy / grafana from
103
+ `path${X_DIGEST:?err}` to `${X_IMAGE:?err}`; inventory every `${…_IMAGE}` hole
104
+ (at minimum `SEAWEEDFS_AUDIT_IMAGE`) and ensure a declaration exists for each.
105
+ - Dockerfiles: convert every base `FROM` to `ARG NAME` + `FROM ${NAME}`.
106
+ - Version-coupling site registry (consumer preflight): rename `base_image` site
107
+ names to the new declaration keys — locate the registry during execution.
108
+ - Regenerate `.env.lock` (`uv run build --lock-only`); preflight green; repin
109
+ docker-devkit `>=0.1.15`.
110
+ - AGENTS.md mentions of `x-image-ref` updated (separate prose commit).
111
+
112
+ ### capture-tool
113
+
114
+ - 3 Dockerfiles + `compose.zed.bake.yml` `x-base-args` key renames; regen
115
+ `.env.lock`; repin docker-devkit.
116
+
117
+ ### make-it-sing — NOT this initiative
118
+
119
+ Its own `plan.md` resumes AFTER this one lands (its Stage 3/5 consume the new
120
+ pattern). Its committed Stage 3 checkpoint (`6b4120c`) still carries the suffix
121
+ form (`ARG UV_BASE_DIGEST` + `FROM mirror/…${UV_BASE_DIGEST}`) — flip to
122
+ `ARG UV_BASE_IMAGE` + `FROM ${UV_BASE_IMAGE}` when resuming that plan.
123
+
124
+ ## Ordering
125
+
126
+ docker-devkit 0.1.15 → release-devkit 0.1.11 → placeframe PR → capture-tool.
127
+ All releases are operator pushes; the sandbox App token has Contents:read only.
128
+
129
+ ## Verification
130
+
131
+ - docker-devkit: pytest + ruff + basedpyright all green (the checkpoint's known
132
+ failures must be gone — their absence is the proof the deletion was complete).
133
+ - placeframe / capture-tool: `uv run build --lock-only` round-trip, preflight
134
+ battery, `docker compose config` render.
135
+
136
+ ## Deliberately out of scope
137
+
138
+ - Score yaml scanning changes.
139
+ - Renaming the `x-base-images` key itself (it now covers runtime images too —
140
+ placeframe's loki already made that de facto; note it in a bake-file comment).
141
+ - make-it-sing CI stages (owned by its own plan).
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "docker-devkit"
3
- version = "0.1.13"
4
- description = "Docker-stack lifecycle commands (up/down/build) with a native/consumer split"
3
+ version = "0.1.15"
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 = [
7
7
  "bashrun>=0.1.0",
@@ -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
 
@@ -51,11 +50,8 @@ def build(
51
50
  no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild by disabling cache usage."),
52
51
  targets_opt: Annotated[
53
52
  list[str] | None,
54
- typer.Option("--targets", "-t", help="Build only these services (from the selected bake file)."),
53
+ typer.Option("--targets", "-t", help="Build only these services (from compose.bake.yml)."),
55
54
  ] = None,
56
- bake_file: Annotated[
57
- Path, typer.Option("--bake-file", help="Bake file to load (e.g. compose.bake.yml or compose.zed.bake.yml).")
58
- ] = DEFAULT_BAKE_FILE,
59
55
  ) -> None:
60
56
  run_build(
61
57
  upgrade=upgrade,
@@ -65,7 +61,6 @@ def build(
65
61
  gpu_only=gpu_only,
66
62
  no_cache=no_cache,
67
63
  targets_opt=targets_opt,
68
- bake_file=bake_file,
69
64
  )
70
65
 
71
66
 
@@ -78,35 +73,27 @@ def run_build(
78
73
  gpu_only: bool = False,
79
74
  no_cache: bool = False,
80
75
  targets_opt: list[str] | None = None,
81
- bake_file: Path = DEFAULT_BAKE_FILE,
82
76
  ) -> None:
83
- service_shas = compute_service_shas(Path.cwd(), bake_file)
77
+ bake = parse_bake(DEFAULT_BAKE_FILE)
78
+
79
+ service_shas = compute_service_shas(Path.cwd(), bake)
84
80
  os.environ.update(service_shas)
85
81
 
86
- # Tags of the images built this run — the local analog of .env.lock's pulled
87
- # digests — so raw `docker compose` can resolve the compose graph's ${*_SHA} holes.
82
+ # Write .env.shas with the locally-built image tags
88
83
  ENV_SHAS_FILE.write_text(
89
84
  "".join(f"{key}={value}\n" for key, value in sorted(service_shas.items())), encoding="utf-8"
90
85
  )
91
86
 
92
- # Read bake, compose, and lock files. A bake-only repo (cross-builds images
93
- # from a bake file, no local compose graph) has no root compose file; the
94
- # only downstream consumers of compose_data are the include merge and the
95
- # third-party x-image-ref scan, both empty-safe.
96
- bake_data: dict[str, Any] = yaml.safe_load(bake_file.read_text(encoding="utf-8"))
97
- compose_data: dict[str, Any] = (
98
- yaml.safe_load(COMPOSE_FILE.read_text(encoding="utf-8")) if COMPOSE_FILE.exists() else {}
99
- )
100
- for include in compose_data.pop("include", []):
101
- include_path = COMPOSE_FILE.parent / (include if isinstance(include, str) else include["path"])
102
- included: dict[str, Any] = yaml.safe_load(include_path.read_text(encoding="utf-8"))
103
- compose_data.setdefault("services", {}).update(included.get("services", {}))
87
+ # Load any existing lock
104
88
  lock_data = parse_env_file(LOCK_FILE) if LOCK_FILE.exists() else {}
105
89
 
106
90
  # TOOD: Create separate commands for ci and local modes so typer can do this validation instead of us
107
91
  if mode == "ci" and gpu == "auto":
108
92
  raise typer.BadParameter("In CI mode, --gpu cannot be 'auto'; specify 'cuda' or 'rocm'.")
109
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
+
110
97
  if gpu_only and gpu not in GPU_TYPES:
111
98
  raise typer.BadParameter("--gpu-only requires a concrete gpu (cuda or rocm), not 'auto' or 'none'.")
112
99
 
@@ -118,28 +105,18 @@ def run_build(
118
105
  if gpu == "auto" and not lock_only:
119
106
  gpu = detect_gpu()
120
107
 
121
- # Resolve base image external dependencies
122
- for occurrence in [ref for ref in bake_base_image_refs(bake_data) if upgrade or ref.name not in lock_data]:
123
- lock_data[occurrence.name] = (
124
- f"@{'' if '$' in occurrence.reference else resolve_remote_digest(occurrence.reference)}"
125
- )
126
-
127
- # Resolve third-party image external dependencies. x-image-ref marks services whose image
128
- # is sourced externally (vs. built from a bake file), so it's the right discriminator
129
- # regardless of how many bake files exist.
130
- third_party_images: dict[str, str] = {
131
- occurrence.name.upper().replace("-", "_") + "_IMAGE": occurrence.reference
132
- for occurrence in compose_service_refs(compose_data)
133
- }
134
-
135
- # Re-resolve when explicitly requested, when unseen, or when the image name in compose.yml
136
- # changed from what's in the lock file (e.g. postgres:16-alpine → postgis/postgres:16-3.4-alpine)
137
- for image, ref in {
138
- image: ref
139
- for image, ref in third_party_images.items()
140
- if upgrade or image not in lock_data or not lock_data[image].startswith(ref + "@")
141
- }.items():
142
- 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)}"
143
120
 
144
121
  # Update main lock file
145
122
  LOCK_FILE.write_text(
@@ -158,17 +135,17 @@ def run_build(
158
135
 
159
136
  # Determine bake targets
160
137
  if targets_opt:
161
- unknown = set(targets_opt) - set(bake_data["services"])
138
+ unknown = set(targets_opt) - set(bake.services)
162
139
  if unknown:
163
- raise typer.BadParameter(f"Unknown targets: {unknown}. Available: {sorted(bake_data['services'])}")
164
- 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)]
165
142
  else:
166
- targets = compute_default_targets(bake_data, gpu, gpu_only)
143
+ targets = compute_default_targets(bake, gpu, gpu_only)
167
144
 
168
145
  # Configure registry caches in CI mode
169
146
  if mode == "ci":
170
147
  for target in targets:
171
- target_cache = f"{bake_data['x-registry-cache']}:{target}"
148
+ target_cache = f"{bake.registry_cache}:{target}"
172
149
  command_arguments.append(
173
150
  f"--set {target}.cache-to+=type=registry,ref={target_cache},mode=max,image-manifest=true,oci-mediatypes=true"
174
151
  )
@@ -187,8 +164,7 @@ def run_build(
187
164
  else:
188
165
  raise RuntimeError(f"Unsupported host architecture: {machine}")
189
166
  for target in targets:
190
- target_platforms = bake_data["services"][target].get("build", {}).get("platforms", [])
191
- if len(target_platforms) > 1:
167
+ if len(bake.services[target].build.platforms) > 1:
192
168
  command_arguments.append(f"--set {target}.platform={host_platform}")
193
169
 
194
170
  # Load or push images based on mode
@@ -207,7 +183,7 @@ def run_build(
207
183
  # Bake images
208
184
  command = [
209
185
  "docker buildx bake",
210
- f"-f {bake_file}",
186
+ f"-f {DEFAULT_BAKE_FILE}",
211
187
  f"--metadata-file {METADATA_PATH}",
212
188
  "--progress auto",
213
189
  "--provenance=false",
@@ -218,7 +194,7 @@ def run_build(
218
194
  # Sanity check
219
195
  baked_images: dict[str, Any] = json.loads(METADATA_PATH.read_text()) if METADATA_PATH.exists() else {}
220
196
  if not set(targets) <= baked_images.keys():
221
- raise RuntimeError("Baked images do not match target images; something went wrong during the bake.")
197
+ raise RuntimeError("Bake output does not cover the requested targets")
222
198
 
223
199
 
224
200
  # Vibe code - Gemini 3
@@ -240,10 +216,7 @@ def _check_gc_limits(min_gb: int = 60):
240
216
  raw = data.get("builder", {}).get("gc", {}).get("defaultKeepStorage")
241
217
  if not raw:
242
218
  sample = json.dumps({"builder": {"gc": {"defaultKeepStorage": f"{min_gb}GB"}}}, indent=2)
243
- raise RuntimeError(
244
- f"Missing 'builder.gc.defaultKeepStorage' in {config}; Docker's default is too low for GPU builds "
245
- f"(need >= {min_gb}GB). Add the following (merging into any existing keys) and restart Docker:\n\n{sample}"
246
- )
219
+ raise RuntimeError(f"builder.gc.defaultKeepStorage missing in {config}; need >= {min_gb}GB:\n\n{sample}")
247
220
 
248
221
  m = re.match(r"^(\d+(?:\.\d+)?)\s*([TGMK]i?B)?$", str(raw), re.IGNORECASE)
249
222
  if not m:
@@ -254,9 +227,7 @@ def _check_gc_limits(min_gb: int = 60):
254
227
  val = float(m.group(1)) * mult.get(unit, 1 / 1024**3)
255
228
 
256
229
  if val < min_gb:
257
- raise RuntimeError(
258
- f"UNSAFE GC LIMIT: {val:.1f}GB in {config} (Required: {min_gb}GB). RESTART DOCKER AFTER FIXING."
259
- )
230
+ raise RuntimeError(f"Docker GC limit {val:.1f}GB in {config} is below the required {min_gb}GB")
260
231
 
261
232
  print(f" [OK] Docker GC Limit verified: {val:.1f}GB")
262
233
 
@@ -267,11 +238,9 @@ def _check_gc_limits(min_gb: int = 60):
267
238
  # Services without build.tags (e.g. neural-networks-base-*) stay out too: they are
268
239
  # build-only dependencies pulled in via additional_contexts, and bake rejects a
269
240
  # tagless target under --push.
270
- def compute_default_targets(bake_data: dict[str, Any], gpu: Gpu, gpu_only: bool = False) -> list[str]:
271
- cross_compile_targets: set[str] = set(bake_data.get("x-cross-compile-targets", []))
272
- tagged_services = [
273
- service for service, config in bake_data["services"].items() if config.get("build", {}).get("tags")
274
- ]
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]
275
244
  if gpu_only:
276
245
  return [
277
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()
@@ -0,0 +1,68 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from bashrun.bash import bash_handoff
7
+ from .detect_gpu import Gpu, detect_gpu
8
+
9
+ from .context_sha import compute_service_shas
10
+ from .documents import parse_bake
11
+ from .lifecycle import BAKE_FILE, enforce_bake_declaration, expand_compose_files, load_lifecycle_config
12
+ from .modes import resolve_auth_mode
13
+
14
+ ENV_FILE = Path(".env")
15
+ LOCK_FILE = Path(".env.lock")
16
+
17
+ app = typer.Typer(add_completion=False)
18
+
19
+
20
+ @app.command()
21
+ def down(
22
+ volumes: bool = typer.Option(False, "--volumes", "-v", help="Remove named volumes."),
23
+ gpu: Annotated[Gpu, typer.Option("--gpu", help="auto|cuda|rocm|none")] = "auto",
24
+ ) -> None:
25
+ config = load_lifecycle_config(Path.cwd())
26
+ enforce_bake_declaration(Path.cwd(), config)
27
+
28
+ if not ENV_FILE.exists():
29
+ raise RuntimeError("No .env file found")
30
+
31
+ if not LOCK_FILE.exists():
32
+ raise RuntimeError("No .env.lock found; run 'uv run build --lock-only' in the repo that authors the images")
33
+
34
+ if gpu == "auto":
35
+ gpu = detect_gpu()
36
+
37
+ resolve_auth_mode(ENV_FILE)
38
+
39
+ if BAKE_FILE.exists():
40
+ os.environ.update(compute_service_shas(Path.cwd(), parse_bake(BAKE_FILE)))
41
+
42
+ # Always layer the dev overlay so containers from a prior --dev bring-up get torn down too
43
+ compose_files = expand_compose_files(config, gpu, include_dev=True)
44
+ if not compose_files:
45
+ raise RuntimeError("No compose files declared; a builds-only repo has no stack to tear down")
46
+
47
+ files_args = " ".join(f"-f {compose_file}" for compose_file in compose_files)
48
+ command = (
49
+ "docker compose "
50
+ f"{files_args} "
51
+ "--profile keycloak " # Always include so any keycloak containers from a previous AUTH_MODE=keycloak run get torn down
52
+ "--env-file .env "
53
+ f"--env-file {LOCK_FILE} "
54
+ "down --remove-orphans"
55
+ )
56
+
57
+ if volumes:
58
+ command += " -v"
59
+
60
+ bash_handoff(command)
61
+
62
+
63
+ def main():
64
+ app()
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()