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
kanibako/container.py ADDED
@@ -0,0 +1,607 @@
1
+ """ContainerRuntime: detect podman/docker, pull/build/run images, list images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from kanibako.containerfiles import get_containerfile
13
+ from kanibako.errors import ContainerError
14
+ from kanibako.log import get_logger
15
+
16
+ logger = get_logger("container")
17
+
18
+
19
+ # Map image name patterns to Containerfile suffixes.
20
+ _IMAGE_CONTAINERFILE_MAP = {
21
+ "kanibako-min": "kanibako",
22
+ "kanibako-oci": "kanibako",
23
+ "kanibako-lxc": "kanibako",
24
+ "kanibako-vm": "kanibako",
25
+ }
26
+
27
+ # Map image name patterns to build variants (for VARIANT build arg).
28
+ _IMAGE_VARIANT_MAP = {
29
+ "kanibako-min": "min",
30
+ "kanibako-oci": "oci",
31
+ "kanibako-lxc": "lxc",
32
+ "kanibako-vm": "vm",
33
+ }
34
+
35
+ # Map image variants to their droste base image for local builds.
36
+ _IMAGE_BASE_MAP = {
37
+ "kanibako-min": "ghcr.io/doctorjei/droste-seed:1.1.0",
38
+ "kanibako-oci": "ghcr.io/doctorjei/droste-fiber:1.1.0",
39
+ "kanibako-lxc": "ghcr.io/doctorjei/droste-thread:1.1.0",
40
+ "kanibako-vm": "ghcr.io/doctorjei/droste-hair:1.1.0",
41
+ }
42
+
43
+
44
+ class ContainerRuntime:
45
+ """Wrapper around podman/docker CLI."""
46
+
47
+ def __init__(self, command: str | None = None) -> None:
48
+ if command:
49
+ self.cmd = command
50
+ else:
51
+ self.cmd = self._detect()
52
+
53
+ @staticmethod
54
+ def _detect() -> str:
55
+ env = os.environ.get("KANIBAKO_DOCKER_CMD")
56
+ if env:
57
+ return env
58
+ for name in ("podman", "docker"):
59
+ path = shutil.which(name)
60
+ if path:
61
+ return path
62
+ raise ContainerError(
63
+ "No container runtime found. "
64
+ "Install podman (https://podman.io/) or Docker."
65
+ )
66
+
67
+ # ------------------------------------------------------------------
68
+ # Image operations
69
+ # ------------------------------------------------------------------
70
+
71
+ def image_exists(self, image: str) -> bool:
72
+ result = subprocess.run(
73
+ [self.cmd, "image", "inspect", image],
74
+ capture_output=True,
75
+ )
76
+ return result.returncode == 0
77
+
78
+ def image_inspect(self, image: str) -> dict | None:
79
+ """Return image metadata as a dict, or None if not found."""
80
+ result = subprocess.run(
81
+ [self.cmd, "image", "inspect", image, "--format", "json"],
82
+ capture_output=True, text=True,
83
+ )
84
+ if result.returncode != 0:
85
+ return None
86
+ import json
87
+ data = json.loads(result.stdout)
88
+ if isinstance(data, list) and data:
89
+ return data[0]
90
+ return data if isinstance(data, dict) else None
91
+
92
+ def pull(self, image: str, *, quiet: bool = True) -> bool:
93
+ """Pull *image* from registry. Returns True on success."""
94
+ result = subprocess.run(
95
+ [self.cmd, "pull", image],
96
+ capture_output=quiet,
97
+ )
98
+ return result.returncode == 0
99
+
100
+ def remove_image(self, image: str) -> None:
101
+ """Remove a local image. Raises ContainerError on failure."""
102
+ result = subprocess.run(
103
+ [self.cmd, "rmi", image],
104
+ capture_output=True,
105
+ text=True,
106
+ )
107
+ if result.returncode != 0:
108
+ raise ContainerError(f"Failed to remove rig {image}:\n{result.stderr}")
109
+
110
+ def unshare_rm(self, path: Path) -> bool:
111
+ """Remove *path* from within the rootless user namespace.
112
+
113
+ Files a ``--userns=keep-id`` container creates as root map to subuids
114
+ the host user cannot ``unlink`` directly, so a plain ``rmtree`` of a
115
+ box's shell dir can fail with EACCES. ``podman unshare`` runs ``rm``
116
+ inside the user namespace where those subuids appear as root, so the
117
+ removal succeeds. Returns True on success. Only podman supports
118
+ ``unshare``; returns False for docker or on any failure.
119
+ """
120
+ if "podman" not in Path(self.cmd).name:
121
+ return False
122
+ result = subprocess.run(
123
+ [self.cmd, "unshare", "rm", "-rf", str(path)],
124
+ capture_output=True,
125
+ text=True,
126
+ )
127
+ return result.returncode == 0
128
+
129
+ def build(self, image: str, containerfile: Path, context: Path) -> None:
130
+ """Build *image* from *containerfile*. Raises ContainerError on failure."""
131
+ result = subprocess.run(
132
+ [self.cmd, "build", "-t", image, "-f", str(containerfile), str(context)],
133
+ capture_output=True,
134
+ text=True,
135
+ )
136
+ if result.returncode != 0:
137
+ raise ContainerError(
138
+ f"Failed to build rig {image}:\n{result.stderr}"
139
+ )
140
+
141
+ def rebuild(
142
+ self,
143
+ image: str,
144
+ containerfile: Path,
145
+ context: Path,
146
+ build_args: dict[str, str] | None = None,
147
+ ) -> int:
148
+ """Rebuild *image* with --no-cache, streaming output. Returns exit code."""
149
+ cmd = [self.cmd, "build", "--no-cache", "-t", image, "-f", str(containerfile)]
150
+ if build_args:
151
+ for key, val in build_args.items():
152
+ cmd.extend(["--build-arg", f"{key}={val}"])
153
+ cmd.append(str(context))
154
+ result = subprocess.run(cmd)
155
+ return result.returncode
156
+
157
+ @staticmethod
158
+ def get_base_image(image: str) -> str | None:
159
+ """Return the droste base image for a kanibako variant, or None."""
160
+ for pattern, base in _IMAGE_BASE_MAP.items():
161
+ if pattern in image:
162
+ return base
163
+ return None
164
+
165
+ @staticmethod
166
+ def get_variant(image: str) -> str | None:
167
+ """Return the build variant (min/oci/lxc/vm) for a kanibako image, or None."""
168
+ for pattern, variant in _IMAGE_VARIANT_MAP.items():
169
+ if pattern in image:
170
+ return variant
171
+ return None
172
+
173
+ def run_interactive(self, image: str, *, container_name: str | None = None) -> int:
174
+ """Run an interactive container. Returns exit code."""
175
+ cmd = [self.cmd, "run", "-it"]
176
+ if container_name:
177
+ cmd.extend(["--name", container_name])
178
+ cmd.append(image)
179
+ result = subprocess.run(cmd)
180
+ return result.returncode
181
+
182
+ def commit(self, container: str, image: str) -> None:
183
+ """Commit a container to a new image. Raises ContainerError on failure."""
184
+ result = subprocess.run(
185
+ [self.cmd, "commit", container, image],
186
+ capture_output=True,
187
+ text=True,
188
+ )
189
+ if result.returncode != 0:
190
+ raise ContainerError(f"Failed to commit container: {result.stderr}")
191
+
192
+ def cp(self, src: Path, dest: str) -> bool:
193
+ """Copy *src* into a container at *dest* (``<container>:<path>``).
194
+
195
+ Returns True on success.
196
+ """
197
+ result = subprocess.run(
198
+ [self.cmd, "cp", str(src), dest],
199
+ capture_output=True,
200
+ )
201
+ return result.returncode == 0
202
+
203
+ def save(self, image: str, out: Path) -> bool:
204
+ """Save *image* to a tar archive at *out*. Returns True on success."""
205
+ result = subprocess.run(
206
+ [self.cmd, "save", "-o", str(out), image],
207
+ capture_output=True,
208
+ )
209
+ return result.returncode == 0
210
+
211
+ def load(self, archive: Path) -> str | None:
212
+ """Load an image from the tar *archive*.
213
+
214
+ Returns the loaded image reference parsed from the runtime's
215
+ ``Loaded image: <ref>`` output (an archive with no RepoTags yields an
216
+ empty string), or ``None`` if the load command itself failed. Reading
217
+ the ref back from the runtime is authoritative -- the archive's
218
+ filename is not a reliable source for the loaded tag.
219
+ """
220
+ result = subprocess.run(
221
+ [self.cmd, "load", "-i", str(archive)],
222
+ capture_output=True,
223
+ text=True,
224
+ )
225
+ if result.returncode != 0:
226
+ return None
227
+ # podman/docker print e.g. "Loaded image: repo:tag",
228
+ # "Loaded image(s): repo:tag", or "Loaded image ID: sha256:...".
229
+ for line in result.stdout.splitlines():
230
+ m = re.search(r"Loaded image(?:\(s\)| ID)?:\s*(\S.*)$", line)
231
+ if m:
232
+ return m.group(1).strip()
233
+ return ""
234
+
235
+ def diff(self, image: str) -> list[str]:
236
+ """Return the changed paths for *image* as verbatim lines.
237
+
238
+ Each line is a changed path, possibly prefixed by a change-type
239
+ letter (``C``/``A``/``D``). Returns an empty list on failure.
240
+ """
241
+ result = subprocess.run(
242
+ [self.cmd, "diff", image],
243
+ capture_output=True,
244
+ text=True,
245
+ )
246
+ if result.returncode != 0:
247
+ return []
248
+ return [line for line in result.stdout.splitlines() if line]
249
+
250
+ def guess_containerfile(self, image: str) -> str | None:
251
+ """Return the Containerfile suffix for a known image pattern, or None."""
252
+ return self._guess_containerfile(image)
253
+
254
+ def ensure_image(self, image: str, containers_dir: Path) -> None:
255
+ """Make sure *image* is available locally: inspect → pull → build fallback."""
256
+ if self.image_exists(image):
257
+ return
258
+
259
+ print(
260
+ f"Rig not found locally. Pulling {image}...",
261
+ file=sys.stderr,
262
+ )
263
+ if self.pull(image):
264
+ print("Rig pulled successfully.", file=sys.stderr)
265
+ return
266
+
267
+ print("Pull failed. Attempting local build...", file=sys.stderr)
268
+ suffix = self._guess_containerfile(image)
269
+ if suffix is None:
270
+ raise ContainerError(
271
+ f"Failed to pull rig '{image}' and no local Containerfile found.\n"
272
+ f"Check your network connection, or run 'kanibako rig rebuild' "
273
+ f"to build locally."
274
+ )
275
+ containerfile = get_containerfile(suffix, containers_dir)
276
+ if containerfile is None:
277
+ raise ContainerError(
278
+ f"Failed to pull rig '{image}' and no local Containerfile found.\n"
279
+ f"Check your network connection, or run 'kanibako rig rebuild' "
280
+ f"to build locally."
281
+ )
282
+ self.build(image, containerfile, containerfile.parent)
283
+ print("Rig built successfully.", file=sys.stderr)
284
+
285
+ @staticmethod
286
+ def _guess_containerfile(image: str) -> str | None:
287
+ for pattern, suffix in _IMAGE_CONTAINERFILE_MAP.items():
288
+ if pattern in image:
289
+ return suffix
290
+ return None
291
+
292
+ @staticmethod
293
+ def buildable_containerfile_suffixes() -> set[str]:
294
+ """Containerfile suffixes a build command can resolve to an image.
295
+
296
+ Suffixes outside this set (e.g. ``jvm``, ``systems``) are example
297
+ templates that layer on a base image via ``ARG BASE_IMAGE`` and are
298
+ not built directly by ``rig rebuild``.
299
+ """
300
+ return set(_IMAGE_CONTAINERFILE_MAP.values())
301
+
302
+ # ------------------------------------------------------------------
303
+ # Run
304
+ # ------------------------------------------------------------------
305
+
306
+ def run(
307
+ self,
308
+ image: str,
309
+ *,
310
+ shell_path: Path,
311
+ project_path: Path,
312
+ vault_ro_path: Path,
313
+ vault_rw_path: Path,
314
+ extra_mounts: list | None = None,
315
+ vault_tmpfs: bool = False,
316
+ enable_vault: bool = True,
317
+ env: dict[str, str] | None = None,
318
+ name: str | None = None,
319
+ entrypoint: str | None = None,
320
+ cli_args: list[str] | None = None,
321
+ detach: bool = False,
322
+ ) -> int:
323
+ """Run a container and return the exit code.
324
+
325
+ When *detach* is True the container runs in the background (``-d``
326
+ instead of ``-it``, no ``--rm``). Returns 0 on success.
327
+ """
328
+ # Pre-create mount destination stubs so crun doesn't need to mkdir
329
+ # inside bind-mounted overlay filesystems (fails in LXC).
330
+ _precreate_mount_stubs(
331
+ shell_path, project_path, extra_mounts,
332
+ enable_vault, vault_ro_path, vault_rw_path, vault_tmpfs,
333
+ )
334
+
335
+ if detach:
336
+ run_flags = ["-dt", "--userns=keep-id"]
337
+ else:
338
+ tty_flag = "-it" if sys.stdin.isatty() else "-i"
339
+ run_flags = [tty_flag, "--rm", "--userns=keep-id"]
340
+ cmd: list[str] = [
341
+ self.cmd, "run", *run_flags,
342
+ # Persistent agent home
343
+ "-v", f"{shell_path}:/home/agent:Z,U",
344
+ # Project workspace
345
+ "-v", f"{project_path}:/home/agent/workspace:Z,U",
346
+ "-w", "/home/agent/workspace",
347
+ ]
348
+ # Vault mounts (only if directories exist and vault is enabled)
349
+ if enable_vault:
350
+ if vault_ro_path.is_dir():
351
+ cmd += ["-v", f"{vault_ro_path}:/home/agent/share-ro:ro"]
352
+ if vault_rw_path.is_dir():
353
+ cmd += ["-v", f"{vault_rw_path}:/home/agent/share-rw:Z,U"]
354
+ # Local vault hiding: read-only tmpfs over workspace/vault
355
+ if vault_tmpfs:
356
+ cmd += ["--mount", "type=tmpfs,dst=/home/agent/workspace/vault,ro"]
357
+ # Mount a .gitignore on top of the tmpfs so the stub
358
+ # directories created by the OCI runtime are ignored.
359
+ import importlib.resources
360
+ gi_ref = importlib.resources.files("kanibako.scripts").joinpath("vault-gitignore")
361
+ gi_path = Path(str(gi_ref))
362
+ if gi_path.is_file():
363
+ cmd += ["-v", f"{gi_path}:/home/agent/workspace/vault/.gitignore:ro"]
364
+ # Extra mounts (target binary mounts, etc.)
365
+ if extra_mounts:
366
+ for mount in extra_mounts:
367
+ cmd += ["-v", mount.to_volume_arg()]
368
+ if env:
369
+ for k, v in sorted(env.items()):
370
+ cmd += ["-e", f"{k}={v}"]
371
+ if name:
372
+ cmd += ["--name", name]
373
+ if entrypoint:
374
+ cmd += ["--entrypoint", entrypoint]
375
+ cmd.append(image)
376
+ if cli_args:
377
+ cmd.extend(cli_args)
378
+
379
+ logger.debug("Container command: %s", cmd)
380
+
381
+ result = subprocess.run(cmd)
382
+ return result.returncode
383
+
384
+ def exec(
385
+ self,
386
+ name: str,
387
+ command: list[str],
388
+ *,
389
+ env: dict[str, str] | None = None,
390
+ ) -> int:
391
+ """Run a command inside a running container. Interactive (inherits stdio).
392
+
393
+ Returns the exit code of the exec'd process.
394
+ """
395
+ # Allocate a pty only when stdin is a real terminal. In scripted /
396
+ # subprocess contexts (CI, e2e tests), -t causes interactive commands
397
+ # like ``tmux attach`` to render but never return.
398
+ tty_flag = "-it" if sys.stdin.isatty() else "-i"
399
+ cmd: list[str] = [self.cmd, "exec", tty_flag]
400
+ if env:
401
+ for k, v in sorted(env.items()):
402
+ cmd += ["-e", f"{k}={v}"]
403
+ cmd.append(name)
404
+ cmd.extend(command)
405
+
406
+ logger.debug("Container exec: %s", cmd)
407
+ result = subprocess.run(cmd)
408
+ return result.returncode
409
+
410
+ def container_exists(self, name: str) -> bool:
411
+ """Check if a container exists (running or stopped)."""
412
+ result = subprocess.run(
413
+ [self.cmd, "inspect", name],
414
+ capture_output=True,
415
+ )
416
+ return result.returncode == 0
417
+
418
+ def stop(self, name: str) -> bool:
419
+ """Stop a running container by name. Returns True if stopped."""
420
+ result = subprocess.run(
421
+ [self.cmd, "stop", name],
422
+ capture_output=True,
423
+ )
424
+ return result.returncode == 0
425
+
426
+ def rm(self, name: str) -> bool:
427
+ """Remove a stopped container by name. Returns True if removed."""
428
+ result = subprocess.run(
429
+ [self.cmd, "rm", name],
430
+ capture_output=True,
431
+ )
432
+ return result.returncode == 0
433
+
434
+ def is_running(self, name: str) -> bool:
435
+ """Check if a named container is currently running."""
436
+ result = subprocess.run(
437
+ [self.cmd, "inspect", "--format", "{{.State.Running}}", name],
438
+ capture_output=True,
439
+ text=True,
440
+ )
441
+ return result.returncode == 0 and result.stdout.strip() == "true"
442
+
443
+ def list_running(self, prefix: str = "kanibako-") -> list[tuple[str, str, str]]:
444
+ """Return running containers matching *prefix* as (name, image, status) tuples."""
445
+ result = subprocess.run(
446
+ [
447
+ self.cmd, "ps",
448
+ "--filter", f"name={prefix}",
449
+ "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}",
450
+ ],
451
+ capture_output=True,
452
+ text=True,
453
+ )
454
+ containers: list[tuple[str, str, str]] = []
455
+ for line in result.stdout.splitlines():
456
+ parts = line.split("\t", 2)
457
+ if len(parts) == 3:
458
+ containers.append((parts[0], parts[1], parts[2]))
459
+ return containers
460
+
461
+ def list_all(self, prefix: str = "kanibako-") -> list[tuple[str, str, str]]:
462
+ """Return all containers (running + stopped) matching *prefix*.
463
+
464
+ Returns (name, image, status) tuples.
465
+ """
466
+ result = subprocess.run(
467
+ [
468
+ self.cmd, "ps", "-a",
469
+ "--filter", f"name={prefix}",
470
+ "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}",
471
+ ],
472
+ capture_output=True,
473
+ text=True,
474
+ )
475
+ containers: list[tuple[str, str, str]] = []
476
+ for line in result.stdout.splitlines():
477
+ parts = line.split("\t", 2)
478
+ if len(parts) == 3:
479
+ containers.append((parts[0], parts[1], parts[2]))
480
+ return containers
481
+
482
+ # ------------------------------------------------------------------
483
+ # Digest
484
+ # ------------------------------------------------------------------
485
+
486
+ def get_local_digest(self, image: str) -> str | None:
487
+ """Return the repo digest (``sha256:...``) for a local image, or None."""
488
+ try:
489
+ result = subprocess.run(
490
+ [self.cmd, "image", "inspect", image, "--format", "json"],
491
+ capture_output=True,
492
+ text=True,
493
+ )
494
+ if result.returncode != 0:
495
+ return None
496
+ import json
497
+ data = json.loads(result.stdout)
498
+ # podman returns a list, docker returns an object
499
+ if isinstance(data, list):
500
+ data = data[0] if data else {}
501
+ digests = data.get("RepoDigests", [])
502
+ if not digests:
503
+ return None
504
+ # Extract the sha256:... portion from e.g. "ghcr.io/x/img@sha256:abc..."
505
+ digest = digests[0]
506
+ if "@" in digest:
507
+ return digest.split("@", 1)[1]
508
+ return digest
509
+ except Exception:
510
+ return None
511
+
512
+ # ------------------------------------------------------------------
513
+ # Listing
514
+ # ------------------------------------------------------------------
515
+
516
+ def list_local_images(self) -> list[tuple[str, str]]:
517
+ """Return local kanibako images as (repo:tag, size) tuples."""
518
+ result = subprocess.run(
519
+ [self.cmd, "images", "--format", "{{.Repository}}:{{.Tag}}\t{{.Size}}"],
520
+ capture_output=True,
521
+ text=True,
522
+ )
523
+ images: list[tuple[str, str]] = []
524
+ for line in result.stdout.splitlines():
525
+ if "kanibako" in line.lower():
526
+ parts = line.split("\t", 1)
527
+ repo = parts[0]
528
+ size = parts[1] if len(parts) > 1 else ""
529
+ images.append((repo, size))
530
+ return images
531
+
532
+
533
+ def _precreate_mount_stubs(
534
+ shell_path: Path,
535
+ project_path: Path,
536
+ extra_mounts: list | None,
537
+ enable_vault: bool,
538
+ vault_ro_path: Path,
539
+ vault_rw_path: Path,
540
+ vault_tmpfs: bool,
541
+ ) -> None:
542
+ """Pre-create mount destination stubs to avoid crun permission errors.
543
+
544
+ In some environments (e.g. LXC nested containers), the OCI runtime
545
+ cannot create mount-point directories inside bind-mounted overlay
546
+ filesystems. Pre-creating the stubs on the host side avoids the
547
+ problem.
548
+
549
+ Mapping: destinations under ``/home/agent/workspace/`` are created
550
+ relative to *project_path*; other destinations under ``/home/agent/``
551
+ are created relative to *shell_path*.
552
+ """
553
+ AGENT_HOME = "/home/agent/"
554
+ WORKSPACE = "/home/agent/workspace/"
555
+
556
+ def _ensure_dir(p: Path) -> None:
557
+ try:
558
+ p.mkdir(parents=True, exist_ok=True)
559
+ logger.debug("stub mkdir: %s", p)
560
+ except OSError as exc:
561
+ logger.debug("stub mkdir FAILED: %s (%s)", p, exc)
562
+
563
+ def _ensure_file(p: Path) -> None:
564
+ try:
565
+ p.parent.mkdir(parents=True, exist_ok=True)
566
+ if not p.exists():
567
+ p.touch()
568
+ logger.debug("stub touch: %s", p)
569
+ else:
570
+ logger.debug("stub exists: %s", p)
571
+ except OSError as exc:
572
+ logger.debug("stub touch FAILED: %s (%s)", p, exc)
573
+
574
+ # Built-in directory mounts.
575
+ _ensure_dir(shell_path / "workspace")
576
+ if enable_vault:
577
+ if vault_ro_path.is_dir():
578
+ _ensure_dir(shell_path / "share-ro")
579
+ if vault_rw_path.is_dir():
580
+ _ensure_dir(shell_path / "share-rw")
581
+ if vault_tmpfs:
582
+ _ensure_dir(project_path / "vault")
583
+
584
+ # Extra mounts: pre-create destination stubs.
585
+ if not extra_mounts:
586
+ return
587
+ for mount in extra_mounts:
588
+ dest = mount.destination
589
+ src = mount.source
590
+ if dest.startswith(WORKSPACE):
591
+ rel = dest[len(WORKSPACE):]
592
+ host_path = project_path / rel
593
+ elif dest.startswith(AGENT_HOME):
594
+ rel = dest[len(AGENT_HOME):]
595
+ host_path = shell_path / rel
596
+ else:
597
+ logger.debug("stub skip (not under home): %s → %s", src, dest)
598
+ continue
599
+
600
+ if src.is_dir():
601
+ _ensure_dir(host_path)
602
+ else:
603
+ logger.debug(
604
+ "stub file: src=%s is_file=%s is_dir=%s exists=%s → %s",
605
+ src, src.is_file(), src.is_dir(), src.exists(), host_path,
606
+ )
607
+ _ensure_file(host_path)
@@ -0,0 +1,58 @@
1
+ """Containerfile resolution: bundled (package data) with user-override support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ from pathlib import Path
7
+
8
+
9
+ def get_containerfile(suffix: str, data_containers_dir: Path | None = None) -> Path | None:
10
+ """Return the path to a Containerfile for *suffix* (e.g. ``"base"``).
11
+
12
+ Checks user-override directory first, then the bundled package data.
13
+ Returns ``None`` if no matching file exists in either location.
14
+ """
15
+ name = f"Containerfile.{suffix}"
16
+
17
+ # 1. User override
18
+ if data_containers_dir is not None:
19
+ override = data_containers_dir / name
20
+ if override.is_file():
21
+ return override
22
+
23
+ # 2. Bundled
24
+ bundled = importlib.resources.files("kanibako.containers").joinpath(name)
25
+ try:
26
+ # as_posix on a Traversable; for installed packages this is a real path
27
+ path = Path(str(bundled))
28
+ if path.is_file():
29
+ return path
30
+ except (TypeError, FileNotFoundError):
31
+ pass
32
+
33
+ return None
34
+
35
+
36
+ def list_containerfile_suffixes(data_containers_dir: Path | None = None) -> list[str]:
37
+ """Return sorted, deduplicated suffixes from bundled + user-override dirs.
38
+
39
+ Each suffix corresponds to a ``Containerfile.<suffix>`` filename.
40
+ """
41
+ suffixes: set[str] = set()
42
+
43
+ # Bundled
44
+ try:
45
+ pkg = importlib.resources.files("kanibako.containers")
46
+ for item in pkg.iterdir():
47
+ name = item.name if hasattr(item, "name") else str(item).rsplit("/", 1)[-1]
48
+ if name.startswith("Containerfile."):
49
+ suffixes.add(name.split(".", 1)[1])
50
+ except (TypeError, FileNotFoundError):
51
+ pass
52
+
53
+ # User overrides
54
+ if data_containers_dir is not None and data_containers_dir.is_dir():
55
+ for cf in data_containers_dir.glob("Containerfile.*"):
56
+ suffixes.add(cf.name.split(".", 1)[1])
57
+
58
+ return sorted(suffixes)