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/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
|
kento/subprocess_util.py
ADDED
|
@@ -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)
|