docker-devkit 0.1.16__tar.gz → 0.1.18__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.18/AGENTS.md +74 -0
  2. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/PKG-INFO +1 -1
  3. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/README.md +9 -5
  4. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/pyproject.toml +2 -1
  5. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/build_docker.py +24 -16
  6. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/context_sha.py +16 -2
  7. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/down.py +20 -9
  8. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/image_refs.py +16 -7
  9. docker_devkit-0.1.18/src/docker_devkit/lifecycle.py +69 -0
  10. docker_devkit-0.1.18/src/docker_devkit/mirror.py +115 -0
  11. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/score_generate.py +56 -27
  12. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/up.py +46 -13
  13. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/tests/test_context_sha.py +46 -25
  14. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/tests/test_image_refs.py +32 -10
  15. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/tests/test_lifecycle.py +53 -1
  16. docker_devkit-0.1.18/tests/test_mirror.py +356 -0
  17. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/tests/test_score_generate.py +5 -2
  18. docker_devkit-0.1.16/AGENTS.md +0 -62
  19. docker_devkit-0.1.16/src/docker_devkit/lifecycle.py +0 -46
  20. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/.gitattributes +0 -0
  21. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/.github/workflows/ci.yml +0 -0
  22. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/.github/workflows/release.yml +0 -0
  23. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/.gitignore +0 -0
  24. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/.python-version +0 -0
  25. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/CLAUDE.md +0 -0
  26. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/LICENSE +0 -0
  27. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/NOTICE +0 -0
  28. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/plan.md +0 -0
  29. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/publish-config.json +0 -0
  30. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/ruff.base.toml +0 -0
  31. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/ruff.toml +0 -0
  32. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/__init__.py +0 -0
  33. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/detect_gpu.py +0 -0
  34. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/documents.py +0 -0
  35. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/modes.py +0 -0
  36. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/src/docker_devkit/py.typed +0 -0
  37. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/tests/test_default_targets.py +0 -0
  38. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/tests/test_documents.py +0 -0
  39. {docker_devkit-0.1.16 → docker_devkit-0.1.18}/uv.lock +0 -0
@@ -0,0 +1,74 @@
1
+ # docker-devkit
2
+
3
+ ## What this is
4
+
5
+ `docker-devkit` owns the Docker-stack lifecycle commands — `up`, `down`, `build`, `mirror` — 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`, `mirror` → `mirror.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`, `--allow-upstream-fallback`. |
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 the resolved image manifest, writing the paired lock (one `NAME=path:tag@sha256:…` entry per `x-base-images` declaration; `@digest`-pinned declarations pass through unresolved; mirror-prefixed declarations resolve their digest against the upstream reference). `--mode ci` hard-requires `x-registry-cache` in the manifest. |
22
+ | `score_generate.py` | `generate-score` — deterministic Score-manifest generation from a consumer's `stack/score/` sources: `${ALL_CAPS}` placeholder expansion into tempdir-rendered workload copies, idempotent `score-compose`/`score-k8s` init (state preserved across runs), and a determinism suite (document sort, state-path normalisation, 0644 modes, LF newlines). Consumer policy — workloads, provisioners, patch templates, output paths, storage classes, publishes, tool versions — is declared in the consumer's root `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). Output and state paths resolve against the repo root: the compose half runs in `score_dir` (its `.score-compose` state stays there, gitignored), the k8s half runs in the configured `k8s_state` parent so the committed `.score-k8s` state lands in the generated tier. `--local` writes the k8s artifact to the configured gitignored local path with the local storage class. |
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), `resolve_manifest()` / `require_manifest()` (the raising form) / `resolve_lock()` (the image manifest and its lock are a named pair — `workloads/images.yml` + `workloads/images.lock`, with the legacy root `compose.bake.yml` + `.env.lock` pair still resolved so repos can upgrade before moving files), `enforce_bake_declaration()` (a manifest under either name 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. The workload-tree root is derived as the single top-level directory containing every tagged service's Dockerfile (a bake graph spanning several top-level directories is a loud error); files under it that belong to no tagged service are excluded from every SHA rather than shared into all of them. |
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 from `workloads/images.yml` and the legacy `compose*.bake.yml` names (parsed and validated via `BakeDocument`) — the single source consumer mirroring consumes. `stray_references(root, …globs)` scans Dockerfile `FROM`, Dockerfile `COPY --from=` (slash-bearing captures only, so build-stage names are skipped), and yaml `image:` lines — hand-written literal refs that bypass the declaration system — across the `workloads/` and `docker/` Dockerfile trees and the `stack/score/` + `score/` source trees; `unpinned_references(root)` polices exactly those strays (every slash-bearing reference must carry a tag, a digest, or a trailing `${build-arg}` suffix — consumer CIs fail on its result). `bake_base_image_refs(bake: BakeDocument)` adapts declarations to `ImageReference` pairs; `version_coupling_violations(root, couplings)` (each `VersionCoupling` names a pyproject-declared version and the `VersionSite` regexes that must restate it — bake `x-base-images` values or raw file text — so non-derivable couplings like uv `required-version` ↔ uv-base-image tag can't drift silently; the mechanism lives here, the registry in consumer repos' preflight) is unchanged. Remote digest resolution goes through `imagetools inspect`. Compose files are parsed by nothing in this package. |
29
+ | `mirror.py` | The mirror namespace's config, inversion, and verbs. `MirrorConfig` (`[tool.docker-devkit.mirror]`, key `prefix`), `load_mirror_config()` (walks `tool → docker-devkit → mirror`, `None` when absent), `is_mirrored`/`upstream_ref` (prefix inversion preserving the `:tag@sha256:…` tail), `reference_exists` (imagetools existence probe honoring docker config creds), `mirror_targets` (declared refs filtered by prefix, sorted), the `mirror` verb (crane install + per-target `crane copy`), and `up`'s fallback helpers (`upstream_fallback`, `write_fallback_overlay`). Constraints in "Mirror namespace and upstream fallback". |
30
+
31
+ ## Constraints
32
+
33
+ ### Lifecycle declaration contract
34
+
35
+ **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 an image 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 lock through the include's `env_file`).
36
+
37
+ **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. The image lock is required unconditionally, no knob; consumers pin stock images via `${…_IMAGE:?err}` holes filled by a committed lock. An image manifest present under either resolved name 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 manifest remains the build/up contract: SHA injection and `--build` acceptance key on the manifest existing, and `build` always reads the resolved manifest. `up`/`down` pass `--project-directory` (the invocation root) explicitly, so compose files living in nested trees (`stack/generated/compose/`, overlays) anchor their relative paths at the repo root instead of the first `-f` file's directory. An empty assembly (`files` empty/omitted, no dev overlay) is a loud `up`/`down` error — the honest state for a builds-only repo.
38
+
39
+ **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 lock.
40
+
41
+ ### File and env conventions the commands assume
42
+
43
+ - `.env` (required) — carries `PUBLIC_URL` and `AUTH_MODE`; `modes.py` reads it and rejects `keycloak` over cleartext `http://`.
44
+ - `workloads/images.lock` (required in every repo, checked in) — third-party and base-image pins, written by `build`: one `NAME=path:tag@sha256:…` entry per manifest declaration. `up`/`down` refuse without it; consumers satisfy it with a committed lock filling the `${…_IMAGE:?err}` holes. The legacy name `.env.lock` resolves while the manifest still carries its legacy name; the lock always sits beside its manifest as the resolved pair.
45
+ - `workloads/images.yml` (where a local build graph exists, required) — the image manifest and buildx-bake description; its top-level `x-cross-compile-targets` list (optional) is the set of services excluded from the default target list because they need per-arch treatment, opt-in via `--targets`. The legacy root name `compose.bake.yml` resolves identically. `build` reads exactly one manifest: the first of `workloads/images.yml`, `compose.bake.yml` that exists. `buildx bake` resolves the manifest's relative paths against its invocation cwd, so `build` always invokes from the repo root.
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
+ `build` injects the `tree-<hash>` tags of locally-built images into the environment rather than writing them anywhere; `up`/`down` do the same via `compute_service_shas`. The lock stays a pure third-party pin file — a CI invariant forbids built-image digests in the committed, reviewed lock.
50
+
51
+ ### Mirror namespace and upstream fallback
52
+
53
+ **Context.** Third-party images are consumed through an org-owned mirror namespace (`ghcr.io/outernet-foundation/mirror/<upstream-host>/<path>…`) instead of pulling upstream registries directly: manifests at upstream get GC'd, and an upstream yank once broke external cloners — digest pins alone don't save you when the manifest is gone. Durability for strangers beats upstream egress: strangers, CI, and prod always pull the mirror. Populating it is CI's job, single writer — engineers never push to the org namespace, and error text must never teach them to (no "run this populate command" hints).
54
+
55
+ **Constraint.** The mirror is declared, not inferred: `[tool.docker-devkit.mirror]` with `prefix` in the consumer's root pyproject. Absent table → all mirror behavior off; `up`/`build` behave exactly as they did before it existed. Under the table:
56
+
57
+ - `build` resolves lock digests for mirror-prefixed declarations against the upstream reference (prefix stripped, tag/digest tail preserved) and records them under the mirror name. Valid because `crane copy` produces byte-identical manifests — upstream and mirror digests are the same digest; a manifest re-wrap (`docker buildx imagetools`-shaped tooling) would change the top-level digest and break lock equality. This makes declare → re-lock walkable before CI has mirrored anything; lock lines are never hand-authored.
58
+ - `up` fails closed: after lock resolution it probes each mirror-prefixed lock entry; a missing mirror image is a hard `RuntimeError`, not a warning — people ignore warnings, and warnings are errors someone was too lazy to fix. The only escape is the per-run `--allow-upstream-fallback` flag. Under the flag, missing entries become digest-preserving address substitutions (same bytes from the upstream host), printed as plain statements — the operator asked for fallback, the run reports what it did. The rewrite is authoritative on both consumption paths: an appended `--env-file` overlay (compose's later env files win) plus `os.environ` before `build`'s bake (shell env wins over env files; `run_build` re-applies the overlay after its own lock update so bake is never clobbered). CI never passes the flag — the mirror job runs before anything needs the images.
59
+ - The `mirror` verb (crane install from a pinned release, per-target `crane copy`, count footer) uses bashrun + plain prints, deliberately not ci-devkit's `ci_step`: docker-devkit and ci-devkit are siblings in the dependency DAG and the mirror verb is the only thing that would ever draw that edge. Zero new dependencies is a hard rule.
60
+
61
+ **Consequences.** Non-mirror lock entries (e.g. `placeframe/*` publishes) are not probed and keep failing naturally at pull. There is no config-level ban on the flag: an air-gap fails closed anyway (no route to upstream — the pull dies with or without it).
62
+
63
+ ### Package split rationale
64
+
65
+ 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.
66
+
67
+ ### `--quiet-pull` logging and GPU auto-detection
68
+
69
+ `--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.
70
+
71
+ ## See also
72
+
73
+ - `README.md` — human-facing setup and usage.
74
+ - [`bashrun`](https://github.com/outernet-foundation/bashrun) — the guardrailed subprocess wrapper the lifecycle commands shell out through.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docker-devkit
3
- Version: 0.1.16
3
+ Version: 0.1.18
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
@@ -1,8 +1,8 @@
1
1
  # docker-devkit
2
2
 
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`.
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 + `workloads/images.yml`) declares its file layers and gets multi-file assembly, per-service SHA injection, and `workloads/images.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 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 declaration contract.
5
+ See [`AGENTS.md`](./AGENTS.md) for the file/layout conventions the commands assume (`workloads/images.yml` + `workloads/images.lock` — with the legacy root `compose.bake.yml` + `.env.lock` pair still resolved — and `PUBLIC_URL` / `AUTH_MODE` in `.env`) and the declaration contract.
6
6
 
7
7
  ## Setup
8
8
 
@@ -19,16 +19,20 @@ From a stack repo's root:
19
19
  ```bash
20
20
  uv run up # docker compose up (auto-detects GPU; assembles the declared lifecycle files)
21
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
+ uv run up --build # build images locally first (requires workloads/images.yml)
23
23
  uv run up --gpu none # override GPU auto-detection
24
+ uv run up --allow-upstream-fallback # per-run escape: pull unmirrored lock entries from upstream at the same digest
24
25
  uv run down # docker compose down
25
26
  uv run down -v # also remove named volumes
26
- uv run build # cross-build all images per compose.bake.yml
27
- uv run build --lock-only # refresh .env.lock without building
27
+ uv run build # cross-build all images per workloads/images.yml
28
+ uv run build --lock-only # refresh the image lock without building
29
+ uv run mirror # populate the org mirror namespace from mirror-prefixed declarations (CI)
28
30
  uv run generate-score # regenerate Score compose/k8s manifests per [tool.docker-devkit.generate-score]
29
31
  uv run generate-score --local # local-cluster variant (local storage class, gitignored output)
30
32
  ```
31
33
 
34
+ Repos that consume third-party images through the org mirror namespace declare `[tool.docker-devkit.mirror]` (`prefix = "ghcr.io/outernet-foundation/mirror"`); `up` then fails closed on any lock entry not mirrored yet, and `build` resolves lock digests against upstream. See the "Mirror namespace and upstream fallback" section in [`AGENTS.md`](./AGENTS.md).
35
+
32
36
  ## Consuming from another repo
33
37
 
34
38
  Install from PyPI and use its entry points:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-devkit"
3
- version = "0.1.16"
3
+ version = "0.1.18"
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 = [
@@ -28,6 +28,7 @@ build-backend = "hatchling.build"
28
28
  up = "docker_devkit.up:app"
29
29
  down = "docker_devkit.down:app"
30
30
  build = "docker_devkit.build_docker:app"
31
+ mirror = "docker_devkit.mirror:app"
31
32
  generate-score = "docker_devkit.score_generate:app"
32
33
 
33
34
  [tool.hatch.build.targets.wheel]
@@ -13,9 +13,11 @@ from bashrun.bash import bash, bash_output
13
13
  from .detect_gpu import GPU_TYPES, Gpu, detect_gpu
14
14
  from .documents import BakeDocument, digest_pinned, parse_bake
15
15
  from pydantic_settings import BaseSettings
16
-
17
16
  from .context_sha import compute_service_shas
17
+
18
18
  from .image_refs import bake_base_image_refs, resolve_remote_digest
19
+ from .lifecycle import require_manifest, resolve_lock
20
+ from .mirror import is_mirrored, load_mirror_config, upstream_ref
19
21
  from .modes import parse_env_file
20
22
 
21
23
 
@@ -25,9 +27,6 @@ class Settings(BaseSettings):
25
27
 
26
28
  settings = Settings.model_validate({})
27
29
 
28
- LOCK_FILE = Path(".env.lock")
29
- ENV_SHAS_FILE = Path(".env.shas")
30
- DEFAULT_BAKE_FILE = Path("compose.bake.yml")
31
30
  METADATA_PATH = Path("metadata.json")
32
31
 
33
32
  Mode = Literal["local", "ci"]
@@ -50,7 +49,7 @@ def build(
50
49
  no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild by disabling cache usage."),
51
50
  targets_opt: Annotated[
52
51
  list[str] | None,
53
- typer.Option("--targets", "-t", help="Build only these services (from compose.bake.yml)."),
52
+ typer.Option("--targets", "-t", help="Build only these services (from the image manifest)."),
54
53
  ] = None,
55
54
  ) -> None:
56
55
  run_build(
@@ -73,19 +72,20 @@ def run_build(
73
72
  gpu_only: bool = False,
74
73
  no_cache: bool = False,
75
74
  targets_opt: list[str] | None = None,
75
+ env_overlay: dict[str, str] | None = None,
76
76
  ) -> None:
77
- bake = parse_bake(DEFAULT_BAKE_FILE)
77
+ root = Path.cwd()
78
+ manifest = require_manifest(root)
79
+ lock_file = resolve_lock(root, manifest)
80
+ mirror_config = load_mirror_config(root)
78
81
 
79
- service_shas = compute_service_shas(Path.cwd(), bake)
80
- os.environ.update(service_shas)
82
+ bake = parse_bake(manifest)
81
83
 
82
- # Write .env.shas with the locally-built image tags
83
- ENV_SHAS_FILE.write_text(
84
- "".join(f"{key}={value}\n" for key, value in sorted(service_shas.items())), encoding="utf-8"
85
- )
84
+ service_shas = compute_service_shas(root, bake)
85
+ os.environ.update(service_shas)
86
86
 
87
87
  # Load any existing lock
88
- lock_data = parse_env_file(LOCK_FILE) if LOCK_FILE.exists() else {}
88
+ lock_data = parse_env_file(lock_file) if lock_file.exists() else {}
89
89
 
90
90
  # TOOD: Create separate commands for ci and local modes so typer can do this validation instead of us
91
91
  if mode == "ci" and gpu == "auto":
@@ -106,6 +106,9 @@ def run_build(
106
106
  gpu = detect_gpu()
107
107
 
108
108
  # One lock entry per declared third-party image: NAME=path:tag@sha256:…
109
+ # Mirror-prefixed declarations resolve their digest against upstream (crane copies
110
+ # are byte-identical, so the digest is valid under both names) and record it under
111
+ # the mirror name — declaring and re-locking works before CI has mirrored anything.
109
112
  for occurrence in bake_base_image_refs(bake):
110
113
  if digest_pinned(occurrence.reference):
111
114
  lock_data[occurrence.name] = occurrence.reference
@@ -116,10 +119,13 @@ def run_build(
116
119
  and lock_data[occurrence.name].startswith(occurrence.reference + "@")
117
120
  ):
118
121
  continue
119
- lock_data[occurrence.name] = f"{occurrence.reference}@{resolve_remote_digest(occurrence.reference)}"
122
+ inspect_target = occurrence.reference
123
+ if mirror_config is not None and is_mirrored(occurrence.reference, mirror_config.prefix):
124
+ inspect_target = upstream_ref(occurrence.reference, mirror_config.prefix)
125
+ lock_data[occurrence.name] = f"{occurrence.reference}@{resolve_remote_digest(inspect_target)}"
120
126
 
121
127
  # Update main lock file
122
- LOCK_FILE.write_text(
128
+ lock_file.write_text(
123
129
  "# Generated by lock.py\n" + "\n".join(f"{key}={value}" for key, value in sorted(lock_data.items())) + "\n",
124
130
  encoding="utf-8",
125
131
  )
@@ -129,6 +135,8 @@ def run_build(
129
135
 
130
136
  # Update environment with external dependency image digests
131
137
  os.environ.update(lock_data)
138
+ if env_overlay:
139
+ os.environ.update(env_overlay)
132
140
 
133
141
  # Build command arguments
134
142
  command_arguments: list[str] = []
@@ -183,7 +191,7 @@ def run_build(
183
191
  # Bake images
184
192
  command = [
185
193
  "docker buildx bake",
186
- f"-f {DEFAULT_BAKE_FILE}",
194
+ f"-f {manifest}",
187
195
  f"--metadata-file {METADATA_PATH}",
188
196
  "--progress auto",
189
197
  "--provenance=false",
@@ -18,6 +18,8 @@ def compute_service_shas(repo_root: Path, bake: BakeDocument) -> dict[str, str]:
18
18
  continue
19
19
  service_dirs.add(str(PurePosixPath(config.build.dockerfile).parent))
20
20
 
21
+ workload_root = _workload_root(service_dirs)
22
+
21
23
  tree_entries = bash_output("git ls-tree -r HEAD", cwd=repo_root).splitlines()
22
24
 
23
25
  dockerignore = repo_root / ".dockerignore"
@@ -31,12 +33,12 @@ def compute_service_shas(repo_root: Path, bake: BakeDocument) -> dict[str, str]:
31
33
  mode, _type, obj_hash = meta.split()
32
34
  allowed_entries.append((mode, obj_hash, path))
33
35
 
34
- docker_prefix = "docker/"
36
+ workload_prefix = workload_root.as_posix() + "/" if workload_root is not None else None
35
37
  shared_entries: list[tuple[str, str, str]] = []
36
38
  per_service: dict[str, list[tuple[str, str, str]]] = {d: [] for d in service_dirs}
37
39
 
38
40
  for mode, obj_hash, path in allowed_entries:
39
- if path.startswith(docker_prefix):
41
+ if workload_prefix is not None and path.startswith(workload_prefix):
40
42
  for service_dir in service_dirs:
41
43
  if path.startswith(service_dir + "/"):
42
44
  per_service[service_dir].append((mode, obj_hash, path))
@@ -57,3 +59,15 @@ def compute_service_shas(repo_root: Path, bake: BakeDocument) -> dict[str, str]:
57
59
  result[PurePosixPath(service_dir).name.upper().replace("-", "_") + "_SHA"] = f"tree-{tree_hash}"
58
60
 
59
61
  return result
62
+
63
+
64
+ def _workload_root(service_dirs: set[str]) -> PurePosixPath | None:
65
+ # The workload tree root is the single top-level directory containing every tagged
66
+ # service. Files under it that belong to no tagged service are excluded from every
67
+ # SHA: they are workload-tree content, not shared build context.
68
+ tops = {PurePosixPath(service_dir).parts[0] for service_dir in service_dirs if PurePosixPath(service_dir).parts}
69
+ if not tops:
70
+ return None
71
+ if len(tops) > 1:
72
+ raise RuntimeError(f"Bake services span multiple top-level directories: {sorted(tops)}")
73
+ return PurePosixPath(next(iter(tops)))
@@ -8,11 +8,16 @@ from .detect_gpu import Gpu, detect_gpu
8
8
 
9
9
  from .context_sha import compute_service_shas
10
10
  from .documents import parse_bake
11
- from .lifecycle import BAKE_FILE, enforce_bake_declaration, expand_compose_files, load_lifecycle_config
11
+ from .lifecycle import (
12
+ enforce_bake_declaration,
13
+ expand_compose_files,
14
+ load_lifecycle_config,
15
+ resolve_lock,
16
+ resolve_manifest,
17
+ )
12
18
  from .modes import resolve_auth_mode
13
19
 
14
20
  ENV_FILE = Path(".env")
15
- LOCK_FILE = Path(".env.lock")
16
21
 
17
22
  app = typer.Typer(add_completion=False)
18
23
 
@@ -22,22 +27,27 @@ def down(
22
27
  volumes: bool = typer.Option(False, "--volumes", "-v", help="Remove named volumes."),
23
28
  gpu: Annotated[Gpu, typer.Option("--gpu", help="auto|cuda|rocm|none")] = "auto",
24
29
  ) -> None:
25
- config = load_lifecycle_config(Path.cwd())
26
- enforce_bake_declaration(Path.cwd(), config)
30
+ root = Path.cwd()
31
+ config = load_lifecycle_config(root)
32
+ enforce_bake_declaration(root, config)
33
+ manifest = resolve_manifest(root)
34
+ lock_file = resolve_lock(root, manifest)
27
35
 
28
36
  if not ENV_FILE.exists():
29
37
  raise RuntimeError("No .env file found")
30
38
 
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")
39
+ if not lock_file.exists():
40
+ raise RuntimeError(
41
+ f"No image lock at {lock_file}; run 'uv run build --lock-only' in the repo that authors the images"
42
+ )
33
43
 
34
44
  if gpu == "auto":
35
45
  gpu = detect_gpu()
36
46
 
37
47
  resolve_auth_mode(ENV_FILE)
38
48
 
39
- if BAKE_FILE.exists():
40
- os.environ.update(compute_service_shas(Path.cwd(), parse_bake(BAKE_FILE)))
49
+ if manifest is not None:
50
+ os.environ.update(compute_service_shas(root, parse_bake(manifest)))
41
51
 
42
52
  # Always layer the dev overlay so containers from a prior --dev bring-up get torn down too
43
53
  compose_files = expand_compose_files(config, gpu, include_dev=True)
@@ -48,9 +58,10 @@ def down(
48
58
  command = (
49
59
  "docker compose "
50
60
  f"{files_args} "
61
+ f"--project-directory {root} "
51
62
  "--profile keycloak " # Always include so any keycloak containers from a previous AUTH_MODE=keycloak run get torn down
52
63
  "--env-file .env "
53
- f"--env-file {LOCK_FILE} "
64
+ f"--env-file {lock_file} "
54
65
  "down --remove-orphans"
55
66
  )
56
67
 
@@ -70,30 +70,39 @@ def unpinned_references(root: Path) -> list[str]:
70
70
  ]
71
71
 
72
72
 
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))]
73
+ def declared_references(
74
+ root: Path, bake_globs: tuple[str, ...] = ("workloads/images.yml", "compose*.bake.yml")
75
+ ) -> list[ImageReference]:
76
+ return [
77
+ reference
78
+ for bake_glob in bake_globs
79
+ for path in sorted(root.glob(bake_glob))
80
+ for reference in bake_base_image_refs(parse_bake(path))
81
+ ]
75
82
 
76
83
 
77
84
  def stray_references(
78
85
  root: Path,
79
- dockerfile_glob: str = "docker/*/Dockerfile*",
80
- image_glob: str = "score/*.yaml",
86
+ dockerfile_globs: tuple[str, ...] = ("workloads/*/Dockerfile*", "docker/*/Dockerfile*"),
87
+ image_globs: tuple[str, ...] = ("stack/score/*.yaml", "score/*.yaml"),
81
88
  ) -> list[ImageReference]:
89
+ dockerfile_paths = [path for dockerfile_glob in dockerfile_globs for path in sorted(root.glob(dockerfile_glob))]
90
+ image_paths = [path for image_glob in image_globs for path in sorted(root.glob(image_glob))]
82
91
  return (
83
92
  [
84
93
  ImageReference("", match.group(1))
85
- for path in sorted(root.glob(dockerfile_glob))
94
+ for path in dockerfile_paths
86
95
  for match in FROM_PATTERN.finditer(path.read_text(encoding="utf-8"))
87
96
  ]
88
97
  + [
89
98
  ImageReference("", match.group(1))
90
- for path in sorted(root.glob(dockerfile_glob))
99
+ for path in dockerfile_paths
91
100
  for match in COPY_FROM_PATTERN.finditer(path.read_text(encoding="utf-8"))
92
101
  if "/" in match.group(1)
93
102
  ]
94
103
  + [
95
104
  ImageReference("", match.group(1))
96
- for path in sorted(root.glob(image_glob))
105
+ for path in image_paths
97
106
  for match in IMAGE_LINE_PATTERN.finditer(path.read_text(encoding="utf-8"))
98
107
  ]
99
108
  )
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from .detect_gpu import Gpu
9
+
10
+ DEFAULT_COMPOSE_FILE = Path("compose.yml")
11
+ GPU_PLACEHOLDER = "{gpu}"
12
+
13
+ # The image manifest and its lock are a named pair living in the same directory. The
14
+ # legacy names keep resolving so a repo can upgrade the devkit before moving its files.
15
+ MANIFEST_CANDIDATES = (Path("workloads/images.yml"), Path("compose.bake.yml"))
16
+ MANIFEST_LOCK_PAIR = {"images.yml": "images.lock", "compose.bake.yml": ".env.lock"}
17
+ LOCK_CANDIDATES = (Path("workloads/images.lock"), Path(".env.lock"))
18
+
19
+
20
+ class LifecycleConfig(BaseModel):
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+ files: list[str] = Field(default_factory=list)
24
+ dev_file: str | None = None
25
+
26
+
27
+ def load_lifecycle_config(root: Path) -> LifecycleConfig | None:
28
+ pyproject_path = root / "pyproject.toml"
29
+ if not pyproject_path.exists():
30
+ return None
31
+ data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
32
+ section = data.get("tool", {}).get("docker-devkit", {}).get("lifecycle")
33
+ if section is None:
34
+ return None
35
+ return LifecycleConfig.model_validate(section)
36
+
37
+
38
+ def resolve_manifest(root: Path) -> Path | None:
39
+ return next((root / candidate for candidate in MANIFEST_CANDIDATES if (root / candidate).exists()), None)
40
+
41
+
42
+ def require_manifest(root: Path) -> Path:
43
+ manifest = resolve_manifest(root)
44
+ if manifest is None:
45
+ raise RuntimeError(f"No image manifest found; {MANIFEST_CANDIDATES[0]} is the canonical name")
46
+ return manifest
47
+
48
+
49
+ def resolve_lock(root: Path, manifest: Path | None) -> Path:
50
+ if manifest is not None:
51
+ return manifest.parent / MANIFEST_LOCK_PAIR[manifest.name]
52
+ existing = next((root / candidate for candidate in LOCK_CANDIDATES if (root / candidate).exists()), None)
53
+ return existing if existing is not None else root / LOCK_CANDIDATES[0]
54
+
55
+
56
+ def enforce_bake_declaration(root: Path, config: LifecycleConfig | None) -> None:
57
+ if config is None and any((root / candidate).exists() for candidate in MANIFEST_CANDIDATES):
58
+ raise RuntimeError(
59
+ "image manifest present without [tool.docker-devkit.lifecycle]; declare the table or rename the file"
60
+ )
61
+
62
+
63
+ def expand_compose_files(config: LifecycleConfig | None, gpu: Gpu, *, include_dev: bool = False) -> list[Path]:
64
+ if config is None:
65
+ return [DEFAULT_COMPOSE_FILE]
66
+ files = [Path(entry.format(gpu=gpu)) for entry in config.files if gpu != "none" or GPU_PLACEHOLDER not in entry]
67
+ if include_dev and config.dev_file is not None:
68
+ files.append(Path(config.dev_file))
69
+ return files
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shlex
5
+ import tempfile
6
+ import tomllib
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from bashrun.bash import bash, bash_check
11
+ from pydantic import BaseModel, ConfigDict, field_validator
12
+
13
+ from .image_refs import ImageReference, declared_references
14
+
15
+ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
16
+
17
+ CRANE_VERSION = "v0.22.1"
18
+
19
+
20
+ class MirrorConfig(BaseModel):
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+ prefix: str
24
+
25
+ @field_validator("prefix")
26
+ @classmethod
27
+ def _require_bare_prefix(cls, prefix: str) -> str:
28
+ if not prefix or prefix.endswith("/"):
29
+ raise ValueError("mirror prefix must be non-empty and carry no trailing slash")
30
+ return prefix
31
+
32
+
33
+ @app.command()
34
+ def mirror() -> None:
35
+ root = Path.cwd()
36
+ config = load_mirror_config(root)
37
+ if config is None:
38
+ raise RuntimeError("[tool.docker-devkit.mirror] with 'prefix' is required to mirror")
39
+
40
+ targets = mirror_targets(root, config.prefix)
41
+ print(f"Install crane {CRANE_VERSION}")
42
+ sudo = "sudo " if os.geteuid() != 0 else ""
43
+ archive = "go-containerregistry_Linux_x86_64.tar.gz"
44
+ bash(f"curl -fsSLO https://github.com/google/go-containerregistry/releases/download/{CRANE_VERSION}/{archive}")
45
+ bash(f"{sudo}tar -xzf {archive} -C /usr/local/bin crane")
46
+ Path(archive).unlink()
47
+ for target in targets:
48
+ upstream = upstream_ref(target.reference, config.prefix)
49
+ print(f"Mirror {upstream} -> {target.reference}")
50
+ bash(f"crane copy {upstream} {target.reference}")
51
+ print(f"Mirrored images: {len(targets)}")
52
+
53
+
54
+ def load_mirror_config(root: Path) -> MirrorConfig | None:
55
+ pyproject_path = root / "pyproject.toml"
56
+ if not pyproject_path.exists():
57
+ return None
58
+ data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
59
+ section = data.get("tool", {}).get("docker-devkit", {}).get("mirror")
60
+ if section is None:
61
+ return None
62
+ return MirrorConfig.model_validate(section)
63
+
64
+
65
+ def is_mirrored(reference: str, prefix: str) -> bool:
66
+ return reference.startswith(f"{prefix}/")
67
+
68
+
69
+ def upstream_ref(reference: str, prefix: str) -> str:
70
+ return reference[len(prefix) + 1 :]
71
+
72
+
73
+ def reference_exists(reference: str) -> bool:
74
+ return bash_check(f"docker buildx imagetools inspect {shlex.quote(reference)}")
75
+
76
+
77
+ def mirror_targets(root: Path, prefix: str) -> list[ImageReference]:
78
+ return sorted(
79
+ (occurrence for occurrence in declared_references(root) if is_mirrored(occurrence.reference, prefix)),
80
+ key=lambda occurrence: occurrence.reference,
81
+ )
82
+
83
+
84
+ def upstream_fallback(lock_data: dict[str, str], prefix: str, *, allow_upstream_fallback: bool) -> dict[str, str]:
85
+ missing = {
86
+ name: value for name, value in lock_data.items() if is_mirrored(value, prefix) and not reference_exists(value)
87
+ }
88
+ if not missing:
89
+ return {}
90
+ if not allow_upstream_fallback:
91
+ value = next(iter(missing.values()))
92
+ reference, _, digest = value.rpartition("@")
93
+ raise RuntimeError(
94
+ f"Image {reference or value} (digest {digest or 'unknown'}) is not mirrored yet. "
95
+ "If this is a new upstream dependency that has not been through CI, pass --allow-upstream-fallback "
96
+ "to pull the same digest from upstream for this run."
97
+ )
98
+ return {name: upstream_ref(value, prefix) for name, value in missing.items()}
99
+
100
+
101
+ def write_fallback_overlay(substitutions: dict[str, str]) -> Path:
102
+ overlay = Path(tempfile.gettempdir()) / f"docker-devkit-upstream-fallback-{os.getpid()}.env"
103
+ overlay.write_text(
104
+ "\n".join(f"{name}={value}" for name, value in sorted(substitutions.items())) + "\n",
105
+ encoding="utf-8",
106
+ )
107
+ return overlay
108
+
109
+
110
+ def main() -> None:
111
+ app()
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()