docker-devkit 0.1.10__tar.gz → 0.1.12__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.10 → docker_devkit-0.1.12}/.github/workflows/release.yml +1 -1
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/AGENTS.md +3 -2
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/PKG-INFO +1 -1
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/README.md +2 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/publish-config.json +1 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/pyproject.toml +2 -1
- docker_devkit-0.1.12/src/docker_devkit/score_generate.py +228 -0
- docker_devkit-0.1.12/tests/test_score_generate.py +133 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/.gitattributes +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/.github/workflows/ci.yml +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/.gitignore +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/.python-version +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/CLAUDE.md +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/LICENSE +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/NOTICE +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/ruff.base.toml +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/ruff.toml +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/__init__.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/build_docker.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/context_sha.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/detect_gpu.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/down.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/image_refs.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/modes.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/py.typed +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/up.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/tests/test_context_sha.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/tests/test_default_targets.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/tests/test_image_refs.py +0 -0
- {docker_devkit-0.1.10 → docker_devkit-0.1.12}/uv.lock +0 -0
|
@@ -8,17 +8,18 @@ The package is `docker_devkit` (src-layout under `src/docker_devkit/`); all depe
|
|
|
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-
|
|
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.
|
|
12
12
|
|
|
13
13
|
## Shape
|
|
14
14
|
|
|
15
|
-
Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:app`, `build` → `build_docker.py:app`. All accept `--help`.
|
|
15
|
+
Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:app`, `build` → `build_docker.py:app`, `generate-score` → `score_generate.py:app`. All accept `--help`.
|
|
16
16
|
|
|
17
17
|
| Module | Role |
|
|
18
18
|
|---|---|
|
|
19
19
|
| `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--no-dev`, `--compose-file`. |
|
|
20
20
|
| `down.py` | `docker compose down`. Flags: `--volumes`/`-v` (also removes named volumes), `--gpu`, `--compose-file`. |
|
|
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
|
+
| `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. |
|
|
22
23
|
| `detect_gpu.py` | `detect_gpu()` → `cuda`/`rocm`/`none` from host devices; the `Gpu` literal. |
|
|
23
24
|
| `modes.py` | `resolve_auth_mode()` — validates `PUBLIC_URL` + `AUTH_MODE` from `.env`, rejecting `keycloak` over cleartext `http://`. |
|
|
24
25
|
| `context_sha.py` | `compute_service_shas()` — per-service `tree-<hash>` image tags over the `.dockerignore`-allowlisted git tree. |
|
|
@@ -24,6 +24,8 @@ uv run down # docker compose down
|
|
|
24
24
|
uv run down -v # also remove named volumes
|
|
25
25
|
uv run build # cross-build all images per compose.bake.yml
|
|
26
26
|
uv run build --lock-only # refresh .env.lock without building
|
|
27
|
+
uv run generate-score # regenerate Score compose/k8s manifests per [tool.docker-devkit.generate-score]
|
|
28
|
+
uv run generate-score --local # local-cluster variant (local storage class, gitignored output)
|
|
27
29
|
```
|
|
28
30
|
|
|
29
31
|
## Consuming from another repo
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "docker-devkit"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.12"
|
|
4
4
|
description = "Docker-stack lifecycle commands (up/down/build) with a native/consumer split"
|
|
5
5
|
requires-python = ">=3.13"
|
|
6
6
|
dependencies = [
|
|
@@ -23,6 +23,7 @@ build-backend = "hatchling.build"
|
|
|
23
23
|
up = "docker_devkit.up:app"
|
|
24
24
|
down = "docker_devkit.down:app"
|
|
25
25
|
build = "docker_devkit.build_docker:app"
|
|
26
|
+
generate-score = "docker_devkit.score_generate:app"
|
|
26
27
|
|
|
27
28
|
[tool.hatch.build.targets.wheel]
|
|
28
29
|
packages = ["src/docker_devkit"]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tempfile import TemporaryDirectory
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
import yaml
|
|
14
|
+
from bashrun.bash import bash
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from .context_sha import compute_service_shas
|
|
18
|
+
from .image_refs import TomlDocument, TomlValue
|
|
19
|
+
|
|
20
|
+
# Placeholders in the workload files are ALL-CAPS, so this cannot collide with Score's own
|
|
21
|
+
# lowercase dotted ${resources.db.host} references.
|
|
22
|
+
PLACEHOLDER = re.compile(r"\$\{([A-Z][A-Z0-9_]*)\}")
|
|
23
|
+
|
|
24
|
+
Target = Literal["both", "compose", "k8s"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ManifestMetadata(BaseModel):
|
|
28
|
+
model_config = ConfigDict(extra="ignore")
|
|
29
|
+
|
|
30
|
+
namespace: str = ""
|
|
31
|
+
name: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ManifestDocument(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="ignore")
|
|
36
|
+
|
|
37
|
+
kind: str = ""
|
|
38
|
+
metadata: ManifestMetadata | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ScoreConfig(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
workloads: list[str]
|
|
45
|
+
project_name: str
|
|
46
|
+
cloud_storage_class: str
|
|
47
|
+
local_storage_class: str
|
|
48
|
+
score_dir: Path = Path("score")
|
|
49
|
+
bake_file: Path = Path("compose.bake.yml")
|
|
50
|
+
compose_output: Path = Path("compose.yaml")
|
|
51
|
+
k8s_output: Path = Path("deploy") / "manifests.yaml"
|
|
52
|
+
# --local writes here instead of the committed k8s output; consumers gitignore this path so a
|
|
53
|
+
# local-only storage class cannot reach the artifact their cluster deploys.
|
|
54
|
+
k8s_local_output: Path = Path("manifests.yaml")
|
|
55
|
+
k8s_state: Path = Path(".score-k8s")
|
|
56
|
+
storage_class_var: str = "SCORE_STORAGE_CLASS"
|
|
57
|
+
compose_provisioners: list[str] = Field(default_factory=list)
|
|
58
|
+
compose_patch_templates: list[str] = Field(default_factory=list)
|
|
59
|
+
k8s_provisioners: list[str] = Field(default_factory=list)
|
|
60
|
+
publishes: list[str] = Field(default_factory=list)
|
|
61
|
+
score_k8s_version: str = "0.15.0"
|
|
62
|
+
score_compose_version: str = "0.42.0"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def cli(
|
|
70
|
+
target: Annotated[Target, typer.Option(help="Which Score artifact to generate.")] = "both",
|
|
71
|
+
local: Annotated[
|
|
72
|
+
bool,
|
|
73
|
+
typer.Option(
|
|
74
|
+
"--local",
|
|
75
|
+
help="Generate for a local cluster: local storage class, written to the configured local output path.",
|
|
76
|
+
),
|
|
77
|
+
] = False,
|
|
78
|
+
) -> None:
|
|
79
|
+
generate(load_score_config(Path.cwd()), target, local)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_score_config(root: Path) -> ScoreConfig:
|
|
83
|
+
value: TomlValue = TomlDocument.model_validate(
|
|
84
|
+
tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
85
|
+
).root
|
|
86
|
+
for component in ("tool", "docker-devkit", "generate-score"):
|
|
87
|
+
if not isinstance(value, dict) or component not in value:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
f"[tool.docker-devkit.generate-score] is missing at {component} in {root / 'pyproject.toml'}"
|
|
90
|
+
)
|
|
91
|
+
value = value[component]
|
|
92
|
+
return ScoreConfig.model_validate(value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def generate(config: ScoreConfig, target: Target = "both", local: bool = False) -> None:
|
|
96
|
+
# The same call up, build, and preflight make, so every image tag is a pure function of
|
|
97
|
+
# committed source rather than a hand-typed value.
|
|
98
|
+
os.environ.update(compute_service_shas(Path.cwd(), config.bake_file))
|
|
99
|
+
os.environ[config.storage_class_var] = config.local_storage_class if local else config.cloud_storage_class
|
|
100
|
+
|
|
101
|
+
if target in ("both", "compose"):
|
|
102
|
+
_generate_compose(config)
|
|
103
|
+
|
|
104
|
+
if target in ("both", "k8s"):
|
|
105
|
+
_generate_k8s(config, config.k8s_local_output if local else config.k8s_output)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def ensure_score_tools(config: ScoreConfig, bin_dir: Path) -> None:
|
|
109
|
+
# Fetched as release binaries rather than `go install`: score-spec tags without a leading v
|
|
110
|
+
# (0.15.0, not v0.15.0), which is not a resolvable Go module version.
|
|
111
|
+
for tool, version in (("score-k8s", config.score_k8s_version), ("score-compose", config.score_compose_version)):
|
|
112
|
+
archive = f"{tool}_{version}_linux_amd64.tar.gz"
|
|
113
|
+
bash(f"curl -fsSLO https://github.com/score-spec/{tool}/releases/download/{version}/{archive}")
|
|
114
|
+
bash(f"tar -xzf {archive} -C {bin_dir} {tool}")
|
|
115
|
+
Path(archive).unlink()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _generate_compose(config: ScoreConfig) -> None:
|
|
119
|
+
_init_state(
|
|
120
|
+
f"score-compose init --project {config.project_name} --no-sample",
|
|
121
|
+
config.compose_provisioners,
|
|
122
|
+
config.compose_patch_templates,
|
|
123
|
+
config.score_dir,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
publishes = "".join(f" --publish {publish}" for publish in config.publishes)
|
|
127
|
+
with _rendered_workloads(config) as workloads:
|
|
128
|
+
bash(
|
|
129
|
+
f"score-compose generate {workloads} --output {config.compose_output.as_posix()}{publishes}",
|
|
130
|
+
cwd=config.score_dir,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _generate_k8s(config: ScoreConfig, output: Path) -> None:
|
|
135
|
+
_init_state("score-k8s init --no-sample", config.k8s_provisioners, [], config.score_dir)
|
|
136
|
+
|
|
137
|
+
(config.score_dir / output).parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
|
|
139
|
+
with _rendered_workloads(config) as workloads:
|
|
140
|
+
bash(f"score-k8s generate {workloads} --output {output.as_posix()}", cwd=config.score_dir)
|
|
141
|
+
|
|
142
|
+
sort_documents(config.score_dir / output)
|
|
143
|
+
normalise_state_paths(config, config.score_dir / config.k8s_state / "state.yaml")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _init_state(command: str, provisioners: list[str], patch_templates: list[str], score_dir: Path) -> None:
|
|
147
|
+
# init is idempotent over an existing state directory: it rewrites the provisioner copies
|
|
148
|
+
# without disturbing state. The state must survive, because score-k8s mints a random uid per
|
|
149
|
+
# workload on first add and emits it as the app.kubernetes.io/instance label — discarding it
|
|
150
|
+
# would change the generated manifests on every run.
|
|
151
|
+
flags = "".join(f" --provisioners ./{name}" for name in provisioners)
|
|
152
|
+
flags += "".join(f" --patch-templates ./{name}" for name in patch_templates)
|
|
153
|
+
bash(f"{command}{flags}", cwd=score_dir)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@contextmanager
|
|
157
|
+
def _rendered_workloads(config: ScoreConfig) -> Generator[str]:
|
|
158
|
+
# Workload files are Score spec documents, not Go templates, so they cannot read the
|
|
159
|
+
# environment the way the provisioner files can. Expanding ${API_SHA} into a rendered copy
|
|
160
|
+
# keeps one mechanism across both halves.
|
|
161
|
+
with TemporaryDirectory() as temporary_directory:
|
|
162
|
+
directory = Path(temporary_directory)
|
|
163
|
+
|
|
164
|
+
for name in config.workloads:
|
|
165
|
+
source = (config.score_dir / name).read_text(encoding="utf-8")
|
|
166
|
+
(directory / name).write_text(expand_placeholders(source, name, config.bake_file), "utf-8")
|
|
167
|
+
|
|
168
|
+
yield " ".join((directory / name).as_posix() for name in config.workloads)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def expand_placeholders(text: str, name: str, bake_file: Path) -> str:
|
|
172
|
+
parts: list[str] = []
|
|
173
|
+
index = 0
|
|
174
|
+
for match in PLACEHOLDER.finditer(text):
|
|
175
|
+
parts.append(text[index : match.start()])
|
|
176
|
+
parts.append(_placeholder_value(match.group(1), name, bake_file))
|
|
177
|
+
index = match.end()
|
|
178
|
+
parts.append(text[index:])
|
|
179
|
+
return "".join(parts)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _placeholder_value(variable: str, name: str, bake_file: Path) -> str:
|
|
183
|
+
value = os.environ.get(variable)
|
|
184
|
+
if not value:
|
|
185
|
+
raise SystemExit(f"{name}: ${{{variable}}} is not set. Is it a service in {bake_file}?")
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def normalise_state_paths(config: ScoreConfig, path: Path) -> None:
|
|
190
|
+
# score-k8s records the absolute path each workload was read from. Ours are rendered into a
|
|
191
|
+
# per-run temporary directory, so the recorded path — and therefore the committed state file —
|
|
192
|
+
# would differ on every run and on every machine. The field is provenance only; the workloads
|
|
193
|
+
# are passed explicitly on each generate, so reducing it to the bare filename is safe and makes
|
|
194
|
+
# the state reproducible.
|
|
195
|
+
text = path.read_text(encoding="utf-8")
|
|
196
|
+
for name in config.workloads:
|
|
197
|
+
text = re.sub(rf"(?m)^(\s*file:\s*).*/{re.escape(name)}$", rf"\g<1>{name}", text)
|
|
198
|
+
write_generated_file(path, text)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def sort_documents(path: Path) -> None:
|
|
202
|
+
# score-k8s emits workloads in Go map order, which is randomised, so two runs otherwise
|
|
203
|
+
# produce the same documents in a different sequence. Documents are parsed only to derive the
|
|
204
|
+
# sort key and re-emitted as their original text, so Score's formatting is preserved.
|
|
205
|
+
documents = [block for block in re.split(r"(?m)^---$\n", path.read_text(encoding="utf-8")) if block.strip()]
|
|
206
|
+
ordered = [block for _, block in sorted(enumerate(documents), key=_document_sort_key)]
|
|
207
|
+
write_generated_file(path, "".join(f"---\n{block}" for block in ordered))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _document_sort_key(item: tuple[int, str]) -> tuple[str, str, str, int]:
|
|
211
|
+
index, block = item
|
|
212
|
+
document = ManifestDocument.model_validate(yaml.safe_load(block) or {})
|
|
213
|
+
metadata = document.metadata or ManifestMetadata()
|
|
214
|
+
return (document.kind, metadata.namespace, metadata.name, index)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def write_generated_file(path: Path, text: str) -> None:
|
|
218
|
+
# newline="" suppresses the platform translation Python applies by default. Without it a
|
|
219
|
+
# Windows run rewrites every line ending as CRLF while git stores LF, so the regenerated file
|
|
220
|
+
# differs from the committed one on content that never changed and the staleness gate can
|
|
221
|
+
# never pass.
|
|
222
|
+
with path.open("w", encoding="utf-8", newline="") as handle:
|
|
223
|
+
handle.write(text)
|
|
224
|
+
|
|
225
|
+
# score-k8s writes its state file executable. Git records the mode, so on Linux that alone
|
|
226
|
+
# shows up as a diff and fails the staleness gate — invisibly on Windows, where core.fileMode
|
|
227
|
+
# is false and the bit is never tracked. These are data files; normalise them to 0644.
|
|
228
|
+
path.chmod(0o644)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from docker_devkit.score_generate import (
|
|
7
|
+
ScoreConfig,
|
|
8
|
+
expand_placeholders,
|
|
9
|
+
normalise_state_paths,
|
|
10
|
+
sort_documents,
|
|
11
|
+
write_generated_file,
|
|
12
|
+
load_score_config,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_config(**overrides: str | list[str]) -> ScoreConfig:
|
|
17
|
+
values: dict[str, str | list[str]] = {
|
|
18
|
+
"workloads": ["api.yaml", "gateway.yaml"],
|
|
19
|
+
"project_name": "example",
|
|
20
|
+
"cloud_storage_class": "cloud-volumes",
|
|
21
|
+
"local_storage_class": "local-path",
|
|
22
|
+
}
|
|
23
|
+
values.update(overrides)
|
|
24
|
+
return ScoreConfig.model_validate(values)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_expand_substitutes_uppercase_placeholders(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
28
|
+
monkeypatch.setenv("API_SHA", "tree-abc")
|
|
29
|
+
expanded = expand_placeholders("image: registry/app:${API_SHA}", "api.yaml", Path("compose.bake.yml"))
|
|
30
|
+
assert expanded == "image: registry/app:tree-abc"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_expand_leaves_score_resource_references(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
34
|
+
monkeypatch.setenv("API_SHA", "tree-abc")
|
|
35
|
+
text = "host: ${resources.db.host}"
|
|
36
|
+
assert expand_placeholders(text, "api.yaml", Path("compose.bake.yml")) == text
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_expand_exits_on_missing_placeholder(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
40
|
+
monkeypatch.delenv("API_SHA", raising=False)
|
|
41
|
+
with pytest.raises(SystemExit, match="API_SHA"):
|
|
42
|
+
expand_placeholders("image: registry/app:${API_SHA}", "api.yaml", Path("compose.bake.yml"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_sort_documents_orders_by_kind_then_namespace(tmp_path: Path) -> None:
|
|
46
|
+
path = tmp_path / "manifests.yaml"
|
|
47
|
+
service_zeta = "apiVersion: v1\nkind: Service\nmetadata:\n name: b\n namespace: zeta\n"
|
|
48
|
+
config_map = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n namespace: zeta\n"
|
|
49
|
+
service_alpha = "apiVersion: v1\nkind: Service\nmetadata:\n name: z\n namespace: alpha\n"
|
|
50
|
+
deployment = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: c\n namespace: alpha\n"
|
|
51
|
+
path.write_text(f"{service_zeta}---\n{config_map}---\n{service_alpha}---\n{deployment}", encoding="utf-8")
|
|
52
|
+
|
|
53
|
+
sort_documents(path)
|
|
54
|
+
|
|
55
|
+
assert path.read_text(encoding="utf-8") == (
|
|
56
|
+
f"---\n{config_map}---\n{deployment}---\n{service_alpha}---\n{service_zeta}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_sort_documents_ignores_empty_blocks(tmp_path: Path) -> None:
|
|
61
|
+
path = tmp_path / "manifests.yaml"
|
|
62
|
+
document = "apiVersion: v1\nkind: Namespace\n"
|
|
63
|
+
path.write_text(f"{document}---\n\n---\n", encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
sort_documents(path)
|
|
66
|
+
|
|
67
|
+
assert path.read_text(encoding="utf-8") == f"---\n{document}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_normalise_state_paths_reduces_rendered_paths_to_filenames(tmp_path: Path) -> None:
|
|
71
|
+
state = tmp_path / "state.yaml"
|
|
72
|
+
state.write_text(
|
|
73
|
+
"workloads:\n- name: api\n file: /tmp/tmp1234/api.yaml\n- name: gateway\n file: /tmp/tmp1234/gateway.yaml\n",
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
normalise_state_paths(make_config(), state)
|
|
78
|
+
|
|
79
|
+
text = state.read_text(encoding="utf-8")
|
|
80
|
+
assert "file: api.yaml" in text
|
|
81
|
+
assert "file: gateway.yaml" in text
|
|
82
|
+
assert "/tmp/tmp1234" not in text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_write_normalises_file_mode(tmp_path: Path) -> None:
|
|
86
|
+
path = tmp_path / "state.yaml"
|
|
87
|
+
path.write_text("placeholder", encoding="utf-8")
|
|
88
|
+
path.chmod(0o755)
|
|
89
|
+
|
|
90
|
+
write_generated_file(path, "content\n")
|
|
91
|
+
|
|
92
|
+
assert path.stat().st_mode & 0o777 == 0o644
|
|
93
|
+
assert path.read_text(encoding="utf-8") == "content\n"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_load_score_config_reads_consumer_table(tmp_path: Path) -> None:
|
|
97
|
+
(tmp_path / "pyproject.toml").write_text(
|
|
98
|
+
"[tool.docker-devkit.generate-score]\n"
|
|
99
|
+
'workloads = ["api.yaml"]\n'
|
|
100
|
+
'project_name = "placeframe"\n'
|
|
101
|
+
'cloud_storage_class = "hcloud-volumes"\n'
|
|
102
|
+
'local_storage_class = "local-path"\n'
|
|
103
|
+
'publishes = ["8000:api:8000"]\n',
|
|
104
|
+
encoding="utf-8",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
config = load_score_config(tmp_path)
|
|
108
|
+
|
|
109
|
+
assert config.workloads == ["api.yaml"]
|
|
110
|
+
assert config.project_name == "placeframe"
|
|
111
|
+
assert config.publishes == ["8000:api:8000"]
|
|
112
|
+
assert config.compose_output == Path("compose.yaml")
|
|
113
|
+
assert config.k8s_output == Path("deploy") / "manifests.yaml"
|
|
114
|
+
assert config.score_k8s_version == "0.15.0"
|
|
115
|
+
assert config.score_compose_version == "0.42.0"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_load_score_config_missing_table_raises(tmp_path: Path) -> None:
|
|
119
|
+
(tmp_path / "pyproject.toml").write_text('[project]\nname = "consumer"\n', encoding="utf-8")
|
|
120
|
+
|
|
121
|
+
with pytest.raises(RuntimeError, match="generate-score"):
|
|
122
|
+
load_score_config(tmp_path)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_score_config_rejects_unknown_keys() -> None:
|
|
126
|
+
with pytest.raises(ValidationError):
|
|
127
|
+
ScoreConfig.model_validate({
|
|
128
|
+
"workloads": ["api.yaml"],
|
|
129
|
+
"project_name": "example",
|
|
130
|
+
"cloud_storage_class": "cloud-volumes",
|
|
131
|
+
"local_storage_class": "local-path",
|
|
132
|
+
"publication": ["8000:api:8000"],
|
|
133
|
+
})
|
|
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
|