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
|
@@ -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
|
File without changes
|
boatyard/verbs/build.py
ADDED
|
@@ -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)
|
boatyard/verbs/matrix.py
ADDED
|
@@ -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
|
boatyard/verbs/mirror.py
ADDED
|
@@ -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")
|