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.
Files changed (85) hide show
  1. kanibako/__init__.py +3 -0
  2. kanibako/__main__.py +6 -0
  3. kanibako/auth_browser.py +296 -0
  4. kanibako/auth_parser.py +51 -0
  5. kanibako/browser_sidecar.py +183 -0
  6. kanibako/browser_state.py +103 -0
  7. kanibako/bun_sea.py +144 -0
  8. kanibako/cli.py +344 -0
  9. kanibako/commands/__init__.py +0 -0
  10. kanibako/commands/archive.py +228 -0
  11. kanibako/commands/box/__init__.py +22 -0
  12. kanibako/commands/box/_duplicate.py +395 -0
  13. kanibako/commands/box/_migrate.py +574 -0
  14. kanibako/commands/box/_parser.py +1178 -0
  15. kanibako/commands/clean.py +166 -0
  16. kanibako/commands/crab_cmd.py +480 -0
  17. kanibako/commands/diagnose.py +239 -0
  18. kanibako/commands/fork_cmd.py +51 -0
  19. kanibako/commands/helper_cmd.py +669 -0
  20. kanibako/commands/image.py +1300 -0
  21. kanibako/commands/install.py +152 -0
  22. kanibako/commands/refresh_credentials.py +67 -0
  23. kanibako/commands/restore.py +298 -0
  24. kanibako/commands/setup_cmd.py +89 -0
  25. kanibako/commands/start.py +1600 -0
  26. kanibako/commands/stop.py +116 -0
  27. kanibako/commands/system_cmd.py +224 -0
  28. kanibako/commands/upgrade.py +161 -0
  29. kanibako/commands/vault_cmd.py +199 -0
  30. kanibako/commands/workset_cmd.py +552 -0
  31. kanibako/config.py +514 -0
  32. kanibako/config_interface.py +573 -0
  33. kanibako/config_io.py +36 -0
  34. kanibako/container.py +607 -0
  35. kanibako/containerfiles.py +58 -0
  36. kanibako/containers/Containerfile.kanibako +99 -0
  37. kanibako/containers/Containerfile.template-android +55 -0
  38. kanibako/containers/Containerfile.template-dotnet +29 -0
  39. kanibako/containers/Containerfile.template-js +43 -0
  40. kanibako/containers/Containerfile.template-jvm +27 -0
  41. kanibako/containers/Containerfile.template-systems +46 -0
  42. kanibako/containers/__init__.py +0 -0
  43. kanibako/crabs.py +89 -0
  44. kanibako/errors.py +33 -0
  45. kanibako/freshness.py +67 -0
  46. kanibako/git.py +114 -0
  47. kanibako/helper_client.py +132 -0
  48. kanibako/helper_listener.py +538 -0
  49. kanibako/helpers.py +339 -0
  50. kanibako/hygiene.py +296 -0
  51. kanibako/image_sharing.py +133 -0
  52. kanibako/instructions.py +160 -0
  53. kanibako/log.py +31 -0
  54. kanibako/names.py +248 -0
  55. kanibako/paths.py +1483 -0
  56. kanibako/plugins/__init__.py +10 -0
  57. kanibako/registry.py +71 -0
  58. kanibako/rig_bundle.py +121 -0
  59. kanibako/rig_meta.py +92 -0
  60. kanibako/rig_registry.py +132 -0
  61. kanibako/rig_resolve.py +182 -0
  62. kanibako/rig_source.py +245 -0
  63. kanibako/scripts/__init__.py +0 -0
  64. kanibako/scripts/helper-init.sh +45 -0
  65. kanibako/scripts/kanibako-entry +12 -0
  66. kanibako/settings_resolve.py +312 -0
  67. kanibako/settings_seeds.py +154 -0
  68. kanibako/settings_shares.py +154 -0
  69. kanibako/shellenv.py +75 -0
  70. kanibako/snapshots.py +281 -0
  71. kanibako/targets/__init__.py +173 -0
  72. kanibako/targets/base.py +243 -0
  73. kanibako/targets/no_agent.py +58 -0
  74. kanibako/templates.py +60 -0
  75. kanibako/templates_image.py +224 -0
  76. kanibako/tweakcc.py +140 -0
  77. kanibako/tweakcc_cache.py +171 -0
  78. kanibako/utils.py +136 -0
  79. kanibako/workset.py +347 -0
  80. kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
  81. kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
  82. kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
  83. kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
  84. kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
  85. 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)
@@ -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)
@@ -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
+ )