docker-devkit 0.1.13__tar.gz → 0.1.14__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.13 → docker_devkit-0.1.14}/AGENTS.md +14 -13
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/PKG-INFO +2 -2
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/README.md +5 -4
- docker_devkit-0.1.14/plan.md +122 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/pyproject.toml +2 -2
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/src/docker_devkit/build_docker.py +9 -23
- docker_devkit-0.1.14/src/docker_devkit/down.py +67 -0
- docker_devkit-0.1.14/src/docker_devkit/lifecycle.py +46 -0
- docker_devkit-0.1.14/src/docker_devkit/up.py +90 -0
- docker_devkit-0.1.14/tests/test_lifecycle.py +117 -0
- docker_devkit-0.1.13/src/docker_devkit/down.py +0 -86
- docker_devkit-0.1.13/src/docker_devkit/up.py +0 -109
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/.gitattributes +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/.github/workflows/ci.yml +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/.github/workflows/release.yml +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/.gitignore +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/.python-version +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/CLAUDE.md +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/LICENSE +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/NOTICE +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/publish-config.json +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/ruff.base.toml +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/ruff.toml +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/src/docker_devkit/__init__.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/src/docker_devkit/context_sha.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/src/docker_devkit/detect_gpu.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/src/docker_devkit/image_refs.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/src/docker_devkit/modes.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/src/docker_devkit/py.typed +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/src/docker_devkit/score_generate.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/tests/test_context_sha.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/tests/test_default_targets.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/tests/test_image_refs.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/tests/test_score_generate.py +0 -0
- {docker_devkit-0.1.13 → docker_devkit-0.1.14}/uv.lock +0 -0
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## What this is
|
|
4
4
|
|
|
5
|
-
`docker-devkit` owns the Docker-stack lifecycle commands — `up`, `down`, `build` — plus the helpers they share (`detect_gpu`, `modes`, `context_sha`). It is a small, generic package that any repo shipping a compose graph can depend on to get the same bring-up/tear-down / cross-build flow, with
|
|
5
|
+
`docker-devkit` owns the Docker-stack lifecycle commands — `up`, `down`, `build` — plus the helpers they share (`detect_gpu`, `modes`, `context_sha`). It is a small, generic package that any repo shipping a compose graph can depend on to get the same bring-up/tear-down / cross-build flow, with the compose assembly declared per consumer in `[tool.docker-devkit.lifecycle]` so a repo that authors its own images and a repo that only OCI-includes an upstream artifact both work through the same commands.
|
|
6
6
|
|
|
7
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
8
|
|
|
9
9
|
## Release flow
|
|
10
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). API-breaking changes ship with a manually bumped `major_minor` — patch-auto assumes additive changes.
|
|
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
12
|
|
|
13
13
|
## Shape
|
|
14
14
|
|
|
@@ -16,10 +16,11 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
|
|
|
16
16
|
|
|
17
17
|
| Module | Role |
|
|
18
18
|
|---|---|
|
|
19
|
-
| `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--
|
|
20
|
-
| `down.py` | `docker compose down`. Flags: `--volumes`/`-v` (also removes named volumes), `--gpu
|
|
19
|
+
| `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--dev`. |
|
|
20
|
+
| `down.py` | `docker compose down`. Flags: `--volumes`/`-v` (also removes named volumes), `--gpu`. |
|
|
21
21
|
| `build_docker.py` | `run_build()` + the `build` command — cross-builds all images per `compose.bake.yml`, writes `.env.lock` (third-party pulled digests) and `.env.shas` (locally-built `tree-<hash>` tags). |
|
|
22
22
|
| `score_generate.py` | `generate-score` — deterministic Score-manifest generation from a consumer's `score/` sources: `${ALL_CAPS}` placeholder expansion into tempdir-rendered workload copies, idempotent `score-compose`/`score-k8s` init (state preserved across runs), and a determinism suite (document sort, state-path normalisation, 0644 modes, LF newlines). Consumer policy — workloads, provisioners, patch templates, output paths, storage classes, publishes, tool versions — is declared in the consumer's root `pyproject.toml` under `[tool.docker-devkit.generate-score]` (`ScoreConfig`); `load_score_config()` reads it, `ensure_score_tools()` fetches the pinned score-compose/score-k8s release binaries (for CI staleness gates). `--local` writes the k8s artifact to the configured gitignored local path with the local storage class. |
|
|
23
|
+
| `lifecycle.py` | `LifecycleConfig` — the `[tool.docker-devkit.lifecycle]` declaration (`files`: compose files in layering order with `{gpu}` templating; `dev_file`: optional dev overlay); `load_lifecycle_config()` (walks `tool → docker-devkit → lifecycle`, `None` when absent), `enforce_bake_declaration()` (a canonical `compose.bake.yml` requires a declaration), `expand_compose_files()` (assembles the `-f` list; a `{gpu}` entry is dropped when the GPU resolves to `none`; `dev_file` appended when a dev overlay applies). |
|
|
23
24
|
| `detect_gpu.py` | `detect_gpu()` → `cuda`/`rocm`/`none` from host devices; the `Gpu` literal. |
|
|
24
25
|
| `modes.py` | `resolve_auth_mode()` — validates `PUBLIC_URL` + `AUTH_MODE` from `.env`, rejecting `keycloak` over cleartext `http://`. |
|
|
25
26
|
| `context_sha.py` | `compute_service_shas()` — per-service `tree-<hash>` image tags over the `.dockerignore`-allowlisted git tree. |
|
|
@@ -27,22 +28,22 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
|
|
|
27
28
|
|
|
28
29
|
## Constraints
|
|
29
30
|
|
|
30
|
-
###
|
|
31
|
+
### Lifecycle declaration contract
|
|
31
32
|
|
|
32
|
-
**Context.** A repo that authors its own stack builds its own images and assembles a multi-file compose graph (`compose.yml` + `compose.postgres.yml` + a GPU layer + `compose.dev.yml`), injecting per-service `${*_SHA}` image tags and a `.env.lock` of third-party digests. A consumer repo has none of that: it ships a single self-contained compose file that OCI-includes the upstream artifact already baked (image digests and internal vars frozen as literals) or includes a sibling checkout (which supplies its own `.env.lock`/`.env.shas` through the include's `env_file`).
|
|
33
|
+
**Context.** A repo that authors its own stack builds its own images and assembles a multi-file compose graph (e.g. `compose.yml` + `compose.postgres.yml` + a GPU layer + `compose.dev.yml`), injecting per-service `${*_SHA}` image tags and a `.env.lock` of third-party digests. A consumer repo has none of that: it ships a single self-contained compose file that OCI-includes the upstream artifact already baked (image digests and internal vars frozen as literals) or includes a sibling checkout (which supplies its own `.env.lock`/`.env.shas` through the include's `env_file`).
|
|
33
34
|
|
|
34
|
-
**Constraint.** `up`/`down`
|
|
35
|
+
**Constraint.** `up`/`down` assemble the graph from `[tool.docker-devkit.lifecycle]` in the consumer's root pyproject — one code path, declared data. Keys: `files` (compose files in layering order, `{gpu}` templating, a `{gpu}` entry dropped when the GPU resolves to `none`; defaults to empty), `dev_file` (dev overlay, appended by `up --dev` and always by `down` so teardown removes containers from prior dev bring-ups). No table → single-file default (`-f compose.yml`). `--compose-file` does not exist; raw `docker compose` is the pressure valve. `--dev` is opt-in — production-shaped bring-up is the default. `.env.lock` is required unconditionally, no knob; consumers pin stock images via `${…_DIGEST:?err}` holes filled by a committed lock. A canonical `compose.bake.yml` present without a lifecycle declaration is a hard error (declare the table, or the file doesn't belong under that name) — guards are budgeted for silent failures only, and a missing lock already fails loudly. The bake file remains the build/up contract: SHA injection and `--build` acceptance key on `compose.bake.yml` existing, and `build` always reads exactly that name. An empty assembly (`files` empty/omitted, no dev overlay) is a loud `up`/`down` error — the honest state for a builds-only repo.
|
|
35
36
|
|
|
36
|
-
**Consequences.**
|
|
37
|
+
**Consequences.** placeframe's shape lives in placeframe's pyproject, not in this package: `files = ["compose.yml", "compose.postgres.yml", "compose.{gpu}.yml"]`, `dev_file = "compose.dev.yml"`. A consumer repo declares a bare table (builds images, no repo-local runtime graph) and runs `uv run up` against its own `.env` + committed `.env.lock`.
|
|
37
38
|
|
|
38
39
|
### File and env conventions the commands assume
|
|
39
40
|
|
|
40
41
|
- `.env` (required) — carries `PUBLIC_URL` and `AUTH_MODE`; `modes.py` reads it and rejects `keycloak` over cleartext `http://`.
|
|
41
|
-
- `.env.lock` (
|
|
42
|
-
- `.env.shas` (
|
|
43
|
-
- `compose.bake.yml` (
|
|
44
|
-
- `compose.yml` / `compose.postgres.yml` / `compose.<gpu>.yml` / `compose.dev.yml
|
|
45
|
-
- `.dockerignore` (
|
|
42
|
+
- `.env.lock` (required in every repo, checked in) — third-party and base-image digests, written by `build`. `up`/`down` refuse without it; consumers satisfy it with a committed lock of `${…_DIGEST:?err}` hole-fills.
|
|
43
|
+
- `.env.shas` (where images are built locally, gitignored, per-build) — `tree-<hash>` tags of locally-built images, written by `build`. `up` injects the `${*_SHA}` values into the environment directly and reads neither file; `.env.shas` exists so a consumer running the compose files through raw `docker compose` can resolve those holes via `--env-file .env.shas`.
|
|
44
|
+
- `compose.bake.yml` (where a local build graph exists, required) — the buildx-bake description; its top-level `x-cross-compile-targets` list (optional) is the set of services excluded from the default target list because they need per-arch treatment, opt-in via `--targets`. One bake file name, ever: `build` reads exactly this name.
|
|
45
|
+
- 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).
|
|
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.
|
|
46
47
|
|
|
47
48
|
The build command writes to two files rather than one because a CI invariant forbids built-image digests in `.env.lock` (which is committed and reviewed) and because `.env.shas` changes on every source edit, so it stays out of version control.
|
|
48
49
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: docker-devkit
|
|
3
|
-
Version: 0.1.
|
|
4
|
-
Summary: Docker-stack lifecycle commands (up/down/build)
|
|
3
|
+
Version: 0.1.14
|
|
4
|
+
Summary: Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
License-File: NOTICE
|
|
7
7
|
Requires-Python: >=3.13
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# docker-devkit
|
|
2
2
|
|
|
3
|
-
Docker-stack lifecycle commands — `up`, `down`, `build` — with
|
|
3
|
+
Docker-stack lifecycle commands — `up`, `down`, `build` — with the compose assembly declared per repo in `[tool.docker-devkit.lifecycle]`. A repo that authors its own stack (Dockerfiles + `compose.bake.yml`) declares its file layers and gets multi-file assembly, per-service SHA injection, and `.env.lock` resolution; a consumer repo that OCI-includes an already-baked upstream stack declares a bare table and runs the same commands as a thin wrapper over its own `.env` + committed `.env.lock`.
|
|
4
4
|
|
|
5
|
-
See [`AGENTS.md`](./AGENTS.md) for the file/layout conventions the commands assume (`compose.bake.yml`, `.env.lock`, `.env.shas`, `PUBLIC_URL` / `AUTH_MODE` in `.env`) and the
|
|
5
|
+
See [`AGENTS.md`](./AGENTS.md) for the file/layout conventions the commands assume (`compose.bake.yml`, `.env.lock`, `.env.shas`, `PUBLIC_URL` / `AUTH_MODE` in `.env`) and the declaration contract.
|
|
6
6
|
|
|
7
7
|
## Setup
|
|
8
8
|
|
|
@@ -17,8 +17,9 @@ uv sync
|
|
|
17
17
|
From a stack repo's root:
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
uv run up # docker compose up (auto-detects GPU;
|
|
21
|
-
uv run up --
|
|
20
|
+
uv run up # docker compose up (auto-detects GPU; assembles the declared lifecycle files)
|
|
21
|
+
uv run up --dev # layer the declared dev overlay (bind-mount/debug bring-up)
|
|
22
|
+
uv run up --build # build images locally first (requires compose.bake.yml)
|
|
22
23
|
uv run up --gpu none # override GPU auto-detection
|
|
23
24
|
uv run down # docker compose down
|
|
24
25
|
uv run down -v # also remove named volumes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Lifecycle declaration rewrite
|
|
2
|
+
|
|
3
|
+
Status note for pickup: this plan was authored mid-initiative after a full
|
|
4
|
+
design review with the operator; every decision below is settled unless marked
|
|
5
|
+
otherwise. It is the master plan for the coordinated change set — the rewrite
|
|
6
|
+
here plus the consumer migrations in placeframe and placeframe-capture-tool,
|
|
7
|
+
and the downstream stages in make-it-sing (that repo's own `plan.md` carries
|
|
8
|
+
its CI bring-up detail). AUDIT CURRENT STATE before executing: what has
|
|
9
|
+
landed, what has been released to PyPI, and what each consumer pins.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
`up`/`down` currently pick their behavior by sniffing for a file named exactly
|
|
14
|
+
`compose.bake.yml`, and the "native" branch hardcodes placeframe's repository
|
|
15
|
+
layout (`-f compose.yml -f compose.postgres.yml -f compose.<gpu>.yml -f
|
|
16
|
+
compose.dev.yml`). Two defects: placeframe's shape is embedded in a supposedly
|
|
17
|
+
generic package, and semantics change at a distance through an unrelated
|
|
18
|
+
filename. `build` compounds it with a `--bake-file` option, legitimizing
|
|
19
|
+
multiple bake files per repo. The fix: compose assembly becomes declared data,
|
|
20
|
+
one code path, one bake file name, ever.
|
|
21
|
+
|
|
22
|
+
## Settled design
|
|
23
|
+
|
|
24
|
+
- **Declaration**: `[tool.docker-devkit.lifecycle]` in the consumer's root
|
|
25
|
+
pyproject (sub-table per the package's own `generate-score` precedent —
|
|
26
|
+
multiple config surfaces, strictly validated per concern). Keys:
|
|
27
|
+
- `files`: optional list of compose files in layering order, `{gpu}`
|
|
28
|
+
templating supported; a `{gpu}` entry is dropped when the GPU resolves to
|
|
29
|
+
`none`. Defaults to empty. Omitted and `[]` are equivalent.
|
|
30
|
+
- `dev_file`: optional dev overlay, appended when `--dev` is passed;
|
|
31
|
+
always included on `down` (teardown must remove containers from prior dev
|
|
32
|
+
bring-ups).
|
|
33
|
+
- **One code path**: table present → assembly from `files`/`dev_file`; no
|
|
34
|
+
table → single-file default (`-f compose.yml`). `--compose-file` is deleted;
|
|
35
|
+
raw `docker compose` is the pressure valve.
|
|
36
|
+
- **`--dev` opt-in** replaces `--no-dev` (production-shaped bring-up is the
|
|
37
|
+
default; bind-mount/debug overlay is explicit).
|
|
38
|
+
- **`.env.lock` unconditionally required**: `up`/`down` refuse without it in
|
|
39
|
+
every repo. No knob. Consequence: consumers pin stock images via
|
|
40
|
+
`${…_DIGEST:?err}` holes filled by a committed lock (capture-tool already
|
|
41
|
+
does this).
|
|
42
|
+
- **Guard**: canonical `compose.bake.yml` present but no `[tool.docker-
|
|
43
|
+
devkit.lifecycle]` table → hard error naming both fixes (declare, or you
|
|
44
|
+
shouldn't have a canonical bake file). Guards are budgeted for silent
|
|
45
|
+
failures only — a missing lock already fails loudly via `${VAR:?err}` and
|
|
46
|
+
needs none.
|
|
47
|
+
- **Bake file remains the build/up contract**: SHA injection and `--build`
|
|
48
|
+
acceptance key on `compose.bake.yml` existing. One bake file name, ever:
|
|
49
|
+
`build` loses `--bake-file` and always reads `compose.bake.yml`; `--targets`
|
|
50
|
+
stays (selects services within the file).
|
|
51
|
+
- **Empty assembly is loud**: `files` empty/omitted with no `--dev` graph
|
|
52
|
+
means `up` errors ("no compose files declared") — this is the honest state
|
|
53
|
+
for a builds-only repo.
|
|
54
|
+
- **Release**: no `major_minor` bump — pre-1.0 policy, breaking is accepted
|
|
55
|
+
while iterating toward stability. Rides the normal patch flow.
|
|
56
|
+
|
|
57
|
+
## Changes in this repo
|
|
58
|
+
|
|
59
|
+
- New `src/docker_devkit/lifecycle.py`: `LifecycleConfig` (pydantic: `files`
|
|
60
|
+
default empty, `dev_file` optional), `load_lifecycle_config` (walks `tool →
|
|
61
|
+
docker-devkit → lifecycle`; `None` when absent), the guard, and
|
|
62
|
+
`expand_compose_files`.
|
|
63
|
+
- `up.py` / `down.py`: mode fork and `--compose-file` deleted; `--dev`
|
|
64
|
+
opt-in; `.env.lock` required unconditionally; assembly from declaration.
|
|
65
|
+
- `build_docker.py`: `--bake-file` deleted.
|
|
66
|
+
- `tests/test_lifecycle.py`: `{gpu}` expansion, `none` drop, `dev_file`
|
|
67
|
+
toggle, loader present/absent, guard accept/reject.
|
|
68
|
+
- Docs: AGENTS.md "Native stack vs consumer stack" → the declaration
|
|
69
|
+
contract; README and the pyproject description string updated to match.
|
|
70
|
+
- Branch + PR, never direct to main (a green main push auto-publishes).
|
|
71
|
+
|
|
72
|
+
## Consumer migrations
|
|
73
|
+
|
|
74
|
+
### placeframe
|
|
75
|
+
|
|
76
|
+
- Add to root pyproject:
|
|
77
|
+
|
|
78
|
+
```toml
|
|
79
|
+
[tool.docker-devkit.lifecycle]
|
|
80
|
+
files = ["compose.yml", "compose.postgres.yml", "compose.{gpu}.yml"]
|
|
81
|
+
dev_file = "compose.dev.yml"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Inert under released versions (unknown sub-tables ignored); required before
|
|
85
|
+
the pin upgrade. Deliberate `uv lock --upgrade-package docker-devkit` after
|
|
86
|
+
this repo's release.
|
|
87
|
+
- Zed is LEFT AS-IS by operator decision: `compose.zed.bake.yml`, the
|
|
88
|
+
`build-zed` CI job, and the zed sources stay. Accepted breakage: `build-zed`
|
|
89
|
+
needs `--bake-file`, which this rewrite deletes — that job fails until the
|
|
90
|
+
operator reconciles it at merge time. Do not "fix" it here.
|
|
91
|
+
|
|
92
|
+
### placeframe-capture-tool
|
|
93
|
+
|
|
94
|
+
- Rename `compose.zed.bake.yml` → `compose.bake.yml` (repo root).
|
|
95
|
+
- Move `docker/zed-capture/compose.rig.yml` → `compose.rig.yml` (repo root);
|
|
96
|
+
check its relative references during the move (expected none — mostly
|
|
97
|
+
absolute host paths).
|
|
98
|
+
- Add the bare declaration (builds images, no repo-local runtime graph):
|
|
99
|
+
|
|
100
|
+
```toml
|
|
101
|
+
[tool.docker-devkit.lifecycle]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- CI `build-zed` drops the `--bake-file` argument; sweep `install-zed`'s
|
|
105
|
+
internal build call for `--bake-file` references at the same time.
|
|
106
|
+
|
|
107
|
+
### make-it-sing
|
|
108
|
+
|
|
109
|
+
Consumer by default — no bake file, no table. Its Stage 3 later adds a
|
|
110
|
+
canonical `compose.bake.yml` + `files = ["compose.yml"]`; its Stage 5 adds the
|
|
111
|
+
committed `.env.lock` and digest-hole conversion of its stock image refs. See
|
|
112
|
+
that repo's `plan.md`.
|
|
113
|
+
|
|
114
|
+
## Sequencing
|
|
115
|
+
|
|
116
|
+
1. This rewrite: branch, PR, operator push, release.
|
|
117
|
+
2. placeframe lifecycle table (any time; inert until the pin bump) → pin bump.
|
|
118
|
+
3. placeframe-capture-tool renames + declaration + CI arg drop (guard only
|
|
119
|
+
bites once its own pin passes the rewrite).
|
|
120
|
+
4. make-it-sing Stages 3 and 5 (gated on its pin).
|
|
121
|
+
|
|
122
|
+
All pushes are operator-handed from the sandbox checkouts.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "docker-devkit"
|
|
3
|
-
version = "0.1.
|
|
4
|
-
description = "Docker-stack lifecycle commands (up/down/build)
|
|
3
|
+
version = "0.1.14"
|
|
4
|
+
description = "Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly"
|
|
5
5
|
requires-python = ">=3.13"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"bashrun>=0.1.0",
|
|
@@ -51,11 +51,8 @@ def build(
|
|
|
51
51
|
no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild by disabling cache usage."),
|
|
52
52
|
targets_opt: Annotated[
|
|
53
53
|
list[str] | None,
|
|
54
|
-
typer.Option("--targets", "-t", help="Build only these services (from
|
|
54
|
+
typer.Option("--targets", "-t", help="Build only these services (from compose.bake.yml)."),
|
|
55
55
|
] = None,
|
|
56
|
-
bake_file: Annotated[
|
|
57
|
-
Path, typer.Option("--bake-file", help="Bake file to load (e.g. compose.bake.yml or compose.zed.bake.yml).")
|
|
58
|
-
] = DEFAULT_BAKE_FILE,
|
|
59
56
|
) -> None:
|
|
60
57
|
run_build(
|
|
61
58
|
upgrade=upgrade,
|
|
@@ -65,7 +62,6 @@ def build(
|
|
|
65
62
|
gpu_only=gpu_only,
|
|
66
63
|
no_cache=no_cache,
|
|
67
64
|
targets_opt=targets_opt,
|
|
68
|
-
bake_file=bake_file,
|
|
69
65
|
)
|
|
70
66
|
|
|
71
67
|
|
|
@@ -78,22 +74,17 @@ def run_build(
|
|
|
78
74
|
gpu_only: bool = False,
|
|
79
75
|
no_cache: bool = False,
|
|
80
76
|
targets_opt: list[str] | None = None,
|
|
81
|
-
bake_file: Path = DEFAULT_BAKE_FILE,
|
|
82
77
|
) -> None:
|
|
83
|
-
service_shas = compute_service_shas(Path.cwd(),
|
|
78
|
+
service_shas = compute_service_shas(Path.cwd(), DEFAULT_BAKE_FILE)
|
|
84
79
|
os.environ.update(service_shas)
|
|
85
80
|
|
|
86
|
-
# Tags of the images built this run — the local analog of .env.lock's pulled
|
|
87
|
-
# digests — so raw `docker compose` can resolve the compose graph's ${*_SHA} holes.
|
|
81
|
+
# Tags of the images built this run — the local analog of .env.lock's pulled digests
|
|
88
82
|
ENV_SHAS_FILE.write_text(
|
|
89
83
|
"".join(f"{key}={value}\n" for key, value in sorted(service_shas.items())), encoding="utf-8"
|
|
90
84
|
)
|
|
91
85
|
|
|
92
|
-
#
|
|
93
|
-
|
|
94
|
-
# only downstream consumers of compose_data are the include merge and the
|
|
95
|
-
# third-party x-image-ref scan, both empty-safe.
|
|
96
|
-
bake_data: dict[str, Any] = yaml.safe_load(bake_file.read_text(encoding="utf-8"))
|
|
86
|
+
# A bake-only repo has no compose.yml; every consumer of compose_data is empty-safe
|
|
87
|
+
bake_data: dict[str, Any] = yaml.safe_load(DEFAULT_BAKE_FILE.read_text(encoding="utf-8"))
|
|
97
88
|
compose_data: dict[str, Any] = (
|
|
98
89
|
yaml.safe_load(COMPOSE_FILE.read_text(encoding="utf-8")) if COMPOSE_FILE.exists() else {}
|
|
99
90
|
)
|
|
@@ -207,7 +198,7 @@ def run_build(
|
|
|
207
198
|
# Bake images
|
|
208
199
|
command = [
|
|
209
200
|
"docker buildx bake",
|
|
210
|
-
f"-f {
|
|
201
|
+
f"-f {DEFAULT_BAKE_FILE}",
|
|
211
202
|
f"--metadata-file {METADATA_PATH}",
|
|
212
203
|
"--progress auto",
|
|
213
204
|
"--provenance=false",
|
|
@@ -218,7 +209,7 @@ def run_build(
|
|
|
218
209
|
# Sanity check
|
|
219
210
|
baked_images: dict[str, Any] = json.loads(METADATA_PATH.read_text()) if METADATA_PATH.exists() else {}
|
|
220
211
|
if not set(targets) <= baked_images.keys():
|
|
221
|
-
raise RuntimeError("
|
|
212
|
+
raise RuntimeError("Bake output does not cover the requested targets")
|
|
222
213
|
|
|
223
214
|
|
|
224
215
|
# Vibe code - Gemini 3
|
|
@@ -240,10 +231,7 @@ def _check_gc_limits(min_gb: int = 60):
|
|
|
240
231
|
raw = data.get("builder", {}).get("gc", {}).get("defaultKeepStorage")
|
|
241
232
|
if not raw:
|
|
242
233
|
sample = json.dumps({"builder": {"gc": {"defaultKeepStorage": f"{min_gb}GB"}}}, indent=2)
|
|
243
|
-
raise RuntimeError(
|
|
244
|
-
f"Missing 'builder.gc.defaultKeepStorage' in {config}; Docker's default is too low for GPU builds "
|
|
245
|
-
f"(need >= {min_gb}GB). Add the following (merging into any existing keys) and restart Docker:\n\n{sample}"
|
|
246
|
-
)
|
|
234
|
+
raise RuntimeError(f"builder.gc.defaultKeepStorage missing in {config}; need >= {min_gb}GB:\n\n{sample}")
|
|
247
235
|
|
|
248
236
|
m = re.match(r"^(\d+(?:\.\d+)?)\s*([TGMK]i?B)?$", str(raw), re.IGNORECASE)
|
|
249
237
|
if not m:
|
|
@@ -254,9 +242,7 @@ def _check_gc_limits(min_gb: int = 60):
|
|
|
254
242
|
val = float(m.group(1)) * mult.get(unit, 1 / 1024**3)
|
|
255
243
|
|
|
256
244
|
if val < min_gb:
|
|
257
|
-
raise RuntimeError(
|
|
258
|
-
f"UNSAFE GC LIMIT: {val:.1f}GB in {config} (Required: {min_gb}GB). RESTART DOCKER AFTER FIXING."
|
|
259
|
-
)
|
|
245
|
+
raise RuntimeError(f"Docker GC limit {val:.1f}GB in {config} is below the required {min_gb}GB")
|
|
260
246
|
|
|
261
247
|
print(f" [OK] Docker GC Limit verified: {val:.1f}GB")
|
|
262
248
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from bashrun.bash import bash_handoff
|
|
7
|
+
from .detect_gpu import Gpu, detect_gpu
|
|
8
|
+
|
|
9
|
+
from .context_sha import compute_service_shas
|
|
10
|
+
from .lifecycle import BAKE_FILE, enforce_bake_declaration, expand_compose_files, load_lifecycle_config
|
|
11
|
+
from .modes import resolve_auth_mode
|
|
12
|
+
|
|
13
|
+
ENV_FILE = Path(".env")
|
|
14
|
+
LOCK_FILE = Path(".env.lock")
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(add_completion=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def down(
|
|
21
|
+
volumes: bool = typer.Option(False, "--volumes", "-v", help="Remove named volumes."),
|
|
22
|
+
gpu: Annotated[Gpu, typer.Option("--gpu", help="auto|cuda|rocm|none")] = "auto",
|
|
23
|
+
) -> None:
|
|
24
|
+
config = load_lifecycle_config(Path.cwd())
|
|
25
|
+
enforce_bake_declaration(Path.cwd(), config)
|
|
26
|
+
|
|
27
|
+
if not ENV_FILE.exists():
|
|
28
|
+
raise RuntimeError("No .env file found")
|
|
29
|
+
|
|
30
|
+
if not LOCK_FILE.exists():
|
|
31
|
+
raise RuntimeError("No .env.lock found; run 'uv run build --lock-only' in the repo that authors the images")
|
|
32
|
+
|
|
33
|
+
if gpu == "auto":
|
|
34
|
+
gpu = detect_gpu()
|
|
35
|
+
|
|
36
|
+
resolve_auth_mode(ENV_FILE)
|
|
37
|
+
|
|
38
|
+
if BAKE_FILE.exists():
|
|
39
|
+
os.environ.update(compute_service_shas(Path.cwd(), BAKE_FILE))
|
|
40
|
+
|
|
41
|
+
# Always layer the dev overlay so containers from a prior --dev bring-up get torn down too
|
|
42
|
+
compose_files = expand_compose_files(config, gpu, include_dev=True)
|
|
43
|
+
if not compose_files:
|
|
44
|
+
raise RuntimeError("No compose files declared; a builds-only repo has no stack to tear down")
|
|
45
|
+
|
|
46
|
+
files_args = " ".join(f"-f {compose_file}" for compose_file in compose_files)
|
|
47
|
+
command = (
|
|
48
|
+
"docker compose "
|
|
49
|
+
f"{files_args} "
|
|
50
|
+
"--profile keycloak " # Always include so any keycloak containers from a previous AUTH_MODE=keycloak run get torn down
|
|
51
|
+
"--env-file .env "
|
|
52
|
+
f"--env-file {LOCK_FILE} "
|
|
53
|
+
"down --remove-orphans"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if volumes:
|
|
57
|
+
command += " -v"
|
|
58
|
+
|
|
59
|
+
bash_handoff(command)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main():
|
|
63
|
+
app()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
main()
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from bashrun.bash import bash_handoff
|
|
7
|
+
from .detect_gpu import Gpu, detect_gpu
|
|
8
|
+
|
|
9
|
+
from .build_docker import run_build
|
|
10
|
+
from .context_sha import compute_service_shas
|
|
11
|
+
from .lifecycle import BAKE_FILE, enforce_bake_declaration, expand_compose_files, load_lifecycle_config
|
|
12
|
+
from .modes import resolve_auth_mode
|
|
13
|
+
|
|
14
|
+
ENV_FILE = Path(".env")
|
|
15
|
+
LOCK_FILE = Path(".env.lock")
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(add_completion=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def up(
|
|
22
|
+
attached: bool = typer.Option(False, "--attached", "-a", help="Run in foreground (not detached)"),
|
|
23
|
+
quiet_pull: bool = typer.Option(
|
|
24
|
+
False,
|
|
25
|
+
"--quiet-pull",
|
|
26
|
+
"-q",
|
|
27
|
+
help="Suppress per-layer pull progress (still shows pull/push totals).",
|
|
28
|
+
),
|
|
29
|
+
build: bool = typer.Option(
|
|
30
|
+
False, "--build", help="Build all images locally before bringing the stack up; skips pulling"
|
|
31
|
+
),
|
|
32
|
+
gpu: Annotated[Gpu, typer.Option("--gpu", help="auto|cuda|rocm|none")] = "auto",
|
|
33
|
+
dev: bool = typer.Option(
|
|
34
|
+
False,
|
|
35
|
+
"--dev",
|
|
36
|
+
help="Layer the declared dev overlay (compose.dev.yml shape) over the production stack for bind-mount/debug bring-up.",
|
|
37
|
+
),
|
|
38
|
+
) -> None:
|
|
39
|
+
config = load_lifecycle_config(Path.cwd())
|
|
40
|
+
enforce_bake_declaration(Path.cwd(), config)
|
|
41
|
+
|
|
42
|
+
if dev and (config is None or config.dev_file is None):
|
|
43
|
+
raise RuntimeError("--dev requires a dev_file in [tool.docker-devkit.lifecycle]")
|
|
44
|
+
|
|
45
|
+
if not ENV_FILE.exists():
|
|
46
|
+
raise RuntimeError("No .env file found; create one first (e.g., copy .env.example)")
|
|
47
|
+
|
|
48
|
+
if not LOCK_FILE.exists():
|
|
49
|
+
raise RuntimeError("No .env.lock found; run 'uv run build --lock-only' in the repo that authors the images")
|
|
50
|
+
|
|
51
|
+
if build and not BAKE_FILE.exists():
|
|
52
|
+
raise typer.BadParameter("--build requires a compose.bake.yml")
|
|
53
|
+
|
|
54
|
+
if gpu == "auto":
|
|
55
|
+
gpu = detect_gpu()
|
|
56
|
+
|
|
57
|
+
auth_mode = resolve_auth_mode(ENV_FILE)
|
|
58
|
+
|
|
59
|
+
if build:
|
|
60
|
+
run_build(gpu=gpu)
|
|
61
|
+
|
|
62
|
+
if BAKE_FILE.exists():
|
|
63
|
+
os.environ.update(compute_service_shas(Path.cwd(), BAKE_FILE))
|
|
64
|
+
|
|
65
|
+
compose_files = expand_compose_files(config, gpu, include_dev=dev)
|
|
66
|
+
if not compose_files:
|
|
67
|
+
raise RuntimeError("No compose files declared; a builds-only repo has no stack to bring up")
|
|
68
|
+
|
|
69
|
+
files_args = " ".join(f"-f {compose_file}" for compose_file in compose_files)
|
|
70
|
+
profile_flag = "--profile keycloak " if auth_mode == "keycloak" else ""
|
|
71
|
+
compose_args = f"{files_args} {profile_flag}--env-file .env --env-file {LOCK_FILE}"
|
|
72
|
+
|
|
73
|
+
up_command = f"docker compose {compose_args} up"
|
|
74
|
+
if not build:
|
|
75
|
+
# tree-<sha> tags are content-addressed and may not be pushed, so pull only what is missing locally
|
|
76
|
+
up_command += " --pull missing"
|
|
77
|
+
if quiet_pull:
|
|
78
|
+
up_command += " --quiet-pull"
|
|
79
|
+
if not attached:
|
|
80
|
+
up_command += " -d"
|
|
81
|
+
|
|
82
|
+
bash_handoff(up_command)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main():
|
|
86
|
+
app()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
from docker_devkit.lifecycle import (
|
|
6
|
+
LifecycleConfig,
|
|
7
|
+
enforce_bake_declaration,
|
|
8
|
+
expand_compose_files,
|
|
9
|
+
load_lifecycle_config,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _write_pyproject(root: Path, lifecycle_toml: str) -> None:
|
|
14
|
+
content = f"[project]\nname = 'stack'\n\n{lifecycle_toml}"
|
|
15
|
+
(root / "pyproject.toml").write_text(content, encoding="utf-8")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestLoadLifecycleConfig:
|
|
19
|
+
def test_loads_declared_table(self, tmp_path: Path):
|
|
20
|
+
_write_pyproject(
|
|
21
|
+
tmp_path,
|
|
22
|
+
"[tool.docker-devkit.lifecycle]\n"
|
|
23
|
+
'files = ["compose.yml", "compose.postgres.yml", "compose.{gpu}.yml"]\n'
|
|
24
|
+
'dev_file = "compose.dev.yml"\n',
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
config = load_lifecycle_config(tmp_path)
|
|
28
|
+
|
|
29
|
+
assert config == LifecycleConfig(
|
|
30
|
+
files=["compose.yml", "compose.postgres.yml", "compose.{gpu}.yml"],
|
|
31
|
+
dev_file="compose.dev.yml",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def test_bare_table_defaults_to_empty(self, tmp_path: Path):
|
|
35
|
+
_write_pyproject(tmp_path, "[tool.docker-devkit.lifecycle]\n")
|
|
36
|
+
|
|
37
|
+
config = load_lifecycle_config(tmp_path)
|
|
38
|
+
|
|
39
|
+
assert config == LifecycleConfig()
|
|
40
|
+
|
|
41
|
+
def test_absent_table_returns_none(self, tmp_path: Path):
|
|
42
|
+
_write_pyproject(tmp_path, "[tool.docker-devkit.generate-score]\nworkloads = []\n")
|
|
43
|
+
|
|
44
|
+
assert load_lifecycle_config(tmp_path) is None
|
|
45
|
+
|
|
46
|
+
def test_absent_tool_section_returns_none(self, tmp_path: Path):
|
|
47
|
+
_write_pyproject(tmp_path, "")
|
|
48
|
+
|
|
49
|
+
assert load_lifecycle_config(tmp_path) is None
|
|
50
|
+
|
|
51
|
+
def test_missing_pyproject_returns_none(self, tmp_path: Path):
|
|
52
|
+
assert load_lifecycle_config(tmp_path) is None
|
|
53
|
+
|
|
54
|
+
def test_rejects_unknown_keys(self, tmp_path: Path):
|
|
55
|
+
_write_pyproject(tmp_path, "[tool.docker-devkit.lifecycle]\nfiles = []\nbogus = true\n")
|
|
56
|
+
|
|
57
|
+
with pytest.raises(ValidationError):
|
|
58
|
+
load_lifecycle_config(tmp_path)
|
|
59
|
+
|
|
60
|
+
def test_rejects_non_string_entries(self, tmp_path: Path):
|
|
61
|
+
_write_pyproject(tmp_path, "[tool.docker-devkit.lifecycle]\nfiles = [1]\n")
|
|
62
|
+
|
|
63
|
+
with pytest.raises(ValidationError):
|
|
64
|
+
load_lifecycle_config(tmp_path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestExpandComposeFiles:
|
|
68
|
+
def test_expands_gpu_placeholder(self):
|
|
69
|
+
config = LifecycleConfig(files=["compose.yml", "compose.{gpu}.yml"])
|
|
70
|
+
|
|
71
|
+
assert expand_compose_files(config, "cuda") == [Path("compose.yml"), Path("compose.cuda.yml")]
|
|
72
|
+
|
|
73
|
+
def test_drops_gpu_entry_when_none(self):
|
|
74
|
+
config = LifecycleConfig(files=["compose.yml", "compose.{gpu}.yml"])
|
|
75
|
+
|
|
76
|
+
assert expand_compose_files(config, "none") == [Path("compose.yml")]
|
|
77
|
+
|
|
78
|
+
def test_keeps_concrete_files_when_none(self):
|
|
79
|
+
config = LifecycleConfig(files=["compose.yml", "compose.cpu.yml"])
|
|
80
|
+
|
|
81
|
+
assert expand_compose_files(config, "none") == [Path("compose.yml"), Path("compose.cpu.yml")]
|
|
82
|
+
|
|
83
|
+
def test_appends_dev_file_when_include_dev(self):
|
|
84
|
+
config = LifecycleConfig(files=["compose.yml"], dev_file="compose.dev.yml")
|
|
85
|
+
|
|
86
|
+
assert expand_compose_files(config, "cuda", include_dev=True) == [
|
|
87
|
+
Path("compose.yml"),
|
|
88
|
+
Path("compose.dev.yml"),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
def test_omits_dev_file_by_default(self):
|
|
92
|
+
config = LifecycleConfig(files=["compose.yml"], dev_file="compose.dev.yml")
|
|
93
|
+
|
|
94
|
+
assert expand_compose_files(config, "cuda") == [Path("compose.yml")]
|
|
95
|
+
|
|
96
|
+
def test_empty_files_stay_empty_without_dev(self):
|
|
97
|
+
assert expand_compose_files(LifecycleConfig(), "cuda") == []
|
|
98
|
+
|
|
99
|
+
def test_no_table_defaults_to_single_compose_file(self):
|
|
100
|
+
assert expand_compose_files(None, "cuda") == [Path("compose.yml")]
|
|
101
|
+
assert expand_compose_files(None, "cuda", include_dev=True) == [Path("compose.yml")]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestEnforceBakeDeclaration:
|
|
105
|
+
def test_accepts_bake_with_declaration(self, tmp_path: Path):
|
|
106
|
+
(tmp_path / "compose.bake.yml").write_text("", encoding="utf-8")
|
|
107
|
+
|
|
108
|
+
enforce_bake_declaration(tmp_path, LifecycleConfig())
|
|
109
|
+
|
|
110
|
+
def test_accepts_missing_bake_without_declaration(self, tmp_path: Path):
|
|
111
|
+
enforce_bake_declaration(tmp_path, None)
|
|
112
|
+
|
|
113
|
+
def test_rejects_bake_without_declaration(self, tmp_path: Path):
|
|
114
|
+
(tmp_path / "compose.bake.yml").write_text("", encoding="utf-8")
|
|
115
|
+
|
|
116
|
+
with pytest.raises(RuntimeError, match="lifecycle"):
|
|
117
|
+
enforce_bake_declaration(tmp_path, None)
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from typing import Annotated
|
|
4
|
-
|
|
5
|
-
import typer
|
|
6
|
-
from bashrun.bash import bash_handoff
|
|
7
|
-
from .detect_gpu import Gpu, detect_gpu
|
|
8
|
-
|
|
9
|
-
from .context_sha import compute_service_shas
|
|
10
|
-
from .modes import resolve_auth_mode
|
|
11
|
-
|
|
12
|
-
ENV_FILE = Path(".env")
|
|
13
|
-
LOCK_FILE = Path(".env.lock")
|
|
14
|
-
BAKE_FILE = Path("compose.bake.yml")
|
|
15
|
-
|
|
16
|
-
app = typer.Typer(add_completion=False)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@app.command()
|
|
20
|
-
def down(
|
|
21
|
-
volumes: bool = typer.Option(False, "--volumes", "-v", help="Remove named volumes."),
|
|
22
|
-
gpu: Annotated[Gpu, typer.Option("--gpu", help="auto|cuda|rocm|none")] = "auto",
|
|
23
|
-
compose_file: Annotated[
|
|
24
|
-
Path,
|
|
25
|
-
typer.Option(
|
|
26
|
-
"--compose-file",
|
|
27
|
-
help=(
|
|
28
|
-
"Base compose file. In a repo that authors its own stack (where compose.bake.yml lives) the default "
|
|
29
|
-
"compose.yml tears down the native multi-file stack. A consumer stack is torn down as the single graph "
|
|
30
|
-
"it was brought up as."
|
|
31
|
-
),
|
|
32
|
-
),
|
|
33
|
-
] = Path("compose.yml"),
|
|
34
|
-
) -> None:
|
|
35
|
-
# Mirror up: native multi-file teardown only when compose.bake.yml is present and the
|
|
36
|
-
# default compose.yml was requested. A consumer stack tears down its single graph.
|
|
37
|
-
native = compose_file == Path("compose.yml") and BAKE_FILE.exists()
|
|
38
|
-
|
|
39
|
-
if not ENV_FILE.exists():
|
|
40
|
-
raise RuntimeError("No .env file found")
|
|
41
|
-
|
|
42
|
-
if native and not LOCK_FILE.exists():
|
|
43
|
-
raise RuntimeError("No lock file found; run 'uv run build --lock-only' first")
|
|
44
|
-
|
|
45
|
-
if gpu == "auto":
|
|
46
|
-
gpu = detect_gpu()
|
|
47
|
-
|
|
48
|
-
resolve_auth_mode(ENV_FILE)
|
|
49
|
-
|
|
50
|
-
if BAKE_FILE.exists():
|
|
51
|
-
os.environ.update(compute_service_shas(Path.cwd(), BAKE_FILE))
|
|
52
|
-
|
|
53
|
-
if native:
|
|
54
|
-
compose_files = (
|
|
55
|
-
"-f compose.yml "
|
|
56
|
-
"-f compose.postgres.yml "
|
|
57
|
-
f"{f'-f compose.{gpu}.yml ' if gpu != 'none' else ''}"
|
|
58
|
-
"-f compose.dev.yml " # Include so containers from a prior dev bring-up get torn down even with --no-dev later
|
|
59
|
-
)
|
|
60
|
-
else:
|
|
61
|
-
compose_files = f"-f {compose_file} "
|
|
62
|
-
|
|
63
|
-
# .env.lock keeps compose from erroring on missing stack-internal vars; it only
|
|
64
|
-
# exists in the native repo, so a consumer stack tears down with .env alone.
|
|
65
|
-
lock_flag = f"--env-file {LOCK_FILE} " if LOCK_FILE.exists() else ""
|
|
66
|
-
command = (
|
|
67
|
-
"docker compose "
|
|
68
|
-
f"{compose_files}"
|
|
69
|
-
"--profile keycloak " # Always include so any keycloak containers from a previous AUTH_MODE=keycloak run get torn down
|
|
70
|
-
"--env-file .env "
|
|
71
|
-
f"{lock_flag}"
|
|
72
|
-
"down --remove-orphans"
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
if volumes:
|
|
76
|
-
command += " -v"
|
|
77
|
-
|
|
78
|
-
bash_handoff(command)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def main():
|
|
82
|
-
app()
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if __name__ == "__main__":
|
|
86
|
-
main()
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from typing import Annotated
|
|
4
|
-
|
|
5
|
-
import typer
|
|
6
|
-
from bashrun.bash import bash_handoff
|
|
7
|
-
from .detect_gpu import Gpu, detect_gpu
|
|
8
|
-
|
|
9
|
-
from .build_docker import run_build
|
|
10
|
-
from .context_sha import compute_service_shas
|
|
11
|
-
from .modes import resolve_auth_mode
|
|
12
|
-
|
|
13
|
-
ENV_FILE = Path(".env")
|
|
14
|
-
LOCK_FILE = Path(".env.lock")
|
|
15
|
-
BAKE_FILE = Path("compose.bake.yml")
|
|
16
|
-
|
|
17
|
-
app = typer.Typer(add_completion=False)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@app.command()
|
|
21
|
-
def up(
|
|
22
|
-
attached: bool = typer.Option(False, "--attached", "-a", help="Run in foreground (not detached)"),
|
|
23
|
-
quiet_pull: bool = typer.Option(
|
|
24
|
-
False,
|
|
25
|
-
"--quiet-pull",
|
|
26
|
-
"-q",
|
|
27
|
-
help="Suppress per-layer pull progress (still shows pull/push totals).",
|
|
28
|
-
),
|
|
29
|
-
build: bool = typer.Option(
|
|
30
|
-
False, "--build", help="Build all images locally before bringing the stack up; skips pulling"
|
|
31
|
-
),
|
|
32
|
-
gpu: Annotated[Gpu, typer.Option("--gpu", help="auto|cuda|rocm|none")] = "auto",
|
|
33
|
-
no_dev: bool = typer.Option(False, "--no-dev", help="Skip layering compose.dev.yml (production-shape bring-up)"),
|
|
34
|
-
compose_file: Annotated[
|
|
35
|
-
Path,
|
|
36
|
-
typer.Option(
|
|
37
|
-
"--compose-file",
|
|
38
|
-
help=(
|
|
39
|
-
"Base compose file. In a repo that authors its own images (where compose.bake.yml lives) the default "
|
|
40
|
-
"compose.yml triggers the native multi-file assembly. A consumer stack — a repo whose compose.yml "
|
|
41
|
-
"OCI-pulls an already-baked upstream artifact and layers on top — is run as the complete graph with "
|
|
42
|
-
"only --env-file .env."
|
|
43
|
-
),
|
|
44
|
-
),
|
|
45
|
-
] = Path("compose.yml"),
|
|
46
|
-
) -> None:
|
|
47
|
-
# A repo that authors its own stack carries compose.bake.yml and builds its own images,
|
|
48
|
-
# so the default compose.yml means the native multi-file stack (postgres + gpu + dev layers,
|
|
49
|
-
# per-service SHA injection, .env.lock). A consumer repo has no bake file: its
|
|
50
|
-
# --compose-file is the whole graph (the upstream stack arrives baked via OCI include or a
|
|
51
|
-
# sibling-checkout include), so SHA resolution and .env.lock don't apply.
|
|
52
|
-
native = compose_file == Path("compose.yml") and BAKE_FILE.exists()
|
|
53
|
-
|
|
54
|
-
if not ENV_FILE.exists():
|
|
55
|
-
raise RuntimeError("No .env file found; create one first (e.g., copy .env.example)")
|
|
56
|
-
|
|
57
|
-
if native and not LOCK_FILE.exists():
|
|
58
|
-
raise RuntimeError("No lock file found; run 'uv run build --lock-only' first")
|
|
59
|
-
|
|
60
|
-
if build and not native:
|
|
61
|
-
raise typer.BadParameter(
|
|
62
|
-
"--build is only supported for the native stack (compose.bake.yml present); a consumer stack "
|
|
63
|
-
"consumes images from an OCI-included upstream artifact and has no local build graph."
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
if gpu == "auto":
|
|
67
|
-
gpu = detect_gpu()
|
|
68
|
-
|
|
69
|
-
auth_mode = resolve_auth_mode(ENV_FILE)
|
|
70
|
-
|
|
71
|
-
if build:
|
|
72
|
-
run_build(gpu=gpu)
|
|
73
|
-
|
|
74
|
-
if BAKE_FILE.exists():
|
|
75
|
-
os.environ.update(compute_service_shas(Path.cwd(), BAKE_FILE))
|
|
76
|
-
|
|
77
|
-
profile_flag = "--profile keycloak " if auth_mode == "keycloak" else ""
|
|
78
|
-
if native:
|
|
79
|
-
gpu_file = f"-f compose.{gpu}.yml " if gpu != "none" else ""
|
|
80
|
-
dev_file = "" if no_dev else "-f compose.dev.yml "
|
|
81
|
-
compose_args = (
|
|
82
|
-
f"-f compose.yml -f compose.postgres.yml {gpu_file}{dev_file}{profile_flag}"
|
|
83
|
-
f"--env-file .env --env-file {LOCK_FILE}"
|
|
84
|
-
)
|
|
85
|
-
else:
|
|
86
|
-
lock_flag = f"--env-file {LOCK_FILE} " if LOCK_FILE.exists() else ""
|
|
87
|
-
compose_args = f"-f {compose_file} {profile_flag}--env-file .env {lock_flag}".rstrip()
|
|
88
|
-
|
|
89
|
-
up_command = f"docker compose {compose_args} up"
|
|
90
|
-
if not build:
|
|
91
|
-
# tree-<sha> tags are immutable (derived from dockerignore-allowlisted
|
|
92
|
-
# context), so a local hit is byte-identical to what the registry would
|
|
93
|
-
# serve. --pull missing skips locally-present tags, avoiding hard errors
|
|
94
|
-
# on images built locally but not yet pushed.
|
|
95
|
-
up_command += " --pull missing"
|
|
96
|
-
if quiet_pull:
|
|
97
|
-
up_command += " --quiet-pull"
|
|
98
|
-
if not attached:
|
|
99
|
-
up_command += " -d"
|
|
100
|
-
|
|
101
|
-
bash_handoff(up_command)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def main():
|
|
105
|
-
app()
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if __name__ == "__main__":
|
|
109
|
-
main()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|