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/reset.py ADDED
@@ -0,0 +1,212 @@
1
+ """Scrub a kento-managed instance back to clean OCI state."""
2
+
3
+ import logging
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from kento import is_running, read_mode, require_root, resolve_container
9
+ from kento.errors import StateError
10
+ from kento.hook import write_hook
11
+ from kento.layers import ensure_image_hold, resolve_layers
12
+ from kento.vm_hook import write_vm_hook
13
+
14
+ logger = logging.getLogger("kento")
15
+
16
+
17
+ def _safe_clear_dir(path: Path) -> None:
18
+ # Crash-safe clear: rename target out of the way, create a fresh
19
+ # empty dir, then remove the old copy. A crash mid-sequence leaves
20
+ # either ``path.old`` (next scrub sweeps it) or an empty ``path``;
21
+ # we never sit in a ``rmtree completed, mkdir not yet`` window where
22
+ # the overlayfs mount point is gone.
23
+ stale = path.with_name(path.name + ".old")
24
+ if stale.exists():
25
+ shutil.rmtree(stale)
26
+ if path.exists():
27
+ path.rename(stale)
28
+ path.mkdir(parents=True)
29
+ if stale.exists():
30
+ shutil.rmtree(stale)
31
+
32
+
33
+ def reset(name: str, *, container_dir: Path | None = None, mode: str | None = None) -> None:
34
+ require_root()
35
+
36
+ if container_dir is None:
37
+ container_dir = resolve_container(name)
38
+
39
+ if mode is None:
40
+ # Detect mode (default lxc for containers created before mode tracking)
41
+ mode = read_mode(container_dir)
42
+
43
+ # Refuse if running
44
+ if is_running(container_dir, mode):
45
+ raise StateError(f"instance is running. Stop it first: kento stop {name}")
46
+
47
+ # Re-resolve layers from image BEFORE touching the writable layer. If the
48
+ # backing image is gone from the store, resolve_layers() raises
49
+ # ImageNotFoundError — we want that to abort with zero side effects, not
50
+ # after _safe_clear_dir() has already wiped upper/work, leaving a
51
+ # half-scrubbed instance with stale kento-layers/hook.
52
+ image = (container_dir / "kento-image").read_text().strip()
53
+ layers = resolve_layers(image)
54
+
55
+ # Read state dir
56
+ state_file = container_dir / "kento-state"
57
+ state_dir = Path(state_file.read_text().strip()) if state_file.is_file() else container_dir
58
+
59
+ # Unmount rootfs if mounted
60
+ rootfs = container_dir / "rootfs"
61
+ if subprocess.run(["mountpoint", "-q", str(rootfs)],
62
+ capture_output=True).returncode == 0:
63
+ result = subprocess.run(["umount", str(rootfs)])
64
+ if result.returncode != 0:
65
+ raise StateError(f"failed to unmount {rootfs}. Is the container still running?")
66
+
67
+ # Unprivileged cleanup: unmount idmapped bind mounts in reverse order
68
+ # and remove the $STATE_DIR/idmap directory. This handles stale mounts
69
+ # left behind by a crashed unprivileged start so that the next start
70
+ # isn't blocked by the bind idempotency guard finding stale mounts that
71
+ # point at the wrong (old) overlay configuration.
72
+ idmap_dir = state_dir / "idmap"
73
+ if idmap_dir.exists():
74
+ # Collect subdirectories in sorted order, unmount in reverse.
75
+ idmap_subdirs = sorted(idmap_dir.iterdir())
76
+ for d in reversed(idmap_subdirs):
77
+ if d.is_dir():
78
+ subprocess.run(["umount", str(d)], capture_output=True)
79
+ shutil.rmtree(idmap_dir, ignore_errors=True)
80
+
81
+ # Clear writable layer (crash-safe: rename then rm, not rm then mkdir)
82
+ _safe_clear_dir(state_dir / "upper")
83
+ _safe_clear_dir(state_dir / "work")
84
+
85
+ # Clean up stale port forwarding state (safety net)
86
+ portfwd_active = container_dir / "kento-portfwd-active"
87
+ portfwd_active.unlink(missing_ok=True)
88
+
89
+ # Re-inject guest config from kento metadata
90
+ from kento.create import (_inject_network_config, _inject_hostname,
91
+ _inject_timezone, _inject_env)
92
+
93
+ # Hostname
94
+ name_file = container_dir / "kento-name"
95
+ if name_file.is_file():
96
+ _inject_hostname(state_dir, name_file.read_text().strip())
97
+
98
+ # Network (static IP + searchdomain)
99
+ net_file = container_dir / "kento-net"
100
+ if net_file.is_file():
101
+ net_cfg = {}
102
+ for line in net_file.read_text().strip().splitlines():
103
+ k, v = line.split("=", 1)
104
+ net_cfg[k] = v
105
+ if "ip" in net_cfg:
106
+ _inject_network_config(state_dir, net_cfg["ip"],
107
+ net_cfg.get("gateway"), net_cfg.get("dns"),
108
+ net_cfg.get("searchdomain"), mode=mode)
109
+ elif net_cfg.get("dns") or net_cfg.get("searchdomain"):
110
+ resolved_dir = state_dir / "upper" / "etc" / "systemd" / "resolved.conf.d"
111
+ resolved_dir.mkdir(parents=True, exist_ok=True)
112
+ lines = ["[Resolve]"]
113
+ if net_cfg.get("dns"):
114
+ lines.append(f"DNS={net_cfg['dns']}")
115
+ if net_cfg.get("searchdomain"):
116
+ lines.append(f"Domains={net_cfg['searchdomain']}")
117
+ lines.append("")
118
+ (resolved_dir / "90-kento.conf").write_text("\n".join(lines))
119
+
120
+ # Timezone
121
+ tz_file = container_dir / "kento-tz"
122
+ if tz_file.is_file():
123
+ _inject_timezone(state_dir, tz_file.read_text().strip())
124
+
125
+ # Environment variables
126
+ env_file = container_dir / "kento-env"
127
+ if env_file.is_file():
128
+ _inject_env(state_dir, env_file.read_text().strip().splitlines())
129
+
130
+ # Regenerate cloud-init seed if in cloudinit mode
131
+ config_mode_file = container_dir / "kento-config-mode"
132
+ if config_mode_file.is_file() and config_mode_file.read_text().strip() == "cloudinit":
133
+ from kento.cloudinit import write_seed
134
+ # Gather config from metadata files
135
+ net_file_ci = container_dir / "kento-net"
136
+ net_cfg_ci = {}
137
+ if net_file_ci.is_file():
138
+ for line in net_file_ci.read_text().strip().splitlines():
139
+ k, v = line.split("=", 1)
140
+ net_cfg_ci[k] = v
141
+ tz_file_ci = container_dir / "kento-tz"
142
+ env_file_ci = container_dir / "kento-env"
143
+ ci_ssh_keys = None
144
+ auth_keys_file = container_dir / "kento-authorized-keys"
145
+ if auth_keys_file.is_file():
146
+ ci_ssh_keys = auth_keys_file.read_text()
147
+ ssh_user_file = container_dir / "kento-ssh-user"
148
+ ci_ssh_user = ssh_user_file.read_text().strip() if ssh_user_file.is_file() else "root"
149
+ ci_host_key_dir = container_dir / "ssh-host-keys"
150
+ name_file_ci = container_dir / "kento-name"
151
+ write_seed(
152
+ container_dir,
153
+ name=name_file_ci.read_text().strip() if name_file_ci.is_file() else name,
154
+ ip=net_cfg_ci.get("ip"),
155
+ gateway=net_cfg_ci.get("gateway"),
156
+ dns=net_cfg_ci.get("dns"),
157
+ searchdomain=net_cfg_ci.get("searchdomain"),
158
+ timezone=tz_file_ci.read_text().strip() if tz_file_ci.is_file() else None,
159
+ env=env_file_ci.read_text().strip().splitlines() if env_file_ci.is_file() else None,
160
+ ssh_keys=ci_ssh_keys, ssh_key_user=ci_ssh_user,
161
+ ssh_host_key_dir=ci_host_key_dir if ci_host_key_dir.is_dir() else None,
162
+ )
163
+
164
+ # Persist the layers resolved up-front (before the writable layer was cleared).
165
+ (container_dir / "kento-layers").write_text(layers + "\n")
166
+
167
+ # Backfill the image-hold container if it's missing (self-heals guests
168
+ # created before the hold mechanism existed). Store-level, mode-agnostic.
169
+ hold_name = (container_dir / "kento-name").read_text().strip() \
170
+ if (container_dir / "kento-name").is_file() else name
171
+ ensure_image_hold(image, hold_name)
172
+
173
+ # Regenerate the mode-appropriate hook so fresh image paths land in it.
174
+ # Plain VM mode has no hook (QEMU starts from Python), but pve-vm does —
175
+ # and its hook shape (VMID $1 / PHASE $2) is incompatible with the LXC
176
+ # hook shape (uses $3 for hook type), so using the wrong writer here
177
+ # silently breaks `qm start` after scrub.
178
+ if mode == "pve-vm":
179
+ write_vm_hook(container_dir, layers, name, state_dir)
180
+ # Re-read memory: from the PVE qm config and rewrite the
181
+ # kento-managed args: line so the embedded memfd size= stays in
182
+ # sync. `qm set --memory N` updates memory: but leaves args:
183
+ # untouched, so without this the pre-start validator aborts on
184
+ # every start after a memory change.
185
+ vmid_file = container_dir / "kento-vmid"
186
+ if vmid_file.is_file():
187
+ from kento.pve import sync_qm_args_to_memory
188
+ try:
189
+ vmid = int(vmid_file.read_text().strip())
190
+ except ValueError:
191
+ vmid = None
192
+ if vmid is not None:
193
+ sync_qm_args_to_memory(vmid, container_dir)
194
+ elif mode != "vm":
195
+ write_hook(container_dir, layers, name, state_dir)
196
+
197
+ # PVE-LXC: regenerate snippets wrapper when the container has
198
+ # port/memory/cores metadata. The wrapper path is derived from the
199
+ # VMID (container_dir.name) and the kento-hook path, so this is
200
+ # idempotent — we rewrite the same file. Done for consistency in
201
+ # case the kento-hook path ever changes across upgrades.
202
+ if mode == "pve":
203
+ if any((container_dir / f).is_file()
204
+ for f in ("kento-port", "kento-memory", "kento-cores")):
205
+ from kento.lxc_hook import write_lxc_snippets_wrapper
206
+ write_lxc_snippets_wrapper(
207
+ int(container_dir.name),
208
+ container_dir / "kento-hook",
209
+ )
210
+
211
+ logger.info("Scrubbed: %s", name)
212
+ logger.info(" Writable layer cleared, layers re-resolved from image.")