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.
- boatyard/__init__.py +0 -0
- boatyard/build.py +267 -0
- boatyard/cli.py +23 -0
- boatyard/compose.py +24 -0
- boatyard/config/__init__.py +0 -0
- boatyard/config/config.py +787 -0
- boatyard/config/lock.py +152 -0
- boatyard/config/values.py +188 -0
- boatyard/crane.py +23 -0
- boatyard/dockerfiles.py +201 -0
- boatyard/lock.py +233 -0
- boatyard/markers.py +201 -0
- boatyard/paths.py +5 -0
- boatyard/publish.py +72 -0
- boatyard/py.typed +0 -0
- boatyard/score.py +267 -0
- boatyard/score_compose.py +274 -0
- boatyard/score_k8s.py +152 -0
- boatyard/score_patch_templates/gpu-cuda.tpl +24 -0
- boatyard/score_patch_templates/gpu-pod.tpl +43 -0
- boatyard/score_patch_templates/gpu-rocm.tpl +30 -0
- boatyard/score_patch_templates/probe-adapter-healthchecks.tpl +55 -0
- boatyard/score_patch_templates/restart.tpl +21 -0
- boatyard/score_patch_templates/scale-to-zero.tpl +30 -0
- boatyard/temp.py +5 -0
- boatyard/verbs/__init__.py +0 -0
- boatyard/verbs/build.py +54 -0
- boatyard/verbs/down.py +39 -0
- boatyard/verbs/lock.py +34 -0
- boatyard/verbs/matrix.py +57 -0
- boatyard/verbs/mirror.py +50 -0
- boatyard/verbs/publish.py +138 -0
- boatyard/verbs/score.py +161 -0
- boatyard/verbs/tree_tags.py +35 -0
- boatyard/verbs/up.py +265 -0
- boatyard/yaml.py +34 -0
- boatyard-0.6.0.dev170.dist-info/METADATA +12 -0
- boatyard-0.6.0.dev170.dist-info/RECORD +42 -0
- boatyard-0.6.0.dev170.dist-info/WHEEL +4 -0
- boatyard-0.6.0.dev170.dist-info/entry_points.txt +2 -0
- boatyard-0.6.0.dev170.dist-info/licenses/LICENSE +201 -0
- 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)
|