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.
- docker_devkit-0.1.15/AGENTS.md +62 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/PKG-INFO +2 -2
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/README.md +5 -4
- docker_devkit-0.1.15/plan.md +141 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/pyproject.toml +8 -3
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/build_docker.py +36 -67
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/context_sha.py +6 -9
- docker_devkit-0.1.15/src/docker_devkit/documents.py +64 -0
- docker_devkit-0.1.15/src/docker_devkit/down.py +68 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/image_refs.py +26 -71
- docker_devkit-0.1.15/src/docker_devkit/lifecycle.py +46 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/score_generate.py +2 -1
- docker_devkit-0.1.15/src/docker_devkit/up.py +91 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/tests/test_context_sha.py +13 -12
- docker_devkit-0.1.15/tests/test_default_targets.py +86 -0
- docker_devkit-0.1.15/tests/test_documents.py +63 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/tests/test_image_refs.py +38 -57
- docker_devkit-0.1.15/tests/test_lifecycle.py +117 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/uv.lock +11 -0
- docker_devkit-0.1.13/AGENTS.md +0 -60
- docker_devkit-0.1.13/src/docker_devkit/down.py +0 -86
- docker_devkit-0.1.13/src/docker_devkit/up.py +0 -109
- docker_devkit-0.1.13/tests/test_default_targets.py +0 -60
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/.gitattributes +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/.github/workflows/ci.yml +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/.github/workflows/release.yml +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/.gitignore +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/.python-version +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/CLAUDE.md +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/LICENSE +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/NOTICE +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/publish-config.json +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/ruff.base.toml +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/ruff.toml +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/__init__.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/detect_gpu.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/modes.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.15}/src/docker_devkit/py.typed +0 -0
- {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.
|
|
4
|
-
Summary: Docker-stack lifecycle commands (up/down/build)
|
|
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
|
|
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
|
|
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;
|
|
21
|
-
uv run up --
|
|
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.
|
|
4
|
-
description = "Docker-stack lifecycle commands (up/down/build)
|
|
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 = [
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
122
|
-
for occurrence in
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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(
|
|
138
|
+
unknown = set(targets_opt) - set(bake.services)
|
|
162
139
|
if unknown:
|
|
163
|
-
raise typer.BadParameter(f"Unknown targets: {unknown}. Available: {sorted(
|
|
164
|
-
targets = [t for t in
|
|
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(
|
|
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"{
|
|
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
|
-
|
|
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 {
|
|
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("
|
|
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(
|
|
271
|
-
cross_compile_targets: set[str] = set(
|
|
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,
|
|
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
|
|
19
|
-
|
|
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.
|
|
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()
|