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
@@ -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
@@ -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