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/__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
|