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/__init__.py ADDED
File without changes
boatyard/build.py ADDED
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import re
7
+ from pathlib import Path
8
+ from subprocess import CalledProcessError
9
+ from tempfile import TemporaryDirectory
10
+ from typing import Self
11
+
12
+ import typer
13
+ import yaml
14
+ from bashrun.bash import bash, bash_check, bash_output
15
+ from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator
16
+
17
+ from boatyard import dockerfiles
18
+ from boatyard.config.config import ConfigFile, Yaml
19
+ from boatyard.dockerfiles import ServiceBuild, service_sha_env
20
+ from boatyard.lock import relock
21
+
22
+
23
+ METADATA_PATH = Path("metadata.json")
24
+
25
+ MIN_KEEP_STORAGE_GB = 60
26
+
27
+ type BakeValue = str | list[str] | dict[str, str]
28
+
29
+
30
+ class BakeMetadata(RootModel[dict[str, "BakedImage"]]):
31
+ @model_validator(mode="after")
32
+ def _require_tagged_references(self) -> Self:
33
+ for target, image in self.root.items():
34
+ if not image.tags:
35
+ raise ValueError(f"target '{target}' must produce tagged image references")
36
+ return self
37
+
38
+
39
+ class BakedImage(BaseModel):
40
+ model_config = ConfigDict(extra="ignore")
41
+
42
+ image_name: str = Field(default="", alias="image.name")
43
+ tags: list[str] = Field(default_factory=list)
44
+
45
+ @model_validator(mode="after")
46
+ def _extract_references(self) -> Self:
47
+ for reference in self.image_name.split(","):
48
+ reference = reference.strip()
49
+ if not reference:
50
+ continue
51
+ _repository, separator, tag = reference.split("@", 1)[0].rpartition(":")
52
+ if separator:
53
+ self.tags.append(tag)
54
+ return self
55
+
56
+
57
+ class _DaemonGc(BaseModel):
58
+ model_config = ConfigDict(extra="ignore")
59
+
60
+ gigabytes: float | None = Field(default=None, alias="defaultKeepStorage")
61
+
62
+ @field_validator("gigabytes", mode="before")
63
+ @classmethod
64
+ def _measure(cls, value: Yaml) -> Yaml:
65
+ if value is None:
66
+ return None
67
+ match = re.match(r"^(\d+(?:\.\d+)?)\s*([TGMK]i?B)?$", str(value), re.IGNORECASE)
68
+ if match is None:
69
+ raise ValueError(f"defaultKeepStorage value {value!r} must be a number with an optional unit (60GB, 1TiB)")
70
+ unit = (match.group(2) or "B")[0].upper()
71
+ multipliers = {"T": 1024, "G": 1, "M": 1 / 1024, "K": 1 / 1024**2, "B": 1 / 1024**3}
72
+ return float(match.group(1)) * multipliers[unit]
73
+
74
+
75
+ class _DaemonBuilder(BaseModel):
76
+ model_config = ConfigDict(extra="ignore")
77
+
78
+ gc: _DaemonGc = Field(default_factory=_DaemonGc)
79
+
80
+
81
+ class _DaemonConfig(BaseModel):
82
+ model_config = ConfigDict(extra="ignore")
83
+
84
+ builder: _DaemonBuilder = Field(default_factory=_DaemonBuilder)
85
+
86
+
87
+ def run_build(
88
+ *,
89
+ config: ConfigFile,
90
+ push: bool = False,
91
+ push_to: str | None = None,
92
+ targets: list[str] | None = None,
93
+ no_cache: bool = False,
94
+ ) -> None:
95
+ pushing = push or push_to is not None
96
+ destination = push_to
97
+
98
+ service_shas = dockerfiles.compute_service_shas(config.root, config.service_builds)
99
+ os.environ.update(service_shas)
100
+
101
+ if not config.service_builds:
102
+ raise ValueError("no Dockerfile service derives under this repo; the lock verb owns relocking declarations")
103
+
104
+ # Check Docker GC limits.
105
+ if not pushing and not targets:
106
+ check_gc_limits()
107
+
108
+ host = host_platform()
109
+
110
+ if targets:
111
+ resolved_targets: list[str] = []
112
+ for target in targets:
113
+ # Resolve each address to a target.
114
+ if "/" in target:
115
+ service_name, _, variant = target.partition("/")
116
+ service = config.service_builds.get(service_name)
117
+ if service is None:
118
+ raise typer.BadParameter(
119
+ f"Unknown target {target!r}. Available: {_address_vocabulary(config.service_builds)}"
120
+ )
121
+ if not service.variants:
122
+ raise typer.BadParameter(f"{service_name} declares no variants")
123
+ if variant not in service.variants:
124
+ raise typer.BadParameter(
125
+ f"Unknown variant {variant!r} for {service_name}; it declares the variants {service.variants}"
126
+ )
127
+ resolved_targets.append(f"{service_name}-{variant}")
128
+ elif target in config.service_builds:
129
+ service = config.service_builds[target]
130
+ if service.variants:
131
+ raise typer.BadParameter(
132
+ f"{target} declares the variants {service.variants}; spell {target}/<variant>"
133
+ )
134
+ resolved_targets.append(target)
135
+ else:
136
+ raise typer.BadParameter(
137
+ f"Unknown target {target!r}. Available: {_address_vocabulary(config.service_builds)}"
138
+ )
139
+ else:
140
+ # Select the default targets for this machine's arch.
141
+ defaults = {service.target_variants[0][0] for service in config.service_builds.values()}
142
+ resolved_targets = sorted(defaults) if not config.platforms or host in config.platforms else []
143
+ if not resolved_targets:
144
+ raise ValueError(
145
+ f"No service targets this machine's arch ({host}); "
146
+ f"other arches require --targets. Services: {_address_vocabulary(config.service_builds)}"
147
+ )
148
+
149
+ # The incremental lock write rides every building run; bake reads the fresh entries as environment.
150
+ os.environ.update(relock(config).index_references())
151
+
152
+ # Render the bake services for every target.
153
+ namespace = destination if destination is not None else config.repository
154
+ services: dict[str, dict[str, dict[str, BakeValue]]] = {}
155
+ for service in config.service_builds.values():
156
+ for target, variant in service.target_variants:
157
+ args: dict[str, str] = {key: f"${{{key}:-}}" for key in config.base_images}
158
+ axis = service.axis
159
+ if variant is not None and axis is not None:
160
+ args[axis] = variant
161
+ args.update({sha: f"${{{sha}:?err}}" for sha in service.sha_args})
162
+ build: dict[str, BakeValue] = {"dockerfile": service.dockerfile}
163
+ if config.platforms:
164
+ # Collapse multi-arch declarations to the host arch in load mode.
165
+ build["platforms"] = [host] if not pushing and len(config.platforms) > 1 else list(config.platforms)
166
+ build["args"] = args
167
+ build["tags"] = [
168
+ f"{namespace}/{target}:latest",
169
+ f"{namespace}/{target}:${{{service_sha_env(service.name)}}}",
170
+ ]
171
+ if service.contexts:
172
+ build["additional_contexts"] = {
173
+ context: f"target:{context}-{variant}" if variant is not None else f"target:{context}"
174
+ for context in service.contexts
175
+ }
176
+ if pushing:
177
+ cache = f"{namespace}/build-cache"
178
+ build["cache_to"] = [
179
+ f"type=registry,ref={cache}:{target},mode=max,image-manifest=true,oci-mediatypes=true"
180
+ ]
181
+ build["cache_from"] = [f"type=registry,ref={cache}:{target}"]
182
+ services[target] = {"build": build}
183
+ rendered = yaml.safe_dump({"services": services}, sort_keys=False)
184
+
185
+ # Drop stale metadata from a previous run.
186
+ METADATA_PATH.unlink(missing_ok=True)
187
+
188
+ command_arguments: list[str] = [
189
+ "--progress auto",
190
+ "--provenance=false",
191
+ "--sbom=false",
192
+ "--push" if pushing else "--load",
193
+ ]
194
+
195
+ if no_cache:
196
+ command_arguments.append("--no-cache")
197
+
198
+ command_arguments.extend(resolved_targets)
199
+
200
+ with TemporaryDirectory(prefix="bake-") as bake_dir:
201
+ bake_file = Path(bake_dir) / "bake.yaml"
202
+ bake_file.write_text(rendered, encoding="utf-8")
203
+ # Re-raise a failed bake with the rendered content attached.
204
+ try:
205
+ bash(
206
+ " ".join([
207
+ "docker buildx bake",
208
+ f"-f {bake_file}",
209
+ f"--metadata-file {METADATA_PATH}",
210
+ *command_arguments,
211
+ ])
212
+ )
213
+ except CalledProcessError as error:
214
+ raise ValueError(
215
+ f"docker buildx bake failed (exit {error.returncode}); rendered bake file:\n{rendered}"
216
+ ) from error
217
+
218
+ metadata_json: dict[str, Yaml] = (
219
+ json.loads(METADATA_PATH.read_text(encoding="utf-8")) if METADATA_PATH.exists() else {}
220
+ )
221
+ baked = BakeMetadata.model_validate(metadata_json)
222
+ if not set(resolved_targets) <= baked.root.keys():
223
+ raise ValueError("Bake output does not cover the requested targets")
224
+
225
+
226
+ def check_gc_limits(candidates: list[Path] | None = None) -> None:
227
+ # Probe the system file, the user file, then the Windows home under WSL.
228
+ if candidates is None:
229
+ candidates = [Path("/etc/docker/daemon.json"), Path(os.path.expanduser("~/.docker/daemon.json"))]
230
+ wslpath = 'wslpath $(cmd.exe /c "echo %UserProfile%" 2>/dev/null)'
231
+ if os.getenv("WSL_DISTRO_NAME") is not None and bash_check(wslpath):
232
+ win_home = bash_output(wslpath).strip()
233
+ candidates.append(Path(win_home) / ".docker" / "daemon.json")
234
+
235
+ config = next((path for path in candidates if os.access(path, os.R_OK)), None)
236
+ if config is None:
237
+ print(f" [WARN] No readable daemon.json found; defaultKeepStorage must be >= {MIN_KEEP_STORAGE_GB}GB.")
238
+ return
239
+
240
+ daemon = _DaemonConfig.model_validate(json.loads(config.read_text(encoding="utf-8")))
241
+ gigabytes = daemon.builder.gc.gigabytes
242
+ if gigabytes is None:
243
+ sample = json.dumps({"builder": {"gc": {"defaultKeepStorage": f"{MIN_KEEP_STORAGE_GB}GB"}}}, indent=2)
244
+ raise ValueError(
245
+ f"builder.gc.defaultKeepStorage missing in {config}; must be >= {MIN_KEEP_STORAGE_GB}GB:\n\n{sample}"
246
+ )
247
+ if gigabytes < MIN_KEEP_STORAGE_GB:
248
+ raise ValueError(f"Docker GC limit {gigabytes:.1f}GB in {config} is below the required {MIN_KEEP_STORAGE_GB}GB")
249
+
250
+ print(f" [OK] Docker GC Limit verified: {gigabytes:.1f}GB")
251
+
252
+
253
+ def host_platform() -> str:
254
+ machine = platform.machine().lower()
255
+ if machine in ("x86_64", "amd64"):
256
+ return "linux/amd64"
257
+ if machine in ("aarch64", "arm64"):
258
+ return "linux/arm64"
259
+ raise ValueError(f"Unsupported host architecture: {machine}")
260
+
261
+
262
+ def _address_vocabulary(services: dict[str, ServiceBuild]) -> list[str]:
263
+ addresses: list[str] = []
264
+ for name in sorted(services):
265
+ addresses.append(name)
266
+ addresses.extend(f"{name}/{variant}" for variant in services[name].variants)
267
+ return addresses
boatyard/cli.py ADDED
@@ -0,0 +1,23 @@
1
+ import typer
2
+
3
+ from boatyard.verbs.build import app as build_app
4
+ from boatyard.verbs.down import app as down_app
5
+ from boatyard.verbs.lock import app as lock_app
6
+ from boatyard.verbs.matrix import app as matrix_app
7
+ from boatyard.verbs.mirror import app as mirror_app
8
+ from boatyard.verbs.publish import app as publish_app
9
+ from boatyard.verbs.score import app as score_app
10
+ from boatyard.verbs.tree_tags import app as tree_tags_app
11
+ from boatyard.verbs.up import app as up_app
12
+
13
+ app = typer.Typer()
14
+
15
+ app.add_typer(up_app)
16
+ app.add_typer(down_app)
17
+ app.add_typer(build_app)
18
+ app.add_typer(lock_app)
19
+ app.add_typer(tree_tags_app)
20
+ app.add_typer(mirror_app)
21
+ app.add_typer(publish_app)
22
+ app.add_typer(score_app)
23
+ app.add_typer(matrix_app)
boatyard/compose.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ from collections.abc import Iterable
5
+ from pathlib import Path
6
+
7
+
8
+ def compose_command(
9
+ root: Path,
10
+ compose_file: Path,
11
+ *arguments: str,
12
+ env_file: Path | None = None,
13
+ slot_names: Iterable[str] = (),
14
+ ) -> str:
15
+ # Spell the one docker compose invocation; the env -u scrub defeats compose's
16
+ # shell-beats-env-file precedence for every slot name.
17
+ quoted_arguments = [shlex.quote(argument) for argument in arguments]
18
+ files_argument = f"-f {shlex.quote(str(compose_file))}"
19
+ env_argument = f" --env-file {shlex.quote(str(env_file))}" if env_file is not None else ""
20
+ scrub = "".join(f" -u {shlex.quote(name)}" for name in sorted(set(slot_names)))
21
+ prefix = f"docker compose {files_argument} --project-directory {shlex.quote(str(root))}{env_argument}"
22
+ if scrub:
23
+ prefix = f"env{scrub} " + prefix
24
+ return " ".join([prefix, *quoted_arguments])
File without changes