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/start.py ADDED
@@ -0,0 +1,65 @@
1
+ """Start a kento-managed instance."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from kento import is_running, read_mode, require_root, resolve_container
7
+ from kento.subprocess_util import run_or_die
8
+
9
+ logger = logging.getLogger("kento")
10
+
11
+
12
+ def start(name: str, *, container_dir: Path | None = None, mode: str | None = None) -> None:
13
+ require_root()
14
+
15
+ if container_dir is None:
16
+ container_dir = resolve_container(name)
17
+ container_id = container_dir.name
18
+
19
+ if mode is None:
20
+ mode = read_mode(container_dir)
21
+
22
+ # F15: idempotent start — calling start on a running instance should
23
+ # be a no-op that reports the current state, not a traceback from
24
+ # lxc-start/pct/qm complaining the container is already up.
25
+ if is_running(container_dir, mode):
26
+ logger.info("Already running: %s", name)
27
+ return
28
+
29
+ # Backfill the image-hold container if missing (self-heals guests created
30
+ # before the hold mechanism). Cheap `container exists` check; runs on every
31
+ # start but NOT on the already-running early-return above.
32
+ from kento.layers import ensure_image_hold
33
+ image = (container_dir / "kento-image").read_text().strip()
34
+ hold_name = (container_dir / "kento-name").read_text().strip() \
35
+ if (container_dir / "kento-name").is_file() else name
36
+ ensure_image_hold(image, hold_name)
37
+
38
+ if mode == "vm":
39
+ from kento.vm import start_vm
40
+ start_vm(container_dir, name)
41
+ return
42
+ elif mode == "pve-vm":
43
+ vmid = (container_dir / "kento-vmid").read_text().strip()
44
+ run_or_die(
45
+ ["qm", "start", vmid],
46
+ what="start PVE VM",
47
+ name=name,
48
+ hint=f"check /var/log/pve/tasks/ or run 'qm start {vmid}' directly.",
49
+ )
50
+ elif mode == "pve":
51
+ run_or_die(
52
+ ["pct", "start", container_id],
53
+ what="start PVE container",
54
+ name=name,
55
+ hint=f"run 'pct start {container_id}' directly for details.",
56
+ )
57
+ else:
58
+ run_or_die(
59
+ ["lxc-start", "-n", container_id],
60
+ what="start LXC container",
61
+ name=name,
62
+ hint=f"run 'lxc-start -F -n {container_id}' in the foreground for details.",
63
+ )
64
+
65
+ logger.info("Started: %s", name)
kento/stop.py ADDED
@@ -0,0 +1,210 @@
1
+ """Shut down a kento-managed instance."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from kento import is_running, read_mode, require_root, resolve_container
7
+ from kento.errors import SubprocessError, ValidationError
8
+ from kento.subprocess_util import run_or_die
9
+
10
+ logger = logging.getLogger("kento")
11
+
12
+ DEFAULT_PVE_VM_SHUTDOWN_TIMEOUT = 30
13
+
14
+ # pct/qm phrasings emitted when the target is already down. is_running()
15
+ # ASSUMES RUNNING on a status-query timeout/non-zero (see its docstring), so a
16
+ # stop may be issued on an instance that is actually stopped; we must treat
17
+ # that as a benign no-op rather than a hard error.
18
+ _NOT_RUNNING_MARKERS = (
19
+ "not running", # "CT is not running", "VM is not running"
20
+ "no such", # defensive: tool variants
21
+ "is stopped",
22
+ )
23
+
24
+
25
+ def _is_not_running_error(text: str) -> bool:
26
+ """Heuristically detect a pct/qm "already stopped" failure from its output."""
27
+ lowered = (text or "").lower()
28
+ return any(marker in lowered for marker in _NOT_RUNNING_MARKERS)
29
+
30
+
31
+ def _pve_shutdown_or_die(cmd, *, what: str, name: str, hint: str,
32
+ container_dir: Path, mode: str):
33
+ """Run a pct/qm stop command, tolerating the already-stopped case.
34
+
35
+ On non-zero exit we distinguish two cases:
36
+ - the instance is actually already down (the status query that drove us
37
+ here merely timed out / returned non-zero, so is_running() assumed
38
+ running) -> log "Already stopped" (info) and return None; OR
39
+ - a genuine failure -> raise SubprocessError (branded message), as before.
40
+
41
+ The non-fatal run is issued via subprocess_util.subprocess so it shares the
42
+ same dispatch (and test patch point) as run_or_die. On a genuine failure
43
+ the branded message mirrors run_or_die's format verbatim.
44
+
45
+ Returns the CompletedProcess on success, or None if the instance was
46
+ already stopped (caller should return without printing a stop action).
47
+ """
48
+ from kento import subprocess_util
49
+ cmd = list(cmd)
50
+ try:
51
+ result = subprocess_util.subprocess.run(
52
+ cmd, capture_output=True, text=True)
53
+ except (FileNotFoundError, OSError):
54
+ # Tool missing / not executable: defer to run_or_die for the branded
55
+ # FileNotFoundError/OSError message + exit (it re-runs and hits the
56
+ # same error deterministically).
57
+ run_or_die(cmd, what=what, name=name, hint=hint)
58
+ return None # unreachable (run_or_die exits)
59
+
60
+ if result.returncode == 0:
61
+ return result
62
+
63
+ combined = (result.stdout or "") + (result.stderr or "")
64
+ # Re-query actual status; if the instance really is down (or its config is
65
+ # gone), this was the already-stopped race, not a real failure.
66
+ if _is_not_running_error(combined) or not is_running(container_dir, mode):
67
+ logger.info("Already stopped: %s", name)
68
+ return None
69
+
70
+ # Genuine failure: raise SubprocessError (mirrors run_or_die's contract).
71
+ label = f"{what} {name}" if name else what
72
+ stderr = (result.stderr or "").strip()
73
+ if len(stderr) > 500:
74
+ stderr = stderr[:500] + "... (truncated)"
75
+ msg = f"failed to {label} (exit {result.returncode})"
76
+ if stderr:
77
+ msg += f": {stderr}"
78
+ if hint:
79
+ logger.info("hint: %s", hint)
80
+ raise SubprocessError(msg, cmd=list(cmd), returncode=result.returncode)
81
+
82
+
83
+ def shutdown(
84
+ name: str,
85
+ *,
86
+ force: bool = False,
87
+ container_dir: Path | None = None,
88
+ mode: str | None = None,
89
+ timeout: int | None = None,
90
+ graceful_only: bool = False,
91
+ ) -> None:
92
+ require_root()
93
+
94
+ if container_dir is None:
95
+ container_dir = resolve_container(name)
96
+ container_id = container_dir.name
97
+
98
+ if mode is None:
99
+ mode = read_mode(container_dir)
100
+
101
+ # Mutually exclusive guards. timeout/graceful-only only meaningful
102
+ # on the bounded-graceful pve-vm path; --force skips graceful entirely.
103
+ if graceful_only and force:
104
+ raise ValidationError(
105
+ "--graceful-only and --force are mutually exclusive "
106
+ "(one waits forever, the other kills now)."
107
+ )
108
+ if graceful_only and timeout is not None:
109
+ raise ValidationError(
110
+ "--timeout has no effect with --graceful-only "
111
+ "(graceful-only drops --forceStop, so the timeout is meaningless)."
112
+ )
113
+ if force and timeout is not None:
114
+ raise ValidationError(
115
+ "--timeout has no effect with --force "
116
+ "(force skips graceful shutdown entirely)."
117
+ )
118
+
119
+ # F15: idempotent stop — calling stop on an already-stopped instance
120
+ # should be a no-op, not a traceback from lxc-stop/pct/qm exiting
121
+ # non-zero because the target is already down.
122
+ if not is_running(container_dir, mode):
123
+ logger.info("Already stopped: %s", name)
124
+ return
125
+
126
+ if mode == "vm":
127
+ from kento.vm import stop_vm
128
+ stop_vm(container_dir, force=force)
129
+ elif mode == "pve-vm":
130
+ vmid = (container_dir / "kento-vmid").read_text().strip()
131
+ if force:
132
+ result = _pve_shutdown_or_die(
133
+ ["qm", "stop", vmid],
134
+ what="stop PVE VM",
135
+ name=name,
136
+ hint=f"run 'qm stop {vmid}' directly for details.",
137
+ container_dir=container_dir, mode=mode,
138
+ )
139
+ if result is None:
140
+ return # already stopped (message already printed)
141
+ elif graceful_only:
142
+ result = _pve_shutdown_or_die(
143
+ ["qm", "shutdown", vmid],
144
+ what="shut down PVE VM",
145
+ name=name,
146
+ hint=f"run 'qm shutdown {vmid}' directly for details.",
147
+ container_dir=container_dir, mode=mode,
148
+ )
149
+ if result is None:
150
+ return # already stopped
151
+ else:
152
+ effective_timeout = (
153
+ timeout if timeout is not None else DEFAULT_PVE_VM_SHUTDOWN_TIMEOUT
154
+ )
155
+ result = _pve_shutdown_or_die(
156
+ [
157
+ "qm", "shutdown", vmid,
158
+ "--timeout", str(effective_timeout),
159
+ "--forceStop",
160
+ ],
161
+ what="shut down PVE VM",
162
+ name=name,
163
+ hint=f"run 'qm shutdown {vmid}' directly for details.",
164
+ container_dir=container_dir, mode=mode,
165
+ )
166
+ if result is None:
167
+ return # already stopped
168
+ combined = (result.stdout or "") + (result.stderr or "")
169
+ lowered = combined.lower()
170
+ if "still running" in lowered or "terminating" in lowered:
171
+ logger.warning(
172
+ "VM %s did not honor ACPI shutdown within %ss, hard-stopped",
173
+ name, effective_timeout,
174
+ )
175
+ elif mode == "pve":
176
+ if force:
177
+ result = _pve_shutdown_or_die(
178
+ ["pct", "stop", container_id],
179
+ what="stop PVE container",
180
+ name=name,
181
+ hint=f"run 'pct stop {container_id}' directly for details.",
182
+ container_dir=container_dir, mode=mode,
183
+ )
184
+ else:
185
+ result = _pve_shutdown_or_die(
186
+ ["pct", "shutdown", container_id],
187
+ what="shut down PVE container",
188
+ name=name,
189
+ hint=f"run 'pct shutdown {container_id}' directly for details.",
190
+ container_dir=container_dir, mode=mode,
191
+ )
192
+ if result is None:
193
+ return # already stopped (message already printed)
194
+ else:
195
+ cmd = ["lxc-stop", "-n", container_id]
196
+ if force:
197
+ cmd.append("-k")
198
+ run_or_die(
199
+ cmd,
200
+ what="stop LXC container",
201
+ name=name,
202
+ hint=f"run 'lxc-stop -n {container_id}' directly for details.",
203
+ )
204
+
205
+ action = "Stopped" if force else "Shut down"
206
+ logger.info("%s: %s", action, name)
207
+
208
+
209
+ # Alias for backward compatibility
210
+ stop = shutdown
@@ -0,0 +1,76 @@
1
+ """Subprocess wrapper that converts CalledProcessError/FileNotFoundError into
2
+ kento-branded error messages, not Python tracebacks."""
3
+
4
+ import logging
5
+ import subprocess
6
+ from typing import Sequence
7
+
8
+ from kento.errors import SubprocessError
9
+
10
+ logger = logging.getLogger("kento")
11
+
12
+
13
+ def run_or_die(
14
+ cmd: Sequence[str],
15
+ what: str,
16
+ *,
17
+ name: str | None = None,
18
+ hint: str | None = None,
19
+ env: dict | None = None,
20
+ cwd: str | None = None,
21
+ ) -> subprocess.CompletedProcess:
22
+ """Run cmd and raise SubprocessError on failure.
23
+
24
+ Args:
25
+ cmd: Command + args (first element is the executable).
26
+ what: Human-readable operation description, e.g. "start LXC container".
27
+ Used in the error message: "failed to {what}..."
28
+ name: Optional instance name for the message.
29
+ hint: Optional follow-on line suggesting what to do next.
30
+ env, cwd: Passed to subprocess.run.
31
+
32
+ Returns the CompletedProcess on success. On failure raises SubprocessError
33
+ with a message of the form:
34
+ failed to {what}[ {name}] (exit {rc})[: {stderr_snippet}]
35
+ carrying cmd and returncode. FileNotFoundError / PermissionError / OSError
36
+ raise SubprocessError with returncode=None.
37
+ """
38
+ try:
39
+ result = subprocess.run(
40
+ list(cmd),
41
+ capture_output=True,
42
+ text=True,
43
+ env=env,
44
+ cwd=cwd,
45
+ )
46
+ except FileNotFoundError:
47
+ tool = cmd[0] if cmd else "(empty cmd)"
48
+ msg = f"'{tool}' not found on PATH. Install it or check your PATH."
49
+ if hint:
50
+ logger.info("hint: %s", hint)
51
+ raise SubprocessError(msg, cmd=list(cmd))
52
+ except OSError as e:
53
+ # PermissionError (binary lacks +x, or is a directory) and other OSError
54
+ # (ENOEXEC / wrong-arch) — surface a branded message, not a traceback.
55
+ tool = cmd[0] if cmd else "(empty cmd)"
56
+ msg = f"cannot execute '{tool}': {e.strerror} (check permissions/arch)"
57
+ if hint:
58
+ logger.info("hint: %s", hint)
59
+ raise SubprocessError(msg, cmd=list(cmd))
60
+
61
+ if result.returncode != 0:
62
+ label = f"{what} {name}" if name else what
63
+ stderr = (result.stderr or "").strip()
64
+ # Keep the stderr snippet short so it's still scannable. Full output
65
+ # from the external tool already went to the user's terminal if the
66
+ # caller didn't capture it.
67
+ if len(stderr) > 500:
68
+ stderr = stderr[:500] + "... (truncated)"
69
+ msg = f"failed to {label} (exit {result.returncode})"
70
+ if stderr:
71
+ msg += f": {stderr}"
72
+ if hint:
73
+ logger.info("hint: %s", hint)
74
+ raise SubprocessError(msg, cmd=list(cmd), returncode=result.returncode)
75
+
76
+ return result
kento/suspend.py ADDED
@@ -0,0 +1,196 @@
1
+ """Suspend / resume a kento-managed VM instance (vCPU pause, not a stop).
2
+
3
+ `kento suspend` pauses a running VM's vCPUs; `kento resume` un-pauses them.
4
+ This is a *pause to RAM* — the VM process keeps running and its memory is
5
+ retained — NOT a shutdown. It is therefore VM-modes-only:
6
+
7
+ - plain vm : QMP ``stop`` / ``cont`` over ``<container_dir>/qmp.sock`` (the
8
+ QMP unix socket exposed by start_vm). See qmp_command below.
9
+ - pve-vm : ``qm suspend <vmid>`` / ``qm resume <vmid>``.
10
+ - lxc / pve-lxc : unsupported — there is no vCPU to pause; use
11
+ ``kento stop`` / ``kento start`` instead.
12
+
13
+ Registered (like attach/exec/logs/set) at the bare + lxc + vm CLI scopes; an
14
+ LXC-scoped invocation just hits the unsupported-mode error.
15
+ """
16
+
17
+ import json
18
+ import logging
19
+ import socket
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ from kento import is_running, read_mode, require_root, resolve_any
24
+ from kento.errors import ModeError, StateError, SubprocessError
25
+
26
+ logger = logging.getLogger("kento")
27
+
28
+ _LXC_UNSUPPORTED = (
29
+ "suspend/resume is not supported for LXC instances; "
30
+ "use 'kento stop' / 'kento start'."
31
+ )
32
+
33
+
34
+ def qmp_command(sock_path, *commands, timeout: float = 10):
35
+ """Connect to a QEMU QMP unix socket, negotiate, run commands, return responses.
36
+
37
+ Opens ``sock_path`` (AF_UNIX, SOCK_STREAM), reads the server's greeting
38
+ line (``{"QMP": {...}}``), leaves capabilities-negotiation mode by issuing
39
+ ``qmp_capabilities``, then sends each command dict in ``commands`` and
40
+ collects its reply.
41
+
42
+ QMP framing: every message is a newline-terminated JSON object. The server
43
+ may interleave asynchronous ``{"event": ...}`` messages with command
44
+ replies, so when waiting for a command's reply we read lines until we get a
45
+ dict that is NOT an event (i.e. carries ``return`` or ``error``).
46
+
47
+ Returns the list of reply dicts (one per command in ``commands``), each
48
+ either ``{"return": ...}`` or ``{"error": ...}``. Raises OSError on socket
49
+ failure and ValueError on a malformed/empty stream.
50
+ """
51
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
52
+ sock.settimeout(timeout)
53
+ try:
54
+ sock.connect(str(sock_path))
55
+ reader = _LineReader(sock)
56
+
57
+ # 1. Greeting: the server speaks first with {"QMP": {...}}.
58
+ greeting = reader.next_json()
59
+ if "QMP" not in greeting:
60
+ raise ValueError(f"unexpected QMP greeting: {greeting!r}")
61
+
62
+ # 2. Leave negotiation mode. The reply is {"return": {}}.
63
+ _send(sock, {"execute": "qmp_capabilities"})
64
+ reader.next_reply()
65
+
66
+ # 3. Run each requested command, collecting its reply.
67
+ replies = []
68
+ for cmd in commands:
69
+ _send(sock, cmd)
70
+ replies.append(reader.next_reply())
71
+ return replies
72
+ finally:
73
+ try:
74
+ sock.shutdown(socket.SHUT_RDWR)
75
+ except OSError:
76
+ pass
77
+ sock.close()
78
+
79
+
80
+ def _send(sock, obj) -> None:
81
+ """Serialize ``obj`` as a newline-terminated UTF-8 JSON line and send it."""
82
+ sock.sendall((json.dumps(obj) + "\n").encode("utf-8"))
83
+
84
+
85
+ class _LineReader:
86
+ """Buffered newline-delimited JSON reader over a blocking socket."""
87
+
88
+ def __init__(self, sock) -> None:
89
+ self._sock = sock
90
+ self._buf = b""
91
+
92
+ def _next_line(self) -> bytes:
93
+ while b"\n" not in self._buf:
94
+ chunk = self._sock.recv(65536)
95
+ if not chunk:
96
+ if self._buf.strip():
97
+ line, self._buf = self._buf, b""
98
+ return line
99
+ raise ValueError("QMP socket closed before a complete message")
100
+ self._buf += chunk
101
+ line, _, self._buf = self._buf.partition(b"\n")
102
+ return line
103
+
104
+ def next_json(self) -> dict:
105
+ """Return the next non-empty JSON object from the stream."""
106
+ while True:
107
+ line = self._next_line().strip()
108
+ if not line:
109
+ continue
110
+ return json.loads(line.decode("utf-8"))
111
+
112
+ def next_reply(self) -> dict:
113
+ """Return the next reply, skipping interleaved async events."""
114
+ while True:
115
+ msg = self.next_json()
116
+ if "event" in msg:
117
+ continue # async event; keep waiting for the command reply
118
+ return msg
119
+
120
+
121
+ def _vm_qmp(name: str, container_dir: Path, command: str, label: str) -> None:
122
+ """Run a single QMP command on a plain VM. command is 'stop' or 'cont'."""
123
+ sock_path = container_dir / "qmp.sock"
124
+ if not sock_path.exists():
125
+ raise SubprocessError(
126
+ f"QMP socket not found for '{name}' ({sock_path}). The "
127
+ f"instance is not running, or it was started by an older kento "
128
+ f"without QMP support. Start it with 'kento start {name}' and retry."
129
+ )
130
+ try:
131
+ (reply,) = qmp_command(sock_path, {"execute": command})
132
+ except (OSError, ValueError) as exc:
133
+ raise SubprocessError(
134
+ f"QMP {command} failed for '{name}': {exc}. Is it running?"
135
+ ) from exc
136
+ if "error" in reply:
137
+ err = reply["error"]
138
+ desc = err.get("desc", err) if isinstance(err, dict) else err
139
+ raise SubprocessError(f"QMP {command} rejected for '{name}': {desc}")
140
+ logger.info("%s: %s", label, name)
141
+
142
+
143
+ def _pve_vm_qm(name: str, container_dir: Path, verb: str, label: str) -> None:
144
+ """Run 'qm suspend|resume <vmid>' for a pve-vm. verb is 'suspend'/'resume'."""
145
+ vmid = (container_dir / "kento-vmid").read_text().strip()
146
+ result = subprocess.run(
147
+ ["qm", verb, vmid], capture_output=True, text=True)
148
+ if result.returncode != 0:
149
+ stderr = (result.stderr or "").strip()
150
+ raise SubprocessError(
151
+ f"'qm {verb} {vmid}' failed for '{name}'"
152
+ + (f": {stderr}" if stderr else "")
153
+ + f". Run 'qm {verb} {vmid}' directly for details.",
154
+ cmd=["qm", verb, vmid],
155
+ returncode=result.returncode,
156
+ )
157
+ logger.info("%s: %s", label, name)
158
+
159
+
160
+ def _dispatch(name: str, *, vm_qmp_command: str, qm_verb: str,
161
+ label: str, namespace: str | None = None) -> None:
162
+ """Shared body for suspend/resume."""
163
+ require_root()
164
+
165
+ container_dir, mode = resolve_any(name, namespace)
166
+ if mode is None:
167
+ mode = read_mode(container_dir)
168
+
169
+ if mode in ("lxc", "pve"):
170
+ raise ModeError(_LXC_UNSUPPORTED)
171
+
172
+ if not is_running(container_dir, mode):
173
+ raise StateError(
174
+ f"instance is not running: {name}. "
175
+ f"Start it first: kento start {name}"
176
+ )
177
+
178
+ if mode == "vm":
179
+ return _vm_qmp(name, container_dir, vm_qmp_command, label)
180
+ if mode == "pve-vm":
181
+ return _pve_vm_qm(name, container_dir, qm_verb, label)
182
+
183
+ # Unknown mode (shouldn't happen — resolve_any returns a known mode).
184
+ raise ModeError(f"unsupported mode {mode!r} for suspend/resume.")
185
+
186
+
187
+ def suspend(name: str, namespace: str | None = None) -> None:
188
+ """Pause a running VM's vCPUs (QMP stop / qm suspend)."""
189
+ _dispatch(name, vm_qmp_command="stop", qm_verb="suspend",
190
+ label="Suspended", namespace=namespace)
191
+
192
+
193
+ def resume(name: str, namespace: str | None = None) -> None:
194
+ """Resume a suspended VM's vCPUs (QMP cont / qm resume)."""
195
+ _dispatch(name, vm_qmp_command="cont", qm_verb="resume",
196
+ label="Resumed", namespace=namespace)