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
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)
|