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/snapshot.py ADDED
@@ -0,0 +1,473 @@
1
+ """
2
+ Integrated Snapshot Manager
3
+
4
+ Manages QCOW2 snapshots for VM storage drives using the unified storage system.
5
+ Provides create, load, and list operations for VM storage snapshots.
6
+ """
7
+
8
+ import fcntl
9
+ import shutil
10
+ import subprocess
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from .constants import Defaults, Retries, Timeouts
16
+ from .exceptions import (
17
+ SnapshotCreationError,
18
+ SnapshotDeleteError,
19
+ SnapshotError,
20
+ SnapshotLoadError,
21
+ SnapshotNotFoundError,
22
+ StorageDeviceNotFoundError,
23
+ StorageError,
24
+ )
25
+ from .logger import LOG
26
+ from .storage import BaseStorageDevice, StorageManager
27
+
28
+
29
+ class SnapshotManager:
30
+ """
31
+ Manages QCOW2 snapshots for VM storage drives.
32
+
33
+ Integrates with the unified storage management system to provide
34
+ snapshot operations on supported storage devices.
35
+
36
+ # NOTE: Good - cleanly integrates with StorageManager abstraction instead
37
+ # of directly manipulating storage files. Respects storage device
38
+ # capabilities.
39
+ #
40
+ # TODO(architect, 2025-10-10): [PERF] Snapshot operations are synchronous and block
41
+ # Context: All snapshot operations (create/load/list) use subprocess.run() which blocks
42
+ # until qemu-img completes. For large disks (100GB+), this can take minutes with no
43
+ # progress indication. Issue #7 in ARCHITECTURAL_REVIEW.md.
44
+ #
45
+ # Impact: CLI freezes, no way to cancel, users don't know if stuck or progressing
46
+ #
47
+ # Recommendation (Option 1 - Async): Use asyncio.create_subprocess_exec() for non-blocking
48
+ # Recommendation (Option 2 - Progress): Add progress callback and timeout warnings
49
+ #
50
+ # Effort: Medium (3-5 days for async, 1-2 days for progress reporting)
51
+ # Priority: High but defer to 1.1 (not critical until working with large disks)
52
+ # See: ARCHITECTURAL_REVIEW.md Issue #7
53
+ """
54
+
55
+ def __init__(self, vm_id: str, storage_manager: StorageManager):
56
+ """
57
+ Initialize snapshot manager for a VM.
58
+
59
+ Args:
60
+ vm_id: VM identifier
61
+ storage_manager: Unified storage manager instance
62
+ """
63
+ self.vm_id = vm_id
64
+ self.storage_manager = storage_manager
65
+
66
+ def create(
67
+ self, drive_name: str, snapshot_name: str, overwrite: bool = False
68
+ ) -> Dict[str, Any]:
69
+ """
70
+ Create QCOW2 snapshot on specified drive.
71
+
72
+ Args:
73
+ drive_name: Name of storage drive
74
+ snapshot_name: Name for the snapshot
75
+ overwrite: Whether to overwrite existing snapshot
76
+
77
+ Returns:
78
+ Result dictionary with operation status
79
+
80
+ Raises:
81
+ SnapshotError: If drive not found or operation fails
82
+ """
83
+ try:
84
+ device = self._get_snapshot_capable_device(drive_name)
85
+ drive_path = self._get_device_file_path(device)
86
+
87
+ # Check if snapshot already exists
88
+ existing_snapshots = self._list_snapshots(drive_path)
89
+ if snapshot_name in existing_snapshots:
90
+ if overwrite:
91
+ # Delete existing snapshot first
92
+ self._run_qemu_img(
93
+ ["snapshot", str(drive_path), "-d", snapshot_name]
94
+ )
95
+ else:
96
+ raise SnapshotCreationError(
97
+ f"Snapshot '{snapshot_name}' already exists on drive '{drive_name}'. "
98
+ f"Use --overwrite flag to replace it."
99
+ )
100
+
101
+ # Create snapshot
102
+ self._run_qemu_img(
103
+ ["snapshot", str(drive_path), "-c", snapshot_name]
104
+ )
105
+
106
+ return {
107
+ "status": "success",
108
+ "operation": "create",
109
+ "vm_id": self.vm_id,
110
+ "drive": drive_name,
111
+ "snapshot": snapshot_name,
112
+ "overwrite": overwrite,
113
+ }
114
+
115
+ except SnapshotCreationError:
116
+ # Re-raise specific errors
117
+ raise
118
+ except Exception as e:
119
+ raise SnapshotCreationError(
120
+ f"Failed to create snapshot '{snapshot_name}' on drive '{drive_name}': {e}"
121
+ )
122
+
123
+ def load(self, drive_name: str, snapshot_name: str) -> Dict[str, Any]:
124
+ """
125
+ Load/revert to QCOW2 snapshot on specified drive.
126
+
127
+ Args:
128
+ drive_name: Name of storage drive
129
+ snapshot_name: Name of snapshot to load
130
+
131
+ Returns:
132
+ Result dictionary with operation status
133
+
134
+ Raises:
135
+ SnapshotError: If drive not found or operation fails
136
+ """
137
+ try:
138
+ device = self._get_snapshot_capable_device(drive_name)
139
+ drive_path = self._get_device_file_path(device)
140
+
141
+ # Check if snapshot exists
142
+ existing_snapshots = self._list_snapshots(drive_path)
143
+ if snapshot_name not in existing_snapshots:
144
+ available = ", ".join(existing_snapshots) if existing_snapshots else "none"
145
+ raise SnapshotNotFoundError(
146
+ f"Snapshot '{snapshot_name}' not found on drive '{drive_name}'. "
147
+ f"Available snapshots: {available}"
148
+ )
149
+
150
+ # Apply/revert to snapshot
151
+ self._run_qemu_img(
152
+ ["snapshot", str(drive_path), "-a", snapshot_name]
153
+ )
154
+
155
+ return {
156
+ "status": "success",
157
+ "operation": "load",
158
+ "vm_id": self.vm_id,
159
+ "drive": drive_name,
160
+ "snapshot": snapshot_name,
161
+ }
162
+
163
+ except SnapshotNotFoundError:
164
+ # Re-raise specific errors
165
+ raise
166
+ except Exception as e:
167
+ raise SnapshotLoadError(
168
+ f"Failed to load snapshot '{snapshot_name}' on drive '{drive_name}': {e}"
169
+ )
170
+
171
+ def list(self, drive_name: str) -> List[str]:
172
+ """
173
+ List all available snapshots for specified drive.
174
+
175
+ Args:
176
+ drive_name: Name of storage drive
177
+
178
+ Returns:
179
+ List of snapshot names
180
+
181
+ Raises:
182
+ SnapshotError: If drive not found or operation fails
183
+ """
184
+ try:
185
+ device = self._get_snapshot_capable_device(drive_name)
186
+ drive_path = self._get_device_file_path(device)
187
+ return self._list_snapshots(drive_path)
188
+
189
+ except Exception as e:
190
+ raise SnapshotError(
191
+ f"Failed to list snapshots for drive '{drive_name}': {e}"
192
+ )
193
+
194
+ def _get_snapshot_capable_device(
195
+ self, drive_name: str
196
+ ) -> BaseStorageDevice:
197
+ """
198
+ Get storage device by name and verify it supports snapshots.
199
+
200
+ Args:
201
+ drive_name: Name of the drive
202
+
203
+ Returns:
204
+ Storage device that supports snapshots
205
+
206
+ Raises:
207
+ SnapshotError: If drive not found or doesn't support snapshots
208
+ """
209
+ device = self.storage_manager.get_device_by_name(drive_name)
210
+ if not device:
211
+ available_devices = [d.name for d in self.storage_manager.devices]
212
+ raise SnapshotError(
213
+ f"Drive '{drive_name}' not found in VM '{self.vm_id}'. "
214
+ f"Available drives: {available_devices}"
215
+ )
216
+
217
+ if not device.supports_snapshots():
218
+ raise SnapshotError(
219
+ f"Drive '{drive_name}' (type: {device.get_type()}) "
220
+ f"does not support snapshots. Only QCOW2 drives support snapshots."
221
+ )
222
+
223
+ return device
224
+
225
+ def _get_device_file_path(self, device: BaseStorageDevice) -> Path:
226
+ """
227
+ Get file path from storage device.
228
+
229
+ Args:
230
+ device: Storage device
231
+
232
+ Returns:
233
+ Path to the storage file
234
+
235
+ Raises:
236
+ SnapshotError: If device doesn't have a file path or file doesn't exist
237
+ """
238
+ if not hasattr(device, "file_path"):
239
+ raise SnapshotError(
240
+ f"Storage device '{device.name}' doesn't have a file path"
241
+ )
242
+
243
+ file_path = device.file_path
244
+ if not file_path.exists():
245
+ raise SnapshotError(f"Storage file '{file_path}' does not exist")
246
+
247
+ return file_path
248
+
249
+ def _run_qemu_img(
250
+ self,
251
+ args: List[str],
252
+ timeout: int = 300,
253
+ max_retries: int = 3,
254
+ ) -> str:
255
+ """
256
+ Run qemu-img command with given arguments.
257
+
258
+ Args:
259
+ args: List of command arguments (e.g., ["snapshot", "/path/to/file", "-c", "name"])
260
+ timeout: Command timeout in seconds (default 5 minutes)
261
+ max_retries: Maximum number of retry attempts for transient failures
262
+
263
+ Returns:
264
+ Command output
265
+
266
+ Raises:
267
+ SnapshotError: If command fails after all retries
268
+ """
269
+ # Verify qemu-img binary exists
270
+ qemu_img_path = shutil.which("qemu-img")
271
+ if not qemu_img_path:
272
+ raise SnapshotError(
273
+ "qemu-img binary not found in PATH. "
274
+ "Please install QEMU tools."
275
+ )
276
+
277
+ # Build secure command list
278
+ command = [qemu_img_path] + args
279
+
280
+ # Extract file path from args for locking (usually args[1])
281
+ # Example: ["snapshot", "/path/to/file.qcow2", "-c", "snap1"]
282
+ lock_file = None
283
+ lock_file_path = None
284
+ if len(args) >= 2 and Path(args[1]).exists():
285
+ file_path = Path(args[1])
286
+ lock_file_path = (
287
+ file_path.parent / f".{file_path.name}.snapshot.lock"
288
+ )
289
+
290
+ # Retry loop for transient failures
291
+ last_error = None
292
+ for attempt in range(max_retries):
293
+ process = None
294
+ try:
295
+ # Acquire file lock to prevent concurrent snapshot operations
296
+ if lock_file_path:
297
+ lock_file = open(lock_file_path, "w")
298
+ try:
299
+ fcntl.flock(
300
+ lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB
301
+ )
302
+ except BlockingIOError:
303
+ raise SnapshotError(
304
+ f"Another snapshot operation is in progress on {args[1]}. "
305
+ f"Please wait for it to complete."
306
+ )
307
+
308
+ LOG.debug(f"Running qemu-img command: {' '.join(command)}")
309
+
310
+ # Use Popen for better process control (kill on timeout)
311
+ process = subprocess.Popen(
312
+ command,
313
+ stdout=subprocess.PIPE,
314
+ stderr=subprocess.PIPE,
315
+ text=True,
316
+ )
317
+
318
+ try:
319
+ stdout, stderr = process.communicate(timeout=timeout)
320
+ except subprocess.TimeoutExpired:
321
+ # Kill the process on timeout to prevent resource leaks
322
+ LOG.warning(
323
+ f"qemu-img command timed out after {timeout}s, killing process"
324
+ )
325
+ process.kill()
326
+ # Wait for process to actually die and collect zombie
327
+ try:
328
+ process.wait(timeout=Timeouts.PROCESS_KILL)
329
+ except subprocess.TimeoutExpired:
330
+ # Force kill if still alive
331
+ process.terminate()
332
+ process.wait(timeout=Timeouts.PROCESS_WAIT_AFTER_KILL)
333
+
334
+ raise SnapshotError(
335
+ f"qemu-img command timed out after {timeout} seconds and was killed"
336
+ )
337
+
338
+ # Check exit code
339
+ if process.returncode != 0:
340
+ raise subprocess.CalledProcessError(
341
+ process.returncode, command, stdout, stderr
342
+ )
343
+
344
+ return stdout.strip()
345
+
346
+ except subprocess.CalledProcessError as e:
347
+ last_error = e
348
+ # Check if error is potentially transient
349
+ if self._is_transient_error(e.stderr):
350
+ if attempt < max_retries - 1:
351
+ wait_time = 2**attempt # Exponential backoff
352
+ LOG.warning(
353
+ f"qemu-img command failed (attempt {attempt + 1}/{max_retries}), "
354
+ f"retrying in {wait_time}s: {e.stderr}"
355
+ )
356
+ time.sleep(wait_time)
357
+ continue
358
+ # Non-transient error or final retry
359
+ raise SnapshotError(
360
+ f"qemu-img command failed: {e.stderr.strip()}"
361
+ )
362
+
363
+ finally:
364
+ # Release lock and clean up
365
+ if lock_file:
366
+ try:
367
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
368
+ lock_file.close()
369
+ if lock_file_path and lock_file_path.exists():
370
+ lock_file_path.unlink()
371
+ except Exception:
372
+ pass # Best effort cleanup
373
+
374
+ # Should not reach here, but handle gracefully
375
+ if last_error:
376
+ raise SnapshotError(
377
+ f"qemu-img command failed after {max_retries} attempts: "
378
+ f"{last_error.stderr.strip()}"
379
+ )
380
+ raise SnapshotError("qemu-img command failed for unknown reason")
381
+
382
+ def _is_transient_error(self, stderr: str) -> bool:
383
+ """
384
+ Check if error message indicates a transient failure.
385
+
386
+ Args:
387
+ stderr: Error output from qemu-img
388
+
389
+ Returns:
390
+ True if error appears transient and worth retrying
391
+ """
392
+ transient_indicators = [
393
+ "resource temporarily unavailable",
394
+ "device or resource busy",
395
+ "try again",
396
+ "temporary failure",
397
+ "connection timed out",
398
+ ]
399
+ stderr_lower = stderr.lower()
400
+ return any(
401
+ indicator in stderr_lower for indicator in transient_indicators
402
+ )
403
+
404
+ def _list_snapshots(self, drive_path: Path) -> List[str]:
405
+ """
406
+ List snapshots for a QCOW2 drive.
407
+
408
+ Args:
409
+ drive_path: Path to the drive file
410
+
411
+ Returns:
412
+ List of snapshot names
413
+ """
414
+ try:
415
+ output = self._run_qemu_img(["snapshot", str(drive_path), "-l"])
416
+
417
+ # Parse qemu-img snapshot output
418
+ # Format: "Snapshot list:\nID TAG VM SIZE DATE VM CLOCK\n1 snap1 0
419
+ # B 2024-01-01 12:00:00 00:00:00.000\n"
420
+ lines = output.split("\n")
421
+ snapshots = []
422
+
423
+ for line in lines[2:]: # Skip header lines
424
+ if line.strip():
425
+ parts = line.split()
426
+ if len(parts) >= 2:
427
+ snapshot_name = parts[1] # TAG column
428
+ snapshots.append(snapshot_name)
429
+
430
+ return snapshots
431
+ except SnapshotError:
432
+ # If snapshot listing fails, assume no snapshots exist
433
+ return []
434
+
435
+ def get_drive_info(self, drive_name: str) -> Dict[str, Any]:
436
+ """
437
+ Get information about a storage drive including snapshot data.
438
+
439
+ Args:
440
+ drive_name: Name of the drive
441
+
442
+ Returns:
443
+ Dictionary with drive information including snapshots
444
+
445
+ Raises:
446
+ SnapshotError: If drive not found
447
+ """
448
+ device = self._get_snapshot_capable_device(drive_name)
449
+ drive_path = self._get_device_file_path(device)
450
+ snapshots = self._list_snapshots(drive_path)
451
+
452
+ # Get base device info and add snapshot information
453
+ info = device.get_info()
454
+ info.update(
455
+ {
456
+ "snapshots": snapshots,
457
+ "snapshot_count": len(snapshots),
458
+ }
459
+ )
460
+
461
+ return info
462
+
463
+ def list_snapshot_capable_drives(self) -> List[str]:
464
+ """
465
+ Get list of drives that support snapshots.
466
+
467
+ Returns:
468
+ List of drive names that support snapshots
469
+ """
470
+ return [
471
+ device.name
472
+ for device in self.storage_manager.get_snapshot_capable_devices()
473
+ ]