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/lock.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from bashrun.bash import bash_output
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from boatyard.config.config import (
|
|
13
|
+
OCI_REFERENCE_PREFIX,
|
|
14
|
+
ConfigFile,
|
|
15
|
+
Yaml,
|
|
16
|
+
bundle_lock_key,
|
|
17
|
+
chart_lock_key,
|
|
18
|
+
is_list,
|
|
19
|
+
is_mapping,
|
|
20
|
+
mirrored_ref,
|
|
21
|
+
scan_bundle_texts,
|
|
22
|
+
)
|
|
23
|
+
from boatyard.crane import install_crane
|
|
24
|
+
from boatyard.config.lock import (
|
|
25
|
+
LOCK_PATH,
|
|
26
|
+
LockFile,
|
|
27
|
+
ConfigLockBundle,
|
|
28
|
+
ConfigLockEntry,
|
|
29
|
+
)
|
|
30
|
+
from boatyard.temp import SHA256_DIGEST_REGEX
|
|
31
|
+
from boatyard.yaml import load_yaml_model
|
|
32
|
+
|
|
33
|
+
DIGEST_TAIL_PATTERN = re.compile(rf"@{SHA256_DIGEST_REGEX}$")
|
|
34
|
+
COMPOSE_FILE_MEDIA_TYPE = "application/vnd.docker.compose.file+yaml"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Model the OCI shapes crane answers with: index children and artifact layers.
|
|
38
|
+
class OciPlatform(BaseModel):
|
|
39
|
+
model_config = ConfigDict(extra="ignore")
|
|
40
|
+
|
|
41
|
+
os: str | None = None
|
|
42
|
+
architecture: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OciDescriptor(BaseModel):
|
|
46
|
+
model_config = ConfigDict(extra="ignore")
|
|
47
|
+
|
|
48
|
+
digest: str
|
|
49
|
+
media_type: str | None = Field(default=None, alias="mediaType")
|
|
50
|
+
data: str | None = None
|
|
51
|
+
platform: OciPlatform | None = None
|
|
52
|
+
annotations: dict[str, str] = Field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OciIndex(BaseModel):
|
|
56
|
+
model_config = ConfigDict(extra="ignore")
|
|
57
|
+
|
|
58
|
+
manifests: list[OciDescriptor] = Field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OciArtifactManifest(BaseModel):
|
|
62
|
+
model_config = ConfigDict(extra="ignore")
|
|
63
|
+
|
|
64
|
+
layers: list[OciDescriptor] = Field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def fetch_compose_texts(cls, pinned: str) -> list[str]:
|
|
68
|
+
# Read the bundle's compose files as inline base64 layer data, with crane blob as the fallback.
|
|
69
|
+
print(f"Reading bundle content for: {pinned}")
|
|
70
|
+
artifact = cls.model_validate(json.loads(bash_output(f"crane manifest {shlex.quote(pinned)}")))
|
|
71
|
+
|
|
72
|
+
# Strip the tag, treating a colon as a tag only when no slash follows it.
|
|
73
|
+
repository = pinned.rpartition("@")[0]
|
|
74
|
+
slash = repository.rfind("/")
|
|
75
|
+
colon = repository.rfind(":")
|
|
76
|
+
repository = repository[:colon] if colon > slash else repository
|
|
77
|
+
compose_texts = [
|
|
78
|
+
base64.b64decode(layer.data).decode("utf-8")
|
|
79
|
+
if layer.data is not None
|
|
80
|
+
else bash_output(f"crane blob {shlex.quote(repository + '@' + layer.digest)}")
|
|
81
|
+
for layer in artifact.layers
|
|
82
|
+
if layer.media_type == COMPOSE_FILE_MEDIA_TYPE
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
# Refuse a bundle that carries no compose file layer.
|
|
86
|
+
if not compose_texts:
|
|
87
|
+
raise ValueError(f"{pinned}: the compose bundle carries no compose file layer")
|
|
88
|
+
return compose_texts
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def relock(config: ConfigFile, *, upgrade: bool = False, check: bool = False) -> LockFile:
|
|
92
|
+
# Install the pinned crane and derive the mirror prefix.
|
|
93
|
+
install_crane()
|
|
94
|
+
prefix = config.mirror_prefix
|
|
95
|
+
# Read the committed lock schema-only; drift is relock's input, never refused.
|
|
96
|
+
committed_path = config.root / LOCK_PATH
|
|
97
|
+
committed = load_yaml_model(LockFile, committed_path) if committed_path.is_file() else LockFile()
|
|
98
|
+
|
|
99
|
+
# Resolve first-party bundles direct, recomputing requires from the pinned content and validating declared references against it.
|
|
100
|
+
bundles: dict[str, ConfigLockBundle] = {}
|
|
101
|
+
for name, reference, entry in config.remote_declared_entries():
|
|
102
|
+
key = bundle_lock_key(name)
|
|
103
|
+
committed_entry = committed.bundles.get(key)
|
|
104
|
+
|
|
105
|
+
# Pin the frozen reference or resolve the declaration's fresh digest.
|
|
106
|
+
pinned = (
|
|
107
|
+
committed_entry.reference
|
|
108
|
+
if committed_entry is not None and not upgrade and committed_entry.reference.startswith(reference + "@")
|
|
109
|
+
else f"{reference}@{resolve_remote_digest(reference)}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Record the pinned reference with its required slots.
|
|
113
|
+
bundles[key] = ConfigLockBundle(
|
|
114
|
+
reference=pinned,
|
|
115
|
+
requires=scan_bundle_texts(
|
|
116
|
+
name, entry.routes, entry.ports, OciArtifactManifest.fetch_compose_texts(pinned)
|
|
117
|
+
).requires,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
images: dict[str, ConfigLockEntry] = {}
|
|
121
|
+
for name, reference in config.base_images.items():
|
|
122
|
+
base = mirrored_ref(reference, prefix)
|
|
123
|
+
committed_entry = committed.images.get(name)
|
|
124
|
+
|
|
125
|
+
# Record a digest-pinned declaration as-is under the mirror prefix.
|
|
126
|
+
if DIGEST_TAIL_PATTERN.search(reference):
|
|
127
|
+
images[name] = ConfigLockEntry(index=base, platforms=dict.fromkeys(config.platforms, base))
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Freeze the committed entry forward while its pinned tag and platform set hold.
|
|
131
|
+
if (
|
|
132
|
+
committed_entry is not None
|
|
133
|
+
and not upgrade
|
|
134
|
+
and committed_entry.index.startswith(base + "@")
|
|
135
|
+
and set(committed_entry.platforms) == set(config.platforms)
|
|
136
|
+
):
|
|
137
|
+
images[name] = committed_entry
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Resolve the reference into an index digest and its child manifests.
|
|
141
|
+
print(f"Resolving digests for: {reference}")
|
|
142
|
+
index_digest = resolve_remote_digest(reference)
|
|
143
|
+
children = OciIndex.model_validate(
|
|
144
|
+
json.loads(bash_output(f"crane manifest {shlex.quote(reference)}"))
|
|
145
|
+
).manifests
|
|
146
|
+
|
|
147
|
+
if not children:
|
|
148
|
+
# Treat a manifest answer as every platform's sole manifest.
|
|
149
|
+
matches = {platform: [OciDescriptor(digest=index_digest)] for platform in config.platforms}
|
|
150
|
+
else:
|
|
151
|
+
# Match each declared platform against its concrete, non-attestation children.
|
|
152
|
+
matches = {
|
|
153
|
+
platform: [
|
|
154
|
+
child
|
|
155
|
+
for child in children
|
|
156
|
+
if child.annotations.get("vnd.docker.reference.type") != "attestation-manifest"
|
|
157
|
+
if child.platform is not None and f"{child.platform.os}/{child.platform.architecture}" == platform
|
|
158
|
+
]
|
|
159
|
+
for platform in config.platforms
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Refuse any platform without exactly one match and record the survivor's digest.
|
|
163
|
+
selected: dict[str, str] = {}
|
|
164
|
+
for platform in config.platforms:
|
|
165
|
+
if len(matches[platform]) != 1:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"base_images entry {name}: platform {platform} "
|
|
168
|
+
f"matches {len(matches[platform])} upstream manifests, expected exactly one"
|
|
169
|
+
)
|
|
170
|
+
child = matches[platform][0]
|
|
171
|
+
if not child.digest.startswith("sha256:"):
|
|
172
|
+
raise ValueError(f"base_images entry {name}: upstream index child must carry a digest ({child!r})")
|
|
173
|
+
selected[platform] = child.digest
|
|
174
|
+
|
|
175
|
+
# Record the mirror-prefixed index and one reference per platform.
|
|
176
|
+
images[name] = ConfigLockEntry(
|
|
177
|
+
index=f"{base}@{index_digest}",
|
|
178
|
+
platforms={platform: f"{base}@{digest}" for platform, digest in selected.items()},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Freeze unchanged charts and resolve fresh tags to mirror-pinned digests.
|
|
182
|
+
charts: dict[str, str] = {}
|
|
183
|
+
for name, reference in config.declared_charts.items():
|
|
184
|
+
key = chart_lock_key(name)
|
|
185
|
+
reference = reference.removeprefix(OCI_REFERENCE_PREFIX)
|
|
186
|
+
base = mirrored_ref(reference, prefix)
|
|
187
|
+
committed_entry = committed.charts.get(key)
|
|
188
|
+
|
|
189
|
+
# Record a digest-pinned declaration as-is under the mirror prefix.
|
|
190
|
+
if DIGEST_TAIL_PATTERN.search(reference):
|
|
191
|
+
charts[key] = base
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Freeze the committed entry forward while its pinned tag holds.
|
|
195
|
+
if committed_entry is not None and not upgrade and committed_entry.startswith(base + "@"):
|
|
196
|
+
charts[key] = committed_entry
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
charts[key] = f"{base}@{resolve_remote_digest(reference)}"
|
|
200
|
+
|
|
201
|
+
# Assemble the lock and render its canonical form: sections in field order, nested keys sorted.
|
|
202
|
+
lock = LockFile(platforms=list(config.platforms), images=images, charts=charts, bundles=bundles)
|
|
203
|
+
document = {key: _canonical(value) for key, value in lock.model_dump(exclude_defaults=True).items()}
|
|
204
|
+
rendered = (
|
|
205
|
+
yaml.safe_dump(document, sort_keys=False, default_flow_style=False, width=10_000_000) if document else "{}\n"
|
|
206
|
+
)
|
|
207
|
+
# Write the rendered lock over the committed file.
|
|
208
|
+
if not check:
|
|
209
|
+
committed_path.write_text(rendered, encoding="utf-8")
|
|
210
|
+
return lock
|
|
211
|
+
# Refuse staleness when the committed lock is not the computed canonical bytes.
|
|
212
|
+
committed_text = committed_path.read_text(encoding="utf-8") if committed_path.is_file() else ""
|
|
213
|
+
if committed_text != rendered:
|
|
214
|
+
raise SystemExit("image lock is stale. Run 'boatyard lock' and commit the result.")
|
|
215
|
+
return lock
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def resolve_remote_digest(reference: str) -> str:
|
|
219
|
+
# Refuse crane digest output that is not one sha256 digest.
|
|
220
|
+
print(f"Resolving digest for: {reference}")
|
|
221
|
+
output = bash_output(f"crane digest {shlex.quote(reference)}").strip()
|
|
222
|
+
if re.fullmatch(SHA256_DIGEST_REGEX, output) is None:
|
|
223
|
+
raise ValueError(f"crane digest output for {reference} must be one sha256 digest")
|
|
224
|
+
return output
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _canonical(value: Yaml) -> Yaml:
|
|
228
|
+
# Return the value with every nested dict's keys sorted.
|
|
229
|
+
if is_mapping(value):
|
|
230
|
+
return {key: _canonical(item) for key, item in sorted(value.items())}
|
|
231
|
+
if is_list(value):
|
|
232
|
+
return [_canonical(item) for item in value]
|
|
233
|
+
return value
|
boatyard/markers.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal, NamedTuple
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
|
|
10
|
+
from boatyard.config.config import Yaml, is_mapping
|
|
11
|
+
|
|
12
|
+
TEXT_MARKER = "include"
|
|
13
|
+
YAML_MARKER = "include-yaml"
|
|
14
|
+
LOCK_MARKER = "lock"
|
|
15
|
+
LOCK_IMAGE_MARKER = "lock-image"
|
|
16
|
+
LOCK_MARKERS = (LOCK_MARKER, LOCK_IMAGE_MARKER)
|
|
17
|
+
IMAGE_FIELD = "image"
|
|
18
|
+
IMAGE_SLOT_PATTERN = re.compile(r"\$\{|{{")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LockImageSpec(BaseModel):
|
|
22
|
+
model_config = ConfigDict(extra="forbid")
|
|
23
|
+
|
|
24
|
+
key: str
|
|
25
|
+
digest_field: Literal["digest", "sha"] = "digest"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MarkerContext(NamedTuple):
|
|
29
|
+
root: Path
|
|
30
|
+
images: dict[str, str]
|
|
31
|
+
source: str
|
|
32
|
+
ancestors: tuple[Path, ...] = ()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ImageReference(NamedTuple):
|
|
36
|
+
registry: str
|
|
37
|
+
repository: str
|
|
38
|
+
tag: str | None
|
|
39
|
+
digest: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_markers(node: Yaml, context: MarkerContext) -> Yaml:
|
|
43
|
+
# Expand includes before locks because spliced content may carry lock markers
|
|
44
|
+
return resolve_locks(expand_includes(node, context), context)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def expand_includes(node: Yaml, context: MarkerContext) -> Yaml:
|
|
48
|
+
match node:
|
|
49
|
+
case list():
|
|
50
|
+
# Resolve every list element
|
|
51
|
+
expanded = [expand_includes(item, context) for item in node]
|
|
52
|
+
case dict():
|
|
53
|
+
match tuple(node.items()):
|
|
54
|
+
# Splice in the included bytes
|
|
55
|
+
case [(marker, value)] if marker == TEXT_MARKER:
|
|
56
|
+
expanded = include_text(value, context)
|
|
57
|
+
# Splice in the included structure
|
|
58
|
+
case [(marker, value)] if marker == YAML_MARKER:
|
|
59
|
+
expanded = include_yaml(value, context)
|
|
60
|
+
# Leave lock markers whole for the lock pass to consume or refuse
|
|
61
|
+
case [(marker, _)] if marker in LOCK_MARKERS:
|
|
62
|
+
expanded = node
|
|
63
|
+
# Resolve plain mappings child by child
|
|
64
|
+
case entries:
|
|
65
|
+
expanded = {key: expand_includes(child, context) for key, child in entries}
|
|
66
|
+
case _:
|
|
67
|
+
# Pass scalars through untouched
|
|
68
|
+
expanded = node
|
|
69
|
+
|
|
70
|
+
return expanded
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def include_text(value: Yaml, context: MarkerContext) -> str:
|
|
74
|
+
path = included_path(value, TEXT_MARKER, context)
|
|
75
|
+
return path.read_text(encoding="utf-8")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def include_yaml(value: Yaml, context: MarkerContext) -> Yaml:
|
|
79
|
+
path = included_path(value, YAML_MARKER, context)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
parsed = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
83
|
+
except yaml.YAMLError as error:
|
|
84
|
+
raise ValueError(f"{context.source}: include {value!r} is not valid YAML: {error}") from error
|
|
85
|
+
|
|
86
|
+
if parsed is None:
|
|
87
|
+
raise ValueError(f"{context.source}: include {value!r} is empty")
|
|
88
|
+
|
|
89
|
+
return expand_includes(parsed, context._replace(ancestors=(*context.ancestors, path)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def included_path(value: Yaml, marker: str, context: MarkerContext) -> Path:
|
|
93
|
+
source = context.source
|
|
94
|
+
|
|
95
|
+
match value:
|
|
96
|
+
case str():
|
|
97
|
+
relative = Path(value)
|
|
98
|
+
case _:
|
|
99
|
+
raise ValueError(f"{source}: {marker} takes a repo-root-relative path string, not {type(value).__name__}")
|
|
100
|
+
|
|
101
|
+
# Anchor include paths at the repo root
|
|
102
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
103
|
+
raise ValueError(f"{source}: include path {value!r} must anchor at the repo root")
|
|
104
|
+
|
|
105
|
+
path = context.root / relative
|
|
106
|
+
|
|
107
|
+
# Refuse cycles and missing targets
|
|
108
|
+
if path in context.ancestors:
|
|
109
|
+
raise ValueError(f"{source}: include {value!r} participates in a cycle")
|
|
110
|
+
if not path.is_file():
|
|
111
|
+
raise ValueError(f"{source}: include {value!r} does not exist at the repo root")
|
|
112
|
+
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve_locks(node: Yaml, context: MarkerContext) -> Yaml:
|
|
117
|
+
match node:
|
|
118
|
+
case list():
|
|
119
|
+
# Resolve every list element
|
|
120
|
+
resolved = [resolve_locks(item, context) for item in node]
|
|
121
|
+
case dict():
|
|
122
|
+
match tuple(node.items()):
|
|
123
|
+
# Substitute the full locked reference as one string
|
|
124
|
+
case [(marker, value)] if marker == LOCK_MARKER:
|
|
125
|
+
resolved = locked_reference(value, context)
|
|
126
|
+
# Split the locked reference into chart fields
|
|
127
|
+
case [(marker, value)] if marker == LOCK_IMAGE_MARKER:
|
|
128
|
+
resolved = lock_image_fields(value, context.images, context.source)
|
|
129
|
+
# Refuse literal images, then resolve plain mappings child by child
|
|
130
|
+
case entries:
|
|
131
|
+
_check_literal_image(node, context.source)
|
|
132
|
+
resolved = {key: resolve_locks(child, context) for key, child in entries}
|
|
133
|
+
case _:
|
|
134
|
+
# Pass scalars through untouched
|
|
135
|
+
resolved = node
|
|
136
|
+
|
|
137
|
+
return resolved
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def locked_reference(value: Yaml, context: MarkerContext) -> str:
|
|
141
|
+
match value:
|
|
142
|
+
case str() if value in context.images:
|
|
143
|
+
reference = context.images[value]
|
|
144
|
+
case str():
|
|
145
|
+
raise ValueError(f"{context.source}: lock entry {value!r} does not exist in the image lock")
|
|
146
|
+
case _:
|
|
147
|
+
raise ValueError(f"{context.source}: lock takes a lock-entry name string, not {type(value).__name__}")
|
|
148
|
+
|
|
149
|
+
return reference
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def lock_image_fields(value: Yaml, images: dict[str, str], source: str) -> dict[str, Yaml]:
|
|
153
|
+
# Accept a bare lock-entry name or a key/digest_field mapping
|
|
154
|
+
if isinstance(value, str):
|
|
155
|
+
image_spec = LockImageSpec(key=value)
|
|
156
|
+
elif is_mapping(value):
|
|
157
|
+
image_spec = LockImageSpec.model_validate(value)
|
|
158
|
+
else:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"{source}: lock-image takes a lock-entry name or a key/digest_field mapping, not {type(value).__name__}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if image_spec.key not in images:
|
|
164
|
+
raise ValueError(f"{source}: lock entry {image_spec.key!r} does not exist in the image lock")
|
|
165
|
+
|
|
166
|
+
reference = split_image_reference(images[image_spec.key], source)
|
|
167
|
+
|
|
168
|
+
# Assemble the chart fields in chart-convention key order under the digest spelling
|
|
169
|
+
fields: dict[str, Yaml] = {"registry": reference.registry, "repository": reference.repository}
|
|
170
|
+
if reference.tag is not None:
|
|
171
|
+
fields["tag"] = reference.tag
|
|
172
|
+
|
|
173
|
+
digest = reference.digest
|
|
174
|
+
fields[image_spec.digest_field] = digest.removeprefix("sha256:") if image_spec.digest_field == "sha" else digest
|
|
175
|
+
|
|
176
|
+
return fields
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _check_literal_image(node: dict[str, Yaml], source: str) -> None:
|
|
180
|
+
image = node.get(IMAGE_FIELD)
|
|
181
|
+
if isinstance(image, str) and IMAGE_SLOT_PATTERN.search(image) is None:
|
|
182
|
+
raise ValueError(f"{source}: image {image!r} must consume the lock (lock: NAME / lock-image: NAME)")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def split_image_reference(reference: str, source: str) -> ImageReference:
|
|
186
|
+
# Peel the trailing digest
|
|
187
|
+
path_part, _, digest = reference.rpartition("@")
|
|
188
|
+
|
|
189
|
+
# Treat a colon as a tag only when no slash follows so registry ports stay whole
|
|
190
|
+
tagged = path_part.rpartition(":")
|
|
191
|
+
tag: str | None = None
|
|
192
|
+
registry_part = path_part
|
|
193
|
+
if tagged[1] and "/" not in tagged[2]:
|
|
194
|
+
registry_part, tag = tagged[0], tagged[2]
|
|
195
|
+
|
|
196
|
+
# Split the registry from the repository at the last slash
|
|
197
|
+
registry, separator, repository = registry_part.rpartition("/")
|
|
198
|
+
if not separator or not registry or not repository:
|
|
199
|
+
raise ValueError(f"{source}: lock reference {reference!r} has no registry/repository split")
|
|
200
|
+
|
|
201
|
+
return ImageReference(registry, repository, tag, digest)
|
boatyard/paths.py
ADDED
boatyard/publish.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from functools import partial
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from boatyard.config.config import Yaml, double_dollars
|
|
11
|
+
from boatyard.score_compose import ComposeDocument
|
|
12
|
+
from boatyard.lock import resolve_remote_digest
|
|
13
|
+
|
|
14
|
+
VAR_PATTERN = re.compile(r"\$\{([A-Z][A-Z0-9_]*)(:[?\-+][^}]*)?\}")
|
|
15
|
+
STUB_VALUE = "publish-stub"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PublishedEntry(NamedTuple):
|
|
19
|
+
artifact: str
|
|
20
|
+
sha_reference: str
|
|
21
|
+
branch_reference: str
|
|
22
|
+
digest: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bake_sources(baked_directory: Path, source_name: str, substitutions: dict[str, str]) -> Path:
|
|
26
|
+
print("Bake compose YAML")
|
|
27
|
+
source_path = Path(source_name)
|
|
28
|
+
baked_text = VAR_PATTERN.sub(
|
|
29
|
+
lambda match: substitutions.get(match.group(1), match.group(0)),
|
|
30
|
+
source_path.read_text(encoding="utf-8"),
|
|
31
|
+
)
|
|
32
|
+
baked_path = baked_directory / source_path
|
|
33
|
+
baked_path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
baked_path.write_text(baked_text, encoding="utf-8")
|
|
35
|
+
return baked_path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def finalize_baked(baked_path: Path, repository: str) -> None:
|
|
39
|
+
print("Inline configs content")
|
|
40
|
+
# Inline file-sourced configs as content entries.
|
|
41
|
+
own_image_pattern = re.compile(rf"{re.escape(repository)}/[a-z0-9][a-z0-9-]*:[^\s\"'@$]+")
|
|
42
|
+
document = yaml.safe_load(baked_path.read_text(encoding="utf-8"))
|
|
43
|
+
parsed = ComposeDocument.model_validate(document)
|
|
44
|
+
if parsed.configs is not None:
|
|
45
|
+
inlined: dict[str, dict[str, Yaml]] = {}
|
|
46
|
+
for config_name, compose_config in parsed.configs.items():
|
|
47
|
+
if compose_config.file is None:
|
|
48
|
+
inlined[config_name] = document["configs"][config_name]
|
|
49
|
+
continue
|
|
50
|
+
entry = compose_config.model_dump(exclude_defaults=True)
|
|
51
|
+
entry.pop("file", None)
|
|
52
|
+
entry["content"] = double_dollars(Path(compose_config.file).read_text(encoding="utf-8"))
|
|
53
|
+
inlined[config_name] = entry
|
|
54
|
+
document["configs"] = inlined
|
|
55
|
+
baked_path.write_text(
|
|
56
|
+
yaml.dump(document, default_flow_style=False, sort_keys=False, width=1_000_000),
|
|
57
|
+
encoding="utf-8",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Pin image refs under the repository to digests.
|
|
61
|
+
digests: dict[str, str] = {}
|
|
62
|
+
baked_path.write_text(
|
|
63
|
+
own_image_pattern.sub(partial(_pinned_reference, digests), baked_path.read_text(encoding="utf-8")),
|
|
64
|
+
encoding="utf-8",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pinned_reference(digests: dict[str, str], match: re.Match[str]) -> str:
|
|
69
|
+
reference = match.group(0)
|
|
70
|
+
if reference not in digests:
|
|
71
|
+
digests[reference] = resolve_remote_digest(reference)
|
|
72
|
+
return f"{reference.rsplit(':', 1)[0]}@{digests[reference]}"
|
boatyard/py.typed
ADDED
|
File without changes
|