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/inject.sh ADDED
@@ -0,0 +1,282 @@
1
+ #!/bin/sh
2
+ # kento guest config injection — standalone POSIX shell script.
3
+ #
4
+ # Usage: inject.sh <ROOTFS> <CONTAINER_DIR>
5
+ # $1 = ROOTFS path (where to inject guest-side files)
6
+ # $2 = CONTAINER_DIR (where to read kento metadata and mode from)
7
+ #
8
+ # Reads config from LXC/PVE config (authoritative, handles `pct set`) and
9
+ # kento metadata files (fallback for values LXC config can't carry).
10
+ set -eu
11
+
12
+ ROOTFS="$1"
13
+ CONTAINER_DIR="$2"
14
+
15
+ # Check config mode — cloud-init seed delivery or file injection
16
+ CONFIG_MODE="injection"
17
+ CONFIG_MODE_FILE="$CONTAINER_DIR/kento-config-mode"
18
+ if [ -f "$CONFIG_MODE_FILE" ]; then
19
+ CONFIG_MODE=$(cat "$CONFIG_MODE_FILE" | tr -d '[:space:]')
20
+ fi
21
+
22
+ # --allow-nesting: inject a networkd drop-in so the guest leaves nested
23
+ # host-side veths unmanaged (fixes nested LXC/docker/podman bridging on
24
+ # networkd images). Matches by Name=veth* — the interface NAME, which is set
25
+ # at creation, so the match is race-free. (Kind=veth would also work but the
26
+ # kind attribute can lag link-appearance, leaving a window where an image's
27
+ # Type=ether DHCP unit claims the veth and strips its bridge master before the
28
+ # unmanaged match applies.) veth* naturally excludes the guest's own uplink
29
+ # eth0, so no separate exclusion is needed. Runs BEFORE the cloudinit
30
+ # early-exit so both injection and cloudinit guests get it.
31
+ NESTING="0"
32
+ NESTING_FILE="$CONTAINER_DIR/kento-nesting"
33
+ if [ -f "$NESTING_FILE" ]; then
34
+ NESTING=$(cat "$NESTING_FILE" | tr -d '[:space:]')
35
+ fi
36
+ if [ "$NESTING" = "1" ]; then
37
+ NEST_NET_DIR="$ROOTFS/etc/systemd/network"
38
+ mkdir -p "$NEST_NET_DIR"
39
+ {
40
+ echo "[Match]"
41
+ echo "Name=veth*"
42
+ echo ""
43
+ echo "[Link]"
44
+ echo "Unmanaged=yes"
45
+ } > "$NEST_NET_DIR/10-kento-nested-veth.network"
46
+ fi
47
+
48
+ if [ "$CONFIG_MODE" = "cloudinit" ]; then
49
+ # Copy pre-generated NoCloud seed files to guest rootfs
50
+ SEED_SRC="$CONTAINER_DIR/cloud-seed"
51
+ SEED_DST="$ROOTFS/var/lib/cloud/seed/nocloud"
52
+ if [ -d "$SEED_SRC" ]; then
53
+ mkdir -p "$SEED_DST"
54
+ cp -f "$SEED_SRC/meta-data" "$SEED_DST/" 2>/dev/null || true
55
+ cp -f "$SEED_SRC/user-data" "$SEED_DST/" 2>/dev/null || true
56
+ cp -f "$SEED_SRC/network-config" "$SEED_DST/" 2>/dev/null || true
57
+ fi
58
+ exit 0
59
+ fi
60
+
61
+ STATIC_IP=""
62
+ STATIC_GW=""
63
+ STATIC_DNS=""
64
+ STATIC_SEARCH=""
65
+ CFG_HOSTNAME=""
66
+ CFG_TZ=""
67
+
68
+ # Read kento metadata (fallback values)
69
+ if [ -f "$CONTAINER_DIR/kento-net" ]; then
70
+ STATIC_DNS=$(sed -n 's/^dns=//p' "$CONTAINER_DIR/kento-net")
71
+ STATIC_SEARCH=$(sed -n 's/^searchdomain=//p' "$CONTAINER_DIR/kento-net")
72
+ fi
73
+ if [ -f "$CONTAINER_DIR/kento-tz" ]; then
74
+ CFG_TZ=$(cat "$CONTAINER_DIR/kento-tz")
75
+ fi
76
+
77
+ # Read LXC/PVE config (authoritative — overrides kento metadata)
78
+ MODE=$(cat "$CONTAINER_DIR/kento-mode" 2>/dev/null || echo "lxc")
79
+ if [ "$MODE" = "pve" ]; then
80
+ VMID=$(basename "$CONTAINER_DIR")
81
+ PVE_CONF="/etc/pve/lxc/${VMID}.conf"
82
+ if [ -f "$PVE_CONF" ]; then
83
+ # Network (from net0 line)
84
+ NET_LINE=$(grep '^net0:' "$PVE_CONF" || true)
85
+ if [ -n "$NET_LINE" ]; then
86
+ CFG_IP=$(echo "$NET_LINE" | tr ',' '\n' | sed -n 's/^ip=//p')
87
+ CFG_GW=$(echo "$NET_LINE" | tr ',' '\n' | sed -n 's/^gw=//p')
88
+ if [ -n "$CFG_IP" ] && [ "$CFG_IP" != "dhcp" ]; then
89
+ STATIC_IP="$CFG_IP"
90
+ STATIC_GW="${CFG_GW:-}"
91
+ else
92
+ # net0 present but no static ip (ip=dhcp or omitted) — the
93
+ # bridged veth is eth0 and needs a DHCP .network injected.
94
+ WANT_DHCP=1
95
+ fi
96
+ fi
97
+ # Top-level PVE directives
98
+ CFG_HOSTNAME=$(sed -n 's/^hostname: *//p' "$PVE_CONF")
99
+ CFG_NS=$(sed -n 's/^nameserver: *//p' "$PVE_CONF")
100
+ [ -n "$CFG_NS" ] && STATIC_DNS="$CFG_NS"
101
+ CFG_SD=$(sed -n 's/^searchdomain: *//p' "$PVE_CONF")
102
+ [ -n "$CFG_SD" ] && STATIC_SEARCH="$CFG_SD"
103
+ CFG_PVE_TZ=$(sed -n 's/^timezone: *//p' "$PVE_CONF")
104
+ [ -n "$CFG_PVE_TZ" ] && CFG_TZ="$CFG_PVE_TZ"
105
+
106
+ # Create guest-side mount point directories for mp[n] entries
107
+ grep '^mp[0-9]*:' "$PVE_CONF" | while IFS= read -r mp_line; do
108
+ MP_PATH=$(echo "$mp_line" | tr ',' '\n' | sed -n 's/^mp=//p')
109
+ if [ -n "$MP_PATH" ]; then
110
+ mkdir -p "$ROOTFS$MP_PATH"
111
+ fi
112
+ done
113
+ fi
114
+ else
115
+ CONFIG_FILE="$CONTAINER_DIR/config"
116
+ if [ -f "$CONFIG_FILE" ]; then
117
+ CFG_IP=$(sed -n 's/^lxc\.net\.0\.ipv4\.address *= *//p' "$CONFIG_FILE")
118
+ CFG_GW=$(sed -n 's/^lxc\.net\.0\.ipv4\.gateway *= *//p' "$CONFIG_FILE")
119
+ CFG_HOSTNAME=$(sed -n 's/^lxc\.uts\.name *= *//p' "$CONFIG_FILE")
120
+ CFG_LINK=$(sed -n 's/^lxc\.net\.0\.link *= *//p' "$CONFIG_FILE")
121
+ if [ -n "$CFG_IP" ]; then
122
+ STATIC_IP="$CFG_IP"
123
+ STATIC_GW="${CFG_GW:-}"
124
+ elif [ "$MODE" = "lxc" ] && [ -n "$CFG_LINK" ]; then
125
+ # Bridge attached (lxc.net.0.link) but no static ip — the veth is
126
+ # eth0, so DHCP needs a matching .network. `--network none` has no
127
+ # link line, so this stays off there.
128
+ WANT_DHCP=1
129
+ fi
130
+ fi
131
+ fi
132
+
133
+ # Fall back to kento-net if LXC config has no IP
134
+ if [ -z "$STATIC_IP" ] && [ -f "$CONTAINER_DIR/kento-net" ]; then
135
+ STATIC_IP=$(sed -n 's/^ip=//p' "$CONTAINER_DIR/kento-net")
136
+ STATIC_GW=$(sed -n 's/^gateway=//p' "$CONTAINER_DIR/kento-net")
137
+ fi
138
+
139
+ # Inject hostname
140
+ if [ -n "${CFG_HOSTNAME:-}" ]; then
141
+ echo "$CFG_HOSTNAME" > "$ROOTFS/etc/hostname"
142
+ fi
143
+
144
+ # Inject network config
145
+ if [ -n "$STATIC_IP" ]; then
146
+ NET_DIR="$ROOTFS/etc/systemd/network"
147
+ mkdir -p "$NET_DIR"
148
+ # VM modes use predictable naming (e.g. enp0s2), match by type.
149
+ # LXC/PVE modes always have eth0 (configured by LXC veth).
150
+ if [ "$MODE" = "vm" ] || [ "$MODE" = "pve-vm" ]; then
151
+ MATCH_LINE="Type=ether"
152
+ else
153
+ MATCH_LINE="Name=eth0"
154
+ fi
155
+ {
156
+ echo "[Match]"
157
+ echo "$MATCH_LINE"
158
+ echo ""
159
+ echo "[Network]"
160
+ echo "Address=$STATIC_IP"
161
+ [ -n "${STATIC_GW:-}" ] && echo "Gateway=$STATIC_GW"
162
+ [ -n "${STATIC_DNS:-}" ] && echo "DNS=$STATIC_DNS"
163
+ [ -n "${STATIC_SEARCH:-}" ] && echo "Domains=$STATIC_SEARCH"
164
+ # The 05- prefix sorts before any image-baked drop-in (e.g. a generic
165
+ # Kind=veth Unmanaged=yes unit like 10-lxc-veth-unmanaged.network). In
166
+ # pve-lxc the guest eth0 presents Kind=veth, so such a unit would match and
167
+ # win; naming kento's per-instance config 05- makes it authoritative.
168
+ } > "$NET_DIR/05-kento-static.network"
169
+ elif [ "${WANT_DHCP:-0}" = 1 ]; then
170
+ # LXC/pve-lxc bridge + DHCP: the image may only match Name=en* (VM-oriented
171
+ # networkd config), but the LXC veth is eth0, so add a matching DHCP unit.
172
+ NET_DIR="$ROOTFS/etc/systemd/network"
173
+ mkdir -p "$NET_DIR"
174
+ {
175
+ echo "[Match]"
176
+ echo "Name=eth0"
177
+ echo ""
178
+ echo "[Network]"
179
+ echo "DHCP=yes"
180
+ [ -n "${STATIC_DNS:-}" ] && echo "DNS=$STATIC_DNS"
181
+ [ -n "${STATIC_SEARCH:-}" ] && echo "Domains=$STATIC_SEARCH"
182
+ # 05- prefix: sort before image drop-ins (e.g. a Kind=veth Unmanaged unit
183
+ # that would otherwise claim the pve-lxc eth0 veth) so kento's config wins.
184
+ } > "$NET_DIR/05-kento-dhcp.network"
185
+ elif [ -n "${STATIC_DNS:-}" ] || [ -n "${STATIC_SEARCH:-}" ]; then
186
+ # No static IP but DNS/search set — use resolved drop-in
187
+ RESOLVED_DIR="$ROOTFS/etc/systemd/resolved.conf.d"
188
+ mkdir -p "$RESOLVED_DIR"
189
+ {
190
+ echo "[Resolve]"
191
+ [ -n "${STATIC_DNS:-}" ] && echo "DNS=$STATIC_DNS"
192
+ [ -n "${STATIC_SEARCH:-}" ] && echo "Domains=$STATIC_SEARCH"
193
+ } > "$RESOLVED_DIR/90-kento.conf"
194
+ fi
195
+
196
+ # Inject timezone
197
+ if [ -n "${CFG_TZ:-}" ]; then
198
+ ln -sf "/usr/share/zoneinfo/$CFG_TZ" "$ROOTFS/etc/localtime"
199
+ echo "$CFG_TZ" > "$ROOTFS/etc/timezone"
200
+ fi
201
+
202
+ # Inject environment variables
203
+ CFG_ENV=""
204
+ if [ "$MODE" = "pve" ] && [ -f "${PVE_CONF:-}" ]; then
205
+ CFG_ENV=$(sed -n 's/^lxc\.environment: *//p' "$PVE_CONF")
206
+ elif [ "$MODE" != "pve" ] && [ -f "${CONFIG_FILE:-}" ]; then
207
+ CFG_ENV=$(sed -n 's/^lxc\.environment *= *//p' "$CONFIG_FILE")
208
+ fi
209
+ # Append kento-env entries (may overlap with lxc.environment keys),
210
+ # then deduplicate by key — config takes priority over kento-env.
211
+ if [ -f "$CONTAINER_DIR/kento-env" ]; then
212
+ KENTO_ENV=$(cat "$CONTAINER_DIR/kento-env")
213
+ if [ -n "$CFG_ENV" ]; then
214
+ CFG_ENV="$CFG_ENV
215
+ $KENTO_ENV"
216
+ else
217
+ CFG_ENV="$KENTO_ENV"
218
+ fi
219
+ fi
220
+ # Auto-inject TZ from timezone config (lowest priority — user --env TZ wins
221
+ # because the awk dedup below keeps the first occurrence of each key).
222
+ if [ -n "${CFG_TZ:-}" ]; then
223
+ if [ -n "$CFG_ENV" ]; then
224
+ CFG_ENV="$CFG_ENV
225
+ TZ=$CFG_TZ"
226
+ else
227
+ CFG_ENV="TZ=$CFG_TZ"
228
+ fi
229
+ fi
230
+ if [ -n "${CFG_ENV:-}" ]; then
231
+ echo "$CFG_ENV" | awk -F= '!seen[$1]++' > "$ROOTFS/etc/environment"
232
+ fi
233
+
234
+ # --- SSH host key injection ---
235
+ HOST_KEY_DIR="$CONTAINER_DIR/ssh-host-keys"
236
+ if [ -d "$HOST_KEY_DIR" ]; then
237
+ mkdir -p "$ROOTFS/etc/ssh"
238
+ for keyfile in "$HOST_KEY_DIR"/ssh_host_*; do
239
+ [ -f "$keyfile" ] || continue
240
+ cp "$keyfile" "$ROOTFS/etc/ssh/$(basename "$keyfile")"
241
+ done
242
+ # Fix permissions
243
+ chmod 600 "$ROOTFS"/etc/ssh/ssh_host_*_key 2>/dev/null || true
244
+ chmod 644 "$ROOTFS"/etc/ssh/ssh_host_*_key.pub 2>/dev/null || true
245
+ fi
246
+
247
+ # --- SSH authorized_keys injection ---
248
+ if [ -f "$CONTAINER_DIR/kento-authorized-keys" ]; then
249
+ SSH_USER="root"
250
+ if [ -f "$CONTAINER_DIR/kento-ssh-user" ]; then
251
+ SSH_USER=$(cat "$CONTAINER_DIR/kento-ssh-user" | tr -d '[:space:]')
252
+ fi
253
+ if [ "$SSH_USER" = "root" ]; then
254
+ SSH_HOME="/root"
255
+ SSH_UID=0
256
+ SSH_GID=0
257
+ else
258
+ # Match the first passwd field LITERALLY (exact string compare, no
259
+ # regex semantics). SSH_USER comes from --ssh-key-user (unvalidated);
260
+ # feeding it to grep as a BRE would let metacharacters (e.g. `ro.t`)
261
+ # match the WRONG account (`root:`) and chown keys for it.
262
+ SSH_PASSWD_LINE=$(awk -F: -v u="$SSH_USER" '$1==u {print; exit}' "$ROOTFS/etc/passwd" || true)
263
+ if [ -z "$SSH_PASSWD_LINE" ]; then
264
+ echo "kento-inject: warning: SSH key user '$SSH_USER' not found in rootfs /etc/passwd, skipping key injection" >&2
265
+ SSH_HOME=""
266
+ else
267
+ SSH_HOME=$(echo "$SSH_PASSWD_LINE" | cut -d: -f6)
268
+ SSH_UID=$(echo "$SSH_PASSWD_LINE" | cut -d: -f3)
269
+ SSH_GID=$(echo "$SSH_PASSWD_LINE" | cut -d: -f4)
270
+ fi
271
+ fi
272
+ if [ -n "$SSH_HOME" ]; then
273
+ mkdir -p "$ROOTFS$SSH_HOME/.ssh"
274
+ chmod 700 "$ROOTFS$SSH_HOME/.ssh"
275
+ cp "$CONTAINER_DIR/kento-authorized-keys" "$ROOTFS$SSH_HOME/.ssh/authorized_keys"
276
+ chmod 600 "$ROOTFS$SSH_HOME/.ssh/authorized_keys"
277
+ if [ "$SSH_USER" != "root" ]; then
278
+ chown "$SSH_UID:$SSH_GID" "$ROOTFS$SSH_HOME/.ssh"
279
+ chown "$SSH_UID:$SSH_GID" "$ROOTFS$SSH_HOME/.ssh/authorized_keys"
280
+ fi
281
+ fi
282
+ fi
kento/layers.py ADDED
@@ -0,0 +1,81 @@
1
+ """Resolve OCI image layer paths via podman."""
2
+
3
+ import logging
4
+ import subprocess
5
+
6
+ from kento.errors import ImageNotFoundError
7
+
8
+ logger = logging.getLogger("kento")
9
+
10
+
11
+ def _podman_cmd() -> list[str]:
12
+ """Return the podman command prefix."""
13
+ return ["podman"]
14
+
15
+
16
+ def resolve_layers(image: str) -> str:
17
+ """Return colon-separated lowerdir string for an OCI image.
18
+
19
+ Queries podman for the image's GraphDriver layer paths.
20
+ Upper layer comes first (topmost), matching overlayfs lowerdir order.
21
+ """
22
+ podman = _podman_cmd()
23
+
24
+ result = subprocess.run(
25
+ [*podman, "image", "exists", image],
26
+ capture_output=True,
27
+ )
28
+ if result.returncode != 0:
29
+ raise ImageNotFoundError(
30
+ f"image not found in local store: {image}"
31
+ f" — pull it first: kento pull {image}"
32
+ )
33
+
34
+ upper = subprocess.run(
35
+ [*podman, "image", "inspect", image,
36
+ "--format", "{{.GraphDriver.Data.UpperDir}}"],
37
+ capture_output=True, text=True, check=True,
38
+ ).stdout.strip()
39
+
40
+ lower = subprocess.run(
41
+ [*podman, "image", "inspect", image,
42
+ "--format", "{{.GraphDriver.Data.LowerDir}}"],
43
+ capture_output=True, text=True, check=True,
44
+ ).stdout.strip()
45
+
46
+ if lower and lower != "<no value>":
47
+ return f"{upper}:{lower}"
48
+ return upper
49
+
50
+
51
+ def create_image_hold(image: str, name: str) -> None:
52
+ """Create a stopped podman container to pin the image against pruning."""
53
+ hold_name = f"kento-hold.{name}"
54
+ subprocess.run(
55
+ [*_podman_cmd(), "create", "--name", hold_name,
56
+ "--label", f"io.kento.hold-for={name}",
57
+ image, "/bin/true"],
58
+ capture_output=True,
59
+ )
60
+
61
+
62
+ def ensure_image_hold(image: str, name: str) -> None:
63
+ """Idempotent — create the image hold only if missing (backfills pre-hold guests)."""
64
+ try:
65
+ exists = subprocess.run(
66
+ [*_podman_cmd(), "container", "exists", f"kento-hold.{name}"],
67
+ capture_output=True,
68
+ )
69
+ if exists.returncode != 0:
70
+ create_image_hold(image, name)
71
+ except Exception:
72
+ pass
73
+
74
+
75
+ def remove_image_hold(name: str) -> None:
76
+ """Remove the podman hold container for the given kento container."""
77
+ hold_name = f"kento-hold.{name}"
78
+ subprocess.run(
79
+ [*_podman_cmd(), "rm", hold_name],
80
+ capture_output=True,
81
+ )
kento/list.py ADDED
@@ -0,0 +1,146 @@
1
+ """List kento-managed instances."""
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from kento import LXC_BASE, VM_BASE, is_running, pve_config_exists, read_mode
8
+ from kento.info import _get_ssh_host_key_fingerprints
9
+
10
+
11
+ def list_containers(scope: str | None = None, show_size: bool = False,
12
+ as_json: bool = False) -> str:
13
+ """List kento-managed instances.
14
+
15
+ Returns the rendered text (either a JSON string, the human-readable columnar
16
+ table, or '(no instances found)'). The caller is responsible for printing the
17
+ returned string; this function does not print anything.
18
+ """
19
+ instances = []
20
+
21
+ image_files = []
22
+ if scope in (None, "lxc"):
23
+ if LXC_BASE.is_dir():
24
+ image_files.extend(LXC_BASE.glob("*/kento-image"))
25
+ if scope in (None, "vm"):
26
+ if VM_BASE.is_dir():
27
+ image_files.extend(VM_BASE.glob("*/kento-image"))
28
+
29
+ for image_file in sorted(image_files, key=lambda f: f.parent.name):
30
+ # list is read-only introspection: a concurrent `kento destroy`
31
+ # (rmtree) can race between the glob above and the reads below,
32
+ # raising FileNotFoundError/OSError. Skip the bad entry rather than
33
+ # aborting the whole listing and hiding all healthy instances.
34
+ try:
35
+ container_dir = image_file.parent
36
+ container_id = container_dir.name
37
+ image = image_file.read_text().strip()
38
+
39
+ name_file = container_dir / "kento-name"
40
+ display_name = name_file.read_text().strip() if name_file.is_file() else container_id
41
+
42
+ mode = read_mode(container_dir)
43
+ # Normalize the raw mode ('pve' -> 'pve-lxc') to match info.py.
44
+ ctype = "pve-lxc" if mode == "pve" else mode
45
+ # type = LXC/VM family (same derivation as info.py).
46
+ family = "VM" if mode in ("vm", "pve-vm") else "LXC"
47
+
48
+ # JSON vmid mirrors info.py exactly: from the kento-vmid file only
49
+ # (so list --json and inspect --json agree). For plain pve-lxc the
50
+ # orphan check uses the dir name (which is the vmid), independent
51
+ # of this.
52
+ vmid_file = container_dir / "kento-vmid"
53
+ vmid = vmid_file.read_text().strip() if vmid_file.is_file() else None
54
+
55
+ # For PVE modes, surface an orphaned instance (PVE config gone,
56
+ # destroyed out-of-band) as "orphan" so the user can see it and
57
+ # clean it up with `destroy -f`.
58
+ if mode in ("pve", "pve-vm"):
59
+ check_vmid = container_dir.name if mode == "pve" else vmid
60
+ if check_vmid is None or not pve_config_exists(check_vmid, mode):
61
+ status = "orphan"
62
+ else:
63
+ status = "running" if is_running(container_dir, mode) else "stopped"
64
+ else:
65
+ status = "running" if is_running(container_dir, mode) else "stopped"
66
+
67
+ # Build the per-instance dict, mirroring the keys inspect --json
68
+ # emits so machine consumers can drop an N+1 inspect call.
69
+ entry: dict = {
70
+ "name": display_name,
71
+ "type": family,
72
+ "mode": ctype,
73
+ "image": image,
74
+ "status": status,
75
+ }
76
+ # The mac/env/vmid/fingerprint fields only surface in --json. The
77
+ # human table never shows them, and _get_ssh_host_key_fingerprints
78
+ # shells out to ssh-keygen per key — so skip all of it (especially
79
+ # the subprocess) on the common columnar path.
80
+ if as_json:
81
+ if vmid is not None:
82
+ try:
83
+ entry["vmid"] = int(vmid)
84
+ except (TypeError, ValueError):
85
+ entry["vmid"] = vmid
86
+
87
+ mac_file = container_dir / "kento-mac"
88
+ if mac_file.is_file():
89
+ mac = mac_file.read_text().strip()
90
+ if mac:
91
+ entry["mac"] = mac
92
+
93
+ env_file = container_dir / "kento-env"
94
+ if env_file.is_file():
95
+ env = env_file.read_text()
96
+ env_lines = env.splitlines()
97
+ if env_lines:
98
+ entry["environment"] = env_lines
99
+
100
+ fingerprints, _ = _get_ssh_host_key_fingerprints(container_dir)
101
+ if fingerprints:
102
+ entry["ssh_host_key_fingerprints"] = fingerprints
103
+
104
+ if show_size:
105
+ state_file = container_dir / "kento-state"
106
+ state_dir = Path(state_file.read_text().strip()) if state_file.is_file() else container_dir
107
+ upper_dir = state_dir / "upper"
108
+ if upper_dir.is_dir():
109
+ du = subprocess.run(
110
+ ["du", "-sh", str(upper_dir)],
111
+ capture_output=True, text=True,
112
+ )
113
+ upper_size = du.stdout.split()[0] if du.returncode == 0 else "?"
114
+ else:
115
+ upper_size = "0"
116
+ entry["upper_size"] = upper_size
117
+
118
+ instances.append(entry)
119
+ except OSError:
120
+ continue
121
+
122
+ if as_json:
123
+ return json.dumps(instances, indent=2)
124
+
125
+ if not instances:
126
+ return "(no instances found)"
127
+
128
+ if show_size:
129
+ rows = [(e["name"], e["mode"], e["image"], e["status"], e["upper_size"])
130
+ for e in instances]
131
+ headers = ("NAME", "TYPE", "IMAGE", "STATUS", "UPPER SIZE")
132
+ else:
133
+ rows = [(e["name"], e["mode"], e["image"], e["status"])
134
+ for e in instances]
135
+ headers = ("NAME", "TYPE", "IMAGE", "STATUS")
136
+
137
+ widths = []
138
+ for i, header in enumerate(headers):
139
+ col_max = max((len(row[i]) for row in rows), default=0)
140
+ widths.append(max(len(header), col_max))
141
+
142
+ lines = [" ".join(h.ljust(w) for h, w in zip(headers, widths)),
143
+ " ".join("-" * w for w in widths)]
144
+ for row in rows:
145
+ lines.append(" ".join(val.ljust(w) for val, w in zip(row, widths)))
146
+ return "\n".join(lines)
kento/locking.py ADDED
@@ -0,0 +1,64 @@
1
+ """Cross-process lock for kento state mutations.
2
+
3
+ Used by create() to serialize allocate-port / next-vmid / next-instance-name
4
+ / container_dir.mkdir across concurrent kento invocations. Without this, two
5
+ concurrent `kento create` runs can allocate the same VMID, port, or
6
+ auto-generated name.
7
+ """
8
+
9
+ import errno
10
+ import fcntl
11
+ import logging
12
+ import os
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+
16
+ from kento.errors import StateError
17
+
18
+ logger = logging.getLogger("kento")
19
+
20
+ # Primary lock path (tmpfs-backed, cleared on reboot — fine for a lock).
21
+ # Fallback when /run is not writable (unusual, but handles root-owned
22
+ # read-only /run under some container setups).
23
+ _PRIMARY_LOCK = Path("/run/kento.lock")
24
+ _FALLBACK_LOCK = Path("/var/lib/kento/.lock")
25
+
26
+
27
+ def _open_lock_fd() -> int:
28
+ """Open (creating if needed) the kento lock file and return its fd.
29
+
30
+ Tries /run/kento.lock first. Falls back to /var/lib/kento/.lock if
31
+ /run is not writable. The file is kept open for the duration of the
32
+ lock; flock is released on close.
33
+ """
34
+ for path in (_PRIMARY_LOCK, _FALLBACK_LOCK):
35
+ try:
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ return os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
38
+ except OSError:
39
+ continue
40
+ raise StateError(
41
+ "could not open a kento lock file at /run/kento.lock or "
42
+ "/var/lib/kento/.lock. Check filesystem permissions."
43
+ )
44
+
45
+
46
+ @contextmanager
47
+ def kento_lock():
48
+ """Acquire the kento-wide exclusive lock for the duration of a block.
49
+
50
+ Blocks until the lock is available. Use this around any sequence that
51
+ must be atomic across concurrent `kento` processes — notably the
52
+ allocate-then-write steps in create() (ports, VMIDs, auto-names, and
53
+ container_dir creation).
54
+ """
55
+ fd = _open_lock_fd()
56
+ try:
57
+ fcntl.flock(fd, fcntl.LOCK_EX)
58
+ yield
59
+ finally:
60
+ try:
61
+ fcntl.flock(fd, fcntl.LOCK_UN)
62
+ except OSError:
63
+ pass
64
+ os.close(fd)
kento/logs.py ADDED
@@ -0,0 +1,48 @@
1
+ """Show journal logs from a kento-managed instance.
2
+
3
+ Dispatch per mode:
4
+ - lxc -> lxc-attach -n <name> -- journalctl <args> (inherited stdio)
5
+ - pve -> pct exec <vmid> -- journalctl <args> (pve-lxc)
6
+ - vm -> error (use 'kento attach' for the serial console, or SSH)
7
+ - pve-vm -> error (same)
8
+
9
+ Extra args (e.g. -f, -n, 50) are forwarded verbatim to journalctl.
10
+ """
11
+
12
+ import logging
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ from kento import read_mode, require_root, resolve_any
17
+ from kento.errors import ModeError
18
+
19
+ logger = logging.getLogger("kento")
20
+
21
+
22
+ def logs(name: str, args: list[str],
23
+ namespace: str | None = None) -> int:
24
+ """Show ``journalctl`` output for instance ``name``. Returns an exit code."""
25
+ require_root()
26
+
27
+ container_dir, mode = resolve_any(name, namespace)
28
+ if mode is None:
29
+ mode = read_mode(container_dir)
30
+
31
+ if mode in ("vm", "pve-vm"):
32
+ raise ModeError(
33
+ "'kento logs' is not supported for VM instances. "
34
+ "Use 'kento attach <name>' for the serial console, or SSH + "
35
+ "journalctl inside the guest."
36
+ )
37
+
38
+ if mode == "pve":
39
+ # pve-lxc: the instance directory name IS the VMID.
40
+ vmid = container_dir.name
41
+ return subprocess.run(
42
+ ["pct", "exec", vmid, "--", "journalctl", *args]
43
+ ).returncode
44
+
45
+ # plain lxc: name is the container name; inherit stdio.
46
+ return subprocess.run(
47
+ ["lxc-attach", "-n", name, "--", "journalctl", *args]
48
+ ).returncode
kento/lxc_hook.py ADDED
@@ -0,0 +1,61 @@
1
+ """Generate PVE hookscript snippets for pve-lxc containers."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from kento.errors import StateError
7
+
8
+ logger = logging.getLogger("kento")
9
+
10
+
11
+ def generate_lxc_snippets_wrapper(hook_path: Path) -> str:
12
+ """Thin wrapper that maps PVE hookscript phases to kento-hook types."""
13
+ return f"""#!/bin/sh
14
+ # Generated by kento. PVE strips lxc.hook.start-host, so we route
15
+ # post-start / post-stop through this snippets wrapper to fire the
16
+ # per-container kento-hook (the plain-LXC hook.sh).
17
+ VMID="$1"
18
+ PHASE="$2"
19
+ case "$PHASE" in
20
+ post-start)
21
+ exec "{hook_path}" "$VMID" "" "start-host"
22
+ ;;
23
+ post-stop)
24
+ exec "{hook_path}" "$VMID" "" "post-stop"
25
+ ;;
26
+ esac
27
+ exit 0
28
+ """
29
+
30
+
31
+ def write_lxc_snippets_wrapper(vmid: int, hook_path: Path, *,
32
+ snippets_dir: Path | None = None,
33
+ storage_name: str | None = None) -> str:
34
+ """Write the wrapper and return a PVE storage ref."""
35
+ if snippets_dir is None or storage_name is None:
36
+ from kento.vm_hook import find_snippets_dir
37
+ snippets_dir, storage_name = find_snippets_dir()
38
+ wrapper_name = f"kento-lxc-{vmid}.sh"
39
+ wrapper_path = snippets_dir / wrapper_name
40
+ wrapper_path.write_text(generate_lxc_snippets_wrapper(hook_path))
41
+ wrapper_path.chmod(0o755)
42
+ return f"{storage_name}:snippets/{wrapper_name}"
43
+
44
+
45
+ def delete_lxc_snippets_wrapper(vmid: int) -> None:
46
+ """Delete the wrapper (no-op if missing)."""
47
+ try:
48
+ from kento.vm_hook import find_snippets_dir
49
+ snippets_dir, _ = find_snippets_dir()
50
+ wrapper = snippets_dir / f"kento-lxc-{vmid}.sh"
51
+ wrapper.unlink(missing_ok=True)
52
+ except StateError:
53
+ # find_snippets_dir() couldn't locate the snippets storage (no longer
54
+ # enabled / unlocatable). We can't compute the wrapper path, so the
55
+ # file (if any) is left behind. Warn rather than silently reporting a
56
+ # clean destroy so a leaked wrapper can be cleaned up manually.
57
+ logger.warning(
58
+ "could not locate snippets storage to remove the "
59
+ "kento-lxc-%s.sh hookscript wrapper; it may need manual cleanup",
60
+ vmid,
61
+ )