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.
Files changed (30) hide show
  1. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/.github/workflows/release.yml +1 -1
  2. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/AGENTS.md +3 -2
  3. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/PKG-INFO +1 -1
  4. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/README.md +2 -0
  5. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/publish-config.json +1 -0
  6. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/pyproject.toml +2 -1
  7. docker_devkit-0.1.12/src/docker_devkit/score_generate.py +228 -0
  8. docker_devkit-0.1.12/tests/test_score_generate.py +133 -0
  9. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/.gitattributes +0 -0
  10. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/.github/workflows/ci.yml +0 -0
  11. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/.gitignore +0 -0
  12. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/.python-version +0 -0
  13. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/CLAUDE.md +0 -0
  14. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/LICENSE +0 -0
  15. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/NOTICE +0 -0
  16. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/ruff.base.toml +0 -0
  17. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/ruff.toml +0 -0
  18. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/__init__.py +0 -0
  19. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/build_docker.py +0 -0
  20. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/context_sha.py +0 -0
  21. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/detect_gpu.py +0 -0
  22. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/down.py +0 -0
  23. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/image_refs.py +0 -0
  24. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/modes.py +0 -0
  25. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/py.typed +0 -0
  26. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/src/docker_devkit/up.py +0 -0
  27. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/tests/test_context_sha.py +0 -0
  28. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/tests/test_default_targets.py +0 -0
  29. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/tests/test_image_refs.py +0 -0
  30. {docker_devkit-0.1.10 → docker_devkit-0.1.12}/uv.lock +0 -0
@@ -28,4 +28,4 @@ jobs:
28
28
  - uses: astral-sh/setup-uv@v7
29
29
 
30
30
  - name: Publish
31
- run: uvx --from release-devkit==0.1.3 publish-packages --config publish-config.json
31
+ run: uvx --from release-devkit==0.1.9 publish-stable --config publish-config.json
@@ -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-packages` (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 (first release `0.1.0`, patch-auto thereafter). API-breaking changes ship with a manually bumped version — 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). 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. |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docker-devkit
3
- Version: 0.1.10
3
+ Version: 0.1.12
4
4
  Summary: Docker-stack lifecycle commands (up/down/build) with a native/consumer split
5
5
  License-File: LICENSE
6
6
  License-File: NOTICE
@@ -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
@@ -3,6 +3,7 @@
3
3
  {
4
4
  "name": "docker-devkit",
5
5
  "path": ".",
6
+ "major_minor": "0.1",
6
7
  "feeds": { "pypi": "docker-devkit" }
7
8
  }
8
9
  ],
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-devkit"
3
- version = "0.1.10"
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