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/maqet.py ADDED
@@ -0,0 +1,1120 @@
1
+ """
2
+ MAQET Core
3
+
4
+ Main MAQET class implementing unified API for VM management.
5
+ All methods are decorated with @api_method to enable automatic CLI
6
+ and Python API generation.
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, Union
13
+
14
+ from .api import (
15
+ API_REGISTRY,
16
+ APIRegistry,
17
+ AutoRegisterAPI,
18
+ api_method,
19
+ )
20
+ from .config import ConfigMerger, ConfigParser
21
+ from .constants import Timeouts
22
+ from .exceptions import MaqetError, QMPError, SnapshotError, VMLifecycleError
23
+ from .generators import CLIGenerator, PythonAPIGenerator
24
+ from .logger import LOG
25
+ from .machine import Machine
26
+ from .managers import QMPManager, SnapshotCoordinator, VMManager
27
+ from .snapshot import SnapshotManager
28
+ from .state import StateManager, VMInstance
29
+ from .storage import StorageManager
30
+
31
+ # Legacy exception aliases (backward compatibility)
32
+ from .exceptions import ConfigError
33
+ from .exceptions import QMPManagerError
34
+ from .exceptions import SnapshotCoordinatorError
35
+ from .exceptions import StateManagerError
36
+ from .exceptions import VMManagerError
37
+
38
+
39
+ class Maqet(AutoRegisterAPI):
40
+ """
41
+ MAQET - M4x0n's QEMU Tool
42
+
43
+ Unified VM management system that provides CLI commands, Python API,
44
+ and configuration-based
45
+ VM orchestration through a single decorated method interface.
46
+
47
+ This class implements your vision of "write once, generate everywhere"
48
+ - each @api_method
49
+ decorated method automatically becomes available as:
50
+ - CLI command (via maqet <command>)
51
+ - Python API method (via maqet.method())
52
+ - Configuration file key (via YAML parsing)
53
+
54
+ # TODO(architect, 2025-10-10): [ARCH] CRITICAL - God Object Pattern (1496 lines)
55
+ # Context: This class violates Single Responsibility Principle. It handles:
56
+ # - VM lifecycle (add, start, stop, rm, ls)
57
+ # - QMP operations (qmp, keys, type, screendump, pause, resume, device-add/del)
58
+ # - Storage/snapshots (snapshot create/load/list)
59
+ # - State coordination (StateManager integration)
60
+ # - Config parsing (ConfigParser integration)
61
+ # - CLI/API generation coordination
62
+ # This is Issue #2 in ARCHITECTURAL_REVIEW.md.
63
+ #
64
+ # Impact: Maintainability, testing difficulty, tight coupling, hard to extend
65
+ #
66
+ # Recommendation: Refactor into sub-managers:
67
+ # - VMManager: start, stop, lifecycle
68
+ # - QMPManager: all QMP operations
69
+ # - SnapshotCoordinator: snapshot operations
70
+ # - ConfigManager: configuration handling
71
+ # Maqet becomes facade delegating to managers.
72
+ #
73
+ # Effort: Large (1-2 weeks), but can be done incrementally
74
+ # Priority: Critical for 1.0 release
75
+ # See: ARCHITECTURAL_REVIEW.md Issue #2 for detailed refactoring plan
76
+
77
+ # ARCHITECTURAL DESIGN: In-memory Machine Instances
78
+ # ================================================
79
+ # Design Choice: Machine instances stored in memory dict (_machines)
80
+ # - Simple, fast, no serialization overhead
81
+ # - Perfect for Python API usage (long-running scripts)
82
+ # - Trade-off: Lost between CLI invocations
83
+ #
84
+ # Implications:
85
+ # - CLI Mode: Each command runs in fresh process
86
+ # * VM state persisted in SQLite (~/.local/share/maqet/instances.db)
87
+ # * Machine objects recreated on each CLI call
88
+ # * QMP connections NOT maintained across CLI calls
89
+ # - Python API Mode: Single process, instances persist
90
+ # * maqet = Maqet(); maqet.start("vm1"); maqet.qmp("vm1", "query-status")
91
+ # * QMP works seamlessly within same process
92
+ #
93
+ # When to use each mode:
94
+ # - CLI Mode: Simple VM management (start, stop, status, info, inspect)
95
+ # - Python API: Automation scripts, CI/CD pipelines, persistent QMP
96
+ #
97
+ # QMP commands work in Python API mode where Machine instances persist
98
+ # across method calls. For CLI workflows requiring QMP, use Python API.
99
+ """
100
+
101
+ def __init__(
102
+ self, data_dir: Optional[str] = None, register_signals: bool = True
103
+ ):
104
+ """
105
+ Initialize MAQET instance.
106
+
107
+ Args:
108
+ data_dir: Override default XDG data directory
109
+ register_signals: Register signal handlers for graceful shutdown (default: True)
110
+ """
111
+ self.state_manager = StateManager(data_dir)
112
+ self.config_parser = ConfigParser(self)
113
+ self._machines: Dict[str, Machine] = {}
114
+ self._signal_handlers_registered = False
115
+
116
+ # Initialize managers
117
+ self.vm_manager = VMManager(self.state_manager, self.config_parser)
118
+ self.qmp_manager = QMPManager(self.state_manager)
119
+ self.snapshot_coordinator = SnapshotCoordinator(self.state_manager)
120
+
121
+ # Create instance-specific API registry
122
+ # This allows parallel test execution and multiple Maqet instances
123
+ # with isolated registries (no cross-contamination)
124
+ self._api_registry = APIRegistry()
125
+ self._api_registry.register_from_instance(self)
126
+
127
+ if register_signals:
128
+ self._register_signal_handlers()
129
+
130
+ # Clean up any stale runner processes via VM manager
131
+ self.vm_manager.cleanup_dead_processes()
132
+
133
+ def __enter__(self):
134
+ """Context manager entry."""
135
+ return self
136
+
137
+ def __exit__(self, exc_type, exc_val, exc_tb):
138
+ """Context manager exit - cleanup resources."""
139
+ self.cleanup()
140
+ return False # Don't suppress exceptions
141
+
142
+ def _register_signal_handlers(self) -> None:
143
+ """Register signal handlers for graceful shutdown."""
144
+ import signal
145
+
146
+ def signal_handler(signum, frame):
147
+ LOG.info(
148
+ f"Received signal {signum}, initiating graceful shutdown..."
149
+ )
150
+ self.cleanup()
151
+ sys.exit(0)
152
+
153
+ # Register handlers for SIGINT (Ctrl+C) and SIGTERM
154
+ signal.signal(signal.SIGINT, signal_handler)
155
+ signal.signal(signal.SIGTERM, signal_handler)
156
+ self._signal_handlers_registered = True
157
+ LOG.debug("Signal handlers registered for graceful shutdown")
158
+
159
+ def get_api_registry(self) -> APIRegistry:
160
+ """
161
+ Get the instance-specific API registry.
162
+
163
+ Returns:
164
+ APIRegistry instance for this Maqet instance
165
+
166
+ Example:
167
+ maqet = Maqet()
168
+ registry = maqet.get_api_registry()
169
+ methods = registry.get_all_methods()
170
+ """
171
+ return self._api_registry
172
+
173
+ def cleanup(self) -> None:
174
+ """Clean up all resources (stop running VMs, close connections).
175
+
176
+ Uses ThreadPoolExecutor for parallel VM shutdown to reduce total cleanup time.
177
+ Global timeout of 60 seconds prevents cleanup from blocking indefinitely.
178
+ """
179
+ from concurrent.futures import ThreadPoolExecutor
180
+ from concurrent.futures import TimeoutError as FuturesTimeoutError
181
+
182
+ LOG.debug("Cleaning up MAQET resources...")
183
+
184
+ # Stop all running VMs
185
+ running_vms = [
186
+ vm_id
187
+ for vm_id, machine in self._machines.items()
188
+ if machine._qemu_machine and machine._qemu_machine.is_running()
189
+ ]
190
+
191
+ if running_vms:
192
+ LOG.info(
193
+ f"Stopping {len(running_vms)} running VM(s) in parallel..."
194
+ )
195
+
196
+ def stop_vm(vm_id: str) -> tuple[str, bool, str]:
197
+ """Stop a single VM. Returns (vm_id, success, error_msg)."""
198
+ try:
199
+ LOG.debug(f"Stopping VM {vm_id}")
200
+ self.stop(vm_id, force=True)
201
+ return (vm_id, True, "")
202
+ except Exception as e:
203
+ return (vm_id, False, str(e))
204
+
205
+ # Use ThreadPoolExecutor for parallel shutdown
206
+ # Max 10 threads to avoid overwhelming system
207
+ max_workers = min(10, len(running_vms))
208
+
209
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
210
+ # Submit all stop tasks
211
+ futures = {
212
+ executor.submit(stop_vm, vm_id): vm_id
213
+ for vm_id in running_vms
214
+ }
215
+
216
+ try:
217
+ # Wait for all tasks with global timeout
218
+ for future in futures:
219
+ try:
220
+ # Per-VM timeout
221
+ vm_id, success, error = future.result(
222
+ timeout=Timeouts.CLEANUP_VM_STOP
223
+ )
224
+ if not success:
225
+ LOG.warning(
226
+ f"Failed to stop VM {
227
+ vm_id} during cleanup: {error}"
228
+ )
229
+ except FuturesTimeoutError:
230
+ vm_id = futures[future]
231
+ LOG.warning(
232
+ f"Timeout stopping VM {vm_id} during cleanup"
233
+ )
234
+ except Exception as e:
235
+ vm_id = futures[future]
236
+ LOG.warning(
237
+ f"Unexpected error stopping VM {vm_id}: {e}"
238
+ )
239
+
240
+ except Exception as e:
241
+ LOG.error(f"Error during parallel VM cleanup: {e}")
242
+
243
+ # Clear machine cache
244
+ self._machines.clear()
245
+ LOG.debug("MAQET cleanup completed")
246
+
247
+ @api_method(
248
+ cli_name="add",
249
+ description="Create a new VM from configuration",
250
+ category="vm",
251
+ examples=[
252
+ "maqet add config.yaml",
253
+ "maqet add config.yaml --name myvm",
254
+ "maqet add --name testvm --memory 4G --cpu 2",
255
+ "maqet add base.yaml custom.yaml --name myvm",
256
+ "maqet add base.yaml --memory 8G",
257
+ "maqet add --name empty-vm --empty",
258
+ ],
259
+ )
260
+ def add(
261
+ self,
262
+ config: Optional[Union[str, List[str]]] = None,
263
+ name: Optional[str] = None,
264
+ empty: bool = False,
265
+ **kwargs,
266
+ ) -> str:
267
+ """
268
+ Create a new VM from configuration file(s) or parameters.
269
+
270
+ Delegates to VMManager for actual VM creation logic.
271
+
272
+ Args:
273
+ config: Path to YAML configuration file, or list of config
274
+ files for deep-merge
275
+ name: VM name (auto-generated if not provided)
276
+ empty: Create empty VM without any configuration (won't be
277
+ startable until configured)
278
+ **kwargs: Additional VM configuration parameters
279
+
280
+ Returns:
281
+ VM instance ID
282
+
283
+ Raises:
284
+ MaqetError: If VM creation fails
285
+
286
+ Examples:
287
+ Single config: add(config="vm.yaml", name="myvm")
288
+ Multiple configs: add(
289
+ config=["base.yaml", "custom.yaml"], name="myvm"
290
+ )
291
+ Config + params: add(config="base.yaml", memory="8G", cpu=4)
292
+ Empty VM: add(name="placeholder-vm", empty=True)
293
+ """
294
+ try:
295
+ return self.vm_manager.add(config, name, empty, **kwargs)
296
+ except VMManagerError as e:
297
+ raise MaqetError(str(e))
298
+
299
+ @api_method(
300
+ cli_name="start",
301
+ description="Start a virtual machine",
302
+ category="vm",
303
+ requires_vm=True,
304
+ examples=["maqet start myvm"],
305
+ )
306
+ def start(self, vm_id: str) -> VMInstance:
307
+ """
308
+ Start a virtual machine by spawning a detached VM runner process.
309
+
310
+ Delegates to VMManager for actual VM start logic.
311
+
312
+ Args:
313
+ vm_id: VM identifier (name or ID)
314
+
315
+ Returns:
316
+ VM instance information
317
+
318
+ Raises:
319
+ MaqetError: If VM start fails
320
+ """
321
+ try:
322
+ return self.vm_manager.start(vm_id)
323
+ except VMManagerError as e:
324
+ raise MaqetError(str(e))
325
+
326
+ @api_method(
327
+ cli_name="stop",
328
+ description="Stop a virtual machine",
329
+ category="vm",
330
+ requires_vm=True,
331
+ examples=["maqet stop myvm", "maqet stop myvm --force"],
332
+ )
333
+ def stop(
334
+ self, vm_id: str, force: bool = False, timeout: int = Timeouts.VM_STOP
335
+ ) -> VMInstance:
336
+ """
337
+ Stop a VM by sending stop command to VM runner or killing runner process.
338
+
339
+ Delegates to VMManager for actual VM stop logic.
340
+
341
+ Args:
342
+ vm_id: VM identifier (name or ID)
343
+ force: If True, kill runner immediately (SIGKILL).
344
+ If False, graceful shutdown (SIGTERM)
345
+ timeout: Timeout for graceful shutdown
346
+
347
+ Returns:
348
+ VM instance information
349
+
350
+ Raises:
351
+ MaqetError: If VM stop fails
352
+ """
353
+ try:
354
+ return self.vm_manager.stop(vm_id, force, timeout)
355
+ except VMManagerError as e:
356
+ raise MaqetError(str(e))
357
+
358
+ @api_method(
359
+ cli_name="rm",
360
+ description="Remove a virtual machine",
361
+ category="vm",
362
+ requires_vm=False,
363
+ examples=[
364
+ "maqet rm myvm",
365
+ "maqet rm myvm --force",
366
+ "maqet rm --all",
367
+ "maqet rm --all --force",
368
+ ],
369
+ )
370
+ def rm(
371
+ self,
372
+ vm_id: Optional[str] = None,
373
+ force: bool = False,
374
+ all: bool = False,
375
+ clean_storage: bool = False,
376
+ ) -> bool:
377
+ """
378
+ Remove a virtual machine completely.
379
+
380
+ Delegates to VMManager for actual VM removal logic.
381
+
382
+ Args:
383
+ vm_id: VM identifier (name or ID)
384
+ force: Force removal even if VM is running
385
+ all: Remove all virtual machines
386
+ clean_storage: Also delete associated storage files
387
+
388
+ Returns:
389
+ True if removed successfully
390
+
391
+ Raises:
392
+ MaqetError: If VM removal fails
393
+ """
394
+ try:
395
+ result = self.vm_manager.remove(vm_id, force, all, clean_storage)
396
+ # Clean up machine instances that were removed
397
+ if all:
398
+ self._machines.clear()
399
+ elif vm_id:
400
+ vm = self.state_manager.get_vm(vm_id)
401
+ if vm:
402
+ self._machines.pop(vm.id, None)
403
+ return result
404
+ except VMManagerError as e:
405
+ raise MaqetError(str(e))
406
+
407
+ @api_method(
408
+ cli_name="ls",
409
+ description="List virtual machines in table format",
410
+ category="vm",
411
+ examples=["maqet ls", "maqet ls --status running"],
412
+ )
413
+ def ls(self, status: Optional[str] = None) -> str:
414
+ """
415
+ List virtual machines in readable table format.
416
+
417
+ Delegates to VMManager for VM list retrieval.
418
+
419
+ Args:
420
+ status: Filter by status ('running', 'stopped', 'created',
421
+ 'failed')
422
+
423
+ Returns:
424
+ Formatted table string
425
+ """
426
+ vms = self.vm_manager.list_vms(status)
427
+
428
+ if not vms:
429
+ return "No virtual machines found."
430
+
431
+ # Create table header
432
+ header = f"{'NAME':<20} {'STATUS':<10} {'PID':<8}"
433
+ separator = "-" * 40
434
+
435
+ # Build table rows
436
+ rows = [header, separator]
437
+ for vm in vms:
438
+ pid_str = str(vm.pid) if vm.pid else "-"
439
+ row = f"{vm.name:<20} {vm.status:<10} {pid_str:<8}"
440
+ rows.append(row)
441
+
442
+ return "\n".join(rows)
443
+
444
+ @api_method(
445
+ cli_name="status",
446
+ description="Show comprehensive VM status information",
447
+ category="vm",
448
+ requires_vm=True,
449
+ examples=["maqet status myvm", "maqet status myvm --detailed"],
450
+ )
451
+ def status(self, vm_id: str, detailed: bool = False) -> Dict[str, Any]:
452
+ """
453
+ Get basic status information for a VM.
454
+
455
+ Args:
456
+ vm_id: VM identifier (name or ID)
457
+ detailed: (DEPRECATED) Use 'maqet inspect' instead for detailed information
458
+
459
+ Returns:
460
+ Dictionary with basic VM status information
461
+
462
+ Raises:
463
+ MaqetError: If VM not found
464
+ """
465
+ # Handle deprecated detailed flag
466
+ if detailed:
467
+ LOG.warning(
468
+ "The --detailed flag for 'status' command is deprecated. "
469
+ "Use 'maqet inspect %s' for detailed VM inspection instead.",
470
+ vm_id
471
+ )
472
+ # Redirect to inspect method for backward compatibility
473
+ return self.inspect(vm_id)
474
+
475
+ try:
476
+ vm = self.state_manager.get_vm(vm_id)
477
+ if not vm:
478
+ raise MaqetError(f"VM '{vm_id}' not found")
479
+
480
+ # Check if process is actually running and update status
481
+ is_actually_running = self._check_process_alive(vm_id, vm)
482
+
483
+ # Check if VM is empty/unconfigured
484
+ is_empty_vm = not vm.config_data or not vm.config_data.get(
485
+ "binary"
486
+ )
487
+
488
+ # Build simplified status response (no configuration or detailed info)
489
+ status_info = {
490
+ "name": vm.name,
491
+ "status": vm.status,
492
+ "is_running": is_actually_running,
493
+ "is_empty": is_empty_vm,
494
+ "pid": vm.pid,
495
+ "socket_path": vm.socket_path,
496
+ }
497
+
498
+ # Add QMP socket info if socket exists
499
+ if vm.socket_path:
500
+ status_info["qmp_socket"] = {
501
+ "path": vm.socket_path,
502
+ "exists": os.path.exists(vm.socket_path),
503
+ }
504
+
505
+ return status_info
506
+
507
+ except Exception as e:
508
+ raise MaqetError(f"Failed to get status for VM '{vm_id}': {e}")
509
+
510
+ @api_method(
511
+ cli_name="info",
512
+ description="Show VM configuration details",
513
+ category="vm",
514
+ requires_vm=True,
515
+ examples=["maqet info myvm"],
516
+ )
517
+ def info(self, vm_id: str) -> Dict[str, Any]:
518
+ """
519
+ Get VM configuration details.
520
+
521
+ This method provides configuration information about a VM,
522
+ including binary, memory, CPU, display settings, and storage devices.
523
+ It's a focused view of the VM's configuration without runtime details.
524
+
525
+ Args:
526
+ vm_id: VM identifier (name or ID)
527
+
528
+ Returns:
529
+ Dictionary with VM configuration details
530
+
531
+ Raises:
532
+ MaqetError: If VM not found
533
+ """
534
+ try:
535
+ vm = self.state_manager.get_vm(vm_id)
536
+ if not vm:
537
+ raise MaqetError(f"VM '{vm_id}' not found")
538
+
539
+ # Build info response with configuration details
540
+ info_data = {
541
+ "vm_id": vm.id,
542
+ "name": vm.name,
543
+ "config_path": vm.config_path,
544
+ "config_data": vm.config_data,
545
+ "configuration": self._get_config_summary(vm.config_data),
546
+ }
547
+
548
+ return info_data
549
+
550
+ except Exception as e:
551
+ raise MaqetError(f"Failed to get info for VM '{vm_id}': {e}")
552
+
553
+ @api_method(
554
+ cli_name="inspect",
555
+ description="Inspect VM with detailed process and resource information",
556
+ category="vm",
557
+ requires_vm=True,
558
+ examples=["maqet inspect myvm"],
559
+ )
560
+ def inspect(self, vm_id: str) -> Dict[str, Any]:
561
+ """
562
+ Get detailed inspection information for a VM.
563
+
564
+ This method provides comprehensive information including VM status,
565
+ configuration, process details (if running), QMP socket status,
566
+ and snapshot information. It's the most detailed view of a VM.
567
+
568
+ Args:
569
+ vm_id: VM identifier (name or ID)
570
+
571
+ Returns:
572
+ Dictionary with comprehensive VM inspection data
573
+
574
+ Raises:
575
+ MaqetError: If VM not found
576
+ """
577
+ try:
578
+ vm = self.state_manager.get_vm(vm_id)
579
+ if not vm:
580
+ raise MaqetError(f"VM '{vm_id}' not found")
581
+
582
+ # Check if process is actually running
583
+ is_actually_running = self._check_process_alive(vm_id, vm)
584
+
585
+ # Build comprehensive inspection response
586
+ inspect_data = {
587
+ "vm_id": vm.id,
588
+ "name": vm.name,
589
+ "status": vm.status,
590
+ "is_running": is_actually_running,
591
+ "pid": vm.pid,
592
+ "socket_path": vm.socket_path,
593
+ "config_path": vm.config_path,
594
+ "created_at": (
595
+ vm.created_at.isoformat() if vm.created_at else None
596
+ ),
597
+ "updated_at": (
598
+ vm.updated_at.isoformat() if vm.updated_at else None
599
+ ),
600
+ "configuration": self._get_config_summary(vm.config_data),
601
+ }
602
+
603
+ # Add process details if running
604
+ if is_actually_running and vm.pid:
605
+ process_info = self._get_process_info(vm.pid)
606
+ if process_info:
607
+ inspect_data["process"] = process_info
608
+
609
+ # Add QMP socket status
610
+ if vm.socket_path:
611
+ inspect_data["qmp_socket"] = {
612
+ "path": vm.socket_path,
613
+ "exists": os.path.exists(vm.socket_path),
614
+ }
615
+
616
+ return inspect_data
617
+
618
+ except Exception as e:
619
+ raise MaqetError(f"Failed to inspect VM '{vm_id}': {e}")
620
+
621
+ def _check_process_alive(self, vm_id: str, vm: VMInstance) -> bool:
622
+ """
623
+ Check if VM process is actually alive and update status if needed.
624
+
625
+ Args:
626
+ vm_id: VM identifier
627
+ vm: VM instance
628
+
629
+ Returns:
630
+ True if process is alive, False otherwise
631
+ """
632
+ if vm.status != "running" or not vm.pid:
633
+ return False
634
+
635
+ try:
636
+ # Send signal 0 to check if process exists
637
+ os.kill(vm.pid, 0)
638
+ return True
639
+ except OSError:
640
+ # Process doesn't exist, update status
641
+ self.state_manager.update_vm_status(
642
+ vm.id, "stopped", pid=None, socket_path=None
643
+ )
644
+ return False
645
+
646
+ def _get_config_summary(self, config_data: Dict[str, Any]) -> Dict[str, Any]:
647
+ """
648
+ Extract configuration summary from config data.
649
+
650
+ Args:
651
+ config_data: VM configuration dictionary
652
+
653
+ Returns:
654
+ Dictionary with configuration summary
655
+ """
656
+ summary = {
657
+ "binary": config_data.get("binary"),
658
+ "memory": config_data.get("memory"),
659
+ "cpu": config_data.get("cpu"),
660
+ "display": config_data.get("display"),
661
+ }
662
+
663
+ # Count and list storage devices
664
+ storage_devices = config_data.get("storage", [])
665
+ if isinstance(storage_devices, list):
666
+ summary["storage_count"] = len(storage_devices)
667
+ summary["storage_devices"] = [
668
+ {
669
+ "name": dev.get("name", "unnamed"),
670
+ "type": dev.get("type", "unknown"),
671
+ "size": dev.get("size"),
672
+ }
673
+ for dev in storage_devices
674
+ ]
675
+ else:
676
+ summary["storage_count"] = 0
677
+ summary["storage_devices"] = []
678
+
679
+ return summary
680
+
681
+ def _get_process_info(self, pid: int) -> Optional[Dict[str, Any]]:
682
+ """
683
+ Get detailed process information using psutil if available.
684
+
685
+ Args:
686
+ pid: Process ID
687
+
688
+ Returns:
689
+ Dictionary with process information or None if psutil not available
690
+ """
691
+ try:
692
+ import psutil
693
+
694
+ try:
695
+ proc = psutil.Process(pid)
696
+ return {
697
+ "cpu_percent": proc.cpu_percent(),
698
+ "memory_info": proc.memory_info()._asdict(),
699
+ "create_time": proc.create_time(),
700
+ "cmdline": proc.cmdline(),
701
+ "status": proc.status(),
702
+ }
703
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
704
+ return None
705
+ except ImportError:
706
+ # psutil not available
707
+ return {"note": "Install psutil for detailed process information"}
708
+
709
+ @api_method(
710
+ cli_name="qmp",
711
+ description="Execute QMP command on VM",
712
+ category="qmp",
713
+ requires_vm=True,
714
+ hidden=True,
715
+ examples=[
716
+ "maqet qmp myvm system_powerdown",
717
+ "maqet qmp myvm screendump --filename screenshot.ppm",
718
+ ],
719
+ )
720
+ def qmp(self, vm_id: str, command: str, **kwargs) -> Dict[str, Any]:
721
+ """
722
+ Execute QMP command (delegates to QMPManager).
723
+
724
+ Args:
725
+ vm_id: VM identifier (name or ID)
726
+ command: QMP command to execute
727
+ **kwargs: Command parameters
728
+
729
+ Returns:
730
+ QMP command result
731
+
732
+ Raises:
733
+ MaqetError: If VM not found or command fails
734
+ """
735
+ try:
736
+ return self.qmp_manager.execute_qmp(vm_id, command, **kwargs)
737
+ except QMPManagerError as e:
738
+ raise MaqetError(str(e))
739
+
740
+ @api_method(
741
+ cli_name="keys",
742
+ description="Send key combination to VM via QMP",
743
+ category="qmp",
744
+ requires_vm=True,
745
+ parent="qmp",
746
+ examples=[
747
+ "maqet qmp keys myvm ctrl alt f2",
748
+ "maqet qmp keys myvm --hold-time 200 ctrl c",
749
+ ],
750
+ )
751
+ def qmp_key(
752
+ self, vm_id: str, *keys: str, hold_time: int = 100
753
+ ) -> Dict[str, Any]:
754
+ """
755
+ Send key combination to VM (delegates to QMPManager).
756
+
757
+ Args:
758
+ vm_id: VM identifier (name or ID)
759
+ *keys: Key names to press (e.g., 'ctrl', 'alt', 'f2')
760
+ hold_time: How long to hold keys in milliseconds
761
+
762
+ Returns:
763
+ QMP command result
764
+
765
+ Raises:
766
+ MaqetError: If VM not found or command fails
767
+ """
768
+ try:
769
+ return self.qmp_manager.send_keys(vm_id, *keys, hold_time=hold_time)
770
+ except QMPManagerError as e:
771
+ raise MaqetError(str(e))
772
+
773
+ @api_method(
774
+ cli_name="type",
775
+ description="Type text string to VM via QMP",
776
+ category="qmp",
777
+ requires_vm=True,
778
+ parent="qmp",
779
+ examples=[
780
+ "maqet qmp type myvm 'hello world'",
781
+ "maqet qmp type myvm --hold-time 50 'slow typing'",
782
+ ],
783
+ )
784
+ def qmp_type(
785
+ self, vm_id: str, text: str, hold_time: int = 100
786
+ ) -> List[Dict[str, Any]]:
787
+ """
788
+ Type text string to VM (delegates to QMPManager).
789
+
790
+ Args:
791
+ vm_id: VM identifier (name or ID)
792
+ text: Text to type
793
+ hold_time: How long to hold each key in milliseconds
794
+
795
+ Returns:
796
+ List of QMP command results
797
+
798
+ Raises:
799
+ MaqetError: If VM not found or command fails
800
+ """
801
+ try:
802
+ return self.qmp_manager.type_text(vm_id, text, hold_time=hold_time)
803
+ except QMPManagerError as e:
804
+ raise MaqetError(str(e))
805
+
806
+ @api_method(
807
+ cli_name="screendump",
808
+ description="Take screenshot of VM screen",
809
+ category="qmp",
810
+ requires_vm=True,
811
+ parent="qmp",
812
+ examples=[
813
+ "maqet qmp screendump myvm screenshot.ppm",
814
+ "maqet qmp screendump myvm /tmp/vm_screen.ppm",
815
+ ],
816
+ )
817
+ def screendump(self, vm_id: str, filename: str) -> Dict[str, Any]:
818
+ """
819
+ Take screenshot of VM screen (delegates to QMPManager).
820
+
821
+ Args:
822
+ vm_id: VM identifier (name or ID)
823
+ filename: Output filename for screenshot
824
+
825
+ Returns:
826
+ QMP command result
827
+
828
+ Raises:
829
+ MaqetError: If VM not found or command fails
830
+ """
831
+ try:
832
+ return self.qmp_manager.take_screenshot(vm_id, filename)
833
+ except QMPManagerError as e:
834
+ raise MaqetError(str(e))
835
+
836
+ @api_method(
837
+ cli_name="pause",
838
+ description="Pause VM execution via QMP",
839
+ category="qmp",
840
+ requires_vm=True,
841
+ parent="qmp",
842
+ examples=["maqet qmp pause myvm"],
843
+ )
844
+ def qmp_stop(self, vm_id: str) -> Dict[str, Any]:
845
+ """
846
+ Pause VM execution via QMP (delegates to QMPManager).
847
+
848
+ Args:
849
+ vm_id: VM identifier (name or ID)
850
+
851
+ Returns:
852
+ QMP command result
853
+
854
+ Raises:
855
+ MaqetError: If VM not found or command fails
856
+ """
857
+ try:
858
+ return self.qmp_manager.pause(vm_id)
859
+ except QMPManagerError as e:
860
+ raise MaqetError(str(e))
861
+
862
+ @api_method(
863
+ cli_name="resume",
864
+ description="Resume VM execution via QMP",
865
+ category="qmp",
866
+ requires_vm=True,
867
+ parent="qmp",
868
+ examples=["maqet qmp resume myvm"],
869
+ )
870
+ def qmp_cont(self, vm_id: str) -> Dict[str, Any]:
871
+ """
872
+ Resume VM execution via QMP (delegates to QMPManager).
873
+
874
+ Args:
875
+ vm_id: VM identifier (name or ID)
876
+
877
+ Returns:
878
+ QMP command result
879
+
880
+ Raises:
881
+ MaqetError: If VM not found or command fails
882
+ """
883
+ try:
884
+ return self.qmp_manager.resume(vm_id)
885
+ except QMPManagerError as e:
886
+ raise MaqetError(str(e))
887
+
888
+ @api_method(
889
+ cli_name="device-add",
890
+ description="Hot-plug device to VM via QMP",
891
+ category="qmp",
892
+ requires_vm=True,
893
+ parent="qmp",
894
+ examples=[
895
+ "maqet qmp device-add myvm usb-storage --device-id usb1 "
896
+ "--drive usb-drive",
897
+ "maqet qmp device-add myvm e1000 --device-id net1 --netdev user1",
898
+ ],
899
+ )
900
+ def device_add(
901
+ self, vm_id: str, driver: str, device_id: str, **kwargs
902
+ ) -> Dict[str, Any]:
903
+ """
904
+ Hot-plug device to VM via QMP (delegates to QMPManager).
905
+
906
+ Args:
907
+ vm_id: VM identifier (name or ID)
908
+ driver: Device driver name (e.g., 'usb-storage', 'e1000')
909
+ device_id: Unique device identifier
910
+ **kwargs: Additional device properties
911
+
912
+ Returns:
913
+ QMP command result
914
+
915
+ Raises:
916
+ MaqetError: If VM not found or command fails
917
+ """
918
+ try:
919
+ return self.qmp_manager.device_add(vm_id, driver, device_id, **kwargs)
920
+ except QMPManagerError as e:
921
+ raise MaqetError(str(e))
922
+
923
+ @api_method(
924
+ cli_name="device-del",
925
+ description="Hot-unplug device from VM via QMP",
926
+ category="qmp",
927
+ requires_vm=True,
928
+ parent="qmp",
929
+ examples=["maqet qmp device-del myvm usb1"],
930
+ )
931
+ def device_del(self, vm_id: str, device_id: str) -> Dict[str, Any]:
932
+ """
933
+ Hot-unplug device from VM via QMP (delegates to QMPManager).
934
+
935
+ Args:
936
+ vm_id: VM identifier (name or ID)
937
+ device_id: Device identifier to remove
938
+
939
+ Returns:
940
+ QMP command result
941
+
942
+ Raises:
943
+ MaqetError: If VM not found or command fails
944
+ """
945
+ try:
946
+ return self.qmp_manager.device_del(vm_id, device_id)
947
+ except QMPManagerError as e:
948
+ raise MaqetError(str(e))
949
+
950
+ @api_method(
951
+ cli_name="snapshot",
952
+ description="Manage VM storage snapshots",
953
+ category="storage",
954
+ requires_vm=True,
955
+ examples=[
956
+ "maqet snapshot myvm create ssd backup_name",
957
+ "maqet snapshot myvm load ssd backup_name",
958
+ "maqet snapshot myvm list ssd",
959
+ "maqet snapshot myvm create ssd backup_name --overwrite",
960
+ ],
961
+ )
962
+ def snapshot(
963
+ self,
964
+ vm_id: str,
965
+ action: str,
966
+ drive: str,
967
+ name: Optional[str] = None,
968
+ overwrite: bool = False,
969
+ ) -> Union[Dict[str, Any], List[str]]:
970
+ """
971
+ Manage VM storage snapshots (delegates to SnapshotCoordinator).
972
+
973
+ Args:
974
+ vm_id: VM identifier (name or ID)
975
+ action: Snapshot action ('create', 'load', 'list')
976
+ drive: Storage drive name
977
+ name: Snapshot name (required for create/load)
978
+ overwrite: Overwrite existing snapshot (create only)
979
+
980
+ Returns:
981
+ Operation result dictionary or list of snapshots
982
+
983
+ Raises:
984
+ MaqetError: If VM not found or snapshot operation fails
985
+ """
986
+ try:
987
+ return self.snapshot_coordinator.snapshot(
988
+ vm_id, action, drive, name, overwrite
989
+ )
990
+ except SnapshotCoordinatorError as e:
991
+ raise MaqetError(str(e))
992
+ except SnapshotError as e:
993
+ raise MaqetError(f"Snapshot operation failed: {e}")
994
+
995
+ @api_method(
996
+ cli_name="apply",
997
+ description="Apply configuration to existing VM",
998
+ category="vm",
999
+ requires_vm=True,
1000
+ examples=[
1001
+ "maqet apply myvm config.yaml",
1002
+ "maqet apply myvm --memory 8G --cpu 4",
1003
+ ],
1004
+ )
1005
+ def apply(
1006
+ self,
1007
+ vm_id: str,
1008
+ config: Optional[Union[str, List[str]]] = None,
1009
+ **kwargs,
1010
+ ) -> VMInstance:
1011
+ """
1012
+ Apply configuration to existing VM, or create it if it doesn't exist.
1013
+
1014
+ Args:
1015
+ vm_id: VM identifier (name or ID)
1016
+ config: Path to configuration file, or list of config
1017
+ files for deep-merge
1018
+ **kwargs: Configuration parameters to update
1019
+
1020
+ Returns:
1021
+ VM instance (created or updated)
1022
+
1023
+ Raises:
1024
+ MaqetError: If configuration is invalid or operation fails
1025
+ """
1026
+ try:
1027
+ # Get VM from database
1028
+ vm = self.state_manager.get_vm(vm_id)
1029
+
1030
+ if not vm:
1031
+ # VM doesn't exist, create it using add functionality
1032
+ LOG.info(f"VM '{vm_id}' not found, creating new VM")
1033
+ new_vm_id = self.add(config=config, name=vm_id, **kwargs)
1034
+ return self.state_manager.get_vm(new_vm_id)
1035
+
1036
+ # VM exists, update its configuration
1037
+ # Load and merge new configuration files
1038
+ if config:
1039
+ new_config = ConfigMerger.load_and_merge_files(config)
1040
+ else:
1041
+ new_config = {}
1042
+
1043
+ # Merge kwargs with new config (kwargs take precedence)
1044
+ if kwargs:
1045
+ new_config = ConfigMerger.deep_merge(new_config, kwargs)
1046
+
1047
+ # Remove name from new_config as it's not QEMU configuration
1048
+ # (VM already exists with its name)
1049
+ if "name" in new_config:
1050
+ new_config = {
1051
+ k: v for k, v in new_config.items() if k != "name"
1052
+ }
1053
+
1054
+ # Merge with existing configuration (existing config provides base)
1055
+ final_config = ConfigMerger.deep_merge(
1056
+ dict(vm.config_data), new_config
1057
+ )
1058
+
1059
+ # Validate the merged configuration
1060
+ final_config = self.config_parser.validate_config(final_config)
1061
+
1062
+ # Update VM configuration in database
1063
+ # This is a simplified approach - in reality we'd update
1064
+ # the existing record
1065
+ self.state_manager.update_vm_config(vm.id, final_config)
1066
+
1067
+ return self.state_manager.get_vm(vm_id)
1068
+
1069
+ except Exception as e:
1070
+ raise MaqetError(
1071
+ f"Failed to apply configuration to VM '{vm_id}': {e}"
1072
+ )
1073
+
1074
+ def cli(self, args: Optional[List[str]] = None) -> Any:
1075
+ """
1076
+ Run CLI interface using CLIGenerator.
1077
+
1078
+ Uses instance-specific API registry for isolated command generation.
1079
+
1080
+ Args:
1081
+ args: Command line arguments (defaults to sys.argv[1:])
1082
+
1083
+ Returns:
1084
+ Result of CLI command execution
1085
+ """
1086
+ # Use instance-specific registry (falls back to global if not available)
1087
+ registry = getattr(self, '_api_registry', API_REGISTRY)
1088
+ generator = CLIGenerator(self, registry)
1089
+ return generator.run(args)
1090
+
1091
+ def __call__(self, method_name: str, **kwargs) -> Any:
1092
+ """
1093
+ Direct Python API access.
1094
+
1095
+ Args:
1096
+ method_name: Method to execute
1097
+ **kwargs: Method parameters
1098
+
1099
+ Returns:
1100
+ Method execution result
1101
+ """
1102
+ generator = PythonAPIGenerator(self, API_REGISTRY)
1103
+ return generator.execute_method(method_name, **kwargs)
1104
+
1105
+ def python_api(self):
1106
+ """
1107
+ Get Python API interface.
1108
+
1109
+ Returns:
1110
+ PythonAPIInterface for direct method access
1111
+ """
1112
+ generator = PythonAPIGenerator(self, API_REGISTRY)
1113
+ return generator.generate()
1114
+
1115
+
1116
+ # NOTE: API methods are automatically registered via AutoRegisterAPI
1117
+ # inheritance
1118
+ # No manual register_class_methods() call needed!
1119
+
1120
+ # NOTE: Line length compliance is now enforced by black formatter