maqet 0.0.1.3__py3-none-any.whl → 0.0.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- maqet/__init__.py +50 -6
- maqet/__main__.py +96 -0
- maqet/__version__.py +3 -0
- maqet/api/__init__.py +35 -0
- maqet/api/decorators.py +184 -0
- maqet/api/metadata.py +147 -0
- maqet/api/registry.py +182 -0
- maqet/cli.py +71 -0
- maqet/config/__init__.py +26 -0
- maqet/config/merger.py +237 -0
- maqet/config/parser.py +198 -0
- maqet/config/validators.py +519 -0
- maqet/config_handlers.py +684 -0
- maqet/constants.py +200 -0
- maqet/exceptions.py +226 -0
- maqet/formatters.py +294 -0
- maqet/generators/__init__.py +12 -0
- maqet/generators/base_generator.py +101 -0
- maqet/generators/cli_generator.py +635 -0
- maqet/generators/python_generator.py +247 -0
- maqet/generators/rest_generator.py +58 -0
- maqet/handlers/__init__.py +12 -0
- maqet/handlers/base.py +108 -0
- maqet/handlers/init.py +147 -0
- maqet/handlers/stage.py +196 -0
- maqet/ipc/__init__.py +29 -0
- maqet/ipc/retry.py +265 -0
- maqet/ipc/runner_client.py +285 -0
- maqet/ipc/unix_socket_server.py +239 -0
- maqet/logger.py +160 -55
- maqet/machine.py +884 -0
- maqet/managers/__init__.py +7 -0
- maqet/managers/qmp_manager.py +333 -0
- maqet/managers/snapshot_coordinator.py +327 -0
- maqet/managers/vm_manager.py +683 -0
- maqet/maqet.py +1120 -0
- maqet/os_interactions.py +46 -0
- maqet/process_spawner.py +395 -0
- maqet/qemu_args.py +76 -0
- maqet/qmp/__init__.py +10 -0
- maqet/qmp/commands.py +92 -0
- maqet/qmp/keyboard.py +311 -0
- maqet/qmp/qmp.py +17 -0
- maqet/snapshot.py +473 -0
- maqet/state.py +958 -0
- maqet/storage.py +702 -162
- maqet/validation/__init__.py +9 -0
- maqet/validation/config_validator.py +170 -0
- maqet/vm_runner.py +523 -0
- maqet-0.0.5.dist-info/METADATA +237 -0
- maqet-0.0.5.dist-info/RECORD +55 -0
- {maqet-0.0.1.3.dist-info → maqet-0.0.5.dist-info}/WHEEL +1 -1
- maqet-0.0.5.dist-info/entry_points.txt +2 -0
- maqet-0.0.5.dist-info/licenses/LICENSE +21 -0
- {maqet-0.0.1.3.dist-info → maqet-0.0.5.dist-info}/top_level.txt +0 -1
- maqet/core.py +0 -395
- maqet/functions.py +0 -104
- maqet-0.0.1.3.dist-info/METADATA +0 -104
- maqet-0.0.1.3.dist-info/RECORD +0 -33
- qemu/machine/__init__.py +0 -36
- qemu/machine/console_socket.py +0 -142
- qemu/machine/machine.py +0 -954
- qemu/machine/py.typed +0 -0
- qemu/machine/qtest.py +0 -191
- qemu/qmp/__init__.py +0 -59
- qemu/qmp/error.py +0 -50
- qemu/qmp/events.py +0 -717
- qemu/qmp/legacy.py +0 -319
- qemu/qmp/message.py +0 -209
- qemu/qmp/models.py +0 -146
- qemu/qmp/protocol.py +0 -1057
- qemu/qmp/py.typed +0 -0
- qemu/qmp/qmp_client.py +0 -655
- qemu/qmp/qmp_shell.py +0 -618
- qemu/qmp/qmp_tui.py +0 -655
- qemu/qmp/util.py +0 -219
- qemu/utils/__init__.py +0 -162
- qemu/utils/accel.py +0 -84
- qemu/utils/py.typed +0 -0
- qemu/utils/qemu_ga_client.py +0 -323
- qemu/utils/qom.py +0 -273
- qemu/utils/qom_common.py +0 -175
- qemu/utils/qom_fuse.py +0 -207
maqet/storage.py
CHANGED
@@ -1,196 +1,736 @@
|
|
1
|
+
"""
|
2
|
+
Unified Storage Management System
|
1
3
|
|
2
|
-
|
3
|
-
|
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
|
-
|
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
|
-
|
28
|
-
|
29
|
-
|
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
|
34
|
-
"""
|
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
|
38
|
-
"""
|
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
|
-
|
42
|
-
|
43
|
-
|
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
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
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
|
-
|
535
|
+
def validate_storage_config(storage_configs: List[Dict[str, Any]]) -> None:
|
536
|
+
"""
|
537
|
+
Validate storage configuration without creating device objects.
|
58
538
|
|
59
|
-
|
60
|
-
|
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
|
-
|
545
|
+
Raises:
|
546
|
+
ValueError: If storage configuration is invalid
|
67
547
|
"""
|
68
|
-
|
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
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
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
|
-
|
77
|
-
|
78
|
-
|
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
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
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
|
-
|
91
|
-
|
92
|
-
|
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
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
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
|
-
|
665
|
+
def add_storage_from_config(self, storage_configs: List[Dict[str, Any]]):
|
666
|
+
"""
|
667
|
+
Add storage devices from configuration list.
|
180
668
|
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
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
|
-
|
190
|
-
|
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
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
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]
|