maqet 0.0.1.3__py3-none-any.whl → 0.0.5__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.
- maqet/__init__.py +50 -6
- maqet/__main__.py +96 -0
- maqet/__version__.py +3 -0
- maqet/api/__init__.py +35 -0
- maqet/api/decorators.py +184 -0
- maqet/api/metadata.py +147 -0
- maqet/api/registry.py +182 -0
- maqet/cli.py +71 -0
- maqet/config/__init__.py +26 -0
- maqet/config/merger.py +237 -0
- maqet/config/parser.py +198 -0
- maqet/config/validators.py +519 -0
- maqet/config_handlers.py +684 -0
- maqet/constants.py +200 -0
- maqet/exceptions.py +226 -0
- maqet/formatters.py +294 -0
- maqet/generators/__init__.py +12 -0
- maqet/generators/base_generator.py +101 -0
- maqet/generators/cli_generator.py +635 -0
- maqet/generators/python_generator.py +247 -0
- maqet/generators/rest_generator.py +58 -0
- maqet/handlers/__init__.py +12 -0
- maqet/handlers/base.py +108 -0
- maqet/handlers/init.py +147 -0
- maqet/handlers/stage.py +196 -0
- maqet/ipc/__init__.py +29 -0
- maqet/ipc/retry.py +265 -0
- maqet/ipc/runner_client.py +285 -0
- maqet/ipc/unix_socket_server.py +239 -0
- maqet/logger.py +160 -55
- maqet/machine.py +884 -0
- maqet/managers/__init__.py +7 -0
- maqet/managers/qmp_manager.py +333 -0
- maqet/managers/snapshot_coordinator.py +327 -0
- maqet/managers/vm_manager.py +683 -0
- maqet/maqet.py +1120 -0
- maqet/os_interactions.py +46 -0
- maqet/process_spawner.py +395 -0
- maqet/qemu_args.py +76 -0
- maqet/qmp/__init__.py +10 -0
- maqet/qmp/commands.py +92 -0
- maqet/qmp/keyboard.py +311 -0
- maqet/qmp/qmp.py +17 -0
- maqet/snapshot.py +473 -0
- maqet/state.py +958 -0
- maqet/storage.py +702 -162
- maqet/validation/__init__.py +9 -0
- maqet/validation/config_validator.py +170 -0
- maqet/vm_runner.py +523 -0
- maqet-0.0.5.dist-info/METADATA +237 -0
- maqet-0.0.5.dist-info/RECORD +55 -0
- {maqet-0.0.1.3.dist-info → maqet-0.0.5.dist-info}/WHEEL +1 -1
- maqet-0.0.5.dist-info/entry_points.txt +2 -0
- maqet-0.0.5.dist-info/licenses/LICENSE +21 -0
- {maqet-0.0.1.3.dist-info → maqet-0.0.5.dist-info}/top_level.txt +0 -1
- maqet/core.py +0 -395
- maqet/functions.py +0 -104
- maqet-0.0.1.3.dist-info/METADATA +0 -104
- maqet-0.0.1.3.dist-info/RECORD +0 -33
- qemu/machine/__init__.py +0 -36
- qemu/machine/console_socket.py +0 -142
- qemu/machine/machine.py +0 -954
- qemu/machine/py.typed +0 -0
- qemu/machine/qtest.py +0 -191
- qemu/qmp/__init__.py +0 -59
- qemu/qmp/error.py +0 -50
- qemu/qmp/events.py +0 -717
- qemu/qmp/legacy.py +0 -319
- qemu/qmp/message.py +0 -209
- qemu/qmp/models.py +0 -146
- qemu/qmp/protocol.py +0 -1057
- qemu/qmp/py.typed +0 -0
- qemu/qmp/qmp_client.py +0 -655
- qemu/qmp/qmp_shell.py +0 -618
- qemu/qmp/qmp_tui.py +0 -655
- qemu/qmp/util.py +0 -219
- qemu/utils/__init__.py +0 -162
- qemu/utils/accel.py +0 -84
- qemu/utils/py.typed +0 -0
- qemu/utils/qemu_ga_client.py +0 -323
- qemu/utils/qom.py +0 -273
- qemu/utils/qom_common.py +0 -175
- qemu/utils/qom_fuse.py +0 -207
maqet/machine.py
ADDED
@@ -0,0 +1,884 @@
|
|
1
|
+
"""
|
2
|
+
MAQET Machine
|
3
|
+
|
4
|
+
Enhanced QEMUMachine integration for MAQET VM management.
|
5
|
+
Handles VM process lifecycle, QMP communication, and state tracking.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import atexit
|
9
|
+
import os
|
10
|
+
import re
|
11
|
+
import signal
|
12
|
+
import subprocess
|
13
|
+
import time
|
14
|
+
from pathlib import Path
|
15
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set
|
16
|
+
|
17
|
+
from qemu.machine import QEMUMachine
|
18
|
+
|
19
|
+
from .config_handlers import ConfigurableMachine
|
20
|
+
from .constants import Intervals, Timeouts
|
21
|
+
from .logger import LOG
|
22
|
+
from .storage import StorageManager
|
23
|
+
from .validation import ConfigValidator
|
24
|
+
|
25
|
+
if TYPE_CHECKING:
|
26
|
+
from .state import StateManager
|
27
|
+
|
28
|
+
# Global registry of active QEMU PIDs for cleanup on exit
|
29
|
+
_active_qemu_pids: Set[int] = set()
|
30
|
+
|
31
|
+
|
32
|
+
class MaqetQEMUMachine(QEMUMachine):
|
33
|
+
"""
|
34
|
+
MAQET's simplified QEMUMachine without display defaults.
|
35
|
+
|
36
|
+
Removes hardcoded display/VGA arguments from _base_args, letting QEMU
|
37
|
+
use its own defaults or user-configured values. Maintains QMP and
|
38
|
+
console configuration from parent class.
|
39
|
+
|
40
|
+
Users configure display explicitly if needed (e.g., -display none for headless).
|
41
|
+
|
42
|
+
Also ensures QEMU dies when parent process dies using PR_SET_PDEATHSIG.
|
43
|
+
"""
|
44
|
+
|
45
|
+
def _launch(self) -> None:
|
46
|
+
"""
|
47
|
+
Launch QEMU with PR_SET_PDEATHSIG to ensure cleanup on parent death.
|
48
|
+
|
49
|
+
PR_SET_PDEATHSIG is a Linux kernel feature that sends a signal to the
|
50
|
+
child process when the parent dies, REGARDLESS of how the parent was killed.
|
51
|
+
This works even for SIGKILL (kill -9) where Python cleanup cannot run.
|
52
|
+
|
53
|
+
When VMRunner dies (crash, kill -9, SIGTERM, etc.), kernel automatically
|
54
|
+
sends SIGKILL to QEMU process. No orphaned processes possible.
|
55
|
+
"""
|
56
|
+
import ctypes
|
57
|
+
import subprocess
|
58
|
+
|
59
|
+
# Import PR_SET_PDEATHSIG constant
|
60
|
+
# This is Linux-specific, set to 1 based on prctl.h
|
61
|
+
PR_SET_PDEATHSIG = 1
|
62
|
+
|
63
|
+
def set_pdeathsig():
|
64
|
+
"""
|
65
|
+
Set parent death signal to SIGKILL for this process.
|
66
|
+
|
67
|
+
Called in child process before exec via preexec_fn.
|
68
|
+
When parent dies, kernel sends SIGKILL to this process.
|
69
|
+
"""
|
70
|
+
try:
|
71
|
+
# Load libc
|
72
|
+
libc = ctypes.CDLL('libc.so.6')
|
73
|
+
|
74
|
+
# Call prctl(PR_SET_PDEATHSIG, SIGKILL)
|
75
|
+
# SIGKILL = 9
|
76
|
+
result = libc.prctl(PR_SET_PDEATHSIG, signal.SIGKILL)
|
77
|
+
|
78
|
+
if result != 0:
|
79
|
+
# prctl failed, but we can't log here (in child process)
|
80
|
+
# Parent will detect if QEMU fails to start
|
81
|
+
pass
|
82
|
+
|
83
|
+
except Exception:
|
84
|
+
# If prctl fails (non-Linux, missing libc), continue anyway
|
85
|
+
# QEMU will start but won't have death signal protection
|
86
|
+
pass
|
87
|
+
|
88
|
+
# Call parent's pre-launch
|
89
|
+
self._pre_launch()
|
90
|
+
LOG.debug('VM launch command: %r', ' '.join(self._qemu_full_args))
|
91
|
+
|
92
|
+
# Launch QEMU with preexec_fn to set parent death signal
|
93
|
+
# pylint: disable=consider-using-with
|
94
|
+
self._popen = subprocess.Popen(
|
95
|
+
self._qemu_full_args,
|
96
|
+
stdin=subprocess.DEVNULL,
|
97
|
+
stdout=self._qemu_log_file,
|
98
|
+
stderr=subprocess.STDOUT,
|
99
|
+
shell=False,
|
100
|
+
close_fds=False,
|
101
|
+
preexec_fn=set_pdeathsig # Set PR_SET_PDEATHSIG before exec
|
102
|
+
)
|
103
|
+
self._launched = True
|
104
|
+
self._post_launch()
|
105
|
+
|
106
|
+
@property
|
107
|
+
def _base_args(self) -> List[str]:
|
108
|
+
"""
|
109
|
+
Override base args to only include essential QMP/console config.
|
110
|
+
|
111
|
+
No display or VGA defaults - users configure these explicitly if needed.
|
112
|
+
QEMU will use its own defaults (typically GTK/SDL if available).
|
113
|
+
"""
|
114
|
+
args = []
|
115
|
+
|
116
|
+
# QMP configuration (from parent class)
|
117
|
+
if self._qmp_set:
|
118
|
+
if self._sock_pair:
|
119
|
+
moncdev = f"socket,id=mon,fd={self._sock_pair[0].fileno()}"
|
120
|
+
elif isinstance(self._monitor_address, tuple):
|
121
|
+
moncdev = "socket,id=mon,host={},port={}".format(
|
122
|
+
*self._monitor_address
|
123
|
+
)
|
124
|
+
else:
|
125
|
+
moncdev = f"socket,id=mon,path={self._monitor_address}"
|
126
|
+
args.extend(
|
127
|
+
["-chardev", moncdev, "-mon", "chardev=mon,mode=control"]
|
128
|
+
)
|
129
|
+
|
130
|
+
# Machine type (from parent class)
|
131
|
+
if self._machine is not None:
|
132
|
+
args.extend(["-machine", self._machine])
|
133
|
+
|
134
|
+
# Console configuration (from parent class)
|
135
|
+
for _ in range(self._console_index):
|
136
|
+
args.extend(["-serial", "null"])
|
137
|
+
if self._console_set:
|
138
|
+
assert self._cons_sock_pair is not None
|
139
|
+
fd = self._cons_sock_pair[0].fileno()
|
140
|
+
chardev = f"socket,id=console,fd={fd}"
|
141
|
+
args.extend(["-chardev", chardev])
|
142
|
+
if self._console_device_type is None:
|
143
|
+
args.extend(["-serial", "chardev:console"])
|
144
|
+
else:
|
145
|
+
device = "%s,chardev=console" % self._console_device_type
|
146
|
+
args.extend(["-device", device])
|
147
|
+
|
148
|
+
return args
|
149
|
+
|
150
|
+
|
151
|
+
# Module-level function - must be outside class for atexit registration.
|
152
|
+
# This function is registered with atexit.register() to cleanup orphaned
|
153
|
+
# QEMU processes when Python exits. It accesses the module-level
|
154
|
+
# _active_qemu_pids set.
|
155
|
+
def _cleanup_orphan_qemu_processes():
|
156
|
+
"""Kill any QEMU processes that are still running when Python exits.
|
157
|
+
|
158
|
+
# NOTE: Good - atexit handler prevents orphaned QEMU processes when Python
|
159
|
+
# exits normally.
|
160
|
+
# This is the best solution without requiring systemd or daemon
|
161
|
+
# infrastructure.
|
162
|
+
# Works for crashes, Ctrl+C, and normal exits.
|
163
|
+
"""
|
164
|
+
if _active_qemu_pids:
|
165
|
+
LOG.debug(
|
166
|
+
f"Cleaning up {len(_active_qemu_pids)
|
167
|
+
} orphan QEMU processes on exit"
|
168
|
+
)
|
169
|
+
for pid in list(_active_qemu_pids):
|
170
|
+
try:
|
171
|
+
os.kill(pid, signal.SIGKILL)
|
172
|
+
LOG.debug(f"Killed orphan QEMU process {pid}")
|
173
|
+
except (ProcessLookupError, OSError):
|
174
|
+
pass # Process already dead
|
175
|
+
_active_qemu_pids.clear()
|
176
|
+
|
177
|
+
|
178
|
+
# Register cleanup handler
|
179
|
+
atexit.register(_cleanup_orphan_qemu_processes)
|
180
|
+
|
181
|
+
|
182
|
+
class MachineError(Exception):
|
183
|
+
"""Machine-related errors"""
|
184
|
+
|
185
|
+
|
186
|
+
class Machine(ConfigurableMachine):
|
187
|
+
"""
|
188
|
+
Enhanced VM machine management.
|
189
|
+
|
190
|
+
Handles QEMU process lifecycle, QMP communication, and integration
|
191
|
+
with MAQET's state management system. Uses extensible handler-based
|
192
|
+
configuration processing.
|
193
|
+
|
194
|
+
# TODO(architect, 2025-10-10): [ARCH] Machine class has too many responsibilities (907 lines)
|
195
|
+
# Context: This class handles VM lifecycle, config validation, QMP communication, storage
|
196
|
+
# setup, config handler registry, process cleanup, signal handling. Issue #4 in
|
197
|
+
# ARCHITECTURAL_REVIEW.md.
|
198
|
+
#
|
199
|
+
# Recommendation: Extract responsibilities:
|
200
|
+
# - ConfigValidator: validate binary, memory, CPU, display, network
|
201
|
+
# - QMPClient: QMP communication only (execute, connect, timeout handling)
|
202
|
+
# - StorageCoordinator: storage device setup
|
203
|
+
# Machine focuses on process lifecycle only.
|
204
|
+
#
|
205
|
+
# Effort: Large (1 week)
|
206
|
+
# Priority: High (should fix for 1.0)
|
207
|
+
# See: ARCHITECTURAL_REVIEW.md Issue #4
|
208
|
+
|
209
|
+
# TODO(architect, 2025-10-10): [ARCH] CRITICAL - Cross-process QMP Communication Impossible
|
210
|
+
# Context: QEMUMachine instances cannot be shared between processes because Python cannot
|
211
|
+
# pickle file descriptors (QMP socket connections). When CLI exits, QMP connections are
|
212
|
+
# destroyed, making "maqet qmp myvm query-status" fail with "No such file or directory".
|
213
|
+
# This is Issue #1 in ARCHITECTURAL_REVIEW.md.
|
214
|
+
#
|
215
|
+
# Root Cause: Machine instances stored in-memory dict (_machines) are lost when CLI exits.
|
216
|
+
#
|
217
|
+
# Current Status: QMP only works in Python API mode (same process). CLI mode doesn't work.
|
218
|
+
#
|
219
|
+
# Recommended Solution for 1.0: Direct Socket Communication
|
220
|
+
# - Bypass QEMUMachine, talk directly to QMP socket stored in database
|
221
|
+
# - Store socket path in database, connect on demand
|
222
|
+
# - No daemon required - VMs run as independent processes
|
223
|
+
# - Effort: Medium (2-4 days)
|
224
|
+
#
|
225
|
+
# Future Enhancement (2.0): Long-Running Service Architecture
|
226
|
+
# - Optional systemd service for centralized VM management
|
227
|
+
# - Advanced features like scheduled snapshots, health monitoring
|
228
|
+
# - CLI continues to work without service (backwards compatible)
|
229
|
+
# - Effort: Large (1-2 weeks)
|
230
|
+
#
|
231
|
+
# Implementation Steps:
|
232
|
+
# 1. Create QMPSocketClient class that connects to existing socket
|
233
|
+
# 2. Add socket_connect() method: read socket path from DB, connect via JSON-RPC
|
234
|
+
# 3. Add execute_qmp_command() method: send JSON, parse response
|
235
|
+
# 4. Modify Maqet.qmp() to use QMPSocketClient instead of Machine._qemu_machine
|
236
|
+
# 5. Test with real VMs: start VM, exit CLI, run qmp commands
|
237
|
+
#
|
238
|
+
# See: ARCHITECTURAL_REVIEW.md Issue #1 for detailed analysis
|
239
|
+
"""
|
240
|
+
|
241
|
+
def __init__(
|
242
|
+
self,
|
243
|
+
config_data: Dict[str, Any],
|
244
|
+
vm_id: str,
|
245
|
+
state_manager: "StateManager",
|
246
|
+
config_validator: Optional[ConfigValidator] = None,
|
247
|
+
):
|
248
|
+
"""
|
249
|
+
Initialize machine instance.
|
250
|
+
|
251
|
+
Args:
|
252
|
+
config_data: VM configuration dictionary
|
253
|
+
vm_id: VM instance ID
|
254
|
+
state_manager: State manager instance
|
255
|
+
config_validator: Configuration validator (optional, creates default if None)
|
256
|
+
"""
|
257
|
+
LOG.debug(f"Initializing Machine for VM {vm_id}")
|
258
|
+
|
259
|
+
# Initialize ConfigurableMachine (creates instance-specific config registry)
|
260
|
+
super().__init__()
|
261
|
+
|
262
|
+
# Initialize validator (use provided or create default)
|
263
|
+
self.config_validator = config_validator or ConfigValidator()
|
264
|
+
|
265
|
+
# Validate configuration
|
266
|
+
self.config_validator.validate_config(config_data)
|
267
|
+
|
268
|
+
self.config_data = config_data
|
269
|
+
self.vm_id = vm_id
|
270
|
+
self.state_manager = state_manager
|
271
|
+
self._qemu_machine: Optional[QEMUMachine] = None
|
272
|
+
self._pid: Optional[int] = None
|
273
|
+
|
274
|
+
# Initialize storage manager
|
275
|
+
self.storage_manager = StorageManager(vm_id)
|
276
|
+
storage_configs = config_data.get("storage", [])
|
277
|
+
if storage_configs:
|
278
|
+
self.storage_manager.add_storage_from_config(storage_configs)
|
279
|
+
|
280
|
+
def _validate_config(self, config_data: Dict[str, Any]) -> None:
|
281
|
+
"""
|
282
|
+
Validate VM configuration data (delegates to ConfigValidator).
|
283
|
+
|
284
|
+
DEPRECATED: This method is kept for backward compatibility.
|
285
|
+
Use self.config_validator.validate_config() directly instead.
|
286
|
+
|
287
|
+
Args:
|
288
|
+
config_data: VM configuration dictionary
|
289
|
+
|
290
|
+
Raises:
|
291
|
+
MachineError: If configuration is invalid
|
292
|
+
"""
|
293
|
+
try:
|
294
|
+
self.config_validator.validate_config(config_data)
|
295
|
+
except Exception as e:
|
296
|
+
# Wrap validation errors as MachineError for backward compatibility
|
297
|
+
raise MachineError(str(e))
|
298
|
+
|
299
|
+
def __enter__(self):
|
300
|
+
"""
|
301
|
+
Context manager entry - allows using Machine with 'with' statement.
|
302
|
+
|
303
|
+
Example:
|
304
|
+
with Machine(config, vm_id, state_manager) as machine:
|
305
|
+
machine.start()
|
306
|
+
# Do work...
|
307
|
+
# QEMU automatically cleaned up on exit
|
308
|
+
"""
|
309
|
+
return self
|
310
|
+
|
311
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
312
|
+
"""
|
313
|
+
Context manager exit - ensures QEMU is stopped when exiting 'with' block.
|
314
|
+
|
315
|
+
This is called AUTOMATICALLY when:
|
316
|
+
- 'with' block completes normally
|
317
|
+
- Exception is raised in 'with' block
|
318
|
+
- Process is killed (Python cleanup)
|
319
|
+
|
320
|
+
This is the SIMPLE, RELIABLE way to ensure QEMU cleanup.
|
321
|
+
"""
|
322
|
+
try:
|
323
|
+
if self._qemu_machine and self._qemu_machine.is_running():
|
324
|
+
LOG.debug(f"Context manager exit: stopping QEMU for {self.vm_id}")
|
325
|
+
# Use graceful stop with short timeout for faster exit
|
326
|
+
self.stop(force=False, timeout=Timeouts.VM_GRACEFUL_SHUTDOWN_SHORT)
|
327
|
+
except Exception as e:
|
328
|
+
LOG.error(f"Error stopping QEMU during context exit: {e}")
|
329
|
+
# Try force kill as last resort
|
330
|
+
if self._pid:
|
331
|
+
try:
|
332
|
+
os.kill(self._pid, signal.SIGKILL)
|
333
|
+
except (ProcessLookupError, OSError):
|
334
|
+
pass
|
335
|
+
|
336
|
+
return False # Don't suppress exceptions
|
337
|
+
|
338
|
+
def __del__(self):
|
339
|
+
"""
|
340
|
+
Cleanup destructor - ensures QEMU process is stopped when Machine is garbage collected.
|
341
|
+
|
342
|
+
This prevents orphaned QEMU processes when tests or scripts exit without
|
343
|
+
explicitly stopping VMs.
|
344
|
+
"""
|
345
|
+
try:
|
346
|
+
if self._qemu_machine and self._qemu_machine.is_running():
|
347
|
+
LOG.debug(
|
348
|
+
f"Machine {
|
349
|
+
self.vm_id} being garbage collected - stopping QEMU process"
|
350
|
+
)
|
351
|
+
# Force kill without trying graceful shutdown to avoid hanging
|
352
|
+
# during GC
|
353
|
+
if self._pid:
|
354
|
+
try:
|
355
|
+
os.kill(self._pid, signal.SIGKILL)
|
356
|
+
LOG.debug(
|
357
|
+
f"Killed orphan QEMU process {
|
358
|
+
self._pid} for VM {self.vm_id}"
|
359
|
+
)
|
360
|
+
# Remove from registry
|
361
|
+
_active_qemu_pids.discard(self._pid)
|
362
|
+
except (ProcessLookupError, OSError):
|
363
|
+
pass # Process already dead
|
364
|
+
# Update state if possible
|
365
|
+
try:
|
366
|
+
self.state_manager.update_vm_status(
|
367
|
+
self.vm_id, "stopped", pid=None, socket_path=None
|
368
|
+
)
|
369
|
+
except Exception:
|
370
|
+
pass # State manager might be gone during shutdown
|
371
|
+
except Exception as e:
|
372
|
+
# Destructors should never raise exceptions
|
373
|
+
try:
|
374
|
+
LOG.debug(f"Error in Machine.__del__ for {self.vm_id}: {e}")
|
375
|
+
except Exception:
|
376
|
+
pass # Logger might be gone during interpreter shutdown
|
377
|
+
|
378
|
+
def start(self) -> None:
|
379
|
+
"""
|
380
|
+
Start VM and wait for it to be ready.
|
381
|
+
|
382
|
+
Implements file locking to prevent concurrent starts and ensures
|
383
|
+
cleanup of partial state (PID, socket) on any failure.
|
384
|
+
Storage file cleanup is handled by storage.py.
|
385
|
+
"""
|
386
|
+
import fcntl
|
387
|
+
|
388
|
+
lock_file = None
|
389
|
+
|
390
|
+
try:
|
391
|
+
# Acquire lock to prevent concurrent VM starts
|
392
|
+
lock_file_path = self.state_manager.get_lock_path(self.vm_id)
|
393
|
+
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
|
394
|
+
lock_file = open(lock_file_path, "w")
|
395
|
+
|
396
|
+
try:
|
397
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
398
|
+
except BlockingIOError:
|
399
|
+
raise MachineError(
|
400
|
+
f"VM {self.vm_id} is already being started by another process. "
|
401
|
+
f"Wait for that process to complete."
|
402
|
+
)
|
403
|
+
|
404
|
+
# Pre-start validation
|
405
|
+
self._pre_start_validation()
|
406
|
+
|
407
|
+
try:
|
408
|
+
self._create_qemu_machine()
|
409
|
+
self._configure_machine()
|
410
|
+
|
411
|
+
LOG.info(f"Starting VM {self.vm_id}")
|
412
|
+
self._qemu_machine.launch()
|
413
|
+
|
414
|
+
# Get process PID
|
415
|
+
self._pid = self._qemu_machine._popen.pid
|
416
|
+
|
417
|
+
# Register PID for cleanup on exit
|
418
|
+
_active_qemu_pids.add(self._pid)
|
419
|
+
|
420
|
+
# Write PID file
|
421
|
+
pid_path = self.state_manager.get_pid_path(self.vm_id)
|
422
|
+
with open(pid_path, "w") as f:
|
423
|
+
f.write(str(self._pid))
|
424
|
+
|
425
|
+
# Get the actual socket path used by QEMUMachine
|
426
|
+
actual_socket_path = self._qemu_machine._monitor_address
|
427
|
+
if not actual_socket_path:
|
428
|
+
LOG.warning(
|
429
|
+
f"QEMUMachine did not create QMP monitor socket for VM {
|
430
|
+
self.vm_id}"
|
431
|
+
)
|
432
|
+
actual_socket_path = str(
|
433
|
+
self.state_manager.get_socket_path(self.vm_id)
|
434
|
+
)
|
435
|
+
|
436
|
+
# Update database with VM status and actual socket path
|
437
|
+
self.state_manager.update_vm_status(
|
438
|
+
self.vm_id,
|
439
|
+
"running",
|
440
|
+
pid=self._pid,
|
441
|
+
socket_path=str(actual_socket_path),
|
442
|
+
)
|
443
|
+
|
444
|
+
LOG.debug(f"VM {self.vm_id} QMP socket: {actual_socket_path}")
|
445
|
+
|
446
|
+
# Wait for VM to be ready (handled by QEMUMachine)
|
447
|
+
self._wait_for_ready()
|
448
|
+
|
449
|
+
except Exception as e:
|
450
|
+
LOG.error(f"Failed to start VM {self.vm_id}: {e}")
|
451
|
+
self._cleanup_failed_start()
|
452
|
+
raise MachineError(f"Failed to start VM: {e}")
|
453
|
+
|
454
|
+
finally:
|
455
|
+
# Release lock
|
456
|
+
if lock_file:
|
457
|
+
try:
|
458
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
459
|
+
lock_file.close()
|
460
|
+
Path(lock_file.name).unlink(missing_ok=True)
|
461
|
+
except Exception as e:
|
462
|
+
LOG.debug(f"Error releasing VM start lock: {e}")
|
463
|
+
|
464
|
+
def _pre_start_validation(self) -> None:
|
465
|
+
"""
|
466
|
+
Perform pre-start validation checks (delegates to ConfigValidator).
|
467
|
+
|
468
|
+
Raises:
|
469
|
+
MachineError: If validation fails
|
470
|
+
"""
|
471
|
+
try:
|
472
|
+
self.config_validator.pre_start_validation(self.config_data)
|
473
|
+
except Exception as e:
|
474
|
+
# Wrap validation errors as MachineError for backward compatibility
|
475
|
+
raise MachineError(str(e))
|
476
|
+
|
477
|
+
def _cleanup_failed_start(self) -> None:
|
478
|
+
"""Clean up partial state after failed VM start.
|
479
|
+
|
480
|
+
Removes PID file, socket file, and updates database status.
|
481
|
+
Storage file cleanup is handled by storage.py (partial file removal).
|
482
|
+
"""
|
483
|
+
try:
|
484
|
+
# Remove PID file if it exists
|
485
|
+
if self._pid:
|
486
|
+
pid_path = self.state_manager.get_pid_path(self.vm_id)
|
487
|
+
if pid_path.exists():
|
488
|
+
pid_path.unlink()
|
489
|
+
LOG.debug(f"Removed PID file for failed start: {pid_path}")
|
490
|
+
|
491
|
+
# Unregister PID from cleanup registry
|
492
|
+
_active_qemu_pids.discard(self._pid)
|
493
|
+
|
494
|
+
# Remove socket file if it exists
|
495
|
+
socket_path = self.state_manager.get_socket_path(self.vm_id)
|
496
|
+
if socket_path.exists():
|
497
|
+
socket_path.unlink()
|
498
|
+
LOG.debug(
|
499
|
+
f"Removed socket file for failed start: {socket_path}"
|
500
|
+
)
|
501
|
+
|
502
|
+
# Update database status to failed
|
503
|
+
self.state_manager.update_vm_status(
|
504
|
+
self.vm_id, "failed", pid=None, socket_path=None
|
505
|
+
)
|
506
|
+
LOG.debug(f"Updated VM {self.vm_id} status to failed")
|
507
|
+
|
508
|
+
except Exception as cleanup_error:
|
509
|
+
LOG.warning(
|
510
|
+
f"Error during cleanup of failed start: {cleanup_error}"
|
511
|
+
)
|
512
|
+
|
513
|
+
def _force_kill_process(self, pid: int) -> None:
|
514
|
+
"""
|
515
|
+
Force kill a process with escalation from SIGTERM to SIGKILL.
|
516
|
+
|
517
|
+
Args:
|
518
|
+
pid: Process ID to kill
|
519
|
+
|
520
|
+
Sends SIGTERM first, waits for process to exit, then sends SIGKILL if needed.
|
521
|
+
"""
|
522
|
+
try:
|
523
|
+
os.kill(pid, signal.SIGTERM)
|
524
|
+
time.sleep(Intervals.SIGTERM_WAIT)
|
525
|
+
if self._is_process_alive(pid):
|
526
|
+
os.kill(pid, signal.SIGKILL)
|
527
|
+
except ProcessLookupError:
|
528
|
+
pass
|
529
|
+
|
530
|
+
def _graceful_shutdown(self, timeout: int = 30) -> bool:
|
531
|
+
"""
|
532
|
+
Attempt graceful shutdown of VM via QMP.
|
533
|
+
|
534
|
+
Args:
|
535
|
+
timeout: Maximum seconds to wait for shutdown
|
536
|
+
|
537
|
+
Returns:
|
538
|
+
True if shutdown succeeded, False otherwise
|
539
|
+
"""
|
540
|
+
if not self._qemu_machine:
|
541
|
+
return False
|
542
|
+
|
543
|
+
try:
|
544
|
+
LOG.debug(f"Attempting graceful shutdown of VM {self.vm_id}")
|
545
|
+
self._qemu_machine.shutdown()
|
546
|
+
|
547
|
+
# Wait for process to exit
|
548
|
+
start_time = time.time()
|
549
|
+
while (
|
550
|
+
self._qemu_machine.is_running()
|
551
|
+
and (time.time() - start_time) < timeout
|
552
|
+
):
|
553
|
+
time.sleep(Intervals.SHUTDOWN_POLL)
|
554
|
+
|
555
|
+
# Check if shutdown succeeded
|
556
|
+
if not self._qemu_machine.is_running():
|
557
|
+
LOG.info(f"VM {self.vm_id} shutdown gracefully")
|
558
|
+
return True
|
559
|
+
else:
|
560
|
+
LOG.warning(
|
561
|
+
f"VM {self.vm_id} didn't shutdown gracefully in {timeout}s"
|
562
|
+
)
|
563
|
+
return False
|
564
|
+
|
565
|
+
except Exception as e:
|
566
|
+
LOG.warning(f"Graceful shutdown failed for VM {self.vm_id}: {e}")
|
567
|
+
return False
|
568
|
+
|
569
|
+
def _force_kill(self) -> None:
|
570
|
+
"""
|
571
|
+
Force kill the VM process using SIGTERM then SIGKILL.
|
572
|
+
|
573
|
+
Sends SIGTERM first, waits briefly, then sends SIGKILL if needed.
|
574
|
+
"""
|
575
|
+
if not self._pid:
|
576
|
+
return
|
577
|
+
|
578
|
+
LOG.info(f"Force killing VM {self.vm_id} (PID {self._pid})")
|
579
|
+
try:
|
580
|
+
# Send SIGTERM first
|
581
|
+
os.kill(self._pid, signal.SIGTERM)
|
582
|
+
time.sleep(Intervals.SIGTERM_WAIT)
|
583
|
+
|
584
|
+
# Check if still alive, send SIGKILL if needed
|
585
|
+
if self._is_process_alive(self._pid):
|
586
|
+
LOG.debug(f"Process {self._pid} still alive, sending SIGKILL")
|
587
|
+
os.kill(self._pid, signal.SIGKILL)
|
588
|
+
except ProcessLookupError:
|
589
|
+
# Process already dead
|
590
|
+
pass
|
591
|
+
|
592
|
+
def _cleanup_after_stop(self) -> None:
|
593
|
+
"""
|
594
|
+
Cleanup after VM stops.
|
595
|
+
|
596
|
+
- Unregisters PID from active registry
|
597
|
+
- Updates database status
|
598
|
+
- Removes temporary files
|
599
|
+
"""
|
600
|
+
# Unregister PID from cleanup registry
|
601
|
+
if self._pid and self._pid in _active_qemu_pids:
|
602
|
+
_active_qemu_pids.discard(self._pid)
|
603
|
+
|
604
|
+
# Update database status to stopped
|
605
|
+
self.state_manager.update_vm_status(
|
606
|
+
self.vm_id, "stopped", pid=None, socket_path=None
|
607
|
+
)
|
608
|
+
|
609
|
+
# Clean up files
|
610
|
+
self._cleanup_files()
|
611
|
+
|
612
|
+
def stop(self, force: bool = False, timeout: int = 30) -> None:
|
613
|
+
"""
|
614
|
+
Stop the VM.
|
615
|
+
|
616
|
+
Args:
|
617
|
+
force: Force kill immediately, skip graceful shutdown
|
618
|
+
timeout: Timeout for graceful shutdown (only used when force=False)
|
619
|
+
"""
|
620
|
+
try:
|
621
|
+
if self._qemu_machine and self._qemu_machine.is_running():
|
622
|
+
LOG.info(f"Stopping VM {self.vm_id}")
|
623
|
+
|
624
|
+
if force:
|
625
|
+
# Force kill immediately - skip graceful shutdown
|
626
|
+
self._force_kill()
|
627
|
+
else:
|
628
|
+
# Try graceful shutdown first
|
629
|
+
if not self._graceful_shutdown(timeout):
|
630
|
+
# Graceful shutdown failed, force kill
|
631
|
+
self._force_kill()
|
632
|
+
|
633
|
+
# Cleanup after stop
|
634
|
+
self._cleanup_after_stop()
|
635
|
+
|
636
|
+
except Exception as e:
|
637
|
+
LOG.error(f"Failed to stop VM {self.vm_id}: {e}")
|
638
|
+
raise MachineError(f"Failed to stop VM: {e}")
|
639
|
+
|
640
|
+
# Dangerous QMP commands that could harm VM or data
|
641
|
+
DANGEROUS_QMP_COMMANDS = {
|
642
|
+
"quit", # Terminates VM without graceful shutdown
|
643
|
+
"system_powerdown", # Powers down VM (safe but user should use stop())
|
644
|
+
"system_reset", # Force reboot without saving
|
645
|
+
"inject-nmi", # Crashes guest OS for debugging
|
646
|
+
"migrate", # Could corrupt VM if done incorrectly
|
647
|
+
"migrate_set_speed",
|
648
|
+
"migrate_cancel",
|
649
|
+
"pmemsave", # Dumps memory (security risk)
|
650
|
+
"memsave", # Dumps memory (security risk)
|
651
|
+
"drive_del", # Removes storage device
|
652
|
+
"blockdev-del", # Removes block device
|
653
|
+
"device_del", # Removes device (should use device_del method)
|
654
|
+
}
|
655
|
+
|
656
|
+
# Safe QMP commands that are commonly used
|
657
|
+
SAFE_QMP_COMMANDS = {
|
658
|
+
"query-status", # Get VM status
|
659
|
+
"query-version", # Get QEMU version
|
660
|
+
"query-commands", # List available commands
|
661
|
+
"query-kvm", # Check if KVM is enabled
|
662
|
+
"query-cpus", # Get CPU info
|
663
|
+
"query-block", # Get block device info
|
664
|
+
"query-chardev", # Get character devices
|
665
|
+
"screendump", # Take screenshot
|
666
|
+
"send-key", # Send keyboard input
|
667
|
+
"human-monitor-command", # Execute monitor command
|
668
|
+
"cont", # Resume VM from pause
|
669
|
+
"stop", # Pause VM
|
670
|
+
"input-send-event", # Send input events
|
671
|
+
}
|
672
|
+
|
673
|
+
def qmp(self, command: str, **kwargs) -> Dict[str, Any]:
|
674
|
+
"""
|
675
|
+
Execute QMP command (alias for qmp_command).
|
676
|
+
|
677
|
+
Args:
|
678
|
+
command: QMP command name
|
679
|
+
**kwargs: Command arguments
|
680
|
+
|
681
|
+
Returns:
|
682
|
+
Command result dictionary
|
683
|
+
|
684
|
+
Raises:
|
685
|
+
MachineError: If VM is not running or command fails
|
686
|
+
"""
|
687
|
+
return self.qmp_command(command, **kwargs)
|
688
|
+
|
689
|
+
def qmp_command(self, command: str, **kwargs) -> Dict[str, Any]:
|
690
|
+
"""
|
691
|
+
Execute QMP command on the VM with security validation and timeout.
|
692
|
+
|
693
|
+
Args:
|
694
|
+
command: QMP command to execute
|
695
|
+
**kwargs: Command parameters
|
696
|
+
|
697
|
+
Returns:
|
698
|
+
QMP command result
|
699
|
+
|
700
|
+
Raises:
|
701
|
+
MachineError: If VM is not running, command is dangerous, or timeout occurs
|
702
|
+
"""
|
703
|
+
if not self._qemu_machine or not self._qemu_machine.is_running():
|
704
|
+
raise MachineError(f"VM {self.vm_id} is not running")
|
705
|
+
|
706
|
+
# Security: Validate command safety
|
707
|
+
if command in self.DANGEROUS_QMP_COMMANDS:
|
708
|
+
LOG.warning(
|
709
|
+
f"QMP command '{
|
710
|
+
command}' is potentially dangerous and may harm the VM. "
|
711
|
+
f"Consider using the appropriate maqet method instead (e.g., stop() for powerdown)."
|
712
|
+
)
|
713
|
+
# Note: We log warning but allow execution for advanced users
|
714
|
+
# A future enhancement could add a confirmation prompt or --force
|
715
|
+
# flag
|
716
|
+
|
717
|
+
elif command not in self.SAFE_QMP_COMMANDS:
|
718
|
+
# Unknown command - warn but allow
|
719
|
+
LOG.info(
|
720
|
+
f"QMP command '{
|
721
|
+
command}' is not in the known safe commands list. "
|
722
|
+
f"Proceeding with caution."
|
723
|
+
)
|
724
|
+
|
725
|
+
try:
|
726
|
+
# Build QMP command
|
727
|
+
qmp_cmd = {"execute": command}
|
728
|
+
if kwargs:
|
729
|
+
qmp_cmd["arguments"] = kwargs
|
730
|
+
|
731
|
+
LOG.debug(f"Executing QMP command on {self.vm_id}: {qmp_cmd}")
|
732
|
+
|
733
|
+
# Execute with timeout using concurrent.futures
|
734
|
+
import concurrent.futures
|
735
|
+
import threading
|
736
|
+
|
737
|
+
timeout = Timeouts.QMP_COMMAND
|
738
|
+
|
739
|
+
# Create a thread-safe result container
|
740
|
+
result_container = {"result": None, "error": None}
|
741
|
+
|
742
|
+
def execute_qmp():
|
743
|
+
try:
|
744
|
+
result_container["result"] = self._qemu_machine.qmp(
|
745
|
+
command, **kwargs
|
746
|
+
)
|
747
|
+
except Exception as e:
|
748
|
+
result_container["error"] = e
|
749
|
+
|
750
|
+
# Execute QMP command in thread with timeout
|
751
|
+
qmp_thread = threading.Thread(target=execute_qmp)
|
752
|
+
qmp_thread.daemon = True
|
753
|
+
qmp_thread.start()
|
754
|
+
qmp_thread.join(timeout=timeout)
|
755
|
+
|
756
|
+
if qmp_thread.is_alive():
|
757
|
+
# Timeout occurred
|
758
|
+
raise MachineError(
|
759
|
+
f"QMP command '{command}' timed out after {
|
760
|
+
timeout} seconds. "
|
761
|
+
f"VM may be unresponsive."
|
762
|
+
)
|
763
|
+
|
764
|
+
# Check if error occurred during execution
|
765
|
+
if result_container["error"]:
|
766
|
+
raise result_container["error"]
|
767
|
+
|
768
|
+
return result_container["result"]
|
769
|
+
|
770
|
+
except MachineError:
|
771
|
+
# Re-raise MachineError as-is
|
772
|
+
raise
|
773
|
+
except Exception as e:
|
774
|
+
LOG.error(f"QMP command failed on VM {self.vm_id}: {e}")
|
775
|
+
raise MachineError(f"QMP command failed: {e}")
|
776
|
+
|
777
|
+
@property
|
778
|
+
def pid(self) -> Optional[int]:
|
779
|
+
"""Get VM process PID."""
|
780
|
+
if self._qemu_machine and self._qemu_machine._popen:
|
781
|
+
return self._qemu_machine._popen.pid
|
782
|
+
return self._pid
|
783
|
+
|
784
|
+
@property
|
785
|
+
def is_running(self) -> bool:
|
786
|
+
"""Check if VM is running."""
|
787
|
+
if self._qemu_machine:
|
788
|
+
return self._qemu_machine.is_running()
|
789
|
+
if self._pid:
|
790
|
+
return self._is_process_alive(self._pid)
|
791
|
+
return False
|
792
|
+
|
793
|
+
def _create_qemu_machine(self) -> None:
|
794
|
+
"""Create QEMUMachine instance."""
|
795
|
+
# Get QEMU binary from config
|
796
|
+
binary = self.config_data.get("binary", "/usr/bin/qemu-system-x86_64")
|
797
|
+
|
798
|
+
# Set up QMP socket
|
799
|
+
socket_path = str(self.state_manager.get_socket_path(self.vm_id))
|
800
|
+
|
801
|
+
# Ensure socket directory exists
|
802
|
+
socket_path_obj = self.state_manager.get_socket_path(self.vm_id)
|
803
|
+
socket_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
804
|
+
|
805
|
+
LOG.debug(f"Creating MaqetQEMUMachine with QMP socket: {socket_path}")
|
806
|
+
|
807
|
+
self._qemu_machine = MaqetQEMUMachine(
|
808
|
+
binary=binary,
|
809
|
+
name=self.vm_id,
|
810
|
+
log_dir=str(self.state_manager.xdg.runtime_dir),
|
811
|
+
monitor_address=socket_path, # Unix socket path as string
|
812
|
+
)
|
813
|
+
|
814
|
+
# Verify QMP is enabled
|
815
|
+
LOG.debug(f"QMP enabled: {self._qemu_machine._qmp_set}")
|
816
|
+
|
817
|
+
def _configure_machine(self) -> None:
|
818
|
+
"""Configure QEMU machine using handler-based system."""
|
819
|
+
if not self._qemu_machine:
|
820
|
+
return
|
821
|
+
|
822
|
+
# Process configuration using registered handlers
|
823
|
+
processed_keys = self.process_configuration(self.config_data)
|
824
|
+
|
825
|
+
# Apply defaults for any unprocessed keys
|
826
|
+
self.apply_default_configuration()
|
827
|
+
|
828
|
+
LOG.debug(
|
829
|
+
f"Machine configuration complete. Processed keys: {processed_keys}"
|
830
|
+
)
|
831
|
+
|
832
|
+
def _add_storage_devices(self) -> None:
|
833
|
+
"""Add all storage devices to QEMU machine using unified storage manager."""
|
834
|
+
if not self._qemu_machine:
|
835
|
+
return
|
836
|
+
|
837
|
+
# Create storage files if needed
|
838
|
+
self.storage_manager.create_storage_files()
|
839
|
+
|
840
|
+
# Add QEMU arguments for all storage devices
|
841
|
+
storage_args = self.storage_manager.get_qemu_args()
|
842
|
+
for args_list in storage_args:
|
843
|
+
# Each args_list is like ["-drive", "file=...,if=...,format=..."]
|
844
|
+
self._qemu_machine.add_args(*args_list)
|
845
|
+
|
846
|
+
# NOTE: Storage handling has been refactored to use the extensible
|
847
|
+
# StorageManager system
|
848
|
+
# New storage types can be added by creating new device classes with
|
849
|
+
# @storage_device decorator
|
850
|
+
|
851
|
+
# NOTE: QEMUMachine handles wait-for-ready, but we verify QMP connectivity
|
852
|
+
def _wait_for_ready(self) -> None:
|
853
|
+
"""Wait for VM QMP to be ready."""
|
854
|
+
if not self._qemu_machine:
|
855
|
+
return
|
856
|
+
|
857
|
+
# QEMUMachine.launch() already establishes QMP connection
|
858
|
+
# Just verify it's working with a simple query
|
859
|
+
try:
|
860
|
+
if self._qemu_machine.is_running():
|
861
|
+
# Test QMP is responsive
|
862
|
+
self._qemu_machine.qmp("query-status")
|
863
|
+
LOG.info(f"VM {self.vm_id} is ready")
|
864
|
+
except Exception as e:
|
865
|
+
LOG.warning(f"VM {self.vm_id} QMP verification failed: {e}")
|
866
|
+
# VM process started but QMP may not be fully ready yet
|
867
|
+
# This is usually not critical as QMP will become available shortly
|
868
|
+
|
869
|
+
def _is_process_alive(self, pid: int) -> bool:
|
870
|
+
"""Check if process is still running."""
|
871
|
+
try:
|
872
|
+
os.kill(pid, 0)
|
873
|
+
return True
|
874
|
+
except (OSError, ProcessLookupError):
|
875
|
+
return False
|
876
|
+
|
877
|
+
def _cleanup_files(self) -> None:
|
878
|
+
"""Clean up temporary files."""
|
879
|
+
# Remove PID file
|
880
|
+
pid_path = self.state_manager.get_pid_path(self.vm_id)
|
881
|
+
if pid_path.exists():
|
882
|
+
pid_path.unlink()
|
883
|
+
|
884
|
+
# Socket cleanup is handled by QEMUMachine
|