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