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/__init__.py +461 -0
- kento/attach.py +188 -0
- kento/cloudinit.py +201 -0
- kento/create.py +1205 -0
- kento/defaults.py +207 -0
- kento/destroy.py +131 -0
- kento/diagnose.py +548 -0
- kento/errors.py +48 -0
- kento/exec_cmd.py +50 -0
- kento/hook.py +28 -0
- kento/hook.sh +525 -0
- kento/images.py +210 -0
- kento/info.py +274 -0
- kento/inject.py +28 -0
- kento/inject.sh +282 -0
- kento/layers.py +81 -0
- kento/list.py +146 -0
- kento/locking.py +64 -0
- kento/logs.py +48 -0
- kento/lxc_hook.py +61 -0
- kento/pve.py +619 -0
- kento/reset.py +212 -0
- kento/set_cmd.py +1036 -0
- kento/start.py +65 -0
- kento/stop.py +210 -0
- kento/subprocess_util.py +76 -0
- kento/suspend.py +196 -0
- kento/vm.py +504 -0
- kento/vm_hook.py +256 -0
- kento_core-1.6.0.dev1.dist-info/METADATA +8 -0
- kento_core-1.6.0.dev1.dist-info/RECORD +34 -0
- kento_core-1.6.0.dev1.dist-info/WHEEL +5 -0
- kento_core-1.6.0.dev1.dist-info/licenses/LICENSE.md +594 -0
- kento_core-1.6.0.dev1.dist-info/top_level.txt +1 -0
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
|