kanibako-cli 1.5.0.dev14__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.
- kanibako/__init__.py +3 -0
- kanibako/__main__.py +6 -0
- kanibako/auth_browser.py +296 -0
- kanibako/auth_parser.py +51 -0
- kanibako/browser_sidecar.py +183 -0
- kanibako/browser_state.py +103 -0
- kanibako/bun_sea.py +144 -0
- kanibako/cli.py +344 -0
- kanibako/commands/__init__.py +0 -0
- kanibako/commands/archive.py +228 -0
- kanibako/commands/box/__init__.py +22 -0
- kanibako/commands/box/_duplicate.py +395 -0
- kanibako/commands/box/_migrate.py +574 -0
- kanibako/commands/box/_parser.py +1178 -0
- kanibako/commands/clean.py +166 -0
- kanibako/commands/crab_cmd.py +480 -0
- kanibako/commands/diagnose.py +239 -0
- kanibako/commands/fork_cmd.py +51 -0
- kanibako/commands/helper_cmd.py +669 -0
- kanibako/commands/image.py +1300 -0
- kanibako/commands/install.py +152 -0
- kanibako/commands/refresh_credentials.py +67 -0
- kanibako/commands/restore.py +298 -0
- kanibako/commands/setup_cmd.py +89 -0
- kanibako/commands/start.py +1600 -0
- kanibako/commands/stop.py +116 -0
- kanibako/commands/system_cmd.py +224 -0
- kanibako/commands/upgrade.py +161 -0
- kanibako/commands/vault_cmd.py +199 -0
- kanibako/commands/workset_cmd.py +552 -0
- kanibako/config.py +514 -0
- kanibako/config_interface.py +573 -0
- kanibako/config_io.py +36 -0
- kanibako/container.py +607 -0
- kanibako/containerfiles.py +58 -0
- kanibako/containers/Containerfile.kanibako +99 -0
- kanibako/containers/Containerfile.template-android +55 -0
- kanibako/containers/Containerfile.template-dotnet +29 -0
- kanibako/containers/Containerfile.template-js +43 -0
- kanibako/containers/Containerfile.template-jvm +27 -0
- kanibako/containers/Containerfile.template-systems +46 -0
- kanibako/containers/__init__.py +0 -0
- kanibako/crabs.py +89 -0
- kanibako/errors.py +33 -0
- kanibako/freshness.py +67 -0
- kanibako/git.py +114 -0
- kanibako/helper_client.py +132 -0
- kanibako/helper_listener.py +538 -0
- kanibako/helpers.py +339 -0
- kanibako/hygiene.py +296 -0
- kanibako/image_sharing.py +133 -0
- kanibako/instructions.py +160 -0
- kanibako/log.py +31 -0
- kanibako/names.py +248 -0
- kanibako/paths.py +1483 -0
- kanibako/plugins/__init__.py +10 -0
- kanibako/registry.py +71 -0
- kanibako/rig_bundle.py +121 -0
- kanibako/rig_meta.py +92 -0
- kanibako/rig_registry.py +132 -0
- kanibako/rig_resolve.py +182 -0
- kanibako/rig_source.py +245 -0
- kanibako/scripts/__init__.py +0 -0
- kanibako/scripts/helper-init.sh +45 -0
- kanibako/scripts/kanibako-entry +12 -0
- kanibako/settings_resolve.py +312 -0
- kanibako/settings_seeds.py +154 -0
- kanibako/settings_shares.py +154 -0
- kanibako/shellenv.py +75 -0
- kanibako/snapshots.py +281 -0
- kanibako/targets/__init__.py +173 -0
- kanibako/targets/base.py +243 -0
- kanibako/targets/no_agent.py +58 -0
- kanibako/templates.py +60 -0
- kanibako/templates_image.py +224 -0
- kanibako/tweakcc.py +140 -0
- kanibako/tweakcc_cache.py +171 -0
- kanibako/utils.py +136 -0
- kanibako/workset.py +347 -0
- kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
- kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
- kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
- kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Namespace package for kanibako plugins.
|
|
2
|
+
|
|
3
|
+
Uses pkgutil.extend_path so that plugin packages installed in separate
|
|
4
|
+
source trees (e.g. editable installs from packages/agent-claude/src/)
|
|
5
|
+
merge into a single kanibako.plugins namespace.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pkgutil
|
|
9
|
+
|
|
10
|
+
__path__ = pkgutil.extend_path(__path__, __name__)
|
kanibako/registry.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""OCI Distribution API client for querying remote image digests (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.request
|
|
7
|
+
import urllib.error
|
|
8
|
+
|
|
9
|
+
_TIMEOUT = 5
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_remote_digest(image: str) -> str | None:
|
|
13
|
+
"""Return the remote manifest digest for *image*, or None on any failure."""
|
|
14
|
+
try:
|
|
15
|
+
registry, repo, tag = _parse_image_ref(image)
|
|
16
|
+
token = _get_anonymous_token(registry, repo)
|
|
17
|
+
return _fetch_manifest_digest(registry, repo, tag, token)
|
|
18
|
+
except Exception:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_image_ref(image: str) -> tuple[str, str, str]:
|
|
23
|
+
"""Split ``registry/owner/name:tag`` into ``(registry, owner/name, tag)``.
|
|
24
|
+
|
|
25
|
+
Raises ``ValueError`` if the format is unrecognised.
|
|
26
|
+
"""
|
|
27
|
+
# Strip tag
|
|
28
|
+
if ":" in image.rsplit("/", 1)[-1]:
|
|
29
|
+
ref, tag = image.rsplit(":", 1)
|
|
30
|
+
else:
|
|
31
|
+
ref, tag = image, "latest"
|
|
32
|
+
|
|
33
|
+
parts = ref.split("/")
|
|
34
|
+
if len(parts) < 3:
|
|
35
|
+
raise ValueError(f"Cannot parse image reference: {image}")
|
|
36
|
+
registry = parts[0]
|
|
37
|
+
repo = "/".join(parts[1:])
|
|
38
|
+
return registry, repo, tag
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_anonymous_token(registry: str, repo: str) -> str | None:
|
|
42
|
+
"""Obtain a bearer token for anonymous pulls (GHCR only for now)."""
|
|
43
|
+
if registry != "ghcr.io":
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
url = f"https://ghcr.io/token?scope=repository:{repo}:pull"
|
|
47
|
+
req = urllib.request.Request(url, headers={"User-Agent": "kanibako"})
|
|
48
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
|
49
|
+
data = json.loads(resp.read())
|
|
50
|
+
return data.get("token")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fetch_manifest_digest(
|
|
54
|
+
registry: str, repo: str, tag: str, token: str | None,
|
|
55
|
+
) -> str | None:
|
|
56
|
+
"""HEAD the manifest to read ``Docker-Content-Digest``."""
|
|
57
|
+
url = f"https://{registry}/v2/{repo}/manifests/{tag}"
|
|
58
|
+
headers: dict[str, str] = {
|
|
59
|
+
"User-Agent": "kanibako",
|
|
60
|
+
"Accept": (
|
|
61
|
+
"application/vnd.docker.distribution.manifest.v2+json, "
|
|
62
|
+
"application/vnd.oci.image.index.v1+json"
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
if token:
|
|
66
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
67
|
+
|
|
68
|
+
req = urllib.request.Request(url, method="HEAD", headers=headers)
|
|
69
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
|
70
|
+
digest = resp.headers.get("Docker-Content-Digest")
|
|
71
|
+
return digest
|
kanibako/rig_bundle.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""The ``.rig.tgz`` export bundle for *extended* rigs.
|
|
2
|
+
|
|
3
|
+
Extended rigs have no external source to re-pull or rebuild from, so they
|
|
4
|
+
travel as a single self-contained bundle. A bundle is a gzip tar containing:
|
|
5
|
+
|
|
6
|
+
* ``rig.yaml`` -- the in-image :class:`~kanibako.rig_meta.RigMeta` metadata
|
|
7
|
+
* ``image.tar`` -- a ``podman save`` of the rig image
|
|
8
|
+
* ``Containerfile`` -- optional, informational build recipe
|
|
9
|
+
|
|
10
|
+
Packing and unpacking use the stdlib :mod:`tarfile` (gzip) only -- no shelling
|
|
11
|
+
out. Extraction is path-traversal-safe.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import tarfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from kanibako.rig_meta import RigMeta, load_rig_meta
|
|
20
|
+
|
|
21
|
+
BUNDLE_SUFFIX = ".rig.tgz"
|
|
22
|
+
|
|
23
|
+
_META_ARCNAME = "rig.yaml"
|
|
24
|
+
_IMAGE_ARCNAME = "image.tar"
|
|
25
|
+
_CONTAINERFILE_ARCNAME = "Containerfile"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pack_bundle(
|
|
29
|
+
out: Path,
|
|
30
|
+
rig_yaml: Path,
|
|
31
|
+
image_tar: Path,
|
|
32
|
+
containerfile: Path | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Pack *rig_yaml* + *image_tar* (+ optional *containerfile*) into *out*.
|
|
35
|
+
|
|
36
|
+
*out* is created as a gzip tar with the metadata stored as ``rig.yaml``,
|
|
37
|
+
the saved image as ``image.tar`` and, when supplied, the build recipe as
|
|
38
|
+
``Containerfile``. Raises :class:`FileNotFoundError` if *rig_yaml* or
|
|
39
|
+
*image_tar* is missing.
|
|
40
|
+
"""
|
|
41
|
+
if not rig_yaml.is_file():
|
|
42
|
+
raise FileNotFoundError(f"rig metadata not found: {rig_yaml}")
|
|
43
|
+
if not image_tar.is_file():
|
|
44
|
+
raise FileNotFoundError(f"image tarball not found: {image_tar}")
|
|
45
|
+
|
|
46
|
+
with tarfile.open(out, "w:gz") as tar:
|
|
47
|
+
tar.add(rig_yaml, arcname=_META_ARCNAME)
|
|
48
|
+
tar.add(image_tar, arcname=_IMAGE_ARCNAME)
|
|
49
|
+
if containerfile is not None:
|
|
50
|
+
tar.add(containerfile, arcname=_CONTAINERFILE_ARCNAME)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_safe_member(name: str) -> bool:
|
|
54
|
+
"""Return True if *name* is a safe, in-tree relative arcname.
|
|
55
|
+
|
|
56
|
+
Rejects absolute paths and any path that escapes the destination via a
|
|
57
|
+
``..`` component (guarding against tar path-traversal attacks).
|
|
58
|
+
"""
|
|
59
|
+
member = Path(name)
|
|
60
|
+
if member.is_absolute():
|
|
61
|
+
return False
|
|
62
|
+
return ".." not in member.parts
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def unpack_bundle(tgz: Path, dest_dir: Path) -> dict[str, Path]:
|
|
66
|
+
"""Extract bundle *tgz* into *dest_dir*, returning the member paths.
|
|
67
|
+
|
|
68
|
+
Extraction is path-traversal-safe: any member whose name is absolute or
|
|
69
|
+
contains a ``..`` component is rejected (raises :class:`ValueError`). The
|
|
70
|
+
returned dict maps ``"rig_yaml"`` / ``"image_tar"`` to their extracted
|
|
71
|
+
paths, plus ``"containerfile"`` only when one was present. Raises
|
|
72
|
+
:class:`ValueError` if the required ``rig.yaml`` or ``image.tar`` members
|
|
73
|
+
are absent.
|
|
74
|
+
"""
|
|
75
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
with tarfile.open(tgz, "r:gz") as tar:
|
|
78
|
+
members = tar.getmembers()
|
|
79
|
+
names = {m.name for m in members}
|
|
80
|
+
|
|
81
|
+
for member in members:
|
|
82
|
+
if not _is_safe_member(member.name):
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"unsafe member name in bundle: {member.name!r}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if _META_ARCNAME not in names:
|
|
88
|
+
raise ValueError(f"bundle is missing required '{_META_ARCNAME}'")
|
|
89
|
+
if _IMAGE_ARCNAME not in names:
|
|
90
|
+
raise ValueError(f"bundle is missing required '{_IMAGE_ARCNAME}'")
|
|
91
|
+
|
|
92
|
+
for member in members:
|
|
93
|
+
tar.extract(member, path=dest_dir, filter="data")
|
|
94
|
+
|
|
95
|
+
result: dict[str, Path] = {
|
|
96
|
+
"rig_yaml": dest_dir / _META_ARCNAME,
|
|
97
|
+
"image_tar": dest_dir / _IMAGE_ARCNAME,
|
|
98
|
+
}
|
|
99
|
+
if _CONTAINERFILE_ARCNAME in names:
|
|
100
|
+
result["containerfile"] = dest_dir / _CONTAINERFILE_ARCNAME
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_bundle_meta(tgz: Path) -> RigMeta:
|
|
105
|
+
"""Read just the ``rig.yaml`` member of *tgz* and return its :class:`RigMeta`.
|
|
106
|
+
|
|
107
|
+
Reads the metadata directly out of the tar without extracting the
|
|
108
|
+
(potentially large) ``image.tar``. Raises :class:`ValueError` if the
|
|
109
|
+
``rig.yaml`` member is absent.
|
|
110
|
+
"""
|
|
111
|
+
with tarfile.open(tgz, "r:gz") as tar:
|
|
112
|
+
try:
|
|
113
|
+
handle = tar.extractfile(_META_ARCNAME)
|
|
114
|
+
except KeyError:
|
|
115
|
+
handle = None
|
|
116
|
+
if handle is None:
|
|
117
|
+
raise ValueError(f"bundle is missing required '{_META_ARCNAME}'")
|
|
118
|
+
with handle:
|
|
119
|
+
text = handle.read().decode("utf-8")
|
|
120
|
+
|
|
121
|
+
return load_rig_meta(text)
|
kanibako/rig_meta.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Truth-in-image metadata for *extended* rigs (``/etc/kanibako/rig.yaml``).
|
|
2
|
+
|
|
3
|
+
An extended rig is an interactively-built, non-reproducible image: there is no
|
|
4
|
+
external source to re-pull or rebuild from, so its identity must travel *inside*
|
|
5
|
+
the image itself. This module defines that in-image metadata file and its
|
|
6
|
+
serialization.
|
|
7
|
+
|
|
8
|
+
Because the metadata lives at a fixed path inside the rootfs, it rides
|
|
9
|
+
``podman save`` / ``load`` / ``push`` natively -- whoever ends up with the image
|
|
10
|
+
also ends up with its ``rig.yaml``, no side-channel required.
|
|
11
|
+
|
|
12
|
+
All on-disk serialization goes through PyYAML (``yaml.safe_load`` /
|
|
13
|
+
``yaml.safe_dump``); there is no hand-rolled serializer here.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, fields
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import yaml # type: ignore[import-untyped]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RigMeta:
|
|
26
|
+
"""In-image metadata for an extended rig.
|
|
27
|
+
|
|
28
|
+
``name`` is the short rig name; ``parent`` and ``foundation_source`` are
|
|
29
|
+
arbitrary image references / source descriptors (not validated here).
|
|
30
|
+
``recipe`` is an optional captured shell history of the steps that built
|
|
31
|
+
the rig (informational only -- extended rigs are not reproducible).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
kind: str = "extended"
|
|
36
|
+
parent: str | None = None
|
|
37
|
+
foundation_source: str | None = None
|
|
38
|
+
reproducible: bool = False
|
|
39
|
+
created: str | None = None
|
|
40
|
+
recipe: list[str] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Field names in a stable, file-friendly order, used to filter unknown keys on
|
|
44
|
+
# load and to drive the ordered dump below.
|
|
45
|
+
_FIELD_NAMES: tuple[str, ...] = tuple(f.name for f in fields(RigMeta))
|
|
46
|
+
|
|
47
|
+
# Fields that are always emitted, even when falsy (False / empty).
|
|
48
|
+
_ALWAYS: frozenset[str] = frozenset({"name", "kind", "reproducible"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def dump_rig_meta(meta: RigMeta) -> str:
|
|
52
|
+
"""Serialize *meta* to a YAML string.
|
|
53
|
+
|
|
54
|
+
``name``, ``kind`` and ``reproducible`` are always emitted. The remaining
|
|
55
|
+
fields are omitted when ``None`` so the file stays clean (in particular
|
|
56
|
+
``recipe`` disappears entirely when unset). Field order is stable and
|
|
57
|
+
readable (``sort_keys=False``).
|
|
58
|
+
"""
|
|
59
|
+
data: dict[str, object] = {}
|
|
60
|
+
for field_name in _FIELD_NAMES:
|
|
61
|
+
value = getattr(meta, field_name)
|
|
62
|
+
if field_name not in _ALWAYS and value is None:
|
|
63
|
+
continue
|
|
64
|
+
data[field_name] = value
|
|
65
|
+
return yaml.safe_dump(data, sort_keys=False)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_rig_meta(meta: RigMeta, path: Path) -> None:
|
|
69
|
+
"""Write *meta* to *path*, creating the parent directory if needed."""
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
path.write_text(dump_rig_meta(meta))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_rig_meta(source: str | Path) -> RigMeta:
|
|
75
|
+
"""Load a :class:`RigMeta` from a file *path* or a raw YAML *string*.
|
|
76
|
+
|
|
77
|
+
A :class:`~pathlib.Path` is read from disk; a ``str`` is parsed directly as
|
|
78
|
+
YAML text. Unknown keys are ignored defensively (only known fields are
|
|
79
|
+
passed to the constructor). Raises :class:`ValueError` if the document is
|
|
80
|
+
empty/invalid or is missing the required ``name`` field.
|
|
81
|
+
"""
|
|
82
|
+
text = source.read_text() if isinstance(source, Path) else source
|
|
83
|
+
|
|
84
|
+
data = yaml.safe_load(text)
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise ValueError("rig.yaml must be a non-empty YAML mapping")
|
|
87
|
+
|
|
88
|
+
kwargs = {k: v for k, v in data.items() if k in _FIELD_NAMES}
|
|
89
|
+
if not kwargs.get("name"):
|
|
90
|
+
raise ValueError("rig.yaml is missing the required 'name' field")
|
|
91
|
+
|
|
92
|
+
return RigMeta(**kwargs)
|
kanibako/rig_registry.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Host-side rig registry stored in a single ``rigs.yaml`` file.
|
|
2
|
+
|
|
3
|
+
Pure load/save/query helpers for "added" rig records, keyed by rig name.
|
|
4
|
+
Rig names may contain ``/`` and ``:`` (e.g. ``"corp/base:1.0"``); they are
|
|
5
|
+
emitted as plain YAML mapping keys (PyYAML quotes them as needed).
|
|
6
|
+
|
|
7
|
+
Reads and writes go through PyYAML (``yaml.safe_load`` / ``yaml.safe_dump``).
|
|
8
|
+
PyYAML handles all escaping and quoting, so there is no hand-rolled
|
|
9
|
+
serializer here.
|
|
10
|
+
|
|
11
|
+
No network, no global state: the registry path is always passed in.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, fields
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from kanibako.paths import StandardPaths
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RigRecord:
|
|
28
|
+
"""A single "added" rig record.
|
|
29
|
+
|
|
30
|
+
``name`` is also the registry key; the remaining fields are optional and
|
|
31
|
+
carry whatever metadata is relevant to the rig's kind (prefab / extended).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
kind: str
|
|
36
|
+
source: str | None = None
|
|
37
|
+
source_type: str | None = None
|
|
38
|
+
image: str | None = None
|
|
39
|
+
parent: str | None = None
|
|
40
|
+
foundation_source: str | None = None
|
|
41
|
+
reproducible: bool | None = None
|
|
42
|
+
created: str | None = None
|
|
43
|
+
added: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Fields stored *inside* the table (i.e. everything except ``name``, which is
|
|
47
|
+
# the mapping key) in a stable, file-friendly order.
|
|
48
|
+
_INNER_FIELDS: tuple[str, ...] = tuple(
|
|
49
|
+
f.name for f in fields(RigRecord) if f.name != "name"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def registry_path(std: StandardPaths) -> Path:
|
|
54
|
+
"""Return the path to ``rigs.yaml`` under the standard data directory."""
|
|
55
|
+
return std.data_path / "rigs.yaml"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_registry(path: Path) -> dict[str, RigRecord]:
|
|
59
|
+
"""Load all rig records from *path*, keyed by rig name.
|
|
60
|
+
|
|
61
|
+
A missing or empty file yields an empty dict. The file is shaped as a
|
|
62
|
+
top-level ``rigs:`` mapping whose keys are rig names::
|
|
63
|
+
|
|
64
|
+
rigs:
|
|
65
|
+
corp/base:1.0:
|
|
66
|
+
kind: prefab
|
|
67
|
+
...
|
|
68
|
+
"""
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
data = yaml.safe_load(path.read_text())
|
|
73
|
+
if not data:
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
rigs = data.get("rigs", {})
|
|
77
|
+
records: dict[str, RigRecord] = {}
|
|
78
|
+
for name, table in rigs.items():
|
|
79
|
+
kwargs: dict[str, object] = {"name": name}
|
|
80
|
+
for field_name in _INNER_FIELDS:
|
|
81
|
+
if field_name in table:
|
|
82
|
+
kwargs[field_name] = table[field_name]
|
|
83
|
+
records[name] = RigRecord(**kwargs) # type: ignore[arg-type]
|
|
84
|
+
return records
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def save_registry(path: Path, records: dict[str, RigRecord]) -> None:
|
|
88
|
+
"""Write *records* to *path* as a ``rigs:`` mapping (one entry per record).
|
|
89
|
+
|
|
90
|
+
``None``-valued fields are omitted so the file stays clean. ``name`` is the
|
|
91
|
+
mapping key and is not duplicated inside the entry. The parent directory is
|
|
92
|
+
created if needed.
|
|
93
|
+
"""
|
|
94
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
rigs: dict[str, dict[str, object]] = {}
|
|
97
|
+
for name, record in records.items():
|
|
98
|
+
table: dict[str, object] = {}
|
|
99
|
+
for field_name in _INNER_FIELDS:
|
|
100
|
+
value = getattr(record, field_name)
|
|
101
|
+
if value is None:
|
|
102
|
+
continue
|
|
103
|
+
table[field_name] = value
|
|
104
|
+
rigs[name] = table
|
|
105
|
+
|
|
106
|
+
data = {"rigs": rigs}
|
|
107
|
+
path.write_text(yaml.safe_dump(data, sort_keys=False))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def upsert(path: Path, record: RigRecord) -> None:
|
|
111
|
+
"""Insert *record* (or overwrite the existing record with the same name)."""
|
|
112
|
+
records = load_registry(path)
|
|
113
|
+
records[record.name] = record
|
|
114
|
+
save_registry(path, records)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def remove(path: Path, name: str) -> bool:
|
|
118
|
+
"""Remove the record named *name*.
|
|
119
|
+
|
|
120
|
+
Returns ``True`` if a record was removed, ``False`` if it was absent.
|
|
121
|
+
"""
|
|
122
|
+
records = load_registry(path)
|
|
123
|
+
if name not in records:
|
|
124
|
+
return False
|
|
125
|
+
del records[name]
|
|
126
|
+
save_registry(path, records)
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get(path: Path, name: str) -> RigRecord | None:
|
|
131
|
+
"""Return the record named *name*, or ``None`` if it is not registered."""
|
|
132
|
+
return load_registry(path).get(name)
|
kanibako/rig_resolve.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Pure rig-name resolution: classify a rig name into kind + prep action.
|
|
2
|
+
|
|
3
|
+
A *rig* is a container image a box can start from. This module answers the
|
|
4
|
+
question "given a name the user typed, what kind of rig is it and what (if
|
|
5
|
+
anything) must happen before it can be used?" -- WITHOUT performing any side
|
|
6
|
+
effects. It never pulls, builds, or commits; that is :func:`prep`'s job in a
|
|
7
|
+
later increment. Resolution only inspects the local image store, the bundled /
|
|
8
|
+
user-override template Containerfiles, and (eventually) the rig registry.
|
|
9
|
+
|
|
10
|
+
Kinds:
|
|
11
|
+
``"prefab"`` -- a published/base image, made ready by pulling.
|
|
12
|
+
``"template"`` -- a buildable ``Containerfile.template-<name>``.
|
|
13
|
+
``"extended"`` -- an interactively built, non-reproducible image.
|
|
14
|
+
|
|
15
|
+
Prep actions:
|
|
16
|
+
``"none"`` -- already prepped (the image exists locally).
|
|
17
|
+
``"pull"`` -- pull from a registry to prep.
|
|
18
|
+
``"build"`` -- build a Containerfile to prep.
|
|
19
|
+
``"missing"`` -- cannot be resolved (reserved; not produced yet).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from kanibako.containerfiles import get_containerfile
|
|
29
|
+
from kanibako.templates_image import (
|
|
30
|
+
list_bundled_templates,
|
|
31
|
+
rig_image_name,
|
|
32
|
+
template_image_name,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from kanibako.config import KanibakoConfig
|
|
37
|
+
from kanibako.container import ContainerRuntime
|
|
38
|
+
from kanibako.paths import StandardPaths
|
|
39
|
+
from kanibako.rig_registry import RigRecord
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class RigResolution:
|
|
44
|
+
"""The classification of a rig name.
|
|
45
|
+
|
|
46
|
+
*kind* is one of ``"prefab"``, ``"template"``, ``"extended"``.
|
|
47
|
+
*prep_action* is one of ``"pull"``, ``"build"``, ``"none"``, ``"missing"``.
|
|
48
|
+
*image* is the OCI reference a prepped rig is (or will be) stored under.
|
|
49
|
+
*containerfile* is set for buildable templates that need building.
|
|
50
|
+
*source_ref* carries the original/source reference for prefabs (reserved).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
kind: str
|
|
55
|
+
image: str
|
|
56
|
+
prep_action: str
|
|
57
|
+
containerfile: Path | None = None
|
|
58
|
+
source_ref: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_rig(
|
|
62
|
+
name: str,
|
|
63
|
+
runtime: ContainerRuntime,
|
|
64
|
+
std: StandardPaths,
|
|
65
|
+
merged: KanibakoConfig,
|
|
66
|
+
*,
|
|
67
|
+
registry: dict[str, RigRecord] | None = None,
|
|
68
|
+
) -> RigResolution:
|
|
69
|
+
"""Classify rig *name* into a :class:`RigResolution`. Pure -- no side effects.
|
|
70
|
+
|
|
71
|
+
Precedence:
|
|
72
|
+
|
|
73
|
+
1. **Already-prepped local image.** If a ``kanibako-template-<name>`` or
|
|
74
|
+
``kanibako-rig-<name>`` image exists locally, it is already prepped
|
|
75
|
+
(``prep_action="none"``) with kind ``"template"`` / ``"extended"``.
|
|
76
|
+
2. **Discovered template.** If *name* matches a bundled or user-override
|
|
77
|
+
``Containerfile.template-<name>``, it is a buildable template
|
|
78
|
+
(``prep_action="build"``).
|
|
79
|
+
3. **Resolvable reference / prefab.** Otherwise defer to
|
|
80
|
+
:func:`resolve_image_reference`; the rig is a ``"prefab"`` made ready by
|
|
81
|
+
``"pull"`` (or ``"none"`` if the resolved image already exists locally).
|
|
82
|
+
|
|
83
|
+
Between steps 1 and 2, if *registry* is provided and contains *name*, the
|
|
84
|
+
matching record decides the result: an ``"extended"`` record resolves to its
|
|
85
|
+
recorded image (``"none"`` if present locally, else ``"missing"``); any other
|
|
86
|
+
(prefab) record resolves to its image / source reference made ready by
|
|
87
|
+
``"pull"`` (or ``"none"`` if already local). Passing ``None`` skips this step.
|
|
88
|
+
"""
|
|
89
|
+
# --- (a) Already-prepped local image -------------------------------
|
|
90
|
+
# rig_image_name / template_image_name raise on names that aren't valid
|
|
91
|
+
# short template names (e.g. anything with '/' or ':'); those can't be a
|
|
92
|
+
# locally-prepped template/extended image, so just skip this step for them.
|
|
93
|
+
try:
|
|
94
|
+
extended_image = rig_image_name(name)
|
|
95
|
+
template_image = template_image_name(name)
|
|
96
|
+
except ValueError:
|
|
97
|
+
extended_image = None
|
|
98
|
+
template_image = None
|
|
99
|
+
|
|
100
|
+
if template_image is not None and runtime.image_exists(template_image):
|
|
101
|
+
return RigResolution(
|
|
102
|
+
name=name,
|
|
103
|
+
kind="template",
|
|
104
|
+
image=template_image,
|
|
105
|
+
prep_action="none",
|
|
106
|
+
)
|
|
107
|
+
if extended_image is not None and runtime.image_exists(extended_image):
|
|
108
|
+
return RigResolution(
|
|
109
|
+
name=name,
|
|
110
|
+
kind="extended",
|
|
111
|
+
image=extended_image,
|
|
112
|
+
prep_action="none",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# --- (a2) Host registry (added rigs: prefabs / imported extended) --
|
|
116
|
+
if registry is not None and name in registry:
|
|
117
|
+
record = registry[name]
|
|
118
|
+
if record.kind == "extended":
|
|
119
|
+
# Extended rigs normally carry their recorded image and a valid
|
|
120
|
+
# short name (so extended_image is set). Guard the malformed case
|
|
121
|
+
# -- no recorded image AND a name that isn't a valid default-image
|
|
122
|
+
# name (extended_image is None) -- by falling back to the name
|
|
123
|
+
# itself, rather than re-calling rig_image_name(name) which would
|
|
124
|
+
# raise the same ValueError that already nulled extended_image.
|
|
125
|
+
img = record.image or extended_image or name
|
|
126
|
+
action = "none" if runtime.image_exists(img) else "missing"
|
|
127
|
+
return RigResolution(
|
|
128
|
+
name=name,
|
|
129
|
+
kind="extended",
|
|
130
|
+
image=img,
|
|
131
|
+
prep_action=action,
|
|
132
|
+
source_ref=record.source,
|
|
133
|
+
)
|
|
134
|
+
# prefab (or any other registry kind): pull-based.
|
|
135
|
+
if record.image:
|
|
136
|
+
img = record.image
|
|
137
|
+
else:
|
|
138
|
+
# Lazy import to avoid a circular import (see step (c)).
|
|
139
|
+
from kanibako.commands.image import resolve_image_reference
|
|
140
|
+
|
|
141
|
+
img = resolve_image_reference(
|
|
142
|
+
record.source or name, runtime, merged.box_image
|
|
143
|
+
)
|
|
144
|
+
action = "none" if runtime.image_exists(img) else "pull"
|
|
145
|
+
return RigResolution(
|
|
146
|
+
name=name,
|
|
147
|
+
kind=record.kind,
|
|
148
|
+
image=img,
|
|
149
|
+
prep_action=action,
|
|
150
|
+
source_ref=record.source,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# --- (b) Discovered template (buildable Containerfile) -------------
|
|
154
|
+
containers_dir = std.data_path / "containers"
|
|
155
|
+
discovered = {t.name for t in list_bundled_templates(override_dir=containers_dir)}
|
|
156
|
+
if name in discovered:
|
|
157
|
+
containerfile = get_containerfile(f"template-{name}", containers_dir)
|
|
158
|
+
return RigResolution(
|
|
159
|
+
name=name,
|
|
160
|
+
# template_image is non-None here: a discovered template name is a
|
|
161
|
+
# valid short name, so template_image_name didn't raise above.
|
|
162
|
+
kind="template",
|
|
163
|
+
image=template_image or template_image_name(name),
|
|
164
|
+
prep_action="build",
|
|
165
|
+
containerfile=containerfile,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# --- (c) Known base / resolvable reference (prefab) ---------------
|
|
169
|
+
# Imported lazily to avoid a circular import: commands.image imports
|
|
170
|
+
# resolve_rig (from Increment 2 onward), and this module would otherwise
|
|
171
|
+
# import commands.image at load time.
|
|
172
|
+
from kanibako.commands.image import resolve_image_reference
|
|
173
|
+
|
|
174
|
+
resolved = resolve_image_reference(name, runtime, merged.box_image)
|
|
175
|
+
prep_action = "none" if runtime.image_exists(resolved) else "pull"
|
|
176
|
+
return RigResolution(
|
|
177
|
+
name=name,
|
|
178
|
+
kind="prefab",
|
|
179
|
+
image=resolved,
|
|
180
|
+
prep_action=prep_action,
|
|
181
|
+
source_ref=name,
|
|
182
|
+
)
|