kento-core 1.6.0.dev1__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.
kento/images.py ADDED
@@ -0,0 +1,210 @@
1
+ """kento images / kento prune — image listing and safe garbage collection.
2
+
3
+ Each kento guest pins its OCI image against `podman prune` via a stopped
4
+ hold container named ``kento-hold.<guestname>`` carrying the label
5
+ ``io.kento.hold-for=<guestname>`` (see layers.py). These two bare-only
6
+ commands report on and reclaim those holds:
7
+
8
+ - ``kento images`` is read-only: it lists kento-managed images, marking
9
+ which are in-use vs orphaned and whether a hold pins them.
10
+ - ``kento prune`` is destructive but conservative: it removes only
11
+ *orphaned* hold containers (a ``kento-hold.<name>`` whose guest no
12
+ longer exists) and then the images those holds freed (only if no
13
+ surviving guest references them and no remaining hold pins them). It
14
+ NEVER touches a hold whose guest still exists and NEVER runs
15
+ ``podman prune -a``.
16
+ """
17
+
18
+ import logging
19
+ import subprocess
20
+
21
+ from kento import LXC_BASE, VM_BASE
22
+ from kento.layers import _podman_cmd, remove_image_hold
23
+
24
+ logger = logging.getLogger("kento")
25
+
26
+ # Exact podman queries used by this module:
27
+ # hold enumeration (name + pinned image, tab-separated, one per line):
28
+ # podman ps -a --filter label=io.kento.hold-for \
29
+ # --format {{.Label "io.kento.hold-for"}}\t{{.Image}}
30
+ # hold removal: podman rm kento-hold.<name> (via remove_image_hold)
31
+ # image removal: podman image rm <image>
32
+ # No exists check is needed: the guest-name set comes from the on-disk
33
+ # kento-image files, and orphan-ness is computed against that set.
34
+
35
+
36
+ def _guest_image_refs() -> dict[str, list[str]]:
37
+ """Map each referenced OCI image -> sorted list of guest names.
38
+
39
+ Iterates LXC_BASE + VM_BASE ``*/kento-image`` (same approach as
40
+ list.list_containers); guest name comes from ``kento-name`` falling
41
+ back to the directory name.
42
+ """
43
+ refs: dict[str, set[str]] = {}
44
+ for base in (LXC_BASE, VM_BASE):
45
+ if not base.is_dir():
46
+ continue
47
+ for image_file in base.glob("*/kento-image"):
48
+ container_dir = image_file.parent
49
+ image = image_file.read_text().strip()
50
+ if not image:
51
+ continue
52
+ name_file = container_dir / "kento-name"
53
+ name = (name_file.read_text().strip()
54
+ if name_file.is_file() else container_dir.name)
55
+ refs.setdefault(image, set()).add(name)
56
+ return {img: sorted(names) for img, names in refs.items()}
57
+
58
+
59
+ def _guest_names() -> set[str]:
60
+ """Set of all kento guest names across both bases (kento-name/dir)."""
61
+ names: set[str] = set()
62
+ for base in (LXC_BASE, VM_BASE):
63
+ if not base.is_dir():
64
+ continue
65
+ for image_file in base.glob("*/kento-image"):
66
+ container_dir = image_file.parent
67
+ name_file = container_dir / "kento-name"
68
+ names.add(name_file.read_text().strip()
69
+ if name_file.is_file() else container_dir.name)
70
+ return names
71
+
72
+
73
+ def _holds() -> list[tuple[str, str]]:
74
+ """Return [(held_for_name, image), ...] for every kento hold container.
75
+
76
+ Single podman query: the format string emits the hold-for label and
77
+ the pinned image, tab-separated.
78
+ """
79
+ result = subprocess.run(
80
+ [*_podman_cmd(), "ps", "-a",
81
+ "--filter", "label=io.kento.hold-for",
82
+ "--format", '{{.Label "io.kento.hold-for"}}\t{{.Image}}'],
83
+ capture_output=True, text=True,
84
+ )
85
+ holds: list[tuple[str, str]] = []
86
+ if result.returncode != 0:
87
+ return holds
88
+ for line in result.stdout.splitlines():
89
+ line = line.strip()
90
+ if not line:
91
+ continue
92
+ parts = line.split("\t")
93
+ held_for = parts[0].strip()
94
+ image = parts[1].strip() if len(parts) > 1 else ""
95
+ if held_for:
96
+ holds.append((held_for, image))
97
+ return holds
98
+
99
+
100
+ def list_images(in_use_only: bool = False) -> str:
101
+ """List kento-managed images (read-only).
102
+
103
+ A managed image is any image referenced by a guest or pinned by a
104
+ hold container. Each row reports the image, the referencing guests,
105
+ whether a hold pins it, and an in-use/orphaned status.
106
+
107
+ Returns the rendered table as a string (no trailing newline).
108
+ """
109
+ refs = _guest_image_refs()
110
+ holds = _holds()
111
+
112
+ held_images: set[str] = {img for _, img in holds if img}
113
+
114
+ managed = set(refs) | held_images
115
+ if not managed:
116
+ return "No kento-managed images."
117
+
118
+ rows = []
119
+ for image in sorted(managed):
120
+ guests = refs.get(image, [])
121
+ status = "in-use" if guests else "orphaned"
122
+ if in_use_only and status != "in-use":
123
+ continue
124
+ guests_cell = ",".join(guests) if guests else "-"
125
+ hold_cell = "yes" if image in held_images else "no"
126
+ rows.append((image, guests_cell, hold_cell, status))
127
+
128
+ if not rows:
129
+ return "No kento-managed images."
130
+
131
+ headers = ("IMAGE", "GUESTS", "HOLD", "STATUS")
132
+ widths = []
133
+ for i, header in enumerate(headers):
134
+ col_max = max((len(row[i]) for row in rows), default=0)
135
+ widths.append(max(len(header), col_max))
136
+
137
+ lines = []
138
+ lines.append(" ".join(h.ljust(w) for h, w in zip(headers, widths)))
139
+ lines.append(" ".join("-" * w for w in widths))
140
+ for row in rows:
141
+ lines.append(" ".join(val.ljust(w) for val, w in zip(row, widths)))
142
+ return "\n".join(lines)
143
+
144
+
145
+ def prune(yes: bool = False) -> str:
146
+ """Safe GC of orphaned kento hold containers and the images they freed.
147
+
148
+ DRY-RUN by default. Removes only holds whose guest no longer exists,
149
+ then images pinned solely by those orphaned holds and referenced by
150
+ no surviving guest. Never removes a hold whose guest exists; never
151
+ prunes all images.
152
+
153
+ Returns the user-facing plan/summary text as a string (no trailing newline).
154
+ """
155
+ guest_names = _guest_names()
156
+ refs = _guest_image_refs() # image -> guests still present
157
+ holds = _holds()
158
+
159
+ orphaned = [(name, image) for name, image in holds
160
+ if name not in guest_names]
161
+ surviving = [(name, image) for name, image in holds
162
+ if name in guest_names]
163
+
164
+ if not orphaned:
165
+ return "Nothing to prune."
166
+
167
+ # Images still pinned by a surviving (non-orphaned) hold must not be
168
+ # removed. Likewise, images referenced by any guest must not be removed.
169
+ surviving_hold_images = {img for _, img in surviving if img}
170
+ orphaned_images = {img for _, img in orphaned if img}
171
+ candidate_images = sorted(
172
+ img for img in orphaned_images
173
+ if img not in refs and img not in surviving_hold_images
174
+ )
175
+
176
+ if not yes:
177
+ lines = []
178
+ lines.append("Dry run — nothing removed. The following would be removed:")
179
+ lines.append(" Orphaned hold containers:")
180
+ for name, image in orphaned:
181
+ lines.append(f" kento-hold.{name} (pinned {image or '?'})")
182
+ if candidate_images:
183
+ lines.append(" Images then eligible for removal:")
184
+ for image in candidate_images:
185
+ lines.append(f" {image}")
186
+ else:
187
+ lines.append(" Images then eligible for removal: (none)")
188
+ lines.append("Run 'kento prune --yes' to remove them.")
189
+ return "\n".join(lines)
190
+
191
+ removed_holds = 0
192
+ for name, _image in orphaned:
193
+ remove_image_hold(name)
194
+ removed_holds += 1
195
+
196
+ removed_images = 0
197
+ for image in candidate_images:
198
+ result = subprocess.run(
199
+ [*_podman_cmd(), "image", "rm", image],
200
+ capture_output=True, text=True,
201
+ )
202
+ if result.returncode == 0:
203
+ removed_images += 1
204
+ else:
205
+ # podman refuses if the image is still in use — that is the
206
+ # intended safety net, so we tolerate the failure and report.
207
+ msg = (result.stderr or result.stdout or "").strip()
208
+ logger.warning("skipped image %s: %s", image, msg)
209
+
210
+ return f"Removed {removed_holds} orphaned hold(s), {removed_images} image(s)."
kento/info.py ADDED
@@ -0,0 +1,274 @@
1
+ """Show instance details."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from kento import is_running
11
+
12
+
13
+ def _read_meta(container_dir: Path, filename: str) -> str | None:
14
+ """Read a kento metadata file, return stripped content or None."""
15
+ f = container_dir / filename
16
+ return f.read_text().strip() if f.is_file() else None
17
+
18
+
19
+ def _get_size(path: Path) -> str:
20
+ """Get human-readable size of a directory."""
21
+ result = subprocess.run(
22
+ ["du", "-sh", str(path)],
23
+ capture_output=True, text=True,
24
+ )
25
+ return result.stdout.split()[0] if result.returncode == 0 else "?"
26
+
27
+
28
+ def _read_passthrough_args(container_dir: Path, filename: str) -> list[str]:
29
+ """Read a pass-through args file (kento-qemu-args or kento-pve-args).
30
+
31
+ Returns a list of non-empty lines. Absent file returns []. Written at
32
+ create time (v1.2.0 Phase B1); surfaced here for info output.
33
+ """
34
+ f = container_dir / filename
35
+ if not f.is_file():
36
+ return []
37
+ return [line for line in f.read_text().splitlines() if line]
38
+
39
+
40
+ def _get_ssh_host_key_fingerprints(
41
+ container_dir: Path,
42
+ ) -> tuple[dict[str, str], bool]:
43
+ """Read SSH host key fingerprints from ssh-host-keys/ directory.
44
+
45
+ Returns (fingerprints_dict, has_keys) where:
46
+ - fingerprints_dict maps key type (e.g. "rsa") to fingerprint string
47
+ - has_keys is True when .pub files exist (even if ssh-keygen failed)
48
+
49
+ fingerprints_dict is empty if no host keys, no .pub files, or
50
+ ssh-keygen is unavailable.
51
+ """
52
+ keys_dir = container_dir / "ssh-host-keys"
53
+ if not keys_dir.is_dir():
54
+ return {}, False
55
+
56
+ pub_files = sorted(keys_dir.glob("*.pub"))
57
+ if not pub_files:
58
+ return {}, False
59
+
60
+ fingerprints: dict[str, str] = {}
61
+ for pub_path in pub_files:
62
+ # Extract key type from filename: ssh_host_rsa_key.pub -> rsa
63
+ stem = pub_path.stem # e.g. ssh_host_rsa_key
64
+ parts = stem.split("_")
65
+ # Expected: ssh_host_<type>_key
66
+ if len(parts) >= 4 and parts[0] == "ssh" and parts[1] == "host":
67
+ key_type = "_".join(parts[2:-1]) # handles multi-word types
68
+ else:
69
+ key_type = stem # fallback to full stem
70
+
71
+ try:
72
+ result = subprocess.run(
73
+ ["ssh-keygen", "-lf", str(pub_path)],
74
+ capture_output=True, text=True,
75
+ )
76
+ except FileNotFoundError:
77
+ return {}, True # has keys but ssh-keygen not available
78
+
79
+ if result.returncode == 0 and result.stdout.strip():
80
+ # Output: "<bits> <fingerprint> <comment> (<type>)"
81
+ fields = result.stdout.strip().split()
82
+ if len(fields) >= 2:
83
+ fingerprints[key_type] = fields[1]
84
+
85
+ return fingerprints, True
86
+
87
+
88
+ def info(name: str, *, container_dir: Path, mode: str,
89
+ as_json: bool = False, verbose: bool = False) -> str:
90
+ """Return container information as a rendered string."""
91
+
92
+ # Gather metadata
93
+ data = {}
94
+ data["name"] = _read_meta(container_dir, "kento-name") or name
95
+ data["image"] = _read_meta(container_dir, "kento-image") or "unknown"
96
+ # Normalize the raw mode ('pve' -> 'pve-lxc') so inspect --json and
97
+ # list --json agree on the mode string. type stays the LXC/VM family.
98
+ data["mode"] = "pve-lxc" if mode == "pve" else mode
99
+ data["type"] = "VM" if mode in ("vm", "pve-vm") else "LXC"
100
+ data["status"] = "running" if is_running(container_dir, mode) else "stopped"
101
+ data["directory"] = str(container_dir)
102
+
103
+ # State dir
104
+ state_text = _read_meta(container_dir, "kento-state")
105
+ state_dir = Path(state_text) if state_text else container_dir
106
+ data["state_directory"] = str(state_dir)
107
+
108
+ # Optional metadata
109
+ config_mode = _read_meta(container_dir, "kento-config-mode")
110
+ if config_mode:
111
+ data["config_mode"] = config_mode
112
+
113
+ vmid = _read_meta(container_dir, "kento-vmid")
114
+ if vmid:
115
+ data["vmid"] = int(vmid)
116
+
117
+ port = _read_meta(container_dir, "kento-port")
118
+ if port:
119
+ data["port"] = port
120
+
121
+ net = _read_meta(container_dir, "kento-net")
122
+ if net:
123
+ data["network"] = net
124
+
125
+ mac = _read_meta(container_dir, "kento-mac")
126
+ if mac:
127
+ data["mac"] = mac
128
+
129
+ nesting = _read_meta(container_dir, "kento-nesting")
130
+ if nesting is not None:
131
+ data["nesting"] = (nesting == "1")
132
+
133
+ tz = _read_meta(container_dir, "kento-tz")
134
+ if tz:
135
+ data["timezone"] = tz
136
+
137
+ ssh_user = _read_meta(container_dir, "kento-ssh-user") or "root"
138
+ data["ssh_user"] = ssh_user
139
+
140
+ env = _read_meta(container_dir, "kento-env")
141
+ if env:
142
+ data["environment"] = env.splitlines()
143
+
144
+ # Layers
145
+ layers_text = _read_meta(container_dir, "kento-layers")
146
+ if layers_text:
147
+ layer_paths = layers_text.split(":")
148
+ data["layer_count"] = len(layer_paths)
149
+ else:
150
+ layer_paths = []
151
+ data["layer_count"] = 0
152
+
153
+ # Created timestamp (directory mtime)
154
+ try:
155
+ mtime = os.path.getmtime(container_dir)
156
+ data["created"] = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
157
+ except OSError:
158
+ data["created"] = "unknown"
159
+
160
+ # SSH host key fingerprints
161
+ fingerprints, has_host_keys = _get_ssh_host_key_fingerprints(container_dir)
162
+ data["ssh_host_key_fingerprints"] = fingerprints
163
+ # Track whether keys exist but ssh-keygen was missing (for human output)
164
+ _ssh_keygen_missing = has_host_keys and not fingerprints
165
+
166
+ # Pass-through flags (v1.2.0 Phase B4). Always emitted in JSON (empty
167
+ # list when absent) so machine consumers get a stable schema. Human
168
+ # output surfaces them only under --verbose and only when non-empty.
169
+ data["qemu_args"] = _read_passthrough_args(container_dir, "kento-qemu-args")
170
+ data["pve_args"] = _read_passthrough_args(container_dir, "kento-pve-args")
171
+ data["lxc_args"] = _read_passthrough_args(container_dir, "kento-lxc-args")
172
+
173
+ # Verbose additions
174
+ if verbose:
175
+ upper = state_dir / "upper"
176
+ if upper.is_dir():
177
+ data["upper_size"] = _get_size(upper)
178
+
179
+ if layer_paths:
180
+ data["layers"] = layer_paths
181
+ # Individual layer sizes, positionally aligned with layers:
182
+ # absent layer dirs get a None placeholder so layer_sizes[i]
183
+ # always corresponds to layers[i].
184
+ sizes = []
185
+ for lp in layer_paths:
186
+ p = Path(lp)
187
+ sizes.append(_get_size(p) if p.is_dir() else None)
188
+ data["layer_sizes"] = sizes
189
+
190
+ # Output
191
+ if as_json:
192
+ return json.dumps(data, indent=2)
193
+ return _format_human(data, verbose, ssh_keygen_missing=_ssh_keygen_missing)
194
+
195
+
196
+ def _format_human(data: dict, verbose: bool, *,
197
+ ssh_keygen_missing: bool = False) -> str:
198
+ """Return container info as a human-readable string."""
199
+ lines = []
200
+ lines.append(f"Name: {data['name']}")
201
+ lines.append(f"Image: {data['image']}")
202
+ lines.append(f"Mode: {data['mode']} ({data['type']})")
203
+ lines.append(f"Status: {data['status']}")
204
+ lines.append(f"Created: {data['created']}")
205
+ lines.append(f"Directory: {data['directory']}")
206
+ lines.append(f"State: {data['state_directory']}")
207
+
208
+ if "config_mode" in data:
209
+ lines.append(f"Config: {data['config_mode']}")
210
+
211
+ if "vmid" in data:
212
+ lines.append(f"VMID: {data['vmid']}")
213
+ if "port" in data:
214
+ lines.append(f"Port: {data['port']}")
215
+ if "network" in data:
216
+ lines.append(f"Network: {data['network']}")
217
+ if "mac" in data:
218
+ lines.append(f"MAC: {data['mac']}")
219
+ if "nesting" in data:
220
+ lines.append(f"Nesting: {'allowed' if data['nesting'] else 'disabled'}")
221
+ if "timezone" in data:
222
+ lines.append(f"Timezone: {data['timezone']}")
223
+ if data.get("ssh_user", "root") != "root":
224
+ lines.append(f"SSH user: {data['ssh_user']}")
225
+ if "environment" in data:
226
+ lines.append(f"Env: {', '.join(data['environment'])}")
227
+
228
+ lines.append(f"Layers: {data['layer_count']}")
229
+
230
+ fp = data.get("ssh_host_key_fingerprints", {})
231
+ if fp:
232
+ lines.append("SSH host key fingerprints:")
233
+ # Display order: rsa, ecdsa, ed25519, then any others alphabetically
234
+ order = ["rsa", "ecdsa", "ed25519"]
235
+ ordered_keys = [k for k in order if k in fp]
236
+ ordered_keys += sorted(k for k in fp if k not in order)
237
+ for kt in ordered_keys:
238
+ label = kt.upper()
239
+ lines.append(f" {label + ':':<10} {fp[kt]}")
240
+ elif ssh_keygen_missing:
241
+ lines.append("SSH host key fingerprints:")
242
+ lines.append(" ssh-keygen not found, cannot display fingerprints")
243
+
244
+ if verbose:
245
+ if "upper_size" in data:
246
+ lines.append(f"Upper size: {data['upper_size']}")
247
+ if "layers" in data:
248
+ lines.append("Layer paths:")
249
+ layer_sizes = data.get("layer_sizes", [])
250
+ for i, lp in enumerate(data["layers"]):
251
+ size = layer_sizes[i] if i < len(layer_sizes) else None
252
+ if size is None:
253
+ size = "missing"
254
+ lines.append(f" [{i}] {lp} ({size})")
255
+
256
+ qemu_args = data.get("qemu_args", [])
257
+ pve_args = data.get("pve_args", [])
258
+ lxc_args = data.get("lxc_args", [])
259
+ if qemu_args or pve_args or lxc_args:
260
+ lines.append("Pass-through flags:")
261
+ if qemu_args:
262
+ lines.append(" --qemu-arg:")
263
+ for line in qemu_args:
264
+ lines.append(f" {line}")
265
+ if pve_args:
266
+ lines.append(" --pve-arg:")
267
+ for line in pve_args:
268
+ lines.append(f" {line}")
269
+ if lxc_args:
270
+ lines.append(" --lxc-arg:")
271
+ for line in lxc_args:
272
+ lines.append(f" {line}")
273
+
274
+ return "\n".join(lines)
kento/inject.py ADDED
@@ -0,0 +1,28 @@
1
+ """Install the shared guest-config injection script per container.
2
+
3
+ ``inject.sh`` is a standalone POSIX shell script (no templating) that reads
4
+ kento metadata + LXC/PVE config and writes guest-side config into a mounted
5
+ rootfs. It is invoked by the LXC hook (and, in subsequent steps, by VM and
6
+ PVE-VM code paths).
7
+
8
+ Copying per container — rather than referencing the package path — keeps
9
+ each container self-contained: it survives kento upgrades and uninstalls
10
+ the same way ``kento-hook`` does.
11
+ """
12
+
13
+ from pathlib import Path
14
+
15
+ _SCRIPT = (Path(__file__).parent / "inject.sh").read_text()
16
+
17
+
18
+ def generate_inject() -> str:
19
+ """Return the inject.sh content verbatim (no substitutions)."""
20
+ return _SCRIPT
21
+
22
+
23
+ def write_inject(container_dir: Path) -> Path:
24
+ """Write the inject script into the container directory."""
25
+ inject_path = container_dir / "kento-inject.sh"
26
+ inject_path.write_text(generate_inject())
27
+ inject_path.chmod(0o755)
28
+ return inject_path