docker-devkit 0.1.15__tar.gz → 0.1.17__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 → docker_devkit-0.1.17}/AGENTS.md +11 -12
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/PKG-INFO +1 -1
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/README.md +5 -5
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/plan.md +17 -13
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/pyproject.toml +1 -1
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/build_docker.py +12 -15
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/context_sha.py +16 -2
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/down.py +20 -9
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/image_refs.py +16 -7
- docker_devkit-0.1.17/src/docker_devkit/lifecycle.py +69 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/score_generate.py +56 -27
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/up.py +21 -11
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/tests/test_context_sha.py +46 -25
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/tests/test_image_refs.py +32 -10
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/tests/test_lifecycle.py +53 -1
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/tests/test_score_generate.py +5 -2
- docker_devkit-0.1.15/src/docker_devkit/lifecycle.py +0 -46
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/.gitattributes +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/.github/workflows/ci.yml +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/.github/workflows/release.yml +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/.gitignore +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/.python-version +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/CLAUDE.md +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/LICENSE +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/NOTICE +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/publish-config.json +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/ruff.base.toml +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/ruff.toml +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/__init__.py +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/detect_gpu.py +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/documents.py +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/modes.py +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/src/docker_devkit/py.typed +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/tests/test_default_targets.py +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/tests/test_documents.py +0 -0
- {docker_devkit-0.1.15 → docker_devkit-0.1.17}/uv.lock +0 -0
|
@@ -18,35 +18,34 @@ 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
|
|
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), `
|
|
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). `--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
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(repo_root, bake: BakeDocument)` — per-service `tree-<hash>` image tags over the `.dockerignore`-allowlisted git tree. |
|
|
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
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
|
+
| `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
29
|
|
|
30
30
|
## Constraints
|
|
31
31
|
|
|
32
32
|
### Lifecycle declaration contract
|
|
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
|
|
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 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`).
|
|
35
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.
|
|
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. 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.
|
|
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
|
|
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 lock.
|
|
39
39
|
|
|
40
40
|
### File and env conventions the commands assume
|
|
41
41
|
|
|
42
42
|
- `.env` (required) — carries `PUBLIC_URL` and `AUTH_MODE`; `modes.py` reads it and rejects `keycloak` over cleartext `http://`.
|
|
43
|
-
-
|
|
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.
|
|
43
|
+
- `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.
|
|
44
|
+
- `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
45
|
- 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
46
|
- `.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
47
|
|
|
49
|
-
|
|
48
|
+
`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
49
|
|
|
51
50
|
### Package split rationale
|
|
52
51
|
|
|
@@ -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 + `
|
|
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
|
|
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,12 +19,12 @@ 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
|
|
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
24
|
uv run down # docker compose down
|
|
25
25
|
uv run down -v # also remove named volumes
|
|
26
|
-
uv run build # cross-build all images per
|
|
27
|
-
uv run build --lock-only # refresh
|
|
26
|
+
uv run build # cross-build all images per workloads/images.yml
|
|
27
|
+
uv run build --lock-only # refresh the image lock without building
|
|
28
28
|
uv run generate-score # regenerate Score compose/k8s manifests per [tool.docker-devkit.generate-score]
|
|
29
29
|
uv run generate-score --local # local-cluster variant (local storage class, gitignored output)
|
|
30
30
|
```
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# One home for third-party image declarations; docker-devkit stops parsing compose
|
|
2
2
|
|
|
3
|
-
Status note for pickup:
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
Status note for pickup: COMPLETE. Shipped: docker-devkit 0.1.15, release-devkit
|
|
4
|
+
0.1.11 (both on PyPI). placeframe `dev`: `f2c94d16` (declarations conversion +
|
|
5
|
+
generate-score port completion), `33ddfb26` (score artifacts), `e0bcd9f8`
|
|
6
|
+
(prose) — preflight green. capture-tool `dev`: `ef7660d` (renames + repin),
|
|
7
|
+
`6025c5e` (prose) — lock round-trip stable, rig render green. All four repos
|
|
8
|
+
need operator pushes. Resolved during execution: the fluent-bit declaration key
|
|
9
|
+
is `SEAWEEDFS_AUDIT_IMAGE` (the plan's `FLUENT_BIT_IMAGE` never matched the
|
|
10
|
+
actual compose hole — holes stayed textually unchanged); stale `DOTNET_*`
|
|
11
|
+
declarations and `PYTHON_BASE`/`ZED_BASE` lock entries were pruned in placeframe
|
|
12
|
+
(zero consumers); `INITIALIZE_S3_IMAGE` (consumed by scripts/sweep_postprocess)
|
|
13
|
+
was promoted to a declaration; the generate-score console-script collision was
|
|
14
|
+
resolved by completing the port (config table in placeframe pyproject,
|
|
15
|
+
duplicate module deleted); capture-tool's bake file is `compose.bake.yml`, not
|
|
16
|
+
the `compose.zed.bake.yml` this plan named. NEXT: make-it-sing's own plan.md
|
|
17
|
+
resumes — flip its committed Stage 3 checkpoint (`6b4120c`) from
|
|
18
|
+
`ARG UV_BASE_DIGEST` + `FROM mirror/…${UV_BASE_DIGEST}` to
|
|
19
|
+
`ARG UV_BASE_IMAGE` + `FROM ${UV_BASE_IMAGE}`, repin docker-devkit >=0.1.15.
|
|
16
20
|
|
|
17
21
|
## Why this initiative exists
|
|
18
22
|
|
|
@@ -13,9 +13,10 @@ 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
|
|
19
20
|
from .modes import parse_env_file
|
|
20
21
|
|
|
21
22
|
|
|
@@ -25,9 +26,6 @@ class Settings(BaseSettings):
|
|
|
25
26
|
|
|
26
27
|
settings = Settings.model_validate({})
|
|
27
28
|
|
|
28
|
-
LOCK_FILE = Path(".env.lock")
|
|
29
|
-
ENV_SHAS_FILE = Path(".env.shas")
|
|
30
|
-
DEFAULT_BAKE_FILE = Path("compose.bake.yml")
|
|
31
29
|
METADATA_PATH = Path("metadata.json")
|
|
32
30
|
|
|
33
31
|
Mode = Literal["local", "ci"]
|
|
@@ -50,7 +48,7 @@ def build(
|
|
|
50
48
|
no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild by disabling cache usage."),
|
|
51
49
|
targets_opt: Annotated[
|
|
52
50
|
list[str] | None,
|
|
53
|
-
typer.Option("--targets", "-t", help="Build only these services (from
|
|
51
|
+
typer.Option("--targets", "-t", help="Build only these services (from the image manifest)."),
|
|
54
52
|
] = None,
|
|
55
53
|
) -> None:
|
|
56
54
|
run_build(
|
|
@@ -74,18 +72,17 @@ def run_build(
|
|
|
74
72
|
no_cache: bool = False,
|
|
75
73
|
targets_opt: list[str] | None = None,
|
|
76
74
|
) -> None:
|
|
77
|
-
|
|
75
|
+
root = Path.cwd()
|
|
76
|
+
manifest = require_manifest(root)
|
|
77
|
+
lock_file = resolve_lock(root, manifest)
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
os.environ.update(service_shas)
|
|
79
|
+
bake = parse_bake(manifest)
|
|
81
80
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"".join(f"{key}={value}\n" for key, value in sorted(service_shas.items())), encoding="utf-8"
|
|
85
|
-
)
|
|
81
|
+
service_shas = compute_service_shas(root, bake)
|
|
82
|
+
os.environ.update(service_shas)
|
|
86
83
|
|
|
87
84
|
# Load any existing lock
|
|
88
|
-
lock_data = parse_env_file(
|
|
85
|
+
lock_data = parse_env_file(lock_file) if lock_file.exists() else {}
|
|
89
86
|
|
|
90
87
|
# TOOD: Create separate commands for ci and local modes so typer can do this validation instead of us
|
|
91
88
|
if mode == "ci" and gpu == "auto":
|
|
@@ -119,7 +116,7 @@ def run_build(
|
|
|
119
116
|
lock_data[occurrence.name] = f"{occurrence.reference}@{resolve_remote_digest(occurrence.reference)}"
|
|
120
117
|
|
|
121
118
|
# Update main lock file
|
|
122
|
-
|
|
119
|
+
lock_file.write_text(
|
|
123
120
|
"# Generated by lock.py\n" + "\n".join(f"{key}={value}" for key, value in sorted(lock_data.items())) + "\n",
|
|
124
121
|
encoding="utf-8",
|
|
125
122
|
)
|
|
@@ -183,7 +180,7 @@ def run_build(
|
|
|
183
180
|
# Bake images
|
|
184
181
|
command = [
|
|
185
182
|
"docker buildx bake",
|
|
186
|
-
f"-f {
|
|
183
|
+
f"-f {manifest}",
|
|
187
184
|
f"--metadata-file {METADATA_PATH}",
|
|
188
185
|
"--progress auto",
|
|
189
186
|
"--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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
32
|
-
raise RuntimeError(
|
|
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
|
|
40
|
-
os.environ.update(compute_service_shas(
|
|
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 {
|
|
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(
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
+
import shlex
|
|
5
6
|
import tomllib
|
|
6
7
|
from collections.abc import Generator
|
|
7
8
|
from contextlib import contextmanager
|
|
@@ -46,14 +47,14 @@ class ScoreConfig(BaseModel):
|
|
|
46
47
|
project_name: str
|
|
47
48
|
cloud_storage_class: str
|
|
48
49
|
local_storage_class: str
|
|
49
|
-
score_dir: Path = Path("score")
|
|
50
|
-
bake_file: Path = Path("
|
|
51
|
-
compose_output: Path = Path("compose.yaml")
|
|
52
|
-
k8s_output: Path = Path("
|
|
50
|
+
score_dir: Path = Path("stack/score")
|
|
51
|
+
bake_file: Path = Path("workloads/images.yml")
|
|
52
|
+
compose_output: Path = Path("stack/generated/compose/compose.yaml")
|
|
53
|
+
k8s_output: Path = Path("stack/generated/k8s/manifests.yaml")
|
|
53
54
|
# --local writes here instead of the committed k8s output; consumers gitignore this path so a
|
|
54
55
|
# local-only storage class cannot reach the artifact their cluster deploys.
|
|
55
|
-
k8s_local_output: Path = Path("manifests.yaml")
|
|
56
|
-
k8s_state: Path = Path("
|
|
56
|
+
k8s_local_output: Path = Path("stack/generated/k8s/manifests.local.yaml")
|
|
57
|
+
k8s_state: Path = Path("stack/generated/k8s/.score-k8s")
|
|
57
58
|
storage_class_var: str = "SCORE_STORAGE_CLASS"
|
|
58
59
|
compose_provisioners: list[str] = Field(default_factory=list)
|
|
59
60
|
compose_patch_templates: list[str] = Field(default_factory=list)
|
|
@@ -94,16 +95,17 @@ def load_score_config(root: Path) -> ScoreConfig:
|
|
|
94
95
|
|
|
95
96
|
|
|
96
97
|
def generate(config: ScoreConfig, target: Target = "both", local: bool = False) -> None:
|
|
98
|
+
root = Path.cwd()
|
|
97
99
|
# The same call up, build, and preflight make, so every image tag is a pure function of
|
|
98
100
|
# committed source rather than a hand-typed value.
|
|
99
|
-
os.environ.update(compute_service_shas(
|
|
101
|
+
os.environ.update(compute_service_shas(root, parse_bake(config.bake_file)))
|
|
100
102
|
os.environ[config.storage_class_var] = config.local_storage_class if local else config.cloud_storage_class
|
|
101
103
|
|
|
102
104
|
if target in ("both", "compose"):
|
|
103
|
-
_generate_compose(config)
|
|
105
|
+
_generate_compose(root, config)
|
|
104
106
|
|
|
105
107
|
if target in ("both", "k8s"):
|
|
106
|
-
_generate_k8s(config, config.k8s_local_output if local else config.k8s_output)
|
|
108
|
+
_generate_k8s(root, config, config.k8s_local_output if local else config.k8s_output)
|
|
107
109
|
|
|
108
110
|
|
|
109
111
|
def ensure_score_tools(config: ScoreConfig, bin_dir: Path) -> None:
|
|
@@ -116,46 +118,73 @@ def ensure_score_tools(config: ScoreConfig, bin_dir: Path) -> None:
|
|
|
116
118
|
Path(archive).unlink()
|
|
117
119
|
|
|
118
120
|
|
|
119
|
-
def _generate_compose(config: ScoreConfig) -> None:
|
|
121
|
+
def _generate_compose(root: Path, config: ScoreConfig) -> None:
|
|
122
|
+
# score-compose keeps its state directory inside score_dir; the consumer gitignores it.
|
|
120
123
|
_init_state(
|
|
121
124
|
f"score-compose init --project {config.project_name} --no-sample",
|
|
122
125
|
config.compose_provisioners,
|
|
123
126
|
config.compose_patch_templates,
|
|
124
|
-
config.score_dir,
|
|
127
|
+
source_dir=root / config.score_dir,
|
|
128
|
+
working_dir=root / config.score_dir,
|
|
125
129
|
)
|
|
126
130
|
|
|
131
|
+
(root / config.compose_output).parent.mkdir(parents=True, exist_ok=True)
|
|
127
132
|
publishes = "".join(f" --publish {publish}" for publish in config.publishes)
|
|
128
|
-
with _rendered_workloads(config) as workloads:
|
|
133
|
+
with _rendered_workloads(root, config) as workloads:
|
|
129
134
|
bash(
|
|
130
|
-
f"score-compose generate {workloads} --output {config.compose_output.as_posix()}{publishes}",
|
|
131
|
-
cwd=config.score_dir,
|
|
135
|
+
f"score-compose generate {workloads} --output {(root / config.compose_output).as_posix()}{publishes}",
|
|
136
|
+
cwd=root / config.score_dir,
|
|
132
137
|
)
|
|
133
138
|
|
|
134
139
|
|
|
135
|
-
def _generate_k8s(config: ScoreConfig, output: Path) -> None:
|
|
136
|
-
|
|
140
|
+
def _generate_k8s(root: Path, config: ScoreConfig, output: Path) -> None:
|
|
141
|
+
# score-k8s writes its state directory to the .score-k8s of its cwd; running it from the
|
|
142
|
+
# generated k8s home keeps the committed state file in the generated tier while the
|
|
143
|
+
# provisioners still resolve from the authored score sources.
|
|
144
|
+
state_dir = root / config.k8s_state
|
|
145
|
+
_init_state(
|
|
146
|
+
"score-k8s init --no-sample",
|
|
147
|
+
config.k8s_provisioners,
|
|
148
|
+
[],
|
|
149
|
+
source_dir=root / config.score_dir,
|
|
150
|
+
working_dir=state_dir.parent,
|
|
151
|
+
)
|
|
137
152
|
|
|
138
|
-
|
|
153
|
+
output_path = root / output
|
|
154
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
139
155
|
|
|
140
|
-
with _rendered_workloads(config) as workloads:
|
|
141
|
-
bash(f"score-k8s generate {workloads} --output {
|
|
156
|
+
with _rendered_workloads(root, config) as workloads:
|
|
157
|
+
bash(f"score-k8s generate {workloads} --output {output_path.as_posix()}", cwd=state_dir.parent)
|
|
142
158
|
|
|
143
|
-
sort_documents(
|
|
144
|
-
normalise_state_paths(config,
|
|
159
|
+
sort_documents(output_path)
|
|
160
|
+
normalise_state_paths(config, state_dir / "state.yaml")
|
|
145
161
|
|
|
146
162
|
|
|
147
|
-
def _init_state(
|
|
163
|
+
def _init_state(
|
|
164
|
+
command: str,
|
|
165
|
+
provisioners: list[str],
|
|
166
|
+
patch_templates: list[str],
|
|
167
|
+
*,
|
|
168
|
+
source_dir: Path,
|
|
169
|
+
working_dir: Path,
|
|
170
|
+
) -> None:
|
|
148
171
|
# init is idempotent over an existing state directory: it rewrites the provisioner copies
|
|
149
172
|
# without disturbing state. The state must survive, because score-k8s mints a random uid per
|
|
150
173
|
# workload on first add and emits it as the app.kubernetes.io/instance label — discarding it
|
|
151
174
|
# would change the generated manifests on every run.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
175
|
+
# Provisioner and template paths are passed relative to the working directory so no
|
|
176
|
+
# absolute machine path can reach a committed state file.
|
|
177
|
+
flags = "".join(
|
|
178
|
+
f" --provisioners {shlex.quote(os.path.relpath(source_dir / name, working_dir))}" for name in provisioners
|
|
179
|
+
)
|
|
180
|
+
flags += "".join(
|
|
181
|
+
f" --patch-templates {shlex.quote(os.path.relpath(source_dir / name, working_dir))}" for name in patch_templates
|
|
182
|
+
)
|
|
183
|
+
bash(f"{command}{flags}", cwd=working_dir)
|
|
155
184
|
|
|
156
185
|
|
|
157
186
|
@contextmanager
|
|
158
|
-
def _rendered_workloads(config: ScoreConfig) -> Generator[str]:
|
|
187
|
+
def _rendered_workloads(root: Path, config: ScoreConfig) -> Generator[str]:
|
|
159
188
|
# Workload files are Score spec documents, not Go templates, so they cannot read the
|
|
160
189
|
# environment the way the provisioner files can. Expanding ${API_SHA} into a rendered copy
|
|
161
190
|
# keeps one mechanism across both halves.
|
|
@@ -163,7 +192,7 @@ def _rendered_workloads(config: ScoreConfig) -> Generator[str]:
|
|
|
163
192
|
directory = Path(temporary_directory)
|
|
164
193
|
|
|
165
194
|
for name in config.workloads:
|
|
166
|
-
source = (config.score_dir / name).read_text(encoding="utf-8")
|
|
195
|
+
source = (root / config.score_dir / name).read_text(encoding="utf-8")
|
|
167
196
|
(directory / name).write_text(expand_placeholders(source, name, config.bake_file), "utf-8")
|
|
168
197
|
|
|
169
198
|
yield " ".join((directory / name).as_posix() for name in config.workloads)
|
|
@@ -9,11 +9,16 @@ from .detect_gpu import Gpu, detect_gpu
|
|
|
9
9
|
from .build_docker import run_build
|
|
10
10
|
from .context_sha import compute_service_shas
|
|
11
11
|
from .documents import parse_bake
|
|
12
|
-
from .lifecycle import
|
|
12
|
+
from .lifecycle import (
|
|
13
|
+
enforce_bake_declaration,
|
|
14
|
+
expand_compose_files,
|
|
15
|
+
load_lifecycle_config,
|
|
16
|
+
resolve_lock,
|
|
17
|
+
resolve_manifest,
|
|
18
|
+
)
|
|
13
19
|
from .modes import resolve_auth_mode
|
|
14
20
|
|
|
15
21
|
ENV_FILE = Path(".env")
|
|
16
|
-
LOCK_FILE = Path(".env.lock")
|
|
17
22
|
|
|
18
23
|
app = typer.Typer(add_completion=False)
|
|
19
24
|
|
|
@@ -37,8 +42,11 @@ def up(
|
|
|
37
42
|
help="Layer the declared dev overlay (compose.dev.yml shape) over the production stack for bind-mount/debug bring-up.",
|
|
38
43
|
),
|
|
39
44
|
) -> None:
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
root = Path.cwd()
|
|
46
|
+
config = load_lifecycle_config(root)
|
|
47
|
+
enforce_bake_declaration(root, config)
|
|
48
|
+
manifest = resolve_manifest(root)
|
|
49
|
+
lock_file = resolve_lock(root, manifest)
|
|
42
50
|
|
|
43
51
|
if dev and (config is None or config.dev_file is None):
|
|
44
52
|
raise RuntimeError("--dev requires a dev_file in [tool.docker-devkit.lifecycle]")
|
|
@@ -46,11 +54,13 @@ def up(
|
|
|
46
54
|
if not ENV_FILE.exists():
|
|
47
55
|
raise RuntimeError("No .env file found; create one first (e.g., copy .env.example)")
|
|
48
56
|
|
|
49
|
-
if not
|
|
50
|
-
raise RuntimeError(
|
|
57
|
+
if not lock_file.exists():
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
f"No image lock at {lock_file}; run 'uv run build --lock-only' in the repo that authors the images"
|
|
60
|
+
)
|
|
51
61
|
|
|
52
|
-
if build and
|
|
53
|
-
raise typer.BadParameter("--build requires
|
|
62
|
+
if build and manifest is None:
|
|
63
|
+
raise typer.BadParameter("--build requires an image manifest (workloads/images.yml)")
|
|
54
64
|
|
|
55
65
|
if gpu == "auto":
|
|
56
66
|
gpu = detect_gpu()
|
|
@@ -60,8 +70,8 @@ def up(
|
|
|
60
70
|
if build:
|
|
61
71
|
run_build(gpu=gpu)
|
|
62
72
|
|
|
63
|
-
if
|
|
64
|
-
os.environ.update(compute_service_shas(
|
|
73
|
+
if manifest is not None:
|
|
74
|
+
os.environ.update(compute_service_shas(root, parse_bake(manifest)))
|
|
65
75
|
|
|
66
76
|
compose_files = expand_compose_files(config, gpu, include_dev=dev)
|
|
67
77
|
if not compose_files:
|
|
@@ -69,7 +79,7 @@ def up(
|
|
|
69
79
|
|
|
70
80
|
files_args = " ".join(f"-f {compose_file}" for compose_file in compose_files)
|
|
71
81
|
profile_flag = "--profile keycloak " if auth_mode == "keycloak" else ""
|
|
72
|
-
compose_args = f"{files_args} {profile_flag}--env-file .env --env-file {
|
|
82
|
+
compose_args = f"{files_args} {profile_flag}--project-directory {root} --env-file .env --env-file {lock_file}"
|
|
73
83
|
|
|
74
84
|
up_command = f"docker compose {compose_args} up"
|
|
75
85
|
if not build:
|
|
@@ -3,26 +3,26 @@ from pathlib import Path
|
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
from docker_devkit.context_sha import compute_service_shas
|
|
6
|
-
from docker_devkit.documents import parse_bake
|
|
6
|
+
from docker_devkit.documents import BakeDocument, parse_bake
|
|
7
7
|
|
|
8
8
|
BAKE_CONTENT = """\
|
|
9
9
|
services:
|
|
10
10
|
app:
|
|
11
11
|
build:
|
|
12
12
|
context: .
|
|
13
|
-
dockerfile:
|
|
13
|
+
dockerfile: workloads/app/Dockerfile
|
|
14
14
|
tags:
|
|
15
15
|
- "registry/app:${APP_SHA}"
|
|
16
16
|
worker:
|
|
17
17
|
build:
|
|
18
18
|
context: .
|
|
19
|
-
dockerfile:
|
|
19
|
+
dockerfile: workloads/worker/Dockerfile
|
|
20
20
|
tags:
|
|
21
21
|
- "registry/worker:${WORKER_SHA}"
|
|
22
22
|
base:
|
|
23
23
|
build:
|
|
24
24
|
context: .
|
|
25
|
-
dockerfile:
|
|
25
|
+
dockerfile: workloads/base/Dockerfile
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
|
|
@@ -40,71 +40,92 @@ def _commit_all(path: Path) -> None:
|
|
|
40
40
|
@pytest.fixture()
|
|
41
41
|
def repo(tmp_path: Path) -> Path:
|
|
42
42
|
_init_repo(tmp_path)
|
|
43
|
-
(tmp_path / ".dockerignore").write_text("*\n!
|
|
44
|
-
(tmp_path / "
|
|
45
|
-
(tmp_path / "
|
|
46
|
-
(tmp_path / "
|
|
47
|
-
(tmp_path / "
|
|
43
|
+
(tmp_path / ".dockerignore").write_text("*\n!workloads/\n!packages/\n")
|
|
44
|
+
(tmp_path / "workloads" / "app").mkdir(parents=True)
|
|
45
|
+
(tmp_path / "workloads" / "app" / "main.py").write_text("print('hello')")
|
|
46
|
+
(tmp_path / "workloads" / "worker").mkdir(parents=True)
|
|
47
|
+
(tmp_path / "workloads" / "worker" / "run.py").write_text("print('work')")
|
|
48
48
|
(tmp_path / "packages").mkdir()
|
|
49
49
|
(tmp_path / "packages" / "lib.py").write_text("x = 1")
|
|
50
50
|
(tmp_path / "README.md").write_text("ignored")
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
manifest = tmp_path / "workloads" / "images.yml"
|
|
52
|
+
manifest.write_text(BAKE_CONTENT)
|
|
53
53
|
_commit_all(tmp_path)
|
|
54
54
|
return tmp_path
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
class TestComputeServiceShas:
|
|
58
58
|
def test_should_return_deterministic_hashes(self, repo: Path):
|
|
59
|
-
shas1 = compute_service_shas(repo, parse_bake(repo / "
|
|
60
|
-
shas2 = compute_service_shas(repo, parse_bake(repo / "
|
|
59
|
+
shas1 = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
60
|
+
shas2 = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
61
61
|
|
|
62
62
|
assert shas1 == shas2
|
|
63
63
|
|
|
64
64
|
def test_should_have_tree_prefix(self, repo: Path):
|
|
65
|
-
shas = compute_service_shas(repo, parse_bake(repo / "
|
|
65
|
+
shas = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
66
66
|
|
|
67
67
|
for sha in shas.values():
|
|
68
68
|
assert sha.startswith("tree-")
|
|
69
69
|
|
|
70
70
|
def test_should_return_only_tagged_services(self, repo: Path):
|
|
71
|
-
shas = compute_service_shas(repo, parse_bake(repo / "
|
|
71
|
+
shas = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
72
72
|
|
|
73
73
|
assert set(shas.keys()) == {"APP_SHA", "WORKER_SHA"}
|
|
74
74
|
|
|
75
75
|
def test_should_change_only_affected_service_when_service_file_changes(self, repo: Path):
|
|
76
|
-
shas_before = compute_service_shas(repo, parse_bake(repo / "
|
|
76
|
+
shas_before = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
77
77
|
|
|
78
|
-
(repo / "
|
|
78
|
+
(repo / "workloads" / "app" / "main.py").write_text("print('changed')")
|
|
79
79
|
_commit_all(repo)
|
|
80
80
|
|
|
81
|
-
shas_after = compute_service_shas(repo, parse_bake(repo / "
|
|
81
|
+
shas_after = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
82
82
|
assert shas_before["APP_SHA"] != shas_after["APP_SHA"]
|
|
83
83
|
assert shas_before["WORKER_SHA"] == shas_after["WORKER_SHA"]
|
|
84
84
|
|
|
85
85
|
def test_should_change_all_services_when_shared_file_changes(self, repo: Path):
|
|
86
|
-
shas_before = compute_service_shas(repo, parse_bake(repo / "
|
|
86
|
+
shas_before = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
87
87
|
|
|
88
88
|
(repo / "packages" / "lib.py").write_text("x = 2")
|
|
89
89
|
_commit_all(repo)
|
|
90
90
|
|
|
91
|
-
shas_after = compute_service_shas(repo, parse_bake(repo / "
|
|
91
|
+
shas_after = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
92
92
|
assert shas_before["APP_SHA"] != shas_after["APP_SHA"]
|
|
93
93
|
assert shas_before["WORKER_SHA"] != shas_after["WORKER_SHA"]
|
|
94
94
|
|
|
95
95
|
def test_should_not_change_when_ignored_file_changes(self, repo: Path):
|
|
96
|
-
shas_before = compute_service_shas(repo, parse_bake(repo / "
|
|
96
|
+
shas_before = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
97
97
|
|
|
98
98
|
(repo / "README.md").write_text("changed readme")
|
|
99
99
|
_commit_all(repo)
|
|
100
100
|
|
|
101
|
-
shas_after = compute_service_shas(repo, parse_bake(repo / "
|
|
101
|
+
shas_after = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
102
|
+
assert shas_before == shas_after
|
|
103
|
+
|
|
104
|
+
def test_should_not_change_when_untagged_workload_file_changes(self, repo: Path):
|
|
105
|
+
shas_before = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
106
|
+
|
|
107
|
+
(repo / "workloads" / "loki").mkdir()
|
|
108
|
+
(repo / "workloads" / "loki" / "config.yaml").write_text("unused by tagged services")
|
|
109
|
+
_commit_all(repo)
|
|
110
|
+
|
|
111
|
+
shas_after = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
102
112
|
assert shas_before == shas_after
|
|
103
113
|
|
|
104
114
|
def test_should_not_change_from_uncommitted_edits(self, repo: Path):
|
|
105
|
-
shas_before = compute_service_shas(repo, parse_bake(repo / "
|
|
115
|
+
shas_before = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
106
116
|
|
|
107
|
-
(repo / "
|
|
117
|
+
(repo / "workloads" / "app" / "main.py").write_text("print('uncommitted')")
|
|
108
118
|
|
|
109
|
-
shas_after = compute_service_shas(repo, parse_bake(repo / "
|
|
119
|
+
shas_after = compute_service_shas(repo, parse_bake(repo / "workloads" / "images.yml"))
|
|
110
120
|
assert shas_before == shas_after
|
|
121
|
+
|
|
122
|
+
def test_should_raise_when_services_span_multiple_top_level_dirs(self, repo: Path):
|
|
123
|
+
bake = BakeDocument.model_validate({
|
|
124
|
+
"services": {
|
|
125
|
+
"app": {"build": {"dockerfile": "workloads/app/Dockerfile", "tags": ["registry/app:latest"]}},
|
|
126
|
+
"worker": {"build": {"dockerfile": "other/worker/Dockerfile", "tags": ["registry/worker:latest"]}},
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
with pytest.raises(RuntimeError, match="multiple top-level"):
|
|
131
|
+
compute_service_shas(repo, bake)
|
|
@@ -59,14 +59,21 @@ class TestBakeBaseImageRefs:
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
class TestDeclaredReferences:
|
|
62
|
-
def
|
|
62
|
+
def test_should_yield_images_manifest_declarations(self, tmp_path: Path):
|
|
63
|
+
(tmp_path / "workloads").mkdir()
|
|
64
|
+
(tmp_path / "workloads" / "images.yml").write_text(
|
|
65
|
+
"x-base-images:\n ALPINE_IMAGE: alpine:3.20\n", encoding="utf-8"
|
|
66
|
+
)
|
|
67
|
+
assert declared_references(tmp_path) == [ImageReference("ALPINE_IMAGE", "alpine:3.20")]
|
|
68
|
+
|
|
69
|
+
def test_should_yield_legacy_bake_declarations(self, tmp_path: Path):
|
|
63
70
|
(tmp_path / "compose.bake.yml").write_text("x-base-images:\n ALPINE_IMAGE: alpine:3.20\n", encoding="utf-8")
|
|
64
71
|
assert declared_references(tmp_path) == [ImageReference("ALPINE_IMAGE", "alpine:3.20")]
|
|
65
72
|
|
|
66
73
|
def test_should_return_empty_for_empty_tree(self, tmp_path: Path):
|
|
67
74
|
assert declared_references(tmp_path) == []
|
|
68
75
|
|
|
69
|
-
def
|
|
76
|
+
def test_should_scan_only_manifests(self, tmp_path: Path):
|
|
70
77
|
(tmp_path / "compose.yml").write_text(
|
|
71
78
|
"services:\n minio:\n x-image-ref: docker.io/minio/minio:latest\n", encoding="utf-8"
|
|
72
79
|
)
|
|
@@ -82,16 +89,32 @@ class TestDeclaredReferences:
|
|
|
82
89
|
|
|
83
90
|
|
|
84
91
|
class TestStrayReferences:
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
(
|
|
92
|
+
def test_should_collect_workload_and_score_sources(self, tmp_path: Path):
|
|
93
|
+
workloads_directory = tmp_path / "workloads" / "api"
|
|
94
|
+
workloads_directory.mkdir(parents=True)
|
|
95
|
+
(workloads_directory / "Dockerfile").write_text(
|
|
89
96
|
"FROM --platform=linux/amd64 python:3.13-slim AS base\n"
|
|
90
97
|
"COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/\n"
|
|
91
98
|
"COPY --from=base /x /y\n"
|
|
92
99
|
"RUN pip install x\n",
|
|
93
100
|
encoding="utf-8",
|
|
94
101
|
)
|
|
102
|
+
score_directory = tmp_path / "stack" / "score"
|
|
103
|
+
score_directory.mkdir(parents=True)
|
|
104
|
+
(score_directory / "app.provisioners.yaml").write_text(
|
|
105
|
+
"containers:\n - name: keycloak\n image: quay.io/keycloak/keycloak:26.3.5@sha256:abc\n",
|
|
106
|
+
encoding="utf-8",
|
|
107
|
+
)
|
|
108
|
+
assert stray_references(tmp_path) == [
|
|
109
|
+
ImageReference("", "python:3.13-slim"),
|
|
110
|
+
ImageReference("", "ghcr.io/astral-sh/uv:latest"),
|
|
111
|
+
ImageReference("", "quay.io/keycloak/keycloak:26.3.5@sha256:abc"),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
def test_should_collect_legacy_layout_sources(self, tmp_path: Path):
|
|
115
|
+
docker_directory = tmp_path / "docker" / "api"
|
|
116
|
+
docker_directory.mkdir(parents=True)
|
|
117
|
+
(docker_directory / "Dockerfile").write_text("FROM python:3.13-slim\n", encoding="utf-8")
|
|
95
118
|
score_directory = tmp_path / "score"
|
|
96
119
|
score_directory.mkdir()
|
|
97
120
|
(score_directory / "app.provisioners.yaml").write_text(
|
|
@@ -100,7 +123,6 @@ class TestStrayReferences:
|
|
|
100
123
|
)
|
|
101
124
|
assert stray_references(tmp_path) == [
|
|
102
125
|
ImageReference("", "python:3.13-slim"),
|
|
103
|
-
ImageReference("", "ghcr.io/astral-sh/uv:latest"),
|
|
104
126
|
ImageReference("", "quay.io/keycloak/keycloak:26.3.5@sha256:abc"),
|
|
105
127
|
]
|
|
106
128
|
|
|
@@ -118,7 +140,7 @@ class TestStripBuildArgs:
|
|
|
118
140
|
|
|
119
141
|
class TestUnpinnedReferences:
|
|
120
142
|
def test_should_return_empty_when_everything_is_pinned(self, tmp_path: Path):
|
|
121
|
-
docker_directory = tmp_path / "
|
|
143
|
+
docker_directory = tmp_path / "workloads" / "api"
|
|
122
144
|
docker_directory.mkdir(parents=True)
|
|
123
145
|
(docker_directory / "Dockerfile").write_text(
|
|
124
146
|
"FROM ghcr.io/outernet-foundation/mirror/ghcr.io/astral-sh/uv${UV_BASE_DIGEST}\n"
|
|
@@ -130,7 +152,7 @@ class TestUnpinnedReferences:
|
|
|
130
152
|
assert unpinned_references(tmp_path) == []
|
|
131
153
|
|
|
132
154
|
def test_should_flag_slash_refs_without_tag_digest_or_arg(self, tmp_path: Path):
|
|
133
|
-
docker_directory = tmp_path / "
|
|
155
|
+
docker_directory = tmp_path / "workloads" / "api"
|
|
134
156
|
docker_directory.mkdir(parents=True)
|
|
135
157
|
(docker_directory / "Dockerfile").write_text(
|
|
136
158
|
"FROM ghcr.io/outernet-foundation/mirror/docker.io/library/caddy\n", encoding="utf-8"
|
|
@@ -138,7 +160,7 @@ class TestUnpinnedReferences:
|
|
|
138
160
|
assert unpinned_references(tmp_path) == ["ghcr.io/outernet-foundation/mirror/docker.io/library/caddy"]
|
|
139
161
|
|
|
140
162
|
def test_should_ignore_stage_names_and_arg_only_refs(self, tmp_path: Path):
|
|
141
|
-
docker_directory = tmp_path / "
|
|
163
|
+
docker_directory = tmp_path / "workloads" / "api"
|
|
142
164
|
docker_directory.mkdir(parents=True)
|
|
143
165
|
(docker_directory / "Dockerfile").write_text(
|
|
144
166
|
"FROM neural-networks-base AS dev\nCOPY --from=build /x /y\n", encoding="utf-8"
|
|
@@ -7,6 +7,8 @@ from docker_devkit.lifecycle import (
|
|
|
7
7
|
enforce_bake_declaration,
|
|
8
8
|
expand_compose_files,
|
|
9
9
|
load_lifecycle_config,
|
|
10
|
+
resolve_lock,
|
|
11
|
+
resolve_manifest,
|
|
10
12
|
)
|
|
11
13
|
|
|
12
14
|
|
|
@@ -101,15 +103,65 @@ class TestExpandComposeFiles:
|
|
|
101
103
|
assert expand_compose_files(None, "cuda", include_dev=True) == [Path("compose.yml")]
|
|
102
104
|
|
|
103
105
|
|
|
106
|
+
class TestResolveManifest:
|
|
107
|
+
def test_prefers_images_manifest(self, tmp_path: Path):
|
|
108
|
+
(tmp_path / "workloads").mkdir()
|
|
109
|
+
(tmp_path / "workloads" / "images.yml").write_text("", encoding="utf-8")
|
|
110
|
+
(tmp_path / "compose.bake.yml").write_text("", encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
assert resolve_manifest(tmp_path) == tmp_path / "workloads" / "images.yml"
|
|
113
|
+
|
|
114
|
+
def test_falls_back_to_legacy_bake_file(self, tmp_path: Path):
|
|
115
|
+
(tmp_path / "compose.bake.yml").write_text("", encoding="utf-8")
|
|
116
|
+
|
|
117
|
+
assert resolve_manifest(tmp_path) == tmp_path / "compose.bake.yml"
|
|
118
|
+
|
|
119
|
+
def test_returns_none_without_manifest(self, tmp_path: Path):
|
|
120
|
+
assert resolve_manifest(tmp_path) is None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestResolveLock:
|
|
124
|
+
def test_images_manifest_pairs_with_images_lock(self, tmp_path: Path):
|
|
125
|
+
manifest = tmp_path / "workloads" / "images.yml"
|
|
126
|
+
|
|
127
|
+
assert resolve_lock(tmp_path, manifest) == tmp_path / "workloads" / "images.lock"
|
|
128
|
+
|
|
129
|
+
def test_legacy_manifest_pairs_with_legacy_lock(self, tmp_path: Path):
|
|
130
|
+
manifest = tmp_path / "compose.bake.yml"
|
|
131
|
+
|
|
132
|
+
assert resolve_lock(tmp_path, manifest) == tmp_path / ".env.lock"
|
|
133
|
+
|
|
134
|
+
def test_without_manifest_prefers_existing_lock(self, tmp_path: Path):
|
|
135
|
+
(tmp_path / ".env.lock").write_text("", encoding="utf-8")
|
|
136
|
+
|
|
137
|
+
assert resolve_lock(tmp_path, None) == tmp_path / ".env.lock"
|
|
138
|
+
|
|
139
|
+
def test_without_manifest_defaults_to_images_lock(self, tmp_path: Path):
|
|
140
|
+
assert resolve_lock(tmp_path, None) == tmp_path / "workloads" / "images.lock"
|
|
141
|
+
|
|
142
|
+
|
|
104
143
|
class TestEnforceBakeDeclaration:
|
|
144
|
+
def test_accepts_images_manifest_with_declaration(self, tmp_path: Path):
|
|
145
|
+
(tmp_path / "workloads").mkdir()
|
|
146
|
+
(tmp_path / "workloads" / "images.yml").write_text("", encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
enforce_bake_declaration(tmp_path, LifecycleConfig())
|
|
149
|
+
|
|
105
150
|
def test_accepts_bake_with_declaration(self, tmp_path: Path):
|
|
106
151
|
(tmp_path / "compose.bake.yml").write_text("", encoding="utf-8")
|
|
107
152
|
|
|
108
153
|
enforce_bake_declaration(tmp_path, LifecycleConfig())
|
|
109
154
|
|
|
110
|
-
def
|
|
155
|
+
def test_accepts_missing_manifest_without_declaration(self, tmp_path: Path):
|
|
111
156
|
enforce_bake_declaration(tmp_path, None)
|
|
112
157
|
|
|
158
|
+
def test_rejects_images_manifest_without_declaration(self, tmp_path: Path):
|
|
159
|
+
(tmp_path / "workloads").mkdir()
|
|
160
|
+
(tmp_path / "workloads" / "images.yml").write_text("", encoding="utf-8")
|
|
161
|
+
|
|
162
|
+
with pytest.raises(RuntimeError, match="lifecycle"):
|
|
163
|
+
enforce_bake_declaration(tmp_path, None)
|
|
164
|
+
|
|
113
165
|
def test_rejects_bake_without_declaration(self, tmp_path: Path):
|
|
114
166
|
(tmp_path / "compose.bake.yml").write_text("", encoding="utf-8")
|
|
115
167
|
|
|
@@ -109,8 +109,11 @@ def test_load_score_config_reads_consumer_table(tmp_path: Path) -> None:
|
|
|
109
109
|
assert config.workloads == ["api.yaml"]
|
|
110
110
|
assert config.project_name == "placeframe"
|
|
111
111
|
assert config.publishes == ["8000:api:8000"]
|
|
112
|
-
assert config.
|
|
113
|
-
assert config.
|
|
112
|
+
assert config.score_dir == Path("stack/score")
|
|
113
|
+
assert config.bake_file == Path("workloads/images.yml")
|
|
114
|
+
assert config.compose_output == Path("stack/generated/compose/compose.yaml")
|
|
115
|
+
assert config.k8s_output == Path("stack/generated/k8s/manifests.yaml")
|
|
116
|
+
assert config.k8s_state == Path("stack/generated/k8s/.score-k8s")
|
|
114
117
|
assert config.score_k8s_version == "0.15.0"
|
|
115
118
|
assert config.score_compose_version == "0.42.0"
|
|
116
119
|
|
|
@@ -1,46 +0,0 @@
|
|
|
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
|
-
BAKE_FILE = Path("compose.bake.yml")
|
|
11
|
-
DEFAULT_COMPOSE_FILE = Path("compose.yml")
|
|
12
|
-
GPU_PLACEHOLDER = "{gpu}"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class LifecycleConfig(BaseModel):
|
|
16
|
-
model_config = ConfigDict(extra="forbid")
|
|
17
|
-
|
|
18
|
-
files: list[str] = Field(default_factory=list)
|
|
19
|
-
dev_file: str | None = None
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def load_lifecycle_config(root: Path) -> LifecycleConfig | None:
|
|
23
|
-
pyproject_path = root / "pyproject.toml"
|
|
24
|
-
if not pyproject_path.exists():
|
|
25
|
-
return None
|
|
26
|
-
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
|
27
|
-
section = data.get("tool", {}).get("docker-devkit", {}).get("lifecycle")
|
|
28
|
-
if section is None:
|
|
29
|
-
return None
|
|
30
|
-
return LifecycleConfig.model_validate(section)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def enforce_bake_declaration(root: Path, config: LifecycleConfig | None) -> None:
|
|
34
|
-
if config is None and (root / BAKE_FILE).exists():
|
|
35
|
-
raise RuntimeError(
|
|
36
|
-
f"{BAKE_FILE} present without [tool.docker-devkit.lifecycle]; declare the table or rename the file"
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def expand_compose_files(config: LifecycleConfig | None, gpu: Gpu, *, include_dev: bool = False) -> list[Path]:
|
|
41
|
-
if config is None:
|
|
42
|
-
return [DEFAULT_COMPOSE_FILE]
|
|
43
|
-
files = [Path(entry.format(gpu=gpu)) for entry in config.files if gpu != "none" or GPU_PLACEHOLDER not in entry]
|
|
44
|
-
if include_dev and config.dev_file is not None:
|
|
45
|
-
files.append(Path(config.dev_file))
|
|
46
|
-
return files
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|