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.
Files changed (83) hide show
  1. maqet/__init__.py +50 -6
  2. maqet/__main__.py +96 -0
  3. maqet/__version__.py +3 -0
  4. maqet/api/__init__.py +35 -0
  5. maqet/api/decorators.py +184 -0
  6. maqet/api/metadata.py +147 -0
  7. maqet/api/registry.py +182 -0
  8. maqet/cli.py +71 -0
  9. maqet/config/__init__.py +26 -0
  10. maqet/config/merger.py +237 -0
  11. maqet/config/parser.py +198 -0
  12. maqet/config/validators.py +519 -0
  13. maqet/config_handlers.py +684 -0
  14. maqet/constants.py +200 -0
  15. maqet/exceptions.py +226 -0
  16. maqet/formatters.py +294 -0
  17. maqet/generators/__init__.py +12 -0
  18. maqet/generators/base_generator.py +101 -0
  19. maqet/generators/cli_generator.py +635 -0
  20. maqet/generators/python_generator.py +247 -0
  21. maqet/generators/rest_generator.py +58 -0
  22. maqet/handlers/__init__.py +12 -0
  23. maqet/handlers/base.py +108 -0
  24. maqet/handlers/init.py +147 -0
  25. maqet/handlers/stage.py +196 -0
  26. maqet/ipc/__init__.py +29 -0
  27. maqet/ipc/retry.py +265 -0
  28. maqet/ipc/runner_client.py +285 -0
  29. maqet/ipc/unix_socket_server.py +239 -0
  30. maqet/logger.py +160 -55
  31. maqet/machine.py +884 -0
  32. maqet/managers/__init__.py +7 -0
  33. maqet/managers/qmp_manager.py +333 -0
  34. maqet/managers/snapshot_coordinator.py +327 -0
  35. maqet/managers/vm_manager.py +683 -0
  36. maqet/maqet.py +1120 -0
  37. maqet/os_interactions.py +46 -0
  38. maqet/process_spawner.py +395 -0
  39. maqet/qemu_args.py +76 -0
  40. maqet/qmp/__init__.py +10 -0
  41. maqet/qmp/commands.py +92 -0
  42. maqet/qmp/keyboard.py +311 -0
  43. maqet/qmp/qmp.py +17 -0
  44. maqet/snapshot.py +473 -0
  45. maqet/state.py +958 -0
  46. maqet/storage.py +702 -162
  47. maqet/validation/__init__.py +9 -0
  48. maqet/validation/config_validator.py +170 -0
  49. maqet/vm_runner.py +523 -0
  50. maqet-0.0.5.dist-info/METADATA +237 -0
  51. maqet-0.0.5.dist-info/RECORD +55 -0
  52. {maqet-0.0.1.3.dist-info → maqet-0.0.5.dist-info}/WHEEL +1 -1
  53. maqet-0.0.5.dist-info/entry_points.txt +2 -0
  54. maqet-0.0.5.dist-info/licenses/LICENSE +21 -0
  55. {maqet-0.0.1.3.dist-info → maqet-0.0.5.dist-info}/top_level.txt +0 -1
  56. maqet/core.py +0 -395
  57. maqet/functions.py +0 -104
  58. maqet-0.0.1.3.dist-info/METADATA +0 -104
  59. maqet-0.0.1.3.dist-info/RECORD +0 -33
  60. qemu/machine/__init__.py +0 -36
  61. qemu/machine/console_socket.py +0 -142
  62. qemu/machine/machine.py +0 -954
  63. qemu/machine/py.typed +0 -0
  64. qemu/machine/qtest.py +0 -191
  65. qemu/qmp/__init__.py +0 -59
  66. qemu/qmp/error.py +0 -50
  67. qemu/qmp/events.py +0 -717
  68. qemu/qmp/legacy.py +0 -319
  69. qemu/qmp/message.py +0 -209
  70. qemu/qmp/models.py +0 -146
  71. qemu/qmp/protocol.py +0 -1057
  72. qemu/qmp/py.typed +0 -0
  73. qemu/qmp/qmp_client.py +0 -655
  74. qemu/qmp/qmp_shell.py +0 -618
  75. qemu/qmp/qmp_tui.py +0 -655
  76. qemu/qmp/util.py +0 -219
  77. qemu/utils/__init__.py +0 -162
  78. qemu/utils/accel.py +0 -84
  79. qemu/utils/py.typed +0 -0
  80. qemu/utils/qemu_ga_client.py +0 -323
  81. qemu/utils/qom.py +0 -273
  82. qemu/utils/qom_common.py +0 -175
  83. 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