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,24 @@
1
+ # CUDA device access for GPU-marked workloads (metadata annotation
2
+ # outernet.internal/gpu): `gpus: all` semantics in the long-form device
3
+ # reservation (score-compose's output schema accepts no `gpus` scalar and no
4
+ # `count: all`; an omitted count means every GPU). Per-variant twin of
5
+ # gpu-rocm.tpl — the devkit registers this file exactly when the compose
6
+ # rendering's gpu variant is cuda. Service names are computed from the
7
+ # workload/container pair, never hardcoded.
8
+ {{- $matched := false }}
9
+ {{- range $workloadName, $workload := .Workloads }}
10
+ {{- if eq (dig "metadata" "annotations" "outernet.internal/gpu" "" $workload) "true" }}
11
+ {{- range $containerName, $_ := $workload.containers }}
12
+ {{- $matched = true }}
13
+ - op: set
14
+ path: services.{{ $workloadName }}-{{ $containerName }}.deploy.resources.reservations.devices
15
+ value:
16
+ - driver: nvidia
17
+ capabilities: [gpu]
18
+ description: CUDA device access for {{ $workloadName }}-{{ $containerName }}
19
+ {{- end }}
20
+ {{- end }}
21
+ {{- end }}
22
+ {{- if not $matched }}
23
+ []
24
+ {{- end }}
@@ -0,0 +1,43 @@
1
+ # GPU pod plumbing for workloads marked outernet.internal/gpu, ported from the
2
+ # infra Pulumi prior art: runtimeClassName nvidia, the GPU nodeSelector, and one
3
+ # nvidia.com/gpu per container. Score has no GPU concept, so the annotation on
4
+ # the workload file is the single declaration and this template computes every
5
+ # patch target — nothing is keyed on hardcoded service names. The runtime class,
6
+ # label, and resource name are a seam contract with the cluster tier (device
7
+ # plugin + worker labeling, both Pulumi-side); the annotation is org-namespaced
8
+ # outernet.internal/* while the nodeSelector label stays placeframe.io/gpu —
9
+ # exactly what Pulumi labels GPU nodes with. .Workloads must be captured before
10
+ # the range: inside a range body this template engine resolves the outer dot's
11
+ # map fields to zero values.
12
+ {{- $workloads := .Workloads }}
13
+ {{- $matched := false }}
14
+ {{- range $i, $manifest := .Manifests }}
15
+ {{- if eq $manifest.kind "Deployment" }}
16
+ {{- $workloadName := dig "metadata" "annotations" "k8s.score.dev/workload-name" "" $manifest }}
17
+ {{- if $workloadName }}
18
+ {{- $workload := index $workloads $workloadName }}
19
+ {{- if and $workload (eq (dig "metadata" "annotations" "outernet.internal/gpu" "" $workload) "true") }}
20
+ {{- $matched = true }}
21
+ - op: set
22
+ path: {{ $i }}.spec.template.spec.runtimeClassName
23
+ value: nvidia
24
+ description: NVIDIA runtime class for {{ $workloadName }}
25
+ - op: set
26
+ path: {{ $i }}.spec.template.spec.nodeSelector
27
+ value:
28
+ placeframe.io/gpu: "true"
29
+ description: GPU node selector for {{ $workloadName }}
30
+ {{- range $containerIndex, $_ := $manifest.spec.template.spec.containers }}
31
+ - op: set
32
+ path: {{ $i }}.spec.template.spec.containers.{{ $containerIndex }}.resources.limits
33
+ value:
34
+ nvidia.com/gpu: "1"
35
+ description: One NVIDIA GPU for container {{ $containerIndex }} of {{ $workloadName }}
36
+ {{- end }}
37
+ {{- end }}
38
+ {{- end }}
39
+ {{- end }}
40
+ {{- end }}
41
+ {{- if not $matched }}
42
+ []
43
+ {{- end }}
@@ -0,0 +1,30 @@
1
+ # ROCm device access for GPU-marked workloads (metadata annotation
2
+ # outernet.internal/gpu): the render/video device nodes plus the groups allowed
3
+ # to open them (score-compose's output schema wants DeviceMapping objects and
4
+ # string group ids). Per-variant twin of gpu-cuda.tpl — the devkit registers
5
+ # this file exactly when the compose rendering's gpu variant is rocm.
6
+ {{- $matched := false }}
7
+ {{- range $workloadName, $workload := .Workloads }}
8
+ {{- if eq (dig "metadata" "annotations" "outernet.internal/gpu" "" $workload) "true" }}
9
+ {{- range $containerName, $_ := $workload.containers }}
10
+ {{- $matched = true }}
11
+ - op: set
12
+ path: services.{{ $workloadName }}-{{ $containerName }}.devices
13
+ value:
14
+ - source: /dev/kfd
15
+ target: /dev/kfd
16
+ - source: /dev/dri
17
+ target: /dev/dri
18
+ description: ROCm device nodes for {{ $workloadName }}-{{ $containerName }}
19
+ - op: set
20
+ path: services.{{ $workloadName }}-{{ $containerName }}.group_add
21
+ value:
22
+ - "44"
23
+ - "110"
24
+ description: ROCm render/video group membership for {{ $workloadName }}-{{ $containerName }}
25
+ {{- end }}
26
+ {{- end }}
27
+ {{- end }}
28
+ {{- if not $matched }}
29
+ []
30
+ {{- end }}
@@ -0,0 +1,55 @@
1
+ # score-compose validates and drops workload httpGet probes (httpGet is not a
2
+ # supported compose concept), so the generated compose would gate nothing on
3
+ # workload health while the k8s rendering emits real probes. This patch-template
4
+ # class closes that drift for workloads declaring a probe adapter
5
+ # (outernet.internal/probe-adapter): it converts the workload's readinessProbe,
6
+ # else livenessProbe, into a compose healthcheck against the computed
7
+ # <workload>-<container> service name. The adapter picks the exec spell the
8
+ # image runtime can run — python-urllib (python-based workloads shipping
9
+ # neither wget nor curl) or bash-http (workloads whose image ships bash and
10
+ # nothing else, spelling HTTP over /dev/tcp); a workload declaring an httpGet
11
+ # probe without the adapter refuses at generation (devkit code — templates are
12
+ # data).
13
+ {{- $matched := false }}
14
+ {{- range $workloadName, $workload := .Workloads }}
15
+ {{- $adapter := dig "metadata" "annotations" "outernet.internal/probe-adapter" "" $workload }}
16
+ {{- if or (eq $adapter "python-urllib") (eq $adapter "bash-http") }}
17
+ {{- range $containerName, $container := $workload.containers }}
18
+ {{- $probe := $container.readinessProbe.httpGet }}
19
+ {{- if not $probe }}
20
+ {{- $probe = $container.livenessProbe.httpGet }}
21
+ {{- end }}
22
+ {{- with $probe }}
23
+ {{- $matched = true }}
24
+ - op: set
25
+ path: services.{{ $workloadName }}-{{ $containerName }}.healthcheck
26
+ value:
27
+ {{- if eq $adapter "python-urllib" }}
28
+ test:
29
+ [
30
+ "CMD",
31
+ "python",
32
+ "-c",
33
+ "import urllib.request; urllib.request.urlopen('http://localhost:{{ .port }}{{ .path }}')",
34
+ ]
35
+ {{- else }}
36
+ test:
37
+ [
38
+ "CMD",
39
+ "bash",
40
+ "-c",
41
+ "exec 3<>/dev/tcp/127.0.0.1/{{ .port }} && printf 'GET {{ .path }} HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && read -r status <&3 && case $$status in *200*) exit 0;; *) exit 1;; esac",
42
+ ]
43
+ {{- end }}
44
+ interval: 5s
45
+ start_period: 5s
46
+ timeout: 5s
47
+ retries: 3
48
+ description: Healthcheck from httpGet probe for {{ $workloadName }}-{{ $containerName }}
49
+ {{- end }}
50
+ {{- end }}
51
+ {{- end }}
52
+ {{- end }}
53
+ {{- if not $matched }}
54
+ []
55
+ {{- end }}
@@ -0,0 +1,21 @@
1
+ # Restart policy for workloads declaring outernet.internal/restart — the
2
+ # workload file carrying the one sanctioned exception to the no-restart-policies
3
+ # law where its reader lives. The value is validated against the compose
4
+ # restart-policy enum by devkit code before generation (templates are data and
5
+ # cannot refuse); presence is the trigger, the annotation value the policy.
6
+ {{- $matched := false }}
7
+ {{- range $workloadName, $workload := .Workloads }}
8
+ {{- $restart := dig "metadata" "annotations" "outernet.internal/restart" "" $workload }}
9
+ {{- if $restart }}
10
+ {{- range $containerName, $_ := $workload.containers }}
11
+ {{- $matched = true }}
12
+ - op: set
13
+ path: services.{{ $workloadName }}-{{ $containerName }}.restart
14
+ value: {{ $restart }}
15
+ description: Restart the {{ $workloadName }}-{{ $containerName }} service {{ $restart }}
16
+ {{- end }}
17
+ {{- end }}
18
+ {{- end }}
19
+ {{- if not $matched }}
20
+ []
21
+ {{- end }}
@@ -0,0 +1,30 @@
1
+ # KEDA owns /spec/replicas for workloads declaring a keda-scaled-object resource, so
2
+ # the rendered Deployment starts at zero: no queue means no pods, and a fresh apply
3
+ # must not boot a GPU worker before the operator is watching. Argo's selfHeal fights
4
+ # KEDA over this field unless the Application carries ignoreDifferences — infra-side
5
+ # configuration, not artifact content. .Workloads is captured before the range (see
6
+ # gpu-pod.tpl).
7
+ {{- $workloads := .Workloads }}
8
+ {{- $matched := false }}
9
+ {{- range $i, $manifest := .Manifests }}
10
+ {{- if eq $manifest.kind "Deployment" }}
11
+ {{- $workloadName := dig "metadata" "annotations" "k8s.score.dev/workload-name" "" $manifest }}
12
+ {{- if $workloadName }}
13
+ {{- $workload := index $workloads $workloadName }}
14
+ {{- if $workload }}
15
+ {{- range $_, $resource := $workload.resources }}
16
+ {{- if eq $resource.type "keda-scaled-object" }}
17
+ {{- $matched = true }}
18
+ - op: set
19
+ path: {{ $i }}.spec.replicas
20
+ value: 0
21
+ description: Scale-to-zero start for KEDA-managed {{ $workloadName }}
22
+ {{- end }}
23
+ {{- end }}
24
+ {{- end }}
25
+ {{- end }}
26
+ {{- end }}
27
+ {{- end }}
28
+ {{- if not $matched }}
29
+ []
30
+ {{- end }}
boatyard/temp.py ADDED
@@ -0,0 +1,5 @@
1
+ import re
2
+
3
+ PLATFORM_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*$")
4
+
5
+ SHA256_DIGEST_REGEX = r"sha256:[a-f0-9]{64}"
File without changes
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from boatyard.build import run_build
9
+ from boatyard.config.config import ConfigFile
10
+ from boatyard.paths import DEFAULT_CONFIG_PATH
11
+
12
+ app = typer.Typer()
13
+
14
+
15
+ @app.command()
16
+ def build(
17
+ push: Annotated[
18
+ bool, typer.Option("--push", help="Push images and registry caches to the repository declared in the config.")
19
+ ] = False,
20
+ push_to: Annotated[
21
+ str | None,
22
+ typer.Option("--push-to", help="Push images and registry caches to this registry address instead."),
23
+ ] = None,
24
+ targets: Annotated[
25
+ list[str] | None,
26
+ typer.Option(
27
+ "--targets",
28
+ "-t",
29
+ help="Build these targets: bare name for an axis-less service, name/variant when it declares variants.",
30
+ ),
31
+ ] = None,
32
+ no_cache: Annotated[bool, typer.Option("--no-cache", help="Force rebuild by disabling cache usage.")] = False,
33
+ ) -> None:
34
+ root = Path.cwd()
35
+ config = ConfigFile.load(root / DEFAULT_CONFIG_PATH)
36
+
37
+ if push and push_to is not None:
38
+ raise typer.BadParameter(
39
+ "--push and --push-to are mutually exclusive: --push targets the config's repository, --push-to targets that address"
40
+ )
41
+
42
+ if push_to is not None:
43
+ registry_host = push_to.partition("/")[0]
44
+ if "://" in push_to or push_to.endswith("/") or ("." not in registry_host and ":" not in registry_host):
45
+ raise typer.BadParameter(
46
+ f"--push-to destination {push_to!r} must be a registry address (localhost:5000, ghcr.io/org) "
47
+ "with no scheme and no trailing slash"
48
+ )
49
+
50
+ if push and config.repository is None:
51
+ raise typer.BadParameter("--push targets the config's repository; repository is required")
52
+
53
+ targets = [address for value in targets or [] for address in value.split()] or None
54
+ run_build(config=config, push=push, push_to=push_to, targets=targets, no_cache=no_cache)
boatyard/verbs/down.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import ExitStack
4
+ from pathlib import Path
5
+ from tempfile import NamedTemporaryFile
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ import yaml
10
+ from bashrun.bash import bash_handoff
11
+
12
+ from boatyard.compose import compose_command
13
+ from boatyard.config.config import ConfigFile
14
+ from boatyard.paths import DEFAULT_CONFIG_PATH
15
+
16
+ app = typer.Typer()
17
+
18
+
19
+ @app.command()
20
+ def down(
21
+ volumes: Annotated[bool, typer.Option("--volumes", "-v", help="Remove named volumes.")] = False,
22
+ ) -> None:
23
+ root = Path.cwd()
24
+ config = ConfigFile.load(root / DEFAULT_CONFIG_PATH)
25
+
26
+ # Tear down by project name alone; the name-only root consumes no values, lock, or selection.
27
+ with ExitStack() as stack:
28
+ with NamedTemporaryFile(
29
+ "w", suffix=".yaml", prefix="devkit-assembly-", delete=False, encoding="utf-8"
30
+ ) as handle:
31
+ handle.write(yaml.safe_dump({"name": config.project_name}, sort_keys=False, default_flow_style=False))
32
+ assembly_root = Path(handle.name)
33
+ stack.callback(assembly_root.unlink, missing_ok=True)
34
+
35
+ command = compose_command(root, assembly_root, "down", "--remove-orphans")
36
+ if volumes:
37
+ command += " -v"
38
+
39
+ bash_handoff(command)
boatyard/verbs/lock.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from boatyard.config.config import ConfigFile
9
+ from boatyard.lock import relock
10
+ from boatyard.paths import DEFAULT_CONFIG_PATH
11
+
12
+ app = typer.Typer()
13
+
14
+
15
+ @app.command()
16
+ def lock(
17
+ upgrade: Annotated[
18
+ bool,
19
+ typer.Option("--upgrade", "-u", help="Re-resolve every declaration instead of freezing unchanged entries."),
20
+ ] = False,
21
+ check: Annotated[
22
+ bool,
23
+ typer.Option("--check", help="Assert the committed lock is fresh instead of writing it."),
24
+ ] = False,
25
+ ) -> None:
26
+ config_path = Path.cwd() / DEFAULT_CONFIG_PATH
27
+ if not config_path.is_file():
28
+ print("no boatyard.yml; no lock declarations to relock")
29
+ return
30
+ config = ConfigFile.load(config_path)
31
+ if not config.base_images and not config.declared_charts and not config.remote_declared_entries():
32
+ print("no lock declarations (none of base_images, k8s.charts, service refs); nothing to relock")
33
+ return
34
+ relock(config, upgrade=upgrade, check=check)
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from boatyard.config.config import ConfigFile
9
+ from boatyard.paths import DEFAULT_CONFIG_PATH
10
+
11
+ app = typer.Typer()
12
+
13
+
14
+ @app.command()
15
+ def docker_build_matrix() -> None:
16
+ config_path = Path.cwd() / DEFAULT_CONFIG_PATH
17
+ config = ConfigFile.load(config_path) if config_path.is_file() else None
18
+ if config is None:
19
+ print(json.dumps([]))
20
+ return
21
+ # Partition the services into dependency closures x variants.
22
+ services = config.service_builds
23
+ parent = {service: service for service in services}
24
+
25
+ for name, service in services.items():
26
+ for dependency in service.contexts:
27
+ left, right = _find(parent, name), _find(parent, dependency)
28
+ if left != right:
29
+ parent[right] = left
30
+
31
+ members: dict[str, list[str]] = {}
32
+ for service in services:
33
+ members.setdefault(_find(parent, service), []).append(service)
34
+
35
+ entries_by_variant: dict[tuple[str | None, str], list[str]] = {}
36
+ for group in sorted(sorted(member_list) for member_list in members.values()):
37
+ for name in group:
38
+ for variant in services[name].variants or [None]:
39
+ partition = variant if variant is not None else "common"
40
+ address = f"{name}/{variant}" if variant is not None else name
41
+ entries_by_variant.setdefault((services[name].axis, partition), []).append(address)
42
+
43
+ print(
44
+ json.dumps([
45
+ {"targets": " ".join(sorted(addresses)), "variant": partition}
46
+ for (_axis, partition), addresses in sorted(entries_by_variant.items(), key=lambda item: item[0][1])
47
+ ])
48
+ )
49
+
50
+
51
+ def _find(parent: dict[str, str], service: str) -> str:
52
+ root = service
53
+ while parent[root] != root:
54
+ root = parent[root]
55
+ while parent[service] != service:
56
+ parent[service], service = root, parent[service]
57
+ return root
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from bashrun.bash import bash, bash_check
7
+
8
+ from boatyard.config.config import (
9
+ ConfigFile,
10
+ chart_lock_key,
11
+ mirrored_ref,
12
+ )
13
+ from boatyard.crane import install_crane
14
+ from boatyard.config.lock import LOCK_PATH, LockFile
15
+ from boatyard.paths import DEFAULT_CONFIG_PATH
16
+
17
+ app = typer.Typer()
18
+
19
+
20
+ @app.command()
21
+ def mirror() -> None:
22
+ root = Path.cwd()
23
+ config = ConfigFile.load(root / DEFAULT_CONFIG_PATH)
24
+ prefix = config.mirror_prefix
25
+
26
+ lock = LockFile.load(root / LOCK_PATH, config)
27
+ mirror_targets: list[tuple[str, list[str]]] = []
28
+ for name, reference in sorted(config.base_images.items(), key=lambda item: item[1]):
29
+ mirror_targets.append((reference, lock.images[name].recorded_digests))
30
+ for chart_name, chart_ref in sorted(config.declared_charts.items()):
31
+ mirror_targets.append((chart_ref.removeprefix("oci://"), [lock.charts[chart_lock_key(chart_name)]]))
32
+
33
+ if not mirror_targets:
34
+ raise ValueError("nothing is declared to mirror (base_images and k8s.charts are empty)")
35
+
36
+ install_crane()
37
+ copied = 0
38
+ already_present = 0
39
+ for reference, recorded in mirror_targets:
40
+ probes = sorted(set(recorded))
41
+ if probes and all(bash_check(f"crane digest {probe}") for probe in probes):
42
+ for probe in probes:
43
+ print(f"Already mirrored {probe}")
44
+ already_present += 1
45
+ continue
46
+ destination = mirrored_ref(reference, prefix)
47
+ print(f"Mirror {reference} -> {destination}")
48
+ bash(f"crane copy {reference} {destination}")
49
+ copied += 1
50
+ print(f"Mirrored references: {copied} copied, {already_present} already present")
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shlex
6
+ from pathlib import Path
7
+ from tempfile import TemporaryDirectory
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from bashrun.bash import bash, bash_output
12
+
13
+ from boatyard import dockerfiles
14
+ from boatyard.config.config import ConfigFile, compose_bundle_output_path, lock_key_prefix
15
+ from boatyard.crane import install_crane
16
+ from boatyard.config.lock import LOCK_PATH, LockFile
17
+ from boatyard.paths import DEFAULT_CONFIG_PATH
18
+ from boatyard.publish import (
19
+ STUB_VALUE,
20
+ VAR_PATTERN,
21
+ PublishedEntry,
22
+ bake_sources,
23
+ finalize_baked,
24
+ )
25
+
26
+ app = typer.Typer()
27
+
28
+
29
+ @app.command()
30
+ def publish_compose(
31
+ bundle: Annotated[
32
+ str | None,
33
+ typer.Option(
34
+ "--bundle",
35
+ help="Publish this bundle's entry (one artifact per workload-bearing entry or option); a bare invocation publishes every entry.",
36
+ ),
37
+ ] = None,
38
+ branch: Annotated[
39
+ str | None,
40
+ typer.Option("--branch", help="Branch tag for the second publish; defaults to the checked-out branch."),
41
+ ] = None,
42
+ plan: Annotated[
43
+ bool,
44
+ typer.Option(
45
+ "--plan", help="Print the publish entries as a bare JSON array (empty when no section); no publish."
46
+ ),
47
+ ] = False,
48
+ ) -> None:
49
+ root = Path.cwd()
50
+ config_path = root / DEFAULT_CONFIG_PATH
51
+ config = ConfigFile.load(config_path) if config_path.is_file() else None
52
+ bundles = (
53
+ [(name, compose_bundle_output_path(root, name)) for name, _entry in config.local_service_entries()]
54
+ if config is not None
55
+ else []
56
+ )
57
+ names = [name for name, _output in bundles]
58
+ if config is None or not names:
59
+ if plan:
60
+ print(json.dumps([]))
61
+ return
62
+ raise ValueError("publishing requires local entries (workloads:) in boatyard.yml")
63
+
64
+ if plan:
65
+ entries = [{"bundle": name} for name in names]
66
+ print(json.dumps(entries))
67
+ return
68
+
69
+ if bundle is not None and bundle not in names:
70
+ raise typer.BadParameter(f"unknown --bundle {bundle!r}; declared bundles: {names}")
71
+
72
+ username = os.environ.get("REGISTRY_USERNAME", "")
73
+ token = os.environ.get("REGISTRY_TOKEN", "")
74
+ if not username or not token:
75
+ raise SystemExit("REGISTRY_USERNAME and REGISTRY_TOKEN must be set for registry login")
76
+ repository = config.repository
77
+ if repository is None:
78
+ raise ValueError("local entries (workloads:) are declared; repository is required")
79
+ registry_host = repository.partition("/")[0]
80
+ print(f"Login to {registry_host}")
81
+ bash(f"docker login {shlex.quote(registry_host)} -u {shlex.quote(username)} --password-stdin", stdin_text=token)
82
+ install_crane()
83
+
84
+ sha = bash_output("git rev-parse HEAD").strip()
85
+ branch_tag = (branch or bash_output("git rev-parse --abbrev-ref HEAD").strip()).replace("/", "-")
86
+
87
+ service_shas = dockerfiles.compute_service_shas(config.root, config.service_builds)
88
+ lock_entries = LockFile.load(root / LOCK_PATH, config).index_references()
89
+
90
+ published: list[PublishedEntry] = []
91
+ for bundle_name, output in bundles:
92
+ if bundle is not None and bundle_name != bundle:
93
+ continue
94
+ with TemporaryDirectory(prefix="compose-bundle-") as baked_directory_string:
95
+ baked_directory = Path(baked_directory_string)
96
+ # Fan the lock keys under the bundle prefix
97
+ substitutions = {
98
+ **service_shas,
99
+ **lock_entries,
100
+ **{lock_key_prefix(bundle_name) + key: value for key, value in lock_entries.items()},
101
+ }
102
+ baked_path = bake_sources(baked_directory, output.as_posix(), substitutions)
103
+
104
+ # Set non-empty stubs for unresolved vars.
105
+ unresolved = {
106
+ match.group(1) for match in VAR_PATTERN.finditer(baked_path.read_text(encoding="utf-8"))
107
+ } - substitutions.keys()
108
+ for name in unresolved:
109
+ os.environ.setdefault(name, STUB_VALUE)
110
+
111
+ finalize_baked(baked_path, repository)
112
+
113
+ # Publish once under the sha tag and alias the branch tag.
114
+ sha_reference = f"{repository}/{bundle_name}:{sha}"
115
+ print(f"Publish {sha_reference}")
116
+ bash(f"docker compose -f {shlex.quote(str(baked_path))} publish {shlex.quote(sha_reference)} --yes")
117
+ branch_reference = f"{repository}/{bundle_name}:{branch_tag}"
118
+ print(f"Tag {branch_reference}")
119
+ bash(f"crane tag {shlex.quote(sha_reference)} {shlex.quote(branch_tag)}")
120
+ digest = bash_output(f"crane digest {shlex.quote(sha_reference)}").strip()
121
+ print(f"Digest {sha_reference} {digest}")
122
+ print(f"Digest {branch_reference} {digest}")
123
+ published.append(PublishedEntry(bundle_name, sha_reference, branch_reference, digest))
124
+
125
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
126
+ if summary_path is not None and published:
127
+ lines = [
128
+ "## Published compose bundles",
129
+ "",
130
+ "| artifact | sha tag | branch tag | digest |",
131
+ "| --- | --- | --- | --- |",
132
+ ]
133
+ for entry in published:
134
+ lines.append(
135
+ f"| {entry.artifact} | `{entry.sha_reference}` | `{entry.branch_reference}` | `{entry.digest}` |"
136
+ )
137
+ with Path(summary_path).open("a", encoding="utf-8") as summary:
138
+ summary.write("\n".join(lines) + "\n")