boatyard 0.6.0.dev170__py3-none-any.whl

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 (42) hide show
  1. boatyard/__init__.py +0 -0
  2. boatyard/build.py +267 -0
  3. boatyard/cli.py +23 -0
  4. boatyard/compose.py +24 -0
  5. boatyard/config/__init__.py +0 -0
  6. boatyard/config/config.py +787 -0
  7. boatyard/config/lock.py +152 -0
  8. boatyard/config/values.py +188 -0
  9. boatyard/crane.py +23 -0
  10. boatyard/dockerfiles.py +201 -0
  11. boatyard/lock.py +233 -0
  12. boatyard/markers.py +201 -0
  13. boatyard/paths.py +5 -0
  14. boatyard/publish.py +72 -0
  15. boatyard/py.typed +0 -0
  16. boatyard/score.py +267 -0
  17. boatyard/score_compose.py +274 -0
  18. boatyard/score_k8s.py +152 -0
  19. boatyard/score_patch_templates/gpu-cuda.tpl +24 -0
  20. boatyard/score_patch_templates/gpu-pod.tpl +43 -0
  21. boatyard/score_patch_templates/gpu-rocm.tpl +30 -0
  22. boatyard/score_patch_templates/probe-adapter-healthchecks.tpl +55 -0
  23. boatyard/score_patch_templates/restart.tpl +21 -0
  24. boatyard/score_patch_templates/scale-to-zero.tpl +30 -0
  25. boatyard/temp.py +5 -0
  26. boatyard/verbs/__init__.py +0 -0
  27. boatyard/verbs/build.py +54 -0
  28. boatyard/verbs/down.py +39 -0
  29. boatyard/verbs/lock.py +34 -0
  30. boatyard/verbs/matrix.py +57 -0
  31. boatyard/verbs/mirror.py +50 -0
  32. boatyard/verbs/publish.py +138 -0
  33. boatyard/verbs/score.py +161 -0
  34. boatyard/verbs/tree_tags.py +35 -0
  35. boatyard/verbs/up.py +265 -0
  36. boatyard/yaml.py +34 -0
  37. boatyard-0.6.0.dev170.dist-info/METADATA +12 -0
  38. boatyard-0.6.0.dev170.dist-info/RECORD +42 -0
  39. boatyard-0.6.0.dev170.dist-info/WHEEL +4 -0
  40. boatyard-0.6.0.dev170.dist-info/entry_points.txt +2 -0
  41. boatyard-0.6.0.dev170.dist-info/licenses/LICENSE +201 -0
  42. boatyard-0.6.0.dev170.dist-info/licenses/NOTICE +1 -0
boatyard/score.py ADDED
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import shlex
6
+ from contextlib import ExitStack
7
+ from hashlib import sha256
8
+ from importlib.resources import as_file, files
9
+ from pathlib import Path
10
+ from tempfile import TemporaryDirectory
11
+ from typing import Literal, NamedTuple, Self
12
+
13
+ import yaml
14
+ from bashrun.bash import bash
15
+ from pydantic import (
16
+ BaseModel,
17
+ ConfigDict,
18
+ Field,
19
+ RootModel,
20
+ ValidationInfo,
21
+ field_validator,
22
+ model_validator,
23
+ )
24
+
25
+ from boatyard.config.config import (
26
+ ENV_NAME_PATTERN,
27
+ PLACEHOLDER,
28
+ Yaml,
29
+ is_list,
30
+ is_mapping,
31
+ ConfigFile,
32
+ WorkloadDocument,
33
+ )
34
+ from boatyard.yaml import load_yaml_model
35
+ from boatyard.markers import MarkerContext, resolve_markers
36
+
37
+ PATCH_TEMPLATES_DIR = "score_patch_templates"
38
+
39
+ SECRETS_FILE = Path("stack/secrets.yaml")
40
+ SECRETS_OUTPUT = Path("stack/generated/secrets.txt")
41
+
42
+ STRING_FORM_FIELDS = ("state", "outputs", "volumes", "services", "manifests")
43
+
44
+ Substrate = Literal["compose", "k8s"]
45
+
46
+
47
+ class SecretsDeclaration(RootModel[dict[str, "SecretKey"]]):
48
+ @model_validator(mode="before")
49
+ @classmethod
50
+ def _coerce_string_specs(cls, value: Yaml) -> Yaml:
51
+ if value is None:
52
+ value = {}
53
+ if not is_mapping(value):
54
+ raise ValueError("must be a KEY: description mapping")
55
+ return {name: ({"description": spec} if isinstance(spec, str) else spec) for name, spec in value.items()}
56
+
57
+ @model_validator(mode="after")
58
+ def _require_all_caps_keys(self) -> Self:
59
+ for name in self.root:
60
+ if not ENV_NAME_PATTERN.match(name):
61
+ raise ValueError(f"{name!r} must be an ALL_CAPS key")
62
+ return self
63
+
64
+ def validate_consumed(self, consumed: set[str], rendering: Substrate, source: str) -> None:
65
+ undeclared = sorted(consumed - self.root.keys())
66
+ if undeclared:
67
+ raise ValueError(f"{source}: the {rendering} rendering consumes undeclared keys: " + ", ".join(undeclared))
68
+
69
+
70
+ class SecretKey(BaseModel):
71
+ model_config = ConfigDict(extra="forbid")
72
+
73
+ description: str
74
+ mint: bool = False
75
+
76
+ @field_validator("description")
77
+ @classmethod
78
+ def _require_description(cls, value: str) -> str:
79
+ if not value.strip():
80
+ raise ValueError("description must be non-empty")
81
+ return value
82
+
83
+
84
+ class AuthoredProvisioner(BaseModel):
85
+ model_config = ConfigDict(extra="forbid")
86
+
87
+ uri: str
88
+ type: str
89
+ description: str = ""
90
+ state: dict[str, Yaml] | None = None
91
+ outputs: dict[str, Yaml] | None = None
92
+ volumes: dict[str, Yaml] | None = None
93
+ services: dict[str, Yaml] | None = None
94
+ manifests: list[Yaml] | None = None
95
+
96
+ @field_validator(*STRING_FORM_FIELDS, mode="before")
97
+ @classmethod
98
+ def _reject_string_form(cls, value: Yaml, info: ValidationInfo) -> Yaml:
99
+ if not isinstance(value, str):
100
+ return value
101
+
102
+ shape = "list" if info.field_name == "manifests" else "mapping"
103
+ raise ValueError(f"{info.field_name} is in score's compiled string form; a {shape} is required")
104
+
105
+
106
+ class LiteralBlockDumper(yaml.SafeDumper):
107
+ pass
108
+
109
+
110
+ class LoadedWorkload(NamedTuple):
111
+ file_name: str
112
+ raw: Yaml
113
+ document: WorkloadDocument
114
+
115
+
116
+ class ToolState(BaseModel):
117
+ # Type only the workloads shape of the tool-owned state file
118
+ model_config = ConfigDict(extra="allow")
119
+
120
+ workloads: dict[str, dict[str, Yaml]] = Field(default_factory=dict)
121
+
122
+
123
+ def _represent_multiline(dumper: yaml.SafeDumper, value: str) -> yaml.ScalarNode:
124
+ return yaml.ScalarNode("tag:yaml.org,2002:str", value, style="|" if "\n" in value else None)
125
+
126
+
127
+ LiteralBlockDumper.add_representer(str, _represent_multiline)
128
+
129
+
130
+ def render_score(
131
+ config: ConfigFile,
132
+ substrate: Substrate,
133
+ workloads: list[LoadedWorkload],
134
+ rendering_vars: dict[str, str],
135
+ templates: list[str],
136
+ output: Path,
137
+ images: dict[str, str],
138
+ service_shas: dict[str, str],
139
+ ) -> None:
140
+ # Derive the per-substrate names and create the output parent
141
+ root = config.root
142
+ score_dir = root / config.score_dir
143
+ tool = f"score-{substrate}"
144
+ rendering = substrate
145
+ # Run both tools in the output directory because both write their state dir into the cwd
146
+ working_dir = output.parent
147
+ project_name = None if substrate == "k8s" else config.project_name
148
+ output.parent.mkdir(parents=True, exist_ok=True)
149
+
150
+ with (
151
+ as_file(files("boatyard") / PATCH_TEMPLATES_DIR) as builtin_templates,
152
+ ExitStack() as stack,
153
+ ):
154
+ # Collect the resource types the workloads reference
155
+ referenced = {resource.type for workload in workloads for resource in workload.document.resources.values()}
156
+
157
+ # Select the rendering's provisioner files that declare a referenced type
158
+ selected: list[tuple[str, list[Yaml]]] = []
159
+ rendering_dir = score_dir / rendering
160
+ if rendering_dir.is_dir():
161
+ for path in sorted(rendering_dir.glob("*.yaml")):
162
+ entries = yaml.safe_load(path.read_text(encoding="utf-8"))
163
+ if is_list(entries) and any(is_mapping(entry) and entry.get("type") in referenced for entry in entries):
164
+ selected.append((f"{rendering}/{path.name}", entries))
165
+
166
+ # Compile the selected provisioners into a per-run tempdir under score_dir
167
+ paths: list[Path] = []
168
+ directory = Path(stack.enter_context(TemporaryDirectory(prefix="compiled-provisioners-", dir=score_dir)))
169
+ for name, entries in selected:
170
+ context = MarkerContext(root=root, images=images, source=name)
171
+ compiled_entries: list[Yaml] = []
172
+ for entry in entries:
173
+ resolved = resolve_markers(entry, context)
174
+ AuthoredProvisioner.model_validate(resolved)
175
+ if is_mapping(resolved):
176
+ compiled_entries.append({
177
+ key: dump_yaml(value) if key in STRING_FORM_FIELDS and value is not None else value
178
+ for key, value in resolved.items()
179
+ })
180
+ target = directory / name
181
+ target.parent.mkdir(parents=True, exist_ok=True)
182
+ write_file(target, dump_yaml(compiled_entries))
183
+ paths.append(target)
184
+
185
+ # Build the tool flags and run init
186
+ provisioner_flags = "".join(
187
+ f" --provisioners {shlex.quote(Path(os.path.relpath(path, working_dir)).as_posix())}" for path in paths
188
+ )
189
+ template_flags = "".join(
190
+ f" --patch-templates {shlex.quote(Path(os.path.relpath(builtin_templates / name, working_dir)).as_posix())}"
191
+ for name in templates
192
+ )
193
+ bash(
194
+ f"{tool} init{' --project ' + project_name if project_name is not None else ''}"
195
+ f" --no-sample{provisioner_flags}{template_flags}",
196
+ cwd=working_dir,
197
+ )
198
+
199
+ # Pre-seed each workload's instance suffix so generated names are deterministic
200
+ if substrate == "k8s":
201
+ state_path = output.parent / f".{tool}" / "state.yaml"
202
+ state = load_yaml_model(ToolState, state_path)
203
+ for workload in workloads:
204
+ workload_name = Path(workload.file_name).stem
205
+ state.workloads.setdefault(workload_name, {})["instance_suffix"] = (
206
+ "-" + sha256(workload_name.encode()).hexdigest()[:10]
207
+ )
208
+ write_file(state_path, dump_yaml(state.model_dump()))
209
+
210
+ # Expand each workload's ${NAME} slots into per-run copies
211
+ copies_directory = Path(stack.enter_context(TemporaryDirectory()))
212
+ # List the variable sources lowest-priority first so later ones overwrite earlier ones
213
+ variable_values = {
214
+ variable: value
215
+ for source in (images, service_shas, rendering_vars)
216
+ for variable, value in source.items()
217
+ if value
218
+ }
219
+
220
+ for workload in workloads:
221
+ expanded = dump_yaml(workload.raw)
222
+ for variable, value in variable_values.items():
223
+ expanded = expanded.replace(f"${{{variable}}}", value)
224
+
225
+ # Refuse slots that no source fills
226
+ leftover = PLACEHOLDER.search(expanded)
227
+ if leftover is not None:
228
+ raise ValueError(
229
+ f"{workload.file_name}: ${{{leftover.group(1)}}} is set in none of the rendering vars, "
230
+ "the service shas, or the image lock"
231
+ )
232
+
233
+ (copies_directory / workload.file_name).write_text(expanded, "utf-8")
234
+
235
+ # Run generate over the expanded copies
236
+ rendered = " ".join((copies_directory / workload.file_name).as_posix() for workload in workloads)
237
+ bash(f"{tool} generate {rendered} --output {output.as_posix()}", cwd=working_dir)
238
+
239
+
240
+ def load_workloads(score_dir: Path, names: list[str], lens: type[WorkloadDocument]) -> list[LoadedWorkload]:
241
+ loaded: list[LoadedWorkload] = []
242
+ for name in names:
243
+ raw = yaml.safe_load((score_dir / name).read_text(encoding="utf-8"))
244
+ loaded.append(LoadedWorkload(name, raw, lens.parse(raw, name)))
245
+ return loaded
246
+
247
+
248
+ def dump_yaml(value: Yaml) -> str:
249
+ return yaml.dump(
250
+ value,
251
+ Dumper=LiteralBlockDumper,
252
+ default_flow_style=False,
253
+ sort_keys=False,
254
+ width=1_000_000,
255
+ allow_unicode=True,
256
+ )
257
+
258
+
259
+ def write_file(path: Path, text: str) -> None:
260
+ with path.open("w", encoding="utf-8", newline="") as handle:
261
+ handle.write(text)
262
+
263
+ path.chmod(0o644)
264
+
265
+
266
+ def split_documents(text: str) -> list[str]:
267
+ return [block for block in re.split(r"(?m)^---$\n", text) if block.strip()]
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Literal, Self
6
+
7
+ from pydantic import (
8
+ BaseModel,
9
+ ConfigDict,
10
+ Field,
11
+ field_validator,
12
+ model_validator,
13
+ )
14
+
15
+ from boatyard.config.config import (
16
+ COMPOSE_SLOT,
17
+ ComposeWorkloadDocument,
18
+ WorkloadDocument,
19
+ Yaml,
20
+ compose_bundle_output_path,
21
+ double_dollars,
22
+ env_lock_slot,
23
+ ConfigFile,
24
+ )
25
+ from boatyard.config.lock import LOCK_PATH, LockFile
26
+ from boatyard.yaml import load_yaml_model
27
+ from boatyard.score import (
28
+ SECRETS_FILE,
29
+ SecretsDeclaration,
30
+ dump_yaml,
31
+ load_workloads,
32
+ render_score,
33
+ write_file,
34
+ )
35
+ from boatyard.config.values import ValuesFile
36
+ from boatyard.paths import VALUES_FILE
37
+
38
+ TOOL = "score-compose"
39
+
40
+ BARRIER_SERVICE = "wait-for-resources"
41
+ HEALTHCHECK_TEMPLATE = "probe-adapter-healthchecks.tpl"
42
+ RESTART_TEMPLATE = "restart.tpl"
43
+ GPU_COMPOSE_TEMPLATES = {"cuda": "gpu-cuda.tpl", "rocm": "gpu-rocm.tpl"}
44
+
45
+
46
+ class ComposeDocument(BaseModel):
47
+ model_config = ConfigDict(extra="allow")
48
+
49
+ services: dict[str, ComposeService] = Field(default_factory=dict)
50
+ configs: dict[str, ComposeConfig] | None = None
51
+ networks: dict[str, Yaml] | None = None
52
+
53
+ @model_validator(mode="after")
54
+ def _reject_colliding_bind_basenames(self) -> Self:
55
+ seen: dict[str, str] = {}
56
+ for service in self.services.values():
57
+ for volume in service.bind_volumes:
58
+ source = volume.source
59
+ known = seen.setdefault(Path(source).name, source)
60
+ if known != source:
61
+ raise ValueError(f"bind sources {known} and {source} collide on config name {Path(source).name}")
62
+ return self
63
+
64
+ def dump(self) -> Yaml:
65
+ return self.model_dump(exclude_none=True)
66
+
67
+
68
+ class ComposeService(BaseModel):
69
+ model_config = ConfigDict(extra="allow")
70
+
71
+ volumes: list[BindVolume | OtherVolume] | None = None
72
+ depends_on: dict[str, Yaml] | list[str] | None = None
73
+ configs: list[ComposeConfigReference] | None = None
74
+ image: str | None = None
75
+ networks: dict[str, Yaml] | None = None
76
+
77
+ @property
78
+ def bind_volumes(self) -> list[BindVolume]:
79
+ return [volume for volume in self.volumes or [] if isinstance(volume, BindVolume)]
80
+
81
+ def strip_bind_volumes(self) -> list[BindVolume]:
82
+ binds = self.bind_volumes
83
+ self.volumes = [volume for volume in self.volumes or [] if not isinstance(volume, BindVolume)] or None
84
+ return binds
85
+
86
+
87
+ class ComposeConfigReference(BaseModel):
88
+ model_config = ConfigDict(extra="allow")
89
+
90
+ source: str
91
+ target: str
92
+
93
+
94
+ class BindVolume(BaseModel):
95
+ model_config = ConfigDict(extra="allow")
96
+
97
+ type: Literal["bind"]
98
+ source: str
99
+ target: str
100
+
101
+ @field_validator("source")
102
+ @classmethod
103
+ def _require_relative(cls, value: str) -> str:
104
+ if Path(value).is_absolute():
105
+ raise ValueError(f"bind source {value} must be relative")
106
+ return value
107
+
108
+
109
+ class OtherVolume(BaseModel):
110
+ model_config = ConfigDict(extra="allow")
111
+
112
+ type: str
113
+
114
+ @model_validator(mode="after")
115
+ def _reject_bind(self) -> Self:
116
+ if self.type == "bind":
117
+ raise ValueError("a bind volume is missing its source or target")
118
+ return self
119
+
120
+
121
+ class ComposeConfig(BaseModel):
122
+ model_config = ConfigDict(extra="allow")
123
+
124
+ file: str | None = None
125
+ content: str | None = None
126
+
127
+
128
+ def generate_compose(
129
+ config: ConfigFile, lock: LockFile, service_shas: dict[str, str], secrets: SecretsDeclaration
130
+ ) -> list[Path]:
131
+ score_dir = config.root / config.score_dir
132
+ state_prefix = f".{TOOL}/"
133
+ values_path = config.root / VALUES_FILE
134
+ platform = ValuesFile.load(values_path, config).platform if values_path.is_file() else None
135
+ images = lock.manifest_references(platform)
136
+
137
+ if "ALPINE_IMAGE" not in images:
138
+ raise ValueError(f"{LOCK_PATH.as_posix()}: the image lock must carry the ALPINE_IMAGE entry")
139
+
140
+ outputs: list[Path] = []
141
+ consumed: set[str] = set()
142
+ for name, entry in config.local_service_entries():
143
+ workloads = load_workloads(score_dir, entry.workloads, ComposeWorkloadDocument)
144
+
145
+ output_path = compose_bundle_output_path(config.root, name)
146
+ tool_state = output_path.parent / f".{TOOL}"
147
+
148
+ # Render the bundle from a clean state dir
149
+ shutil.rmtree(tool_state, ignore_errors=True)
150
+ render_score(
151
+ config,
152
+ substrate="compose",
153
+ workloads=workloads,
154
+ rendering_vars=entry.vars,
155
+ templates=[
156
+ HEALTHCHECK_TEMPLATE,
157
+ RESTART_TEMPLATE,
158
+ *(
159
+ template
160
+ for variant, template in GPU_COMPOSE_TEMPLATES.items()
161
+ if variant == entry.vars.get("GPU_VARIANT", "")
162
+ ),
163
+ ],
164
+ output=output_path,
165
+ images=images,
166
+ service_shas=service_shas,
167
+ )
168
+
169
+ # Parse the rendering into the typed document
170
+ document = load_yaml_model(ComposeDocument, output_path)
171
+
172
+ # Attribute rendered container services to their owning workloads
173
+ services_map: dict[str, tuple[ComposeService, WorkloadDocument | None, str]] = {
174
+ (service_name := f"{Path(workload.file_name).stem}-{container_name}"): (
175
+ document.services[service_name],
176
+ workload.document,
177
+ container_name,
178
+ )
179
+ for workload in workloads
180
+ for container_name in workload.document.containers
181
+ }
182
+
183
+ # Carry workload-less services through under their rendered names
184
+ for service_name, service in document.services.items():
185
+ if service_name in services_map:
186
+ continue
187
+
188
+ # Pin the barrier image to the lock slot
189
+ if service_name == BARRIER_SERVICE:
190
+ barrier_service = document.services[BARRIER_SERVICE]
191
+ barrier_service.image = "${ALPINE_IMAGE:?err}"
192
+ services_map[BARRIER_SERVICE] = (barrier_service, None, BARRIER_SERVICE)
193
+ continue
194
+
195
+ services_map[service_name] = (service, None, service_name)
196
+
197
+ # Refuse a collapsed name claimed twice
198
+ collapsed_names = [collapsed_name for _service, _workload, collapsed_name in services_map.values()]
199
+ if len(collapsed_names) != len(set(collapsed_names)):
200
+ duplicates = sorted({collapsed for collapsed in collapsed_names if collapsed_names.count(collapsed) > 1})
201
+ raise ValueError(f"the service names {duplicates} are each claimed by more than one rendered service")
202
+
203
+ # Namespace every service under the entry with its bare name as a network alias
204
+ network = f"{name}-net"
205
+ collapsed = set(collapsed_names)
206
+ document.services = {}
207
+ document.networks = {network: {}}
208
+ for service, workload, collapsed_name in services_map.values():
209
+ document.services[f"{name}-{collapsed_name}"] = service
210
+ service.networks = {network: {"aliases": [collapsed_name]}}
211
+
212
+ # Namespace dependency keys that name rendered services of this bundle
213
+ depends_on = service.depends_on
214
+ if isinstance(depends_on, dict):
215
+ service.depends_on = {
216
+ f"{name}-{dependency}" if dependency in collapsed else dependency: condition
217
+ for dependency, condition in depends_on.items()
218
+ }
219
+ elif isinstance(depends_on, list):
220
+ service.depends_on = [
221
+ f"{name}-{dependency}" if dependency in collapsed else dependency for dependency in depends_on
222
+ ]
223
+
224
+ # Inline every bind volume as a top-level configs entry
225
+ bind_volumes = service.strip_bind_volumes()
226
+ if not bind_volumes:
227
+ continue
228
+
229
+ # Ensure the configs dict exists and the service has a configs list
230
+ if document.configs is None:
231
+ document.configs = {}
232
+ if service.configs is None:
233
+ service.configs = []
234
+
235
+ for bind_volume in bind_volumes:
236
+ # Resolve state-dir sources against the tool's working directory, others against root
237
+ path = (
238
+ output_path.parent / bind_volume.source
239
+ if bind_volume.source.startswith(state_prefix)
240
+ else config.root / bind_volume.source
241
+ )
242
+
243
+ # Double every $ except on targets the workload declared consumer-interpolated
244
+ text = path.read_text(encoding="utf-8")
245
+ if workload and bind_volume.target not in workload.consumer_interpolated_targets:
246
+ text = double_dollars(text)
247
+
248
+ document.configs[f"{name}-{path.name}"] = ComposeConfig(content=text)
249
+ service.configs.append(ComposeConfigReference(source=f"{name}-{path.name}", target=bind_volume.target))
250
+
251
+ # Scan consumed secrets and namespace the lock-key slots in one pass
252
+ text = dump_yaml(document.dump())
253
+ namespaced: list[str] = []
254
+ cursor = 0
255
+ for match in COMPOSE_SLOT.finditer(text):
256
+ namespaced.append(text[cursor : match.start()])
257
+ cursor = match.end()
258
+ if len(match.group(1)) % 2 == 1:
259
+ slot = match.group(2)
260
+ if slot in images:
261
+ namespaced.append(f"{match.group(1)}{{{env_lock_slot(name, slot)}{match.group(3) or ''}}}")
262
+ continue
263
+ consumed.add(slot)
264
+ namespaced.append(match.group(0))
265
+ namespaced.append(text[cursor:])
266
+ write_file(output_path, "".join(namespaced))
267
+ outputs.append(output_path)
268
+
269
+ # Remove the entry's compose state dir
270
+ shutil.rmtree(tool_state, ignore_errors=True)
271
+
272
+ secrets.validate_consumed(consumed, "compose", SECRETS_FILE.as_posix())
273
+
274
+ return outputs
boatyard/score_k8s.py ADDED
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from tempfile import TemporaryDirectory
6
+
7
+ import yaml
8
+ from bashrun.bash import bash_output
9
+ from pydantic import (
10
+ BaseModel,
11
+ ConfigDict,
12
+ Field,
13
+ )
14
+
15
+ from boatyard.config.config import (
16
+ K8sConfig,
17
+ WorkloadDocument,
18
+ Yaml,
19
+ ConfigFile,
20
+ chart_lock_key,
21
+ is_list,
22
+ is_mapping,
23
+ )
24
+ from boatyard.config.lock import LockFile
25
+ from boatyard.markers import MarkerContext, resolve_markers
26
+ from boatyard.score import (
27
+ SECRETS_FILE,
28
+ SecretsDeclaration,
29
+ dump_yaml,
30
+ load_workloads,
31
+ render_score,
32
+ split_documents,
33
+ write_file,
34
+ )
35
+
36
+ GPU_POD_TEMPLATE = "gpu-pod.tpl"
37
+ SCALE_TO_ZERO_TEMPLATE = "scale-to-zero.tpl"
38
+
39
+ STORAGE_CLASS_VAR = "SCORE_STORAGE_CLASS"
40
+ CLOUD_STORAGE_CLASS = "hcloud-volumes"
41
+ LOCAL_STORAGE_CLASS = "local-path"
42
+
43
+
44
+ class ManifestMetadata(BaseModel):
45
+ model_config = ConfigDict(extra="ignore")
46
+
47
+ namespace: str = ""
48
+ name: str = ""
49
+
50
+
51
+ class ManifestDocument(BaseModel):
52
+ model_config = ConfigDict(extra="ignore")
53
+
54
+ kind: str = ""
55
+ metadata: ManifestMetadata = Field(default_factory=ManifestMetadata)
56
+
57
+
58
+ def generate_k8s(
59
+ config: ConfigFile,
60
+ k8s: K8sConfig,
61
+ lock: LockFile,
62
+ service_shas: dict[str, str],
63
+ local: bool,
64
+ secrets: SecretsDeclaration,
65
+ ) -> Path:
66
+ score_dir = config.root / config.score_dir
67
+ images = lock.index_references()
68
+ workloads = load_workloads(score_dir, k8s.workloads, WorkloadDocument)
69
+
70
+ output_path = config.root / (config.k8s_local_output if local else config.k8s_output)
71
+ tool_state = output_path.parent / ".score-k8s"
72
+
73
+ render_score(
74
+ config,
75
+ substrate="k8s",
76
+ workloads=workloads,
77
+ rendering_vars={**k8s.vars, STORAGE_CLASS_VAR: LOCAL_STORAGE_CLASS if local else CLOUD_STORAGE_CLASS},
78
+ templates=[GPU_POD_TEMPLATE, SCALE_TO_ZERO_TEMPLATE],
79
+ output=output_path,
80
+ images=images,
81
+ service_shas=service_shas,
82
+ )
83
+
84
+ # Render the charts through helm template.
85
+ chart_blocks: list[str] = []
86
+ with TemporaryDirectory(prefix="resolved-values-") as values_directory:
87
+ values_dir = Path(values_directory)
88
+ for chart_name in k8s.charts:
89
+ # Every values file rides through marker resolution, so the literal-image guard is universal.
90
+ path = config.root / config.score_dir / "k8s" / "values" / f"{chart_name}.yaml"
91
+ values_path = values_dir / path.relative_to(config.root)
92
+ values_path.parent.mkdir(parents=True, exist_ok=True)
93
+ write_file(
94
+ values_path,
95
+ dump_yaml(
96
+ resolve_markers(
97
+ yaml.safe_load(path.read_text(encoding="utf-8")),
98
+ MarkerContext(config.root, images, path.as_posix()),
99
+ )
100
+ ),
101
+ )
102
+
103
+ # Render the chart through helm template with the values file and the locked chart reference.
104
+ chart_blocks.extend(
105
+ split_documents(
106
+ bash_output(
107
+ f"helm template {chart_name} oci://"
108
+ f"{lock.require(chart_lock_key(chart_name), f'chart {chart_name!r}')}"
109
+ f" --values {values_path.as_posix()}"
110
+ )
111
+ )
112
+ )
113
+
114
+ # Append the rendered charts to the k8s output, preserving any existing content.
115
+ write_file(
116
+ output_path,
117
+ output_path.read_text(encoding="utf-8") + "".join(f"---\n{block}" for block in chart_blocks),
118
+ )
119
+
120
+ # Sort documents by kind, namespace, and name.
121
+ decorated: list[tuple[str, str, str, int, str]] = []
122
+ for index, block in enumerate(split_documents(output_path.read_text(encoding="utf-8"))):
123
+ manifest = ManifestDocument.model_validate(yaml.safe_load(block) or {})
124
+ decorated.append((manifest.kind, manifest.metadata.namespace, manifest.metadata.name, index, block))
125
+ decorated.sort()
126
+ write_file(output_path, "".join(f"---\n{block}" for *_, block in decorated))
127
+
128
+ shutil.rmtree(tool_state, ignore_errors=True)
129
+
130
+ # Validate the consumed secrets against the declaration.
131
+ consumed: set[str] = set()
132
+ for block in split_documents(output_path.read_text(encoding="utf-8")):
133
+ collect_secret_keys(yaml.safe_load(block), f"{config.project_name}-secrets", consumed)
134
+ secrets.validate_consumed(consumed, "k8s", SECRETS_FILE.as_posix())
135
+
136
+ return output_path
137
+
138
+
139
+ def collect_secret_keys(node: Yaml, secret_name: str, consumed: set[str]) -> None:
140
+ if is_mapping(node):
141
+ reference = node.get("secretKeyRef")
142
+ if is_mapping(reference) and reference.get("name") == secret_name:
143
+ key = reference.get("key")
144
+ if isinstance(key, str):
145
+ consumed.add(key)
146
+
147
+ for value in node.values():
148
+ collect_secret_keys(value, secret_name, consumed)
149
+
150
+ elif is_list(node):
151
+ for item in node:
152
+ collect_secret_keys(item, secret_name, consumed)