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/config/lock.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, model_validator
|
|
8
|
+
|
|
9
|
+
from boatyard.config.config import ConfigFile, bundle_lock_key, chart_lock_key
|
|
10
|
+
from boatyard.config.values import PLATFORM_KEY
|
|
11
|
+
from boatyard.paths import LOCK_PATH, VALUES_FILE
|
|
12
|
+
from boatyard.temp import PLATFORM_PATTERN, SHA256_DIGEST_REGEX
|
|
13
|
+
from boatyard.yaml import load_yaml_model
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LockFile(BaseModel):
|
|
17
|
+
model_config = ConfigDict(extra="forbid")
|
|
18
|
+
|
|
19
|
+
platforms: list[str] = Field(default_factory=list)
|
|
20
|
+
images: dict[str, ConfigLockEntry] = Field(default_factory=dict)
|
|
21
|
+
charts: dict[str, str] = Field(default_factory=dict)
|
|
22
|
+
bundles: dict[str, ConfigLockBundle] = Field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
@model_validator(mode="after")
|
|
25
|
+
def _validate(self, info: ValidationInfo) -> Self:
|
|
26
|
+
context: dict[str, object] = info.context or {}
|
|
27
|
+
source = str(context.get("path", "the image lock"))
|
|
28
|
+
for name, reference in self.charts.items():
|
|
29
|
+
if not _digest_pinned(reference):
|
|
30
|
+
raise ValueError(f"{source}: chart {name} is not digest-pinned ({reference!r})")
|
|
31
|
+
shared = sorted(
|
|
32
|
+
(self.images.keys() & self.charts.keys())
|
|
33
|
+
| (self.images.keys() & self.bundles.keys())
|
|
34
|
+
| (self.charts.keys() & self.bundles.keys())
|
|
35
|
+
)
|
|
36
|
+
if shared:
|
|
37
|
+
raise ValueError(f"{source}: names {shared} must appear under at most one of images, charts, bundles")
|
|
38
|
+
config = context.get("config")
|
|
39
|
+
if isinstance(config, ConfigFile):
|
|
40
|
+
self._require_paired(config, source)
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def _require_paired(self, config: ConfigFile, source: str) -> None:
|
|
44
|
+
recorded = sorted(self.platforms)
|
|
45
|
+
declared = sorted(config.platforms)
|
|
46
|
+
if recorded != declared:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"{source}: records platforms {recorded} but boatyard.yml declares {declared} (run boatyard lock)"
|
|
49
|
+
)
|
|
50
|
+
for name, entry in self.images.items():
|
|
51
|
+
if sorted(entry.platforms) != recorded:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"{source}: image entry {name} records platforms {sorted(entry.platforms)} "
|
|
54
|
+
f"but the lock records {recorded} (run boatyard lock)"
|
|
55
|
+
)
|
|
56
|
+
missing_images = sorted(set(config.base_images) - self.images.keys())
|
|
57
|
+
if missing_images:
|
|
58
|
+
raise ValueError(f"{source}: lacks the {missing_images} entries base_images declares (run boatyard lock)")
|
|
59
|
+
missing_charts = sorted({chart_lock_key(chart) for chart in config.declared_charts} - self.charts.keys())
|
|
60
|
+
if missing_charts:
|
|
61
|
+
raise ValueError(f"{source}: lacks the {missing_charts} entries k8s.charts declares (run boatyard lock)")
|
|
62
|
+
declared_names = (
|
|
63
|
+
set(config.base_images)
|
|
64
|
+
| {chart_lock_key(chart) for chart in config.declared_charts}
|
|
65
|
+
| {bundle_lock_key(name) for name, _reference, _entry in config.remote_declared_entries()}
|
|
66
|
+
)
|
|
67
|
+
orphans = sorted((self.images.keys() | self.charts.keys() | self.bundles.keys()) - declared_names)
|
|
68
|
+
if orphans:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"{source}: records entries boatyard.yml no longer declares ({orphans}); run boatyard lock"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def load(cls, path: Path, config: ConfigFile) -> LockFile:
|
|
75
|
+
context: dict[str, object] = {"path": path.as_posix(), "config": config}
|
|
76
|
+
if not path.is_file():
|
|
77
|
+
# Every consumer reads a missing lock as the empty lock paired against the config.
|
|
78
|
+
return cls.model_validate({}, context=context)
|
|
79
|
+
return load_yaml_model(cls, path, context)
|
|
80
|
+
|
|
81
|
+
def require(self, name: str, subject: str) -> str:
|
|
82
|
+
if name in self.images:
|
|
83
|
+
return self.images[name].index
|
|
84
|
+
if name in self.charts:
|
|
85
|
+
return self.charts[name]
|
|
86
|
+
if name in self.bundles:
|
|
87
|
+
return self.bundles[name].reference
|
|
88
|
+
raise ValueError(f"{subject}: {LOCK_PATH.as_posix()} lacks the {name} entry (run boatyard lock)")
|
|
89
|
+
|
|
90
|
+
def index_references(self) -> dict[str, str]:
|
|
91
|
+
return {**{name: entry.index for name, entry in self.images.items()}, **self.charts}
|
|
92
|
+
|
|
93
|
+
def manifest_references(self, platform: str | None) -> dict[str, str]:
|
|
94
|
+
resolved: str | None
|
|
95
|
+
if platform is None:
|
|
96
|
+
if not self.platforms:
|
|
97
|
+
resolved = None
|
|
98
|
+
elif len(self.platforms) == 1:
|
|
99
|
+
resolved = self.platforms[0]
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"platforms {self.platforms} are recorded; {PLATFORM_KEY}: must be named in {VALUES_FILE.name}"
|
|
103
|
+
)
|
|
104
|
+
elif self.platforms and platform not in self.platforms:
|
|
105
|
+
raise ValueError(f"{PLATFORM_KEY}: {platform!r} must be one of the recorded platforms {self.platforms}")
|
|
106
|
+
else:
|
|
107
|
+
resolved = platform
|
|
108
|
+
if resolved is None:
|
|
109
|
+
return self.index_references()
|
|
110
|
+
return {**{name: entry.platforms[resolved] for name, entry in self.images.items()}, **self.charts}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ConfigLockEntry(BaseModel):
|
|
114
|
+
model_config = ConfigDict(extra="forbid")
|
|
115
|
+
|
|
116
|
+
index: str
|
|
117
|
+
platforms: dict[str, str] = Field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
@model_validator(mode="after")
|
|
120
|
+
def _require_digest_pins(self, info: ValidationInfo) -> Self:
|
|
121
|
+
context: dict[str, object] = info.context or {}
|
|
122
|
+
source = str(context.get("path", "the image lock"))
|
|
123
|
+
for site, reference in [("index", self.index), *self.platforms.items()]:
|
|
124
|
+
if not _digest_pinned(reference):
|
|
125
|
+
raise ValueError(f"{source}: entry field {site} is not digest-pinned ({reference!r})")
|
|
126
|
+
for platform in self.platforms:
|
|
127
|
+
if PLATFORM_PATTERN.fullmatch(platform) is None:
|
|
128
|
+
raise ValueError(f"{source}: platform key {platform!r} must be an os/arch pair like linux/arm64")
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def recorded_digests(self) -> list[str]:
|
|
133
|
+
return [self.index, *self.platforms.values()]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ConfigLockBundle(BaseModel):
|
|
137
|
+
model_config = ConfigDict(extra="forbid")
|
|
138
|
+
|
|
139
|
+
reference: str
|
|
140
|
+
requires: list[str] = Field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
@model_validator(mode="after")
|
|
143
|
+
def _require_digest_pin(self, info: ValidationInfo) -> Self:
|
|
144
|
+
context: dict[str, object] = info.context or {}
|
|
145
|
+
source = str(context.get("path", "the image lock"))
|
|
146
|
+
if not _digest_pinned(self.reference):
|
|
147
|
+
raise ValueError(f"{source}: bundle entry is not digest-pinned ({self.reference!r})")
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _digest_pinned(reference: str) -> bool:
|
|
152
|
+
return re.fullmatch(SHA256_DIGEST_REGEX, reference.rpartition("@")[2]) is not None
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator, model_validator
|
|
7
|
+
|
|
8
|
+
from boatyard.config.config import (
|
|
9
|
+
ENV_NAME_PATTERN,
|
|
10
|
+
LOCK_SLOT_MARKER,
|
|
11
|
+
ConfigFile,
|
|
12
|
+
Yaml,
|
|
13
|
+
is_mapping,
|
|
14
|
+
qualified_option_name,
|
|
15
|
+
)
|
|
16
|
+
from boatyard.yaml import load_yaml_model
|
|
17
|
+
from boatyard.paths import VALUES_FILE
|
|
18
|
+
|
|
19
|
+
PLATFORM_KEY = "platform"
|
|
20
|
+
TYPED_KEYS = frozenset({"bundles", "path_overrides", "public_url", PLATFORM_KEY})
|
|
21
|
+
|
|
22
|
+
OVERRIDE_LAYOUT = ("stack", "generated", "compose")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PublicUrl(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
forwarded_proto: str
|
|
29
|
+
forwarded_port: str
|
|
30
|
+
|
|
31
|
+
@model_validator(mode="before")
|
|
32
|
+
@classmethod
|
|
33
|
+
def _parse(cls, value: str) -> dict[str, str]:
|
|
34
|
+
scheme, separator, rest = value.partition("://")
|
|
35
|
+
if not separator:
|
|
36
|
+
raise ValueError(f"public_url: {value!r} must carry a scheme (https://example.dev)")
|
|
37
|
+
hostport = rest.partition("/")[0]
|
|
38
|
+
if ":" in hostport:
|
|
39
|
+
forwarded_port = hostport.rpartition(":")[2]
|
|
40
|
+
elif scheme == "https":
|
|
41
|
+
forwarded_port = "443"
|
|
42
|
+
else:
|
|
43
|
+
forwarded_port = "80"
|
|
44
|
+
return {"forwarded_proto": scheme, "forwarded_port": forwarded_port}
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def forwarded(self) -> dict[str, Yaml]:
|
|
48
|
+
return {"FORWARDED_PROTO": self.forwarded_proto, "FORWARDED_PORT": self.forwarded_port}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ValuesFile(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="forbid")
|
|
53
|
+
|
|
54
|
+
choices: dict[str, str]
|
|
55
|
+
path_overrides: dict[str, Path]
|
|
56
|
+
public_url: PublicUrl | None
|
|
57
|
+
platform: str | None
|
|
58
|
+
variables: dict[str, str]
|
|
59
|
+
|
|
60
|
+
@model_validator(mode="before")
|
|
61
|
+
@classmethod
|
|
62
|
+
def _fold_selection(cls, data: object, info: ValidationInfo) -> object:
|
|
63
|
+
if not is_mapping(data):
|
|
64
|
+
return data
|
|
65
|
+
context: dict[str, object] = info.context or {}
|
|
66
|
+
config = context.get("config")
|
|
67
|
+
raw_bundles = data.get("bundles", [])
|
|
68
|
+
if not isinstance(raw_bundles, list):
|
|
69
|
+
raise TypeError("bundles: must be a list of selection names")
|
|
70
|
+
bundles: list[str] = []
|
|
71
|
+
for name in raw_bundles:
|
|
72
|
+
if not isinstance(name, str):
|
|
73
|
+
raise TypeError("bundles: must be a list of selection names")
|
|
74
|
+
bundles.append(name)
|
|
75
|
+
choices: dict[str, str] = {}
|
|
76
|
+
if not isinstance(config, ConfigFile) or not config.services:
|
|
77
|
+
if bundles:
|
|
78
|
+
raise ValueError("bundles: is set but this repo declares no services")
|
|
79
|
+
else:
|
|
80
|
+
selectable = config.selectable_options
|
|
81
|
+
bare = config.bare_entries
|
|
82
|
+
unknown = [name for name in bundles if name not in selectable]
|
|
83
|
+
if unknown:
|
|
84
|
+
bare_entries_note = f"; bare entries, always included: {bare}" if bare else ""
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"bundles: names {unknown}; the selectable options are {sorted(selectable)}{bare_entries_note}"
|
|
87
|
+
)
|
|
88
|
+
# Fold the selection into one choice per group
|
|
89
|
+
for name in bundles:
|
|
90
|
+
group, option_name = selectable[name]
|
|
91
|
+
if group in choices:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"bundles: selects {qualified_option_name(group, choices[group])} and {name}; "
|
|
94
|
+
f"at most one option per group ({group})"
|
|
95
|
+
)
|
|
96
|
+
choices[group] = option_name
|
|
97
|
+
variables: dict[str, str] = {
|
|
98
|
+
str(key): (str(value).lower() if isinstance(value, bool) else str(value))
|
|
99
|
+
for key, value in data.items()
|
|
100
|
+
if str(key) not in TYPED_KEYS
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
"choices": choices,
|
|
104
|
+
"path_overrides": data.get("path_overrides", {}),
|
|
105
|
+
"public_url": data.get("public_url"),
|
|
106
|
+
"platform": data.get("platform"),
|
|
107
|
+
"variables": variables,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@model_validator(mode="after")
|
|
111
|
+
def _require_ref_entry_overrides(self, info: ValidationInfo) -> Self:
|
|
112
|
+
context: dict[str, object] = info.context or {}
|
|
113
|
+
config = context.get("config")
|
|
114
|
+
if not isinstance(config, ConfigFile) or not config.services:
|
|
115
|
+
return self
|
|
116
|
+
vocabulary = set(config.selectable_options) | set(config.bare_entries)
|
|
117
|
+
remote_names = {name for name, _declared, _entry in config.remote_declared_entries()}
|
|
118
|
+
unknown_overrides: list[str] = []
|
|
119
|
+
local_names: list[str] = []
|
|
120
|
+
for name in self.path_overrides:
|
|
121
|
+
if name not in vocabulary:
|
|
122
|
+
unknown_overrides.append(name)
|
|
123
|
+
elif name not in remote_names:
|
|
124
|
+
local_names.append(name)
|
|
125
|
+
if unknown_overrides:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"path_overrides: names {sorted(unknown_overrides)}; the bundle vocabulary is {sorted(vocabulary)}"
|
|
128
|
+
)
|
|
129
|
+
if local_names:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"path_overrides: names {sorted(local_names)}; an override targets a ref entry; "
|
|
132
|
+
f"the ref entries are {sorted(remote_names)}"
|
|
133
|
+
)
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
@field_validator("path_overrides", mode="before")
|
|
137
|
+
@classmethod
|
|
138
|
+
def _require_path_values(cls, raw_overrides: object) -> object:
|
|
139
|
+
if not is_mapping(raw_overrides):
|
|
140
|
+
return raw_overrides
|
|
141
|
+
path_overrides: dict[str, Path] = {}
|
|
142
|
+
for name, value in raw_overrides.items():
|
|
143
|
+
stripped = str(value).strip()
|
|
144
|
+
if not stripped:
|
|
145
|
+
raise ValueError(f"path_overrides.{name}: empty; a value is a filesystem path")
|
|
146
|
+
if "://" in stripped:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"path_overrides.{name}: {stripped!r} carries a URL scheme; a value is a filesystem path"
|
|
149
|
+
)
|
|
150
|
+
path_overrides[name] = Path(stripped)
|
|
151
|
+
return path_overrides
|
|
152
|
+
|
|
153
|
+
@field_validator("path_overrides")
|
|
154
|
+
@classmethod
|
|
155
|
+
def _require_output_layout(cls, path_overrides: dict[str, Path]) -> dict[str, Path]:
|
|
156
|
+
for name, path in path_overrides.items():
|
|
157
|
+
if path.parts[-4:-1] != OVERRIDE_LAYOUT:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"path_overrides.{name}: {path.as_posix()!r} must point into a stack/generated/compose/ bundle output layout"
|
|
160
|
+
)
|
|
161
|
+
return path_overrides
|
|
162
|
+
|
|
163
|
+
@field_validator("variables")
|
|
164
|
+
@classmethod
|
|
165
|
+
def _require_env_names(cls, variables: dict[str, str], info: ValidationInfo) -> dict[str, str]:
|
|
166
|
+
context: dict[str, object] = info.context or {}
|
|
167
|
+
source = context.get("path", VALUES_FILE.name)
|
|
168
|
+
unknown = sorted(key for key in variables if ENV_NAME_PATTERN.fullmatch(key) is None)
|
|
169
|
+
if unknown:
|
|
170
|
+
raise ValueError(f"{source}: keys {unknown} are not ALL_CAPS environment names")
|
|
171
|
+
return variables
|
|
172
|
+
|
|
173
|
+
@field_validator("variables")
|
|
174
|
+
@classmethod
|
|
175
|
+
def _require_lock_marker_free_names(cls, variables: dict[str, str], info: ValidationInfo) -> dict[str, str]:
|
|
176
|
+
context: dict[str, object] = info.context or {}
|
|
177
|
+
source = context.get("path", VALUES_FILE.name)
|
|
178
|
+
reserved = sorted(name for name in variables if LOCK_SLOT_MARKER in name)
|
|
179
|
+
if reserved:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"{source}: keys {reserved} carry the reserved {LOCK_SLOT_MARKER} marker; "
|
|
182
|
+
f"the lock fills those slots — never set them by hand"
|
|
183
|
+
)
|
|
184
|
+
return variables
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def load(cls, path: Path, config: ConfigFile | None) -> ValuesFile:
|
|
188
|
+
return load_yaml_model(cls, path, {"path": path.as_posix(), "config": config})
|
boatyard/crane.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from bashrun.bash import bash
|
|
7
|
+
|
|
8
|
+
CRANE_VERSION = "v0.22.1"
|
|
9
|
+
|
|
10
|
+
_installed = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def install_crane() -> None:
|
|
14
|
+
global _installed
|
|
15
|
+
if _installed:
|
|
16
|
+
return
|
|
17
|
+
print(f"Install crane {CRANE_VERSION}")
|
|
18
|
+
sudo = "sudo " if os.geteuid() != 0 else ""
|
|
19
|
+
archive = "go-containerregistry_Linux_x86_64.tar.gz"
|
|
20
|
+
bash(f"curl -fsSLO https://github.com/google/go-containerregistry/releases/download/{CRANE_VERSION}/{archive}")
|
|
21
|
+
bash(f"{sudo}tar -xzf {archive} -C /usr/local/bin crane")
|
|
22
|
+
Path(archive).unlink(missing_ok=True)
|
|
23
|
+
_installed = True
|
boatyard/dockerfiles.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
from tempfile import TemporaryDirectory
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from bashrun.bash import bash, bash_output
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
10
|
+
|
|
11
|
+
DOCKERFILE_NAME = "Dockerfile"
|
|
12
|
+
FROM_LINE_PATTERN = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?", re.IGNORECASE)
|
|
13
|
+
ARG_LINE_PATTERN = re.compile(r"^ARG\s+([A-Za-z_][A-Za-z0-9_]*)")
|
|
14
|
+
COPY_FROM_LINE_PATTERN = re.compile(r"^COPY\s+--from=(\S+)", re.IGNORECASE)
|
|
15
|
+
BARE_IDENTIFIER_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
16
|
+
BUILD_ARG_REFERENCE_PATTERN = re.compile(r"^\$\{[A-Za-z0-9_]+\}$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ServiceBuild(BaseModel):
|
|
20
|
+
model_config = ConfigDict(extra="forbid")
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
dockerfile: str
|
|
24
|
+
contexts: list[str] = Field(default_factory=list)
|
|
25
|
+
axis: str | None = None
|
|
26
|
+
variants: list[str] = Field(default_factory=list)
|
|
27
|
+
sha_args: list[str] = Field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@model_validator(mode="after")
|
|
30
|
+
def _validate(self) -> Self:
|
|
31
|
+
if (self.axis is None) != (not self.variants):
|
|
32
|
+
raise ValueError("axis and variants must be declared together")
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def target_variants(self) -> list[tuple[str, str | None]]:
|
|
37
|
+
variants: list[str | None] = list(self.variants) or [None]
|
|
38
|
+
return [(f"{self.name}-{variant}" if variant is not None else self.name, variant) for variant in variants]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def derive_services(root: Path, axes: dict[str, list[str]]) -> dict[str, ServiceBuild]:
|
|
42
|
+
service_dirs: dict[str, Path] = {}
|
|
43
|
+
tops: set[str] = set()
|
|
44
|
+
for top in sorted(root.iterdir()):
|
|
45
|
+
if not top.is_dir() or top.name.startswith("."):
|
|
46
|
+
continue
|
|
47
|
+
for child in sorted(top.iterdir()):
|
|
48
|
+
if child.is_dir() and (child / DOCKERFILE_NAME).is_file():
|
|
49
|
+
service_dirs[child.name] = child
|
|
50
|
+
tops.add(top.name)
|
|
51
|
+
_require_single_top(tops, "Dockerfile service directories")
|
|
52
|
+
|
|
53
|
+
builds: dict[str, ServiceBuild] = {}
|
|
54
|
+
for name, directory in service_dirs.items():
|
|
55
|
+
dockerfile = directory / DOCKERFILE_NAME
|
|
56
|
+
relative = dockerfile.relative_to(root).as_posix()
|
|
57
|
+
from_images: list[str] = []
|
|
58
|
+
copy_from_values: list[str] = []
|
|
59
|
+
stages: set[str] = set()
|
|
60
|
+
args: list[str] = []
|
|
61
|
+
for line in dockerfile.read_text(encoding="utf-8").splitlines():
|
|
62
|
+
from_match = FROM_LINE_PATTERN.match(line.strip())
|
|
63
|
+
if from_match is not None:
|
|
64
|
+
from_images.append(from_match.group(1))
|
|
65
|
+
if from_match.group(2) is not None:
|
|
66
|
+
stages.add(from_match.group(2))
|
|
67
|
+
continue
|
|
68
|
+
copy_from_match = COPY_FROM_LINE_PATTERN.match(line.strip())
|
|
69
|
+
if copy_from_match is not None:
|
|
70
|
+
copy_from_values.append(copy_from_match.group(1))
|
|
71
|
+
continue
|
|
72
|
+
arg_match = ARG_LINE_PATTERN.match(line.strip())
|
|
73
|
+
if arg_match is not None:
|
|
74
|
+
args.append(arg_match.group(1))
|
|
75
|
+
contexts: list[str] = []
|
|
76
|
+
for image in from_images:
|
|
77
|
+
if image in stages:
|
|
78
|
+
continue
|
|
79
|
+
if BARE_IDENTIFIER_PATTERN.fullmatch(image) is None:
|
|
80
|
+
if BUILD_ARG_REFERENCE_PATTERN.fullmatch(image) is None:
|
|
81
|
+
raise ValueError(f"{relative}: FROM {image} must reference a base_images build arg")
|
|
82
|
+
continue
|
|
83
|
+
if image not in service_dirs:
|
|
84
|
+
raise ValueError(f"{relative}: FROM {image} names no service directory or stage")
|
|
85
|
+
contexts.append(image)
|
|
86
|
+
for value in copy_from_values:
|
|
87
|
+
if "/" in value:
|
|
88
|
+
raise ValueError(f"{relative}: COPY --from={value} must reference a base_images build arg")
|
|
89
|
+
declared_axis_args = [argument for argument in args if argument in axes]
|
|
90
|
+
if len(declared_axis_args) > 1:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"{relative}: declares two variant-axis ARGs ({', '.join(declared_axis_args)}); "
|
|
93
|
+
"a service carries one axis"
|
|
94
|
+
)
|
|
95
|
+
axis_arg = declared_axis_args[0] if declared_axis_args else None
|
|
96
|
+
builds[name] = ServiceBuild(
|
|
97
|
+
name=name,
|
|
98
|
+
dockerfile=relative,
|
|
99
|
+
contexts=sorted(set(contexts)),
|
|
100
|
+
axis=axis_arg,
|
|
101
|
+
variants=list(axes[axis_arg]) if axis_arg is not None else [],
|
|
102
|
+
sha_args=sorted({arg for arg in args if arg.endswith("_SHA")}),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
effective_axes = {name: _effective_axis(name, builds, ()) for name in builds}
|
|
106
|
+
for name, build in builds.items():
|
|
107
|
+
axis = effective_axes[name]
|
|
108
|
+
if axis is not None:
|
|
109
|
+
build.axis = axis
|
|
110
|
+
build.variants = list(axes[axis])
|
|
111
|
+
return builds
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _require_single_top(tops: set[str], subject: str) -> None:
|
|
115
|
+
if len(tops) > 1:
|
|
116
|
+
raise ValueError(f"{subject} span multiple top-level directories: {sorted(tops)}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def service_sha_env(name: str) -> str:
|
|
120
|
+
return name.upper().replace("-", "_") + "_SHA"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def compute_service_shas(repo_root: Path, service_builds: dict[str, ServiceBuild]) -> dict[str, str]:
|
|
124
|
+
repo_root = repo_root.resolve()
|
|
125
|
+
|
|
126
|
+
service_dirs = {str(PurePosixPath(service.dockerfile).parent) for service in service_builds.values()}
|
|
127
|
+
|
|
128
|
+
if not service_dirs:
|
|
129
|
+
# Skip the disk walk when no services exist.
|
|
130
|
+
return {}
|
|
131
|
+
|
|
132
|
+
# Derive the workload root as the single top-level directory holding every service.
|
|
133
|
+
tops = {PurePosixPath(service_dir).parts[0] for service_dir in service_dirs}
|
|
134
|
+
_require_single_top(tops, "services")
|
|
135
|
+
workload_prefix = f"{next(iter(tops))}/"
|
|
136
|
+
|
|
137
|
+
# Require that .dockerignore be an allowlist
|
|
138
|
+
rules = [line.strip() for line in (repo_root / ".dockerignore").read_text(encoding="utf-8").splitlines()]
|
|
139
|
+
if sum(1 for rule in rules if rule == "*") != 1:
|
|
140
|
+
raise ValueError(".dockerignore: must contain exactly one '*' line")
|
|
141
|
+
includes: list[str] = []
|
|
142
|
+
for number, rule in enumerate(rules, start=1):
|
|
143
|
+
if not rule or rule.startswith("#") or rule == "*":
|
|
144
|
+
continue
|
|
145
|
+
if not rule.startswith("!"):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f".dockerignore:{number}: only allowlists are supported ('*' + '!path' lines); got {rule!r}"
|
|
148
|
+
)
|
|
149
|
+
path = rule[1:].strip("/")
|
|
150
|
+
if not path or any(character in path for character in "*?[]\\"):
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f".dockerignore:{number}: re-include lines must be literal paths without wildcards; got {rule!r}"
|
|
153
|
+
)
|
|
154
|
+
includes.append(path)
|
|
155
|
+
|
|
156
|
+
# Collect allowlisted files
|
|
157
|
+
allowed = [
|
|
158
|
+
path
|
|
159
|
+
for path in (entry.relative_to(repo_root).as_posix() for entry in repo_root.rglob("*") if entry.is_file())
|
|
160
|
+
if any(path == include or path.startswith(include + "/") for include in includes)
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
# Partition into shared and per-service paths, dropping workload-root files owned by no service.
|
|
164
|
+
shared_paths = [path for path in allowed if not path.startswith(workload_prefix)]
|
|
165
|
+
per_service = {d: [p for p in allowed if p.startswith(d + "/")] for d in service_dirs}
|
|
166
|
+
|
|
167
|
+
result: dict[str, str] = {}
|
|
168
|
+
for service_dir in sorted(service_dirs):
|
|
169
|
+
with TemporaryDirectory() as tmpdir:
|
|
170
|
+
index_env = {"GIT_INDEX_FILE": str(Path(tmpdir) / "index")}
|
|
171
|
+
# Conversion pinned off so blobs hash the raw disk bytes the build compiles.
|
|
172
|
+
bash(
|
|
173
|
+
"git -c core.autocrlf=false update-index --add --stdin",
|
|
174
|
+
cwd=repo_root,
|
|
175
|
+
env=index_env,
|
|
176
|
+
stdin_text="".join(f"{path}\n" for path in shared_paths + per_service[service_dir]),
|
|
177
|
+
)
|
|
178
|
+
tree_hash = bash_output("git write-tree", cwd=repo_root, env=index_env).strip()
|
|
179
|
+
|
|
180
|
+
result[service_sha_env(PurePosixPath(service_dir).name)] = f"tree-{tree_hash}"
|
|
181
|
+
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _effective_axis(name: str, builds: dict[str, ServiceBuild], path: tuple[str, ...]) -> str | None:
|
|
186
|
+
if name in path:
|
|
187
|
+
raise ValueError(f"FROM context cycle: {' -> '.join((*path, name))}")
|
|
188
|
+
build = builds[name]
|
|
189
|
+
parent_axes = [_effective_axis(parent, builds, (*path, name)) for parent in build.contexts]
|
|
190
|
+
if len(set(parent_axes)) > 1:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"workloads/{name}/{DOCKERFILE_NAME}: parents ({', '.join(build.contexts)}) disagree on variants"
|
|
193
|
+
)
|
|
194
|
+
if parent_axes:
|
|
195
|
+
if build.axis is not None:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"workloads/{name}/{DOCKERFILE_NAME}: declares ARG {build.axis} over a "
|
|
198
|
+
f"variant-bearing base (parents: {', '.join(build.contexts)})"
|
|
199
|
+
)
|
|
200
|
+
return parent_axes[0]
|
|
201
|
+
return build.axis
|