docker-devkit 0.1.14__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 (37) hide show
  1. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/AGENTS.md +6 -5
  2. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/PKG-INFO +1 -1
  3. docker_devkit-0.1.15/plan.md +141 -0
  4. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/pyproject.toml +7 -2
  5. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/build_docker.py +31 -48
  6. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/context_sha.py +6 -9
  7. docker_devkit-0.1.15/src/docker_devkit/documents.py +64 -0
  8. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/down.py +2 -1
  9. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/image_refs.py +26 -71
  10. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/score_generate.py +2 -1
  11. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/up.py +2 -1
  12. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/tests/test_context_sha.py +13 -12
  13. docker_devkit-0.1.15/tests/test_default_targets.py +86 -0
  14. docker_devkit-0.1.15/tests/test_documents.py +63 -0
  15. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/tests/test_image_refs.py +38 -57
  16. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/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.15}/.gitattributes +0 -0
  20. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/.github/workflows/ci.yml +0 -0
  21. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/.github/workflows/release.yml +0 -0
  22. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/.gitignore +0 -0
  23. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/.python-version +0 -0
  24. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/CLAUDE.md +0 -0
  25. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/LICENSE +0 -0
  26. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/NOTICE +0 -0
  27. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/README.md +0 -0
  28. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/publish-config.json +0 -0
  29. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/ruff.base.toml +0 -0
  30. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/ruff.toml +0 -0
  31. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/__init__.py +0 -0
  32. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/detect_gpu.py +0 -0
  33. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/lifecycle.py +0 -0
  34. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/modes.py +0 -0
  35. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/src/docker_devkit/py.typed +0 -0
  36. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/tests/test_lifecycle.py +0 -0
  37. {docker_devkit-0.1.14 → docker_devkit-0.1.15}/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.15
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,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,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-devkit"
3
- version = "0.1.14"
3
+ version = "0.1.15"
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()))
@@ -15,6 +15,7 @@ from bashrun.bash import bash
15
15
  from pydantic import BaseModel, ConfigDict, Field
16
16
 
17
17
  from .context_sha import compute_service_shas
18
+ from .documents import parse_bake
18
19
  from .image_refs import TomlDocument, TomlValue
19
20
 
20
21
  # Placeholders in the workload files are ALL-CAPS, so this cannot collide with Score's own
@@ -95,7 +96,7 @@ def load_score_config(root: Path) -> ScoreConfig:
95
96
  def generate(config: ScoreConfig, target: Target = "both", local: bool = False) -> None:
96
97
  # The same call up, build, and preflight make, so every image tag is a pure function of
97
98
  # committed source rather than a hand-typed value.
98
- os.environ.update(compute_service_shas(Path.cwd(), config.bake_file))
99
+ os.environ.update(compute_service_shas(Path.cwd(), parse_bake(config.bake_file)))
99
100
  os.environ[config.storage_class_var] = config.local_storage_class if local else config.cloud_storage_class
100
101
 
101
102
  if target in ("both", "compose"):