maqet 0.0.1.4__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.4.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.4.dist-info → maqet-0.0.5.dist-info}/top_level.txt +0 -1
  56. maqet/core.py +0 -411
  57. maqet/functions.py +0 -104
  58. maqet-0.0.1.4.dist-info/METADATA +0 -6
  59. maqet-0.0.1.4.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/storage.py CHANGED
@@ -1,196 +1,736 @@
1
+ """
2
+ Unified Storage Management System
1
3
 
2
- from abc import ABCMeta, abstractmethod
3
- from pathlib import Path
4
+ Provides extensible storage device management with integrated snapshot support.
5
+ Supports multiple storage types (QCOW2, Raw, VirtFS) with plugin-style extensibility.
6
+ """
4
7
 
5
- from benedict import benedict
8
+ import fcntl
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ from abc import ABC, abstractmethod
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Type
6
16
 
7
- from .functions import parse_args
8
- from .functions import shell_command as cmd
9
17
  from .logger import LOG
10
18
 
11
- MESSAGE = r"""
12
- _ _ __ __ _ _ _ _ _ _
13
- | | ___ ___ | | __ | \/ | __ _ | \ | | ___ | | | | __ _ _ __ __| |___| |
14
- | | / _ \ / _ \| |/ / | |\/| |/ _` | | \| |/ _ \ | |_| |/ _` | '_ \ / _` / __| |
15
- | |__| (_) | (_) | < | | | | (_| |_ | |\ | (_) | | _ | (_| | | | | (_| \__ \_|
16
- |_____\___/ \___/|_|\_\ |_| |_|\__,_( ) |_| \_|\___/ |_| |_|\__,_|_| |_|\__,_|___(_)
17
- |/
18
- """
19
19
 
20
+ class StorageError(Exception):
21
+ """Storage operation errors."""
20
22
 
21
- class DriveError(Exception):
22
- """
23
- Exception called when error in Drive happens
24
- """
25
23
 
24
+ class BaseStorageDevice(ABC):
25
+ """Abstract base class for all storage devices.
26
26
 
27
- class IDrive(metaclass=ABCMeta):
28
- """
29
- Interface for drive classes
27
+ # NOTE: Good - abstract base classes with @abstractmethod decorators
28
+ # enforce
29
+ # a consistent interface across all storage types. This makes the system
30
+ # predictable and extensible.
30
31
  """
31
32
 
33
+ def __init__(self, config: Dict[str, Any], vm_id: str, index: int):
34
+ """
35
+ Initialize storage device.
36
+
37
+ Args:
38
+ config: Storage device configuration dictionary
39
+ vm_id: VM identifier
40
+ index: Device index for naming/ordering
41
+ """
42
+ self.config = config
43
+ self.vm_id = vm_id
44
+ self.index = index
45
+ self.name = config.get("name", f"storage{index}")
46
+
32
47
  @abstractmethod
33
- def __init__(self, path: Path, *args, **kwargs):
34
- """INTERFACE METHOD"""
48
+ def get_qemu_args(self) -> List[str]:
49
+ """
50
+ Get QEMU command line arguments for this storage device.
51
+
52
+ Returns:
53
+ List of QEMU arguments
54
+ """
35
55
 
36
56
  @abstractmethod
37
- def __call__(self) -> str:
38
- """INTERFACE METHOD"""
57
+ def create_if_needed(self) -> None:
58
+ """Create storage device if it doesn't exist and should be auto-created."""
39
59
 
60
+ @abstractmethod
61
+ def supports_snapshots(self) -> bool:
62
+ """Check if this storage type supports snapshots."""
40
63
 
41
- class FileDrive(IDrive):
42
- """
43
- Superclass of drives using files like raw or QCOW2
44
- """
64
+ def get_info(self) -> Dict[str, Any]:
65
+ """
66
+ Get information about this storage device.
67
+
68
+ Returns:
69
+ Dictionary with device information
70
+ """
71
+ return {
72
+ "name": self.name,
73
+ "type": self.get_type(),
74
+ "config": self.config,
75
+ "supports_snapshots": self.supports_snapshots(),
76
+ }
77
+
78
+ @abstractmethod
79
+ def get_type(self) -> str:
80
+ """Get storage device type name."""
81
+
82
+
83
+ class FileBasedStorageDevice(BaseStorageDevice):
84
+ """Base class for file-based storage devices (QCOW2, Raw)."""
85
+
86
+ VALID_INTERFACES = {"virtio", "sata", "ide", "scsi", "none"}
87
+
88
+ def __init__(self, config: Dict[str, Any], vm_id: str, index: int):
89
+ """Initialize file-based storage device."""
90
+ super().__init__(config, vm_id, index)
91
+
92
+ # Get or generate file path
93
+ self.file_path = self._get_file_path()
94
+ self.size = config.get("size", "10G")
95
+ self.interface = config.get("interface", "virtio")
96
+
97
+ # Validate configuration
98
+ self._validate_size(self.size)
99
+ self._validate_interface(self.interface)
100
+
101
+ def _get_file_path(self) -> Path:
102
+ """Get storage file path, generating default if needed."""
103
+ file_config = self.config.get("file")
104
+ if file_config:
105
+ return Path(file_config)
106
+
107
+ # Generate default path in /tmp
108
+ file_extension = self.get_type().lower()
109
+ return Path(f"/tmp/maqet-{self.vm_id}-{self.name}.{file_extension}")
110
+
111
+ def _validate_size(self, size: str) -> None:
112
+ """Validate size format is compatible with qemu-img."""
113
+ size_pattern = r"^\d+[KMGT]?$"
114
+ if not re.match(size_pattern, str(size), re.IGNORECASE):
115
+ raise ValueError(
116
+ f"Invalid size format '{size}'. "
117
+ "Expected format: number[KMGT] (e.g., 10G, 512M)"
118
+ )
119
+
120
+ def _validate_interface(self, interface: str) -> None:
121
+ """Validate interface type is supported."""
122
+ if interface not in self.VALID_INTERFACES:
123
+ raise ValueError(
124
+ f"Invalid interface '{interface}'. "
125
+ f"Valid interfaces: {', '.join(sorted(self.VALID_INTERFACES))}"
126
+ )
127
+
128
+ def create_if_needed(self) -> None:
129
+ """Create storage file if it doesn't exist and should be auto-created."""
130
+ if not self.file_path.exists() and self._should_auto_create():
131
+ self._create_storage_file()
132
+
133
+ def _should_auto_create(self) -> bool:
134
+ """Determine if we should auto-create this storage file."""
135
+ try:
136
+ path_str = str(self.file_path)
137
+
138
+ # Don't auto-create in system directories
139
+ system_paths = [
140
+ "/etc",
141
+ "/var",
142
+ "/usr",
143
+ "/bin",
144
+ "/boot",
145
+ "/sys",
146
+ "/proc",
147
+ ]
148
+ if any(path_str.startswith(sys_path) for sys_path in system_paths):
149
+ return False
150
+
151
+ # Check if we can write to parent directory
152
+ try:
153
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
154
+ return True
155
+ except PermissionError:
156
+ return False
157
+
158
+ except Exception:
159
+ return False
160
+
161
+ def _create_storage_file(self) -> None:
162
+ """Create storage file using qemu-img.
163
+
164
+ Includes disk space validation, concurrency protection via file locking,
165
+ and cleanup of partial files on failure.
166
+ """
167
+ lock_file = None
168
+ partial_file_created = False
169
+
170
+ try:
171
+ # Verify qemu-img binary exists
172
+ qemu_img_path = shutil.which("qemu-img")
173
+ if not qemu_img_path:
174
+ raise StorageError(
175
+ "qemu-img binary not found in PATH. "
176
+ "Please install QEMU tools (qemu-utils or qemu-img package)."
177
+ )
178
+
179
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
180
+
181
+ # Check available disk space before creating file
182
+ required_bytes = self._parse_size_to_bytes(self.size)
183
+ stat = os.statvfs(self.file_path.parent)
184
+ available_bytes = stat.f_bavail * stat.f_frsize
185
+
186
+ # Add 10% buffer for QCOW2 metadata overhead
187
+ if self.get_type().lower() == "qcow2":
188
+ required_bytes = int(required_bytes * 1.1)
189
+
190
+ if required_bytes > available_bytes:
191
+ raise StorageError(
192
+ f"Insufficient disk space for {self.file_path}. "
193
+ f"Required: {self._format_bytes(required_bytes)}, "
194
+ f"Available: {self._format_bytes(available_bytes)}. "
195
+ f"Free up disk space and try again."
196
+ )
197
+
198
+ # Use file locking to prevent concurrent creation of same file
199
+ lock_file_path = (
200
+ self.file_path.parent / f".{self.file_path.name}.lock"
201
+ )
202
+ lock_file = open(lock_file_path, "w")
203
+
204
+ try:
205
+ # Try to acquire exclusive lock (non-blocking)
206
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
207
+ except BlockingIOError:
208
+ raise StorageError(
209
+ f"Storage file {self.file_path} is being created by another process. "
210
+ f"Wait for completion or remove lock: {lock_file_path}"
211
+ )
212
+
213
+ # Check again if file was created while we were waiting for lock
214
+ if self.file_path.exists():
215
+ LOG.warning(
216
+ f"Storage file {self.file_path} already exists, skipping creation"
217
+ )
218
+ return
219
+
220
+ LOG.info(
221
+ f"Creating {self.get_type()} storage file: "
222
+ f"{self.file_path} ({self.size})"
223
+ )
224
+
225
+ cmd = [
226
+ "qemu-img",
227
+ "create",
228
+ "-f",
229
+ self.get_type().lower(),
230
+ str(self.file_path),
231
+ self.size,
232
+ ]
233
+
234
+ result = subprocess.run(
235
+ cmd, capture_output=True, text=True, check=True
236
+ )
237
+ partial_file_created = True # Mark that file was created
238
+ LOG.info(f"Successfully created storage file: {self.file_path}")
239
+
240
+ except subprocess.CalledProcessError as e:
241
+ # Remove partial file if creation failed
242
+ if partial_file_created and self.file_path.exists():
243
+ LOG.warning(f"Removing partial storage file: {self.file_path}")
244
+ try:
245
+ self.file_path.unlink()
246
+ except Exception as cleanup_error:
247
+ LOG.error(f"Failed to remove partial file: {cleanup_error}")
248
+
249
+ LOG.error(
250
+ f"Failed to create storage file {self.file_path}: {e.stderr}"
251
+ )
252
+ raise StorageError(f"Failed to create storage file: {e.stderr}")
253
+
254
+ except Exception as e:
255
+ # Remove partial file on any error
256
+ if partial_file_created and self.file_path.exists():
257
+ LOG.warning(f"Removing partial storage file: {self.file_path}")
258
+ try:
259
+ self.file_path.unlink()
260
+ except Exception as cleanup_error:
261
+ LOG.error(f"Failed to remove partial file: {cleanup_error}")
262
+
263
+ LOG.error(
264
+ f"Unexpected error creating storage file {self.file_path}: {e}"
265
+ )
266
+ raise StorageError(f"Failed to create storage file: {e}")
267
+
268
+ finally:
269
+ # Release lock and clean up lock file
270
+ if lock_file:
271
+ try:
272
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
273
+ lock_file.close()
274
+ Path(lock_file.name).unlink(missing_ok=True)
275
+ except Exception as e:
276
+ LOG.debug(f"Error cleaning up lock file: {e}")
277
+
278
+ def _parse_size_to_bytes(self, size_str: str) -> int:
279
+ """Parse size string (e.g., '10G', '512M') to bytes."""
280
+ match = re.match(r"^(\d+)([KMGT]?)$", str(size_str), re.IGNORECASE)
281
+ if not match:
282
+ raise ValueError(f"Invalid size format: {size_str}")
283
+
284
+ number = int(match.group(1))
285
+ unit = match.group(2).upper() if match.group(2) else ""
286
+
287
+ multipliers = {
288
+ "": 1,
289
+ "K": 1024,
290
+ "M": 1024**2,
291
+ "G": 1024**3,
292
+ "T": 1024**4,
293
+ }
294
+
295
+ return number * multipliers[unit]
296
+
297
+ def _format_bytes(self, bytes_value: int) -> str:
298
+ """Format bytes to human-readable string."""
299
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
300
+ if bytes_value < 1024.0:
301
+ return f"{bytes_value:.1f}{unit}"
302
+ bytes_value /= 1024.0
303
+ return f"{bytes_value:.1f}PB"
304
+
305
+ def get_qemu_args(self) -> List[str]:
306
+ """Get QEMU arguments for file-based storage."""
307
+ drive_spec = (
308
+ f"file={self.file_path},if={self.interface},"
309
+ f"format={self.get_type().lower()}"
310
+ )
311
+ return ["-drive", drive_spec]
312
+
313
+ def get_info(self) -> Dict[str, Any]:
314
+ """Get detailed information about file-based storage."""
315
+ info = super().get_info()
316
+ info.update(
317
+ {
318
+ "file_path": str(self.file_path),
319
+ "size": self.size,
320
+ "interface": self.interface,
321
+ "exists": self.file_path.exists(),
322
+ }
323
+ )
324
+ return info
325
+
326
+
327
+ class QCOW2StorageDevice(FileBasedStorageDevice):
328
+ """QCOW2 storage device with snapshot support."""
329
+
330
+ def get_type(self) -> str:
331
+ """Get storage type."""
332
+ return "qcow2"
333
+
334
+ def supports_snapshots(self) -> bool:
335
+ """QCOW2 supports snapshots."""
336
+ return True
337
+
338
+ @staticmethod
339
+ def __default_options():
340
+ return {
341
+ 'security_model': 'none',
342
+ 'writeout': 'immediate',
343
+ }
344
+
345
+ class RawStorageDevice(FileBasedStorageDevice):
346
+ """Raw storage device without snapshot support."""
347
+
348
+ def get_type(self) -> str:
349
+ """Get storage type."""
350
+ return "raw"
351
+
352
+ def supports_snapshots(self) -> bool:
353
+ """Raw storage does not support snapshots."""
354
+ return False
355
+
356
+
357
+ class VirtFSStorageDevice(BaseStorageDevice):
358
+ """VirtFS (9p) storage device for folder sharing."""
359
+
360
+ VALID_SECURITY_MODELS = {
361
+ "passthrough",
362
+ "mapped-xattr",
363
+ "mapped-file",
364
+ "none",
365
+ }
366
+
367
+ def __init__(self, config: Dict[str, Any], vm_id: str, index: int):
368
+ """Initialize VirtFS storage device."""
369
+ super().__init__(config, vm_id, index)
370
+
371
+ self.share_path = config.get("path", "./share")
372
+ self.options = config.get("options", {})
373
+ self.mount_tag = self.options.get("mount_tag", self.name)
374
+ self.fs_id = self.options.get("id", self.name)
375
+ self.readonly = self.options.get("readonly", False)
376
+
377
+ # Validate configuration
378
+ self._validate_security_model()
379
+ self._validate_share_path()
380
+
381
+ def get_type(self) -> str:
382
+ """Get storage type."""
383
+ return "virtfs"
384
+
385
+ def supports_snapshots(self) -> bool:
386
+ """VirtFS does not support snapshots."""
387
+ return False
388
+
389
+ def _validate_security_model(self) -> None:
390
+ """Validate security_model is one of the supported values."""
391
+ security_model = self.options.get("security_model", "passthrough")
392
+ if security_model not in self.VALID_SECURITY_MODELS:
393
+ raise ValueError(
394
+ f"Invalid security_model '{security_model}'. "
395
+ f"Valid models: {', '.join(sorted(self.VALID_SECURITY_MODELS))}"
396
+ )
397
+
398
+ def _validate_share_path(self) -> None:
399
+ """Validate share_path exists, is accessible, and is safe to share."""
400
+ share_path = Path(self.share_path)
401
+
402
+ # Security: Check for dangerous paths
403
+ # Canonicalize path to resolve symlinks and relative paths
404
+ try:
405
+ canonical_path = share_path.resolve()
406
+ except (OSError, RuntimeError) as e:
407
+ raise ValueError(
408
+ f"Cannot resolve VirtFS share path {share_path}: {e}"
409
+ )
410
+
411
+ # Define dangerous paths that should not be shared
412
+ dangerous_paths = {
413
+ Path("/"),
414
+ Path("/etc"),
415
+ Path("/sys"),
416
+ Path("/proc"),
417
+ Path("/dev"),
418
+ Path("/root"),
419
+ Path("/boot"),
420
+ Path("/var"),
421
+ Path("/usr"),
422
+ Path("/bin"),
423
+ Path("/sbin"),
424
+ Path("/lib"),
425
+ Path("/lib64"),
426
+ }
427
+
428
+ # Check if canonical path matches or is parent of any dangerous path
429
+ for dangerous in dangerous_paths:
430
+ try:
431
+ dangerous_canonical = dangerous.resolve()
432
+ # Check if share path is the dangerous path or a parent of it
433
+ if canonical_path == dangerous_canonical:
434
+ raise ValueError(
435
+ f"Refusing to share dangerous system path: {canonical_path}. "
436
+ f"Sharing {dangerous} would expose critical system files to the VM."
437
+ )
438
+ # Check if any dangerous path is within the share path
439
+ try:
440
+ dangerous_canonical.relative_to(canonical_path)
441
+ raise ValueError(
442
+ f"Refusing to share {canonical_path} because it contains "
443
+ f"dangerous system path {dangerous}. This would expose critical "
444
+ f"system files to the VM."
445
+ )
446
+ except ValueError:
447
+ # relative_to raises ValueError if not a subpath - this is
448
+ # good
449
+ pass
450
+ except (OSError, RuntimeError):
451
+ # Can't resolve dangerous path - skip this check
452
+ pass
453
+
454
+ # Warn if symlink (path is different from canonical path)
455
+ if canonical_path != share_path.resolve():
456
+ LOG.warning(
457
+ f"VirtFS share path {share_path} is a symlink to {canonical_path}. "
458
+ f"The resolved path will be shared."
459
+ )
460
+
461
+ # Use canonical path for remaining checks
462
+ share_path = canonical_path
463
+
464
+ # Check if path exists
465
+ if not share_path.exists():
466
+ LOG.warning(
467
+ f"VirtFS share path does not exist: {share_path}. "
468
+ "It will be created when VM starts."
469
+ )
470
+ return
471
+
472
+ # Check if it's a directory
473
+ if not share_path.is_dir():
474
+ raise ValueError(
475
+ f"VirtFS share path must be a directory: {share_path}"
476
+ )
477
+
478
+ # Check if readable
479
+ if not os.access(share_path, os.R_OK):
480
+ raise ValueError(
481
+ f"VirtFS share path is not readable: {share_path}"
482
+ )
483
+
484
+ # Check if writable (unless readonly mode)
485
+ if not self.readonly and not os.access(share_path, os.W_OK):
486
+ raise ValueError(
487
+ f"VirtFS share path is not writable: {share_path}. "
488
+ "Set readonly=true in options if read-only access is intended."
489
+ )
490
+
491
+ def create_if_needed(self) -> None:
492
+ """Ensure share directory exists."""
493
+ share_dir = Path(self.share_path)
494
+ try:
495
+ share_dir.mkdir(parents=True, exist_ok=True)
496
+ LOG.debug(f"VirtFS share directory ready: {share_dir}")
497
+ except Exception as e:
498
+ LOG.warning(f"Could not create VirtFS directory {share_dir}: {e}")
499
+
500
+ def get_qemu_args(self) -> List[str]:
501
+ """Get QEMU arguments for VirtFS."""
502
+ security_model = self.options.get("security_model", "passthrough")
503
+
504
+ # Build VirtFS arguments starting with required fields
505
+ virtfs_args = (
506
+ f"local,path={self.share_path},mount_tag={self.mount_tag},"
507
+ f"security_model={security_model},id={self.fs_id}"
508
+ )
45
509
 
46
- def size(self) -> int:
47
- r = cmd(
48
- f"qemu-img info {self._path} | grep 'virtual size' | "
49
- "grep -o '(.*)' | grep -o '[0-9]*'")
50
- return int(r.stdout)
510
+ # Add readonly if specified
511
+ if self.readonly:
512
+ virtfs_args += ",readonly=on"
513
+
514
+ # Add any additional options from config
515
+ for key, value in self.options.items():
516
+ if key not in ["mount_tag", "id", "security_model", "readonly"]:
517
+ virtfs_args += f",{key}={value}"
518
+
519
+ return ["-virtfs", virtfs_args]
520
+
521
+ def get_info(self) -> Dict[str, Any]:
522
+ """Get VirtFS information."""
523
+ info = super().get_info()
524
+ info.update(
525
+ {
526
+ "share_path": self.share_path,
527
+ "mount_tag": self.mount_tag,
528
+ "fs_id": self.fs_id,
529
+ "exists": Path(self.share_path).exists(),
530
+ }
531
+ )
532
+ return info
51
533
 
52
- def __call__(self) -> str:
53
- argument = benedict({'drive': {'file': self._path}})
54
- if 'options' in self._config:
55
- argument.drive.merge(self._config.options)
56
534
 
57
- return parse_args([argument])
535
+ def validate_storage_config(storage_configs: List[Dict[str, Any]]) -> None:
536
+ """
537
+ Validate storage configuration without creating device objects.
58
538
 
59
- def _fix_suffix(self, path: Path | str) -> Path:
60
- path = Path(path).resolve()
61
- if path.suffix == '':
62
- path = path.parent / (path.name + '.' + self._config.type)
63
- return path
539
+ This allows early validation during config parsing to fail fast
540
+ with clear errors before VM creation. Called from ConfigParser.
64
541
 
542
+ Args:
543
+ storage_configs: List of storage configuration dictionaries
65
544
 
66
- class RawDrive(FileDrive):
545
+ Raises:
546
+ ValueError: If storage configuration is invalid
67
547
  """
68
- Drive in RAW format, no COW snapshots
548
+ if not isinstance(storage_configs, list):
549
+ raise ValueError("Storage configuration must be a list")
550
+
551
+ for index, config in enumerate(storage_configs):
552
+ if not isinstance(config, dict):
553
+ raise ValueError(f"Storage {index}: Configuration must be a dictionary")
554
+
555
+ storage_type = config.get("type", "qcow2").lower()
556
+
557
+ # Validate storage type
558
+ supported_types = ["qcow2", "raw", "virtfs"]
559
+ if storage_type not in supported_types:
560
+ raise ValueError(
561
+ f"Storage {index}: Unknown type '{storage_type}'. "
562
+ f"Supported types: {', '.join(supported_types)}"
563
+ )
564
+
565
+ # Validate type-specific config
566
+ if storage_type in ("qcow2", "raw"):
567
+ # Validate size format (if provided)
568
+ if "size" in config:
569
+ size = config["size"]
570
+ size_pattern = r"^\d+[KMGT]?$"
571
+ if not re.match(size_pattern, str(size), re.IGNORECASE):
572
+ raise ValueError(
573
+ f"Storage {index}: Invalid size format '{size}'. "
574
+ "Expected format: number[KMGT] (e.g., 10G, 512M)"
575
+ )
576
+
577
+ # Validate interface (if provided)
578
+ if "interface" in config:
579
+ interface = config["interface"]
580
+ valid_interfaces = {"virtio", "sata", "ide", "scsi", "none"}
581
+ if interface not in valid_interfaces:
582
+ raise ValueError(
583
+ f"Storage {index}: Invalid interface '{interface}'. "
584
+ f"Valid interfaces: {', '.join(sorted(valid_interfaces))}"
585
+ )
586
+
587
+ elif storage_type == "virtfs":
588
+ # Validate security model (if provided)
589
+ options = config.get("options", {})
590
+ if "security_model" in options:
591
+ security_model = options["security_model"]
592
+ valid_security_models = {
593
+ "passthrough",
594
+ "mapped-xattr",
595
+ "mapped-file",
596
+ "none",
597
+ }
598
+ if security_model not in valid_security_models:
599
+ raise ValueError(
600
+ f"Storage {index}: Invalid security_model '{security_model}'. "
601
+ f"Valid models: {', '.join(sorted(valid_security_models))}"
602
+ )
603
+
604
+ # Validate path is provided
605
+ if "path" not in config:
606
+ raise ValueError(
607
+ f"Storage {index}: VirtFS storage requires 'path' field"
608
+ )
609
+
610
+
611
+ class StorageManager:
612
+ """
613
+ Manages VM storage devices with extensible type system.
614
+
615
+ Provides unified interface for different storage types and integrates
616
+ with snapshot functionality.
617
+
618
+ # NOTE: Good - plugin architecture with device type registry allows easy
619
+ # addition of new storage types without modifying existing code.
620
+ # Just implement BaseStorageDevice and register.
621
+ # NOTE: Storage validation happens both at config parse time
622
+ # (validate_storage_config)
623
+ # and at device creation time (device __init__). Early validation provides
624
+ # clear errors before VM creation. Device validation includes runtime
625
+ # checks
626
+ # like path resolution and disk space.
69
627
  """
70
628
 
71
- def __init__(self, path: Path,
72
- *args, **kwargs):
73
- self._path = self._fix_suffix(path)
74
- self._config = benedict(kwargs)
629
+ # Registry of storage device types
630
+ _device_types: Dict[str, Type[BaseStorageDevice]] = {
631
+ "qcow2": QCOW2StorageDevice,
632
+ "raw": RawStorageDevice,
633
+ "virtfs": VirtFSStorageDevice,
634
+ }
75
635
 
76
- self._path.parent.mkdir(parents=True, exist_ok=True)
77
- if not self._path.exists():
78
- LOG.info(f'Creating RawDrive on path {self._path}')
79
- cmd(f"qemu-img create -f raw {self._path} {kwargs['size']}")
80
- else:
81
- LOG.info(f'Collecting RawDrive on path {self._path}')
636
+ def __init__(self, vm_id: str):
637
+ """
638
+ Initialize storage manager for a VM.
82
639
 
83
- def clean(self) -> None:
84
- LOG.info(f'Recreating {self._config.type} drive on path {self._path}')
85
- size = self.size()
86
- cmd(f"rm -f {self._path}")
87
- cmd(f"qemu-img create -f {self._config.type} {self._path} {size}")
640
+ Args:
641
+ vm_id: VM identifier
642
+ """
643
+ self.vm_id = vm_id
644
+ self.devices: List[BaseStorageDevice] = []
88
645
 
646
+ @classmethod
647
+ def register_device_type(
648
+ cls, type_name: str, device_class: Type[BaseStorageDevice]
649
+ ):
650
+ """
651
+ Register a new storage device type.
89
652
 
90
- class QCOW2Drive(FileDrive):
91
- """
92
- Drive in QCOW2 format
93
- """
653
+ Args:
654
+ type_name: Storage type name (e.g., 'nvme', 'scsi')
655
+ device_class: Storage device class
656
+ """
657
+ cls._device_types[type_name.lower()] = device_class
658
+ LOG.debug(f"Registered storage device type: {type_name}")
94
659
 
95
- def __init__(self,
96
- path: Path,
97
- *args, **kwargs):
98
- self._path = path
99
- self._config = benedict(kwargs)
100
-
101
- self._path = self._fix_suffix(path)
102
- self._path.parent.mkdir(parents=True, exist_ok=True)
103
-
104
- self.__create()
105
-
106
- def __create(self):
107
- if not self._path.exists():
108
- if 'backing' in self._config:
109
- self._config.backing = Path(self._config.backing).resolve()
110
- LOG.info(f'Creating QCOW2Drive at {self._path} '
111
- f'using backing image at {self._config.backing}')
112
- r = cmd(f"qemu-img create -f qcow2 {self._path} "
113
- f"-b {self._config.backing} -F qcow2")
114
- else:
115
- LOG.info(f'Creating QCOW2Drive on path {self._path}')
116
- r = cmd("qemu-img create -f qcow2 "
117
- f"{self._path} {self._config.size}")
118
- if r.rc > 0:
119
- raise DriveError(f"Error on creating QCOW2Drive:\n{r}")
120
- else:
121
- LOG.info(f'Collecting QCOW2Drive on path {self._path}')
122
-
123
- def clean(self) -> None:
124
- LOG.info(f'Recreating {self._config.type} drive on path {self._path}')
125
- cmd(f"rm -f {self._path}")
126
- self.__create()
127
-
128
- def snapshot(self, name: str, overwrite=False) -> None:
129
- """
130
- create or revert to internal image snapshot
131
- If overwrite stated - existing snapshot deleted and created
132
- If not stated - existing snapshot loaded
133
- """
134
- if name in self.snapshots:
135
- if overwrite:
136
- LOG.info(f'Recreating snapshot {
137
- name} QCOW2Drive on path {self._path}')
138
- cmd(f"qemu-img snapshot {self._path} -d {name}")
139
- cmd(f"qemu-img snapshot {self._path} -c {name}")
140
- else:
141
- LOG.info(f'Reverting QCOW2Drive on path {
142
- self._path} to snapshot {name}')
143
- cmd(f"qemu-img snapshot {self._path} -a {name}")
144
- else:
145
- LOG.info(f'Creating snapshot {
146
- name} QCOW2Drive on path {self._path}')
147
- cmd(f"qemu-img snapshot {self._path} -c {name}")
148
-
149
- @property
150
- def snapshots(self):
151
- return cmd("qemu-img snapshot {path} -l | awk 'NR>2 {{print $2}}'"
152
- .format(path=self._path)).stdout.split('\n')
153
-
154
- @property
155
- def backing_chain(self) -> list[Path]:
156
- """
157
- Backing chain as list of paths
158
- """
159
- paths = cmd(f"qemu-img info --backing-chain {self._path} "
160
- "| grep image | awk '{{print $2}}'").stdout.split('\n')
161
- paths.reverse()
162
- paths = [str(Path(x)) for x in paths]
163
- return paths
164
-
165
-
166
- DRIVE_TYPES = {
167
- 'raw': RawDrive,
168
- 'qcow2': QCOW2Drive,
169
- }
170
-
171
-
172
- def Drive(*args, **kwargs):
173
- """
174
- Drive factory. Returns IDrive object according to arguments
175
- Also checks that size is stated in case if drive doesn't exist
176
- """
177
- LOG.debug(f'Drive factory: creating with arguments {kwargs}')
660
+ @classmethod
661
+ def get_supported_types(cls) -> List[str]:
662
+ """Get list of supported storage types."""
663
+ return list(cls._device_types.keys())
178
664
 
179
- kwargs['path'] = Path(kwargs['path'])
665
+ def add_storage_from_config(self, storage_configs: List[Dict[str, Any]]):
666
+ """
667
+ Add storage devices from configuration list.
180
668
 
181
- if (not kwargs['path'].exists()
182
- and 'size' not in kwargs
183
- and 'backing' not in kwargs):
184
- raise DriveError(
185
- f"Drive on {kwargs['path']} doesn't exists "
186
- "and size or backing not stated"
187
- )
669
+ Args:
670
+ storage_configs: List of storage configuration dictionaries
671
+ """
672
+ for index, config in enumerate(storage_configs):
673
+ storage_type = config.get("type", "qcow2").lower()
674
+
675
+ if storage_type not in self._device_types:
676
+ LOG.warning(
677
+ f"Unknown storage type '{storage_type}', "
678
+ f"defaulting to qcow2. Supported types: "
679
+ f"{', '.join(self.get_supported_types())}"
680
+ )
681
+ storage_type = "qcow2"
682
+
683
+ device_class = self._device_types[storage_type]
684
+ device = device_class(config, self.vm_id, index)
685
+ self.devices.append(device)
686
+
687
+ def create_storage_files(self):
688
+ """Create storage files for all devices that need them."""
689
+ for device in self.devices:
690
+ try:
691
+ device.create_if_needed()
692
+ except StorageError as e:
693
+ LOG.error(f"Failed to create storage for {device.name}: {e}")
694
+ raise
695
+
696
+ def get_qemu_args(self) -> List[List[str]]:
697
+ """
698
+ Get all QEMU arguments for storage devices.
699
+
700
+ Returns:
701
+ List of QEMU argument lists (each device returns a list of args)
702
+ """
703
+ args = []
704
+ for device in self.devices:
705
+ args.append(device.get_qemu_args())
706
+ return args
188
707
 
189
- if 'type' not in kwargs:
190
- raise DriveError("Drive type not stated")
708
+ def get_device_by_name(self, name: str) -> Optional[BaseStorageDevice]:
709
+ """
710
+ Get storage device by name.
711
+
712
+ Args:
713
+ name: Device name
714
+
715
+ Returns:
716
+ Storage device or None if not found
717
+ """
718
+ for device in self.devices:
719
+ if device.name == name:
720
+ return device
721
+ return None
722
+
723
+ def get_snapshot_capable_devices(self) -> List[BaseStorageDevice]:
724
+ """Get list of devices that support snapshots."""
725
+ return [
726
+ device for device in self.devices if device.supports_snapshots()
727
+ ]
728
+
729
+ def get_storage_info(self) -> List[Dict[str, Any]]:
730
+ """
731
+ Get information about all storage devices.
191
732
 
192
- t = kwargs['type'].lower()
193
- if t in DRIVE_TYPES:
194
- return DRIVE_TYPES[t](*args, **kwargs)
195
- else:
196
- raise DriveError("Invalid/unsupported drive type")
733
+ Returns:
734
+ List of device information dictionaries
735
+ """
736
+ return [device.get_info() for device in self.devices]