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/state.py
ADDED
@@ -0,0 +1,958 @@
|
|
1
|
+
"""
|
2
|
+
State Manager
|
3
|
+
|
4
|
+
Manages VM instance state using SQLite backend with XDG directory compliance.
|
5
|
+
Provides persistent storage for VM definitions, process tracking, and session management.
|
6
|
+
|
7
|
+
"""
|
8
|
+
|
9
|
+
# NOTE: Current name vs id design is optimal: UUID primary keys for internal
|
10
|
+
# use,
|
11
|
+
# human-readable names for CLI. This provides both uniqueness and usability.
|
12
|
+
import json
|
13
|
+
import os
|
14
|
+
import re
|
15
|
+
import shutil
|
16
|
+
import sqlite3
|
17
|
+
import time
|
18
|
+
import uuid
|
19
|
+
from contextlib import contextmanager
|
20
|
+
from dataclasses import dataclass
|
21
|
+
from datetime import datetime
|
22
|
+
from pathlib import Path
|
23
|
+
from typing import Any, Dict, Generator, List, Optional
|
24
|
+
|
25
|
+
from benedict import benedict
|
26
|
+
|
27
|
+
from .constants import Database as DBConstants
|
28
|
+
from .constants import Intervals, Retries, Timeouts
|
29
|
+
from .exceptions import DatabaseError, DatabaseLockError, StateError
|
30
|
+
from .logger import LOG
|
31
|
+
|
32
|
+
# Optional dependency - imported inline with fallback
|
33
|
+
try:
|
34
|
+
import psutil
|
35
|
+
|
36
|
+
PSUTIL_AVAILABLE = True
|
37
|
+
except ImportError:
|
38
|
+
PSUTIL_AVAILABLE = False
|
39
|
+
# psutil is optional - only needed for enhanced process validation
|
40
|
+
# Install with: pip install psutil
|
41
|
+
# Without psutil, basic PID tracking still works but lacks ownership checks
|
42
|
+
|
43
|
+
|
44
|
+
# Legacy exception alias (backward compatibility)
|
45
|
+
StateManagerError = StateError
|
46
|
+
|
47
|
+
|
48
|
+
# Database migration registry
|
49
|
+
# Add new migrations here as functions that take a sqlite3.Connection parameter
|
50
|
+
# Example:
|
51
|
+
# def migrate_v1_to_v2(conn: sqlite3.Connection) -> None:
|
52
|
+
# """Add new column to vm_instances table."""
|
53
|
+
# conn.execute("ALTER TABLE vm_instances ADD COLUMN new_field TEXT")
|
54
|
+
#
|
55
|
+
|
56
|
+
|
57
|
+
def migrate_v1_to_v2(conn: sqlite3.Connection) -> None:
|
58
|
+
"""Add runner_pid column for per-VM process architecture."""
|
59
|
+
# Check if column already exists
|
60
|
+
cursor = conn.execute("PRAGMA table_info(vm_instances)")
|
61
|
+
columns = [row[1] for row in cursor.fetchall()]
|
62
|
+
|
63
|
+
if "runner_pid" not in columns:
|
64
|
+
conn.execute("ALTER TABLE vm_instances ADD COLUMN runner_pid INTEGER")
|
65
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_vm_runner_pid ON vm_instances(runner_pid)")
|
66
|
+
|
67
|
+
|
68
|
+
MIGRATIONS: Dict[int, callable] = {
|
69
|
+
2: migrate_v1_to_v2,
|
70
|
+
# Future migrations will be added here
|
71
|
+
# 3: migrate_v2_to_v3,
|
72
|
+
}
|
73
|
+
|
74
|
+
|
75
|
+
@dataclass
|
76
|
+
class VMInstance:
|
77
|
+
"""Represents a VM instance in the state database."""
|
78
|
+
|
79
|
+
id: str
|
80
|
+
name: str
|
81
|
+
config_path: Optional[str]
|
82
|
+
config_data: Dict[str, Any]
|
83
|
+
status: str # 'created', 'running', 'stopped', 'failed'
|
84
|
+
pid: Optional[int] # QEMU process PID
|
85
|
+
runner_pid: Optional[int] = None # VM runner process PID (per-VM architecture)
|
86
|
+
socket_path: Optional[str] = None
|
87
|
+
created_at: Optional[datetime] = None
|
88
|
+
updated_at: Optional[datetime] = None
|
89
|
+
|
90
|
+
|
91
|
+
class XDGDirectories:
|
92
|
+
"""
|
93
|
+
XDG Base Directory Specification compliance for MAQET directories.
|
94
|
+
|
95
|
+
Provides proper directory structure following Linux standards.
|
96
|
+
"""
|
97
|
+
|
98
|
+
def __init__(self, custom_data_dir: Optional[Path] = None) -> None:
|
99
|
+
"""
|
100
|
+
Initialize XDG directories.
|
101
|
+
|
102
|
+
Args:
|
103
|
+
custom_data_dir: Override default data directory (for testing)
|
104
|
+
"""
|
105
|
+
self._custom_data_dir = custom_data_dir
|
106
|
+
self._ensure_directories()
|
107
|
+
|
108
|
+
@property
|
109
|
+
def data_dir(self) -> Path:
|
110
|
+
"""Get XDG data directory (~/.local/share/maqet/)."""
|
111
|
+
if self._custom_data_dir:
|
112
|
+
return self._custom_data_dir
|
113
|
+
base = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
114
|
+
return Path(base) / "maqet"
|
115
|
+
|
116
|
+
@property
|
117
|
+
def runtime_dir(self) -> Path:
|
118
|
+
"""Get XDG runtime directory (/run/user/1000/maqet/)."""
|
119
|
+
if self._custom_data_dir:
|
120
|
+
# Use custom runtime dir adjacent to custom data dir
|
121
|
+
return self._custom_data_dir.parent / "runtime"
|
122
|
+
base = os.getenv("XDG_RUNTIME_DIR", f"/tmp/maqet-{os.getuid()}")
|
123
|
+
return Path(base) / "maqet"
|
124
|
+
|
125
|
+
@property
|
126
|
+
def config_dir(self) -> Path:
|
127
|
+
"""Get XDG config directory (~/.config/maqet/)."""
|
128
|
+
if self._custom_data_dir:
|
129
|
+
# Use custom config dir adjacent to custom data dir
|
130
|
+
return self._custom_data_dir.parent / "config"
|
131
|
+
base = os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
|
132
|
+
return Path(base) / "maqet"
|
133
|
+
|
134
|
+
@property
|
135
|
+
def database_path(self) -> Path:
|
136
|
+
"""Get database file path."""
|
137
|
+
return self.data_dir / "instances.db"
|
138
|
+
|
139
|
+
@property
|
140
|
+
def vm_definitions_dir(self) -> Path:
|
141
|
+
"""Get VM definitions directory."""
|
142
|
+
return self.data_dir / "vm-definitions"
|
143
|
+
|
144
|
+
@property
|
145
|
+
def sockets_dir(self) -> Path:
|
146
|
+
"""Get QMP sockets directory."""
|
147
|
+
return self.runtime_dir / "sockets"
|
148
|
+
|
149
|
+
@property
|
150
|
+
def pids_dir(self) -> Path:
|
151
|
+
"""Get PID files directory."""
|
152
|
+
return self.runtime_dir / "pids"
|
153
|
+
|
154
|
+
@property
|
155
|
+
def locks_dir(self) -> Path:
|
156
|
+
"""Get lock files directory for VM start operations."""
|
157
|
+
return self.runtime_dir / "locks"
|
158
|
+
|
159
|
+
@property
|
160
|
+
def templates_dir(self) -> Path:
|
161
|
+
"""Get VM templates directory."""
|
162
|
+
return self.config_dir / "templates"
|
163
|
+
|
164
|
+
def _ensure_directories(self) -> None:
|
165
|
+
"""Create XDG-compliant directory structure."""
|
166
|
+
dirs = [
|
167
|
+
self.data_dir,
|
168
|
+
self.vm_definitions_dir,
|
169
|
+
self.runtime_dir,
|
170
|
+
self.sockets_dir,
|
171
|
+
self.pids_dir,
|
172
|
+
self.locks_dir,
|
173
|
+
self.config_dir,
|
174
|
+
self.templates_dir,
|
175
|
+
]
|
176
|
+
|
177
|
+
for directory in dirs:
|
178
|
+
directory.mkdir(parents=True, exist_ok=True)
|
179
|
+
|
180
|
+
|
181
|
+
class StateManager:
|
182
|
+
"""
|
183
|
+
Manages VM instance state with SQLite backend.
|
184
|
+
|
185
|
+
Provides persistent storage for VM definitions, process tracking,
|
186
|
+
and session management following XDG directory standards.
|
187
|
+
|
188
|
+
# NOTE: Good - XDG compliance ensures proper file locations across
|
189
|
+
# different
|
190
|
+
# Linux distributions and user configurations. Respects user preferences.
|
191
|
+
# NOTE: Cleanup of stale socket/PID files IS implemented in
|
192
|
+
# cleanup_dead_processes(),
|
193
|
+
# which runs on startup and cleans orphaned processes.
|
194
|
+
|
195
|
+
# ARCHITECTURAL DECISION: Database Migration Strategy
|
196
|
+
# ================================================
|
197
|
+
# Current: No migration system - schema changes require manual database
|
198
|
+
# deletion
|
199
|
+
# Impact: Users must delete ~/.local/share/maqet/instances.db after
|
200
|
+
# upgrades that change schema
|
201
|
+
#
|
202
|
+
# Future Migration Strategy (when needed):
|
203
|
+
# 1. Version table to track schema version (e.g., schema_version INTEGER)
|
204
|
+
# 2. Migration scripts for each schema change:
|
205
|
+
# - Option A: Embedded Python migrations (simple, no dependencies)
|
206
|
+
# - Option B: alembic/yoyo-migrations (robust, industry-standard)
|
207
|
+
# 3. Automatic backup before migration
|
208
|
+
# (~/.local/share/maqet/backups/instances.db.YYYYMMDD)
|
209
|
+
# 4. Rollback capability for failed migrations
|
210
|
+
# 5. Migration status logging (success/failure/skipped)
|
211
|
+
#
|
212
|
+
# Decision: Deferred until schema stabilizes (currently in rapid
|
213
|
+
# development)
|
214
|
+
# Workaround: Document breaking changes in release notes, instruct users to
|
215
|
+
# delete DB
|
216
|
+
# Timeline: Implement before 1.0 release when API/schema stabilizes
|
217
|
+
"""
|
218
|
+
|
219
|
+
def __init__(self, custom_data_dir: Optional[str] = None):
|
220
|
+
"""
|
221
|
+
Initialize state manager.
|
222
|
+
|
223
|
+
Args:
|
224
|
+
custom_data_dir: Override default data directory
|
225
|
+
"""
|
226
|
+
# Pass custom_data_dir to XDGDirectories instead of modifying environment
|
227
|
+
custom_path = Path(custom_data_dir) if custom_data_dir else None
|
228
|
+
self.xdg = XDGDirectories(custom_data_dir=custom_path)
|
229
|
+
self._init_database()
|
230
|
+
|
231
|
+
# Run database migrations if needed
|
232
|
+
self.run_migrations()
|
233
|
+
|
234
|
+
# Automatically clean up dead processes and stale files on startup
|
235
|
+
cleaned = self.cleanup_dead_processes()
|
236
|
+
if cleaned:
|
237
|
+
LOG.debug(f"Startup cleanup completed: {len(cleaned)} VMs cleaned")
|
238
|
+
|
239
|
+
def _init_database(self) -> None:
|
240
|
+
"""Initialize SQLite database with required tables."""
|
241
|
+
with self._get_connection() as conn:
|
242
|
+
conn.executescript(
|
243
|
+
"""
|
244
|
+
CREATE TABLE IF NOT EXISTS vm_instances (
|
245
|
+
id TEXT PRIMARY KEY,
|
246
|
+
name TEXT UNIQUE NOT NULL,
|
247
|
+
config_path TEXT,
|
248
|
+
config_data TEXT NOT NULL,
|
249
|
+
status TEXT NOT NULL DEFAULT 'created',
|
250
|
+
pid INTEGER,
|
251
|
+
socket_path TEXT,
|
252
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
253
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
254
|
+
);
|
255
|
+
|
256
|
+
CREATE INDEX IF NOT EXISTS idx_vm_name ON vm_instances(name);
|
257
|
+
CREATE INDEX IF NOT EXISTS idx_vm_status ON vm_instances(status);
|
258
|
+
CREATE INDEX IF NOT EXISTS idx_vm_pid ON vm_instances(pid);
|
259
|
+
|
260
|
+
CREATE TRIGGER IF NOT EXISTS update_timestamp
|
261
|
+
AFTER UPDATE ON vm_instances
|
262
|
+
BEGIN
|
263
|
+
UPDATE vm_instances SET updated_at = CURRENT_TIMESTAMP
|
264
|
+
WHERE id = NEW.id;
|
265
|
+
END;
|
266
|
+
|
267
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
268
|
+
version INTEGER PRIMARY KEY,
|
269
|
+
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
270
|
+
description TEXT
|
271
|
+
);
|
272
|
+
"""
|
273
|
+
)
|
274
|
+
|
275
|
+
# Initialize schema version if this is a new database
|
276
|
+
current_version = self.get_schema_version()
|
277
|
+
if current_version == 0:
|
278
|
+
self._set_schema_version(1, "Initial schema")
|
279
|
+
|
280
|
+
@contextmanager
|
281
|
+
def _get_connection(self) -> Generator[sqlite3.Connection, None, None]:
|
282
|
+
"""Get database connection with proper error handling.
|
283
|
+
|
284
|
+
Uses WAL (Write-Ahead Logging) mode to reduce lock contention and
|
285
|
+
implements retry logic for transient "database is locked" errors.
|
286
|
+
|
287
|
+
# TODO(architect, 2025-10-10): [PERF] SQLite connection management has race conditions
|
288
|
+
# Context: Retry logic helps but doesn't eliminate all races. No connection pooling,
|
289
|
+
# opens new connection per operation. Issue #6 in ARCHITECTURAL_REVIEW.md.
|
290
|
+
#
|
291
|
+
# Recommendation: Implement connection pooling with Queue(maxsize=10) and threading.Lock
|
292
|
+
# Alternative: Switch to PostgreSQL for production use
|
293
|
+
#
|
294
|
+
# Effort: Medium (3-4 days)
|
295
|
+
# Priority: High (should fix for 1.0)
|
296
|
+
# See: ARCHITECTURAL_REVIEW.md Issue #6
|
297
|
+
|
298
|
+
Yields:
|
299
|
+
sqlite3.Connection: Database connection
|
300
|
+
|
301
|
+
Raises:
|
302
|
+
DatabaseError: If connection fails after retries
|
303
|
+
DatabaseLockError: If database remains locked after all retries
|
304
|
+
"""
|
305
|
+
max_retries = Retries.DB_OPERATION
|
306
|
+
retry_delay = Intervals.DB_RETRY_BASE
|
307
|
+
|
308
|
+
conn = None
|
309
|
+
for attempt in range(max_retries):
|
310
|
+
try:
|
311
|
+
conn = sqlite3.connect(
|
312
|
+
str(self.xdg.database_path),
|
313
|
+
timeout=Timeouts.DB_LOCK,
|
314
|
+
)
|
315
|
+
conn.row_factory = sqlite3.Row
|
316
|
+
|
317
|
+
# Enable WAL mode and pragmas for better concurrent access
|
318
|
+
# WAL allows readers and writers to work simultaneously
|
319
|
+
conn.execute(f"PRAGMA journal_mode={DBConstants.JOURNAL_MODE}")
|
320
|
+
conn.execute(f"PRAGMA synchronous={DBConstants.SYNCHRONOUS}")
|
321
|
+
conn.execute(f"PRAGMA foreign_keys={DBConstants.FOREIGN_KEYS}")
|
322
|
+
|
323
|
+
break # Connection successful
|
324
|
+
|
325
|
+
except sqlite3.OperationalError as e:
|
326
|
+
if (
|
327
|
+
"database is locked" in str(e).lower()
|
328
|
+
and attempt < max_retries - 1
|
329
|
+
):
|
330
|
+
# Retry with exponential backoff
|
331
|
+
wait_time = retry_delay * (2**attempt)
|
332
|
+
from maqet.logger import LOG
|
333
|
+
|
334
|
+
LOG.debug(
|
335
|
+
f"Database locked (attempt {
|
336
|
+
attempt + 1}/{max_retries}), "
|
337
|
+
f"retrying in {wait_time:.2f}s"
|
338
|
+
)
|
339
|
+
time.sleep(wait_time)
|
340
|
+
continue
|
341
|
+
else:
|
342
|
+
# Final attempt failed or non-lock error
|
343
|
+
if "database is locked" in str(e).lower():
|
344
|
+
raise DatabaseLockError(
|
345
|
+
f"Database locked after {max_retries} attempts "
|
346
|
+
f"({Timeouts.DB_LOCK}s timeout per attempt). "
|
347
|
+
f"Another process may be holding a lock."
|
348
|
+
)
|
349
|
+
else:
|
350
|
+
raise DatabaseError(
|
351
|
+
f"Failed to connect to database: {e}"
|
352
|
+
)
|
353
|
+
|
354
|
+
if conn is None:
|
355
|
+
raise DatabaseError("Failed to establish database connection")
|
356
|
+
|
357
|
+
try:
|
358
|
+
yield conn
|
359
|
+
conn.commit()
|
360
|
+
except Exception:
|
361
|
+
conn.rollback()
|
362
|
+
raise
|
363
|
+
finally:
|
364
|
+
conn.close()
|
365
|
+
|
366
|
+
def create_vm(
|
367
|
+
self,
|
368
|
+
name: str,
|
369
|
+
config_data: Dict[str, Any],
|
370
|
+
config_path: Optional[str] = None,
|
371
|
+
) -> str:
|
372
|
+
"""
|
373
|
+
Create a new VM instance.
|
374
|
+
|
375
|
+
Args:
|
376
|
+
name: VM name (must be unique)
|
377
|
+
config_data: VM configuration dictionary
|
378
|
+
config_path: Optional path to config file
|
379
|
+
|
380
|
+
Returns:
|
381
|
+
VM instance ID
|
382
|
+
|
383
|
+
Raises:
|
384
|
+
StateManagerError: If validation fails or DB operation fails
|
385
|
+
"""
|
386
|
+
# Validation: Check VM name
|
387
|
+
if not name or not name.strip():
|
388
|
+
raise StateManagerError("VM name cannot be empty")
|
389
|
+
|
390
|
+
# Check name length (prevent filesystem issues with very long names)
|
391
|
+
if len(name) > 255:
|
392
|
+
raise StateManagerError(
|
393
|
+
f"VM name too long ({len(
|
394
|
+
name)} chars). Maximum is 255 characters."
|
395
|
+
)
|
396
|
+
|
397
|
+
# Validate name contains only safe characters (alphanumeric, dash,
|
398
|
+
# underscore, dot)
|
399
|
+
if not re.match(r"^[a-zA-Z0-9._-]+$", name):
|
400
|
+
raise StateManagerError(
|
401
|
+
f"VM name '{name}' contains invalid characters. "
|
402
|
+
f"Only alphanumeric, dash (-), underscore (_), and dot (.) are allowed."
|
403
|
+
)
|
404
|
+
|
405
|
+
# Check for name conflicts BEFORE attempting insert
|
406
|
+
# Provides clearer error message than SQLite IntegrityError
|
407
|
+
existing_vm = self.get_vm(name)
|
408
|
+
if existing_vm:
|
409
|
+
raise StateManagerError(
|
410
|
+
f"VM with name '{name}' already exists (ID: {existing_vm.id}). "
|
411
|
+
f"Use 'maqet rm {name}' to remove it first, "
|
412
|
+
f"or choose a different name."
|
413
|
+
)
|
414
|
+
|
415
|
+
# Validation: Check config_data size to prevent DB bloat
|
416
|
+
config_json = benedict(config_data).to_json()
|
417
|
+
config_size = len(config_json.encode("utf-8"))
|
418
|
+
max_config_size = 10 * 1024 * 1024 # 10MB limit
|
419
|
+
|
420
|
+
if config_size > max_config_size:
|
421
|
+
raise StateManagerError(
|
422
|
+
f"Configuration data too large ({config_size} bytes, max {
|
423
|
+
max_config_size}). "
|
424
|
+
f"Consider reducing storage device configurations or using external config files."
|
425
|
+
)
|
426
|
+
|
427
|
+
vm_id = str(uuid.uuid4())
|
428
|
+
|
429
|
+
try:
|
430
|
+
with self._get_connection() as conn:
|
431
|
+
conn.execute(
|
432
|
+
"""
|
433
|
+
INSERT INTO vm_instances (id, name, config_path, config_data, status)
|
434
|
+
VALUES (?, ?, ?, ?, 'created')
|
435
|
+
""",
|
436
|
+
(vm_id, name, config_path, config_json),
|
437
|
+
)
|
438
|
+
except sqlite3.IntegrityError as e:
|
439
|
+
raise StateManagerError(f"VM with name '{name}' already exists")
|
440
|
+
except sqlite3.OperationalError as e:
|
441
|
+
# Handle disk full, database locked, etc.
|
442
|
+
error_msg = str(e).lower()
|
443
|
+
if "disk" in error_msg or "space" in error_msg:
|
444
|
+
raise StateManagerError(
|
445
|
+
f"Cannot create VM: Disk full or insufficient space. "
|
446
|
+
f"Free up disk space and try again. Error: {e}"
|
447
|
+
)
|
448
|
+
elif "locked" in error_msg:
|
449
|
+
raise StateManagerError(
|
450
|
+
f"Cannot create VM: Database is locked. "
|
451
|
+
f"Another process may be accessing it. Retry in a moment. Error: {
|
452
|
+
e}"
|
453
|
+
)
|
454
|
+
else:
|
455
|
+
raise StateManagerError(
|
456
|
+
f"Database error while creating VM: {e}"
|
457
|
+
)
|
458
|
+
except Exception as e:
|
459
|
+
# Log unexpected errors with context
|
460
|
+
from ..logger import LOG
|
461
|
+
|
462
|
+
LOG.error(
|
463
|
+
f"Unexpected error creating VM '{name}' (ID: {vm_id}): {
|
464
|
+
type(e).__name__}: {e}"
|
465
|
+
)
|
466
|
+
raise StateManagerError(f"Failed to create VM: {e}")
|
467
|
+
|
468
|
+
return vm_id
|
469
|
+
|
470
|
+
def get_vm(self, identifier: str) -> Optional[VMInstance]:
|
471
|
+
"""
|
472
|
+
Get VM instance by ID or name.
|
473
|
+
|
474
|
+
Args:
|
475
|
+
identifier: VM ID or name
|
476
|
+
|
477
|
+
Returns:
|
478
|
+
VM instance or None if not found
|
479
|
+
"""
|
480
|
+
# NOTE: SECURITY - Uses parameterized queries, safe from SQL injection
|
481
|
+
with self._get_connection() as conn:
|
482
|
+
# Try by ID first, then by name
|
483
|
+
row = conn.execute(
|
484
|
+
"SELECT * FROM vm_instances WHERE id = ? OR name = ?",
|
485
|
+
(identifier, identifier),
|
486
|
+
).fetchone()
|
487
|
+
|
488
|
+
if row:
|
489
|
+
return self._row_to_vm_instance(row)
|
490
|
+
|
491
|
+
return None
|
492
|
+
|
493
|
+
def list_vms(
|
494
|
+
self, status_filter: Optional[str] = None
|
495
|
+
) -> List[VMInstance]:
|
496
|
+
"""
|
497
|
+
List all VM instances.
|
498
|
+
|
499
|
+
Args:
|
500
|
+
status_filter: Optional status to filter by
|
501
|
+
|
502
|
+
Returns:
|
503
|
+
List of VM instances
|
504
|
+
"""
|
505
|
+
with self._get_connection() as conn:
|
506
|
+
if status_filter:
|
507
|
+
rows = conn.execute(
|
508
|
+
"SELECT * FROM vm_instances WHERE status = ? ORDER BY created_at",
|
509
|
+
(status_filter,),
|
510
|
+
).fetchall()
|
511
|
+
else:
|
512
|
+
rows = conn.execute(
|
513
|
+
"SELECT * FROM vm_instances ORDER BY created_at"
|
514
|
+
).fetchall()
|
515
|
+
|
516
|
+
return [self._row_to_vm_instance(row) for row in rows]
|
517
|
+
|
518
|
+
def _validate_pid_ownership(self, pid: int) -> None:
|
519
|
+
"""
|
520
|
+
Validate that PID belongs to current user and is a QEMU process.
|
521
|
+
|
522
|
+
Args:
|
523
|
+
pid: Process ID to validate
|
524
|
+
|
525
|
+
Raises:
|
526
|
+
ValueError: If PID is invalid, not owned by user, or not a QEMU process
|
527
|
+
"""
|
528
|
+
# psutil is optional - skip PID validation if not available
|
529
|
+
if not PSUTIL_AVAILABLE:
|
530
|
+
LOG.debug(
|
531
|
+
"psutil not available, skipping PID ownership validation. "
|
532
|
+
"Install psutil for enhanced security checks."
|
533
|
+
)
|
534
|
+
return
|
535
|
+
|
536
|
+
try:
|
537
|
+
process = psutil.Process(pid)
|
538
|
+
|
539
|
+
# Check if process is owned by current user (Unix only)
|
540
|
+
if hasattr(os, "getuid"):
|
541
|
+
current_uid = os.getuid()
|
542
|
+
process_uid = process.uids().real
|
543
|
+
|
544
|
+
if process_uid != current_uid:
|
545
|
+
raise ValueError(
|
546
|
+
f"PID {pid} is owned by UID {
|
547
|
+
process_uid}, not current user (UID {current_uid}). "
|
548
|
+
f"Refusing to manage process owned by another user for security reasons."
|
549
|
+
)
|
550
|
+
|
551
|
+
# Verify it's a QEMU process
|
552
|
+
cmdline = process.cmdline()
|
553
|
+
if not cmdline:
|
554
|
+
raise ValueError(
|
555
|
+
f"PID {
|
556
|
+
pid} has no command line. Cannot verify it's a QEMU process."
|
557
|
+
)
|
558
|
+
|
559
|
+
# Check if command contains 'qemu' (case insensitive)
|
560
|
+
is_qemu = any("qemu" in arg.lower() for arg in cmdline)
|
561
|
+
if not is_qemu:
|
562
|
+
LOG.warning(
|
563
|
+
f"PID {pid} does not appear to be a QEMU process. "
|
564
|
+
f"Command: {' '.join(cmdline[:3])}..."
|
565
|
+
)
|
566
|
+
# We warn but don't block, as the binary might be renamed
|
567
|
+
|
568
|
+
except psutil.NoSuchProcess:
|
569
|
+
raise ValueError(f"PID {pid} does not exist")
|
570
|
+
except psutil.AccessDenied:
|
571
|
+
raise ValueError(
|
572
|
+
f"Access denied when checking PID {
|
573
|
+
pid}. Cannot verify ownership."
|
574
|
+
)
|
575
|
+
|
576
|
+
def update_vm_status(
|
577
|
+
self,
|
578
|
+
identifier: str,
|
579
|
+
status: str,
|
580
|
+
pid: Optional[int] = None,
|
581
|
+
runner_pid: Optional[int] = None,
|
582
|
+
socket_path: Optional[str] = None,
|
583
|
+
) -> bool:
|
584
|
+
"""
|
585
|
+
Update VM status and process information.
|
586
|
+
|
587
|
+
Args:
|
588
|
+
identifier: VM ID or name
|
589
|
+
status: New status
|
590
|
+
pid: QEMU process ID (if running)
|
591
|
+
runner_pid: VM runner process ID (if running)
|
592
|
+
socket_path: QMP socket path (if running)
|
593
|
+
|
594
|
+
Returns:
|
595
|
+
True if updated, False if VM not found
|
596
|
+
|
597
|
+
Raises:
|
598
|
+
ValueError: If PID ownership validation fails
|
599
|
+
|
600
|
+
Note: Performance optimization for WHERE clause (id OR name) deferred to future
|
601
|
+
when query performance becomes an issue. Current approach is simpler and works
|
602
|
+
well for typical VM counts (<1000).
|
603
|
+
"""
|
604
|
+
# Security: Validate PID ownership if provided
|
605
|
+
if pid is not None:
|
606
|
+
try:
|
607
|
+
self._validate_pid_ownership(pid)
|
608
|
+
except ValueError as e:
|
609
|
+
from ..logger import LOG
|
610
|
+
|
611
|
+
LOG.error(f"PID validation failed: {e}")
|
612
|
+
raise
|
613
|
+
|
614
|
+
if runner_pid is not None:
|
615
|
+
try:
|
616
|
+
self._validate_pid_ownership(runner_pid)
|
617
|
+
except ValueError as e:
|
618
|
+
from ..logger import LOG
|
619
|
+
|
620
|
+
LOG.error(f"Runner PID validation failed: {e}")
|
621
|
+
raise
|
622
|
+
|
623
|
+
with self._get_connection() as conn:
|
624
|
+
cursor = conn.execute(
|
625
|
+
"""
|
626
|
+
UPDATE vm_instances
|
627
|
+
SET status = ?, pid = ?, runner_pid = ?, socket_path = ?
|
628
|
+
WHERE id = ? OR name = ?
|
629
|
+
""",
|
630
|
+
(status, pid, runner_pid, socket_path, identifier, identifier),
|
631
|
+
)
|
632
|
+
|
633
|
+
return cursor.rowcount > 0
|
634
|
+
|
635
|
+
def remove_vm(self, identifier: str) -> bool:
|
636
|
+
"""
|
637
|
+
Remove VM instance from database.
|
638
|
+
|
639
|
+
Args:
|
640
|
+
identifier: VM ID or name
|
641
|
+
|
642
|
+
Returns:
|
643
|
+
True if removed, False if VM not found
|
644
|
+
"""
|
645
|
+
with self._get_connection() as conn:
|
646
|
+
cursor = conn.execute(
|
647
|
+
"DELETE FROM vm_instances WHERE id = ? OR name = ?",
|
648
|
+
(identifier, identifier),
|
649
|
+
)
|
650
|
+
|
651
|
+
return cursor.rowcount > 0
|
652
|
+
|
653
|
+
def cleanup_dead_processes(self) -> List[str]:
|
654
|
+
"""
|
655
|
+
Clean up VMs with dead processes and stale files.
|
656
|
+
|
657
|
+
This method:
|
658
|
+
- Checks all VMs marked as 'running' in database
|
659
|
+
- Verifies their processes are actually alive
|
660
|
+
- Updates status to 'stopped' for dead processes
|
661
|
+
- Removes stale socket and PID files
|
662
|
+
|
663
|
+
Returns:
|
664
|
+
List of VM IDs that were cleaned up
|
665
|
+
"""
|
666
|
+
cleaned_up = []
|
667
|
+
running_vms = self.list_vms(status_filter="running")
|
668
|
+
|
669
|
+
for vm in running_vms:
|
670
|
+
if vm.pid and not self._is_process_alive(vm.pid):
|
671
|
+
LOG.info(
|
672
|
+
f"Cleaning up dead VM {
|
673
|
+
vm.name} (ID: {vm.id}, PID: {vm.pid})"
|
674
|
+
)
|
675
|
+
|
676
|
+
# Update database status
|
677
|
+
self.update_vm_status(
|
678
|
+
vm.id, "stopped", pid=None, socket_path=None
|
679
|
+
)
|
680
|
+
|
681
|
+
# Clean up stale socket file
|
682
|
+
socket_path = self.get_socket_path(vm.id)
|
683
|
+
if socket_path.exists():
|
684
|
+
try:
|
685
|
+
socket_path.unlink()
|
686
|
+
LOG.debug(f"Removed stale socket: {socket_path}")
|
687
|
+
except OSError as e:
|
688
|
+
LOG.warning(
|
689
|
+
f"Failed to remove stale socket {socket_path}: {e}"
|
690
|
+
)
|
691
|
+
|
692
|
+
# Clean up stale PID file
|
693
|
+
pid_path = self.get_pid_path(vm.id)
|
694
|
+
if pid_path.exists():
|
695
|
+
try:
|
696
|
+
pid_path.unlink()
|
697
|
+
LOG.debug(f"Removed stale PID file: {pid_path}")
|
698
|
+
except OSError as e:
|
699
|
+
LOG.warning(
|
700
|
+
f"Failed to remove stale PID file {pid_path}: {e}"
|
701
|
+
)
|
702
|
+
|
703
|
+
cleaned_up.append(vm.id)
|
704
|
+
|
705
|
+
if cleaned_up:
|
706
|
+
LOG.info(
|
707
|
+
f"Cleaned up {len(cleaned_up)} dead VM(s): {
|
708
|
+
', '.join(cleaned_up)}"
|
709
|
+
)
|
710
|
+
|
711
|
+
return cleaned_up
|
712
|
+
|
713
|
+
def _is_process_alive(self, pid: int) -> bool:
|
714
|
+
"""Check if process is still running."""
|
715
|
+
try:
|
716
|
+
os.kill(pid, 0) # Send signal 0 to check if process exists
|
717
|
+
return True
|
718
|
+
except (OSError, ProcessLookupError):
|
719
|
+
return False
|
720
|
+
|
721
|
+
def _row_to_vm_instance(self, row: sqlite3.Row) -> VMInstance:
|
722
|
+
"""Convert database row to VMInstance object."""
|
723
|
+
config_data = benedict.from_json(row["config_data"])
|
724
|
+
|
725
|
+
# Handle runner_pid which may not exist in older schemas
|
726
|
+
runner_pid = row["runner_pid"] if "runner_pid" in row.keys() else None
|
727
|
+
|
728
|
+
return VMInstance(
|
729
|
+
id=row["id"],
|
730
|
+
name=row["name"],
|
731
|
+
config_path=row["config_path"],
|
732
|
+
config_data=config_data,
|
733
|
+
status=row["status"],
|
734
|
+
pid=row["pid"],
|
735
|
+
runner_pid=runner_pid,
|
736
|
+
socket_path=row["socket_path"],
|
737
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
738
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
739
|
+
)
|
740
|
+
|
741
|
+
def get_socket_path(self, vm_id: str) -> Path:
|
742
|
+
"""Get QMP socket path for VM."""
|
743
|
+
return self.xdg.sockets_dir / f"{vm_id}.sock"
|
744
|
+
|
745
|
+
def get_pid_path(self, vm_id: str) -> Path:
|
746
|
+
"""Get PID file path for VM."""
|
747
|
+
return self.xdg.pids_dir / f"{vm_id}.pid"
|
748
|
+
|
749
|
+
def get_lock_path(self, vm_id: str) -> Path:
|
750
|
+
"""Get lock file path for VM start operations."""
|
751
|
+
return self.xdg.locks_dir / f"{vm_id}.lock"
|
752
|
+
|
753
|
+
def update_vm_config(
|
754
|
+
self, identifier: str, new_config: Dict[str, Any]
|
755
|
+
) -> bool:
|
756
|
+
"""
|
757
|
+
Update VM configuration in database.
|
758
|
+
|
759
|
+
Args:
|
760
|
+
identifier: VM name or ID
|
761
|
+
new_config: New configuration data
|
762
|
+
|
763
|
+
Returns:
|
764
|
+
True if update successful, False if VM not found
|
765
|
+
|
766
|
+
Raises:
|
767
|
+
StateManagerError: If database operation fails
|
768
|
+
"""
|
769
|
+
try:
|
770
|
+
with self._get_connection() as conn:
|
771
|
+
cursor = conn.cursor()
|
772
|
+
|
773
|
+
# Update config_data in database
|
774
|
+
cursor.execute(
|
775
|
+
"""
|
776
|
+
UPDATE vm_instances
|
777
|
+
SET config_data = ?
|
778
|
+
WHERE name = ? OR id = ?
|
779
|
+
""",
|
780
|
+
(json.dumps(new_config), identifier, identifier),
|
781
|
+
)
|
782
|
+
|
783
|
+
if cursor.rowcount == 0:
|
784
|
+
return False
|
785
|
+
|
786
|
+
conn.commit()
|
787
|
+
return True
|
788
|
+
|
789
|
+
except sqlite3.Error as e:
|
790
|
+
raise StateManagerError(f"Database error updating VM config: {e}")
|
791
|
+
except Exception as e:
|
792
|
+
raise StateManagerError(f"Error updating VM config: {e}")
|
793
|
+
|
794
|
+
def get_schema_version(self) -> int:
|
795
|
+
"""
|
796
|
+
Get current database schema version.
|
797
|
+
|
798
|
+
Returns:
|
799
|
+
Current schema version, or 0 if schema_version table doesn't exist
|
800
|
+
"""
|
801
|
+
try:
|
802
|
+
with self._get_connection() as conn:
|
803
|
+
result = conn.execute(
|
804
|
+
"SELECT MAX(version) FROM schema_version"
|
805
|
+
).fetchone()
|
806
|
+
return result[0] if result[0] is not None else 0
|
807
|
+
except sqlite3.OperationalError:
|
808
|
+
# schema_version table doesn't exist (pre-migration database)
|
809
|
+
return 0
|
810
|
+
|
811
|
+
def _set_schema_version(self, version: int, description: str = "") -> None:
|
812
|
+
"""
|
813
|
+
Set database schema version.
|
814
|
+
|
815
|
+
Args:
|
816
|
+
version: Schema version number
|
817
|
+
description: Optional description of the schema version
|
818
|
+
"""
|
819
|
+
with self._get_connection() as conn:
|
820
|
+
conn.execute(
|
821
|
+
"""
|
822
|
+
INSERT OR REPLACE INTO schema_version (version, description, applied_at)
|
823
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
824
|
+
""",
|
825
|
+
(version, description),
|
826
|
+
)
|
827
|
+
|
828
|
+
def backup_database(self) -> Path:
|
829
|
+
"""
|
830
|
+
Create timestamped database backup.
|
831
|
+
|
832
|
+
Returns:
|
833
|
+
Path to backup file
|
834
|
+
|
835
|
+
Raises:
|
836
|
+
StateManagerError: If backup fails
|
837
|
+
"""
|
838
|
+
try:
|
839
|
+
# Create backups directory
|
840
|
+
backup_dir = self.xdg.data_dir / "backups"
|
841
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
842
|
+
|
843
|
+
# Generate timestamped backup filename
|
844
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
845
|
+
schema_version = self.get_schema_version()
|
846
|
+
backup_filename = f"instances_v{schema_version}_{timestamp}.db"
|
847
|
+
backup_path = backup_dir / backup_filename
|
848
|
+
|
849
|
+
# Copy database file
|
850
|
+
shutil.copy2(self.xdg.database_path, backup_path)
|
851
|
+
|
852
|
+
LOG.info(f"Database backed up to {backup_path}")
|
853
|
+
return backup_path
|
854
|
+
|
855
|
+
except Exception as e:
|
856
|
+
raise StateManagerError(f"Failed to backup database: {e}")
|
857
|
+
|
858
|
+
def run_migrations(self) -> None:
|
859
|
+
"""
|
860
|
+
Run all pending database migrations.
|
861
|
+
|
862
|
+
Migrations are applied sequentially from current version to target version.
|
863
|
+
Database is automatically backed up before applying migrations.
|
864
|
+
|
865
|
+
Raises:
|
866
|
+
StateManagerError: If migration fails
|
867
|
+
"""
|
868
|
+
current_version = self.get_schema_version()
|
869
|
+
target_version = max(MIGRATIONS.keys()) if MIGRATIONS else 1
|
870
|
+
|
871
|
+
if current_version >= target_version:
|
872
|
+
LOG.debug(
|
873
|
+
f"Database schema is up to date (version {current_version})"
|
874
|
+
)
|
875
|
+
return
|
876
|
+
|
877
|
+
LOG.info(
|
878
|
+
f"Migrating database from version {
|
879
|
+
current_version} to {target_version}"
|
880
|
+
)
|
881
|
+
|
882
|
+
# Backup database before migration
|
883
|
+
try:
|
884
|
+
backup_path = self.backup_database()
|
885
|
+
LOG.info(f"Pre-migration backup created: {backup_path}")
|
886
|
+
except Exception as e:
|
887
|
+
LOG.error(f"Failed to create backup before migration: {e}")
|
888
|
+
raise StateManagerError(
|
889
|
+
f"Cannot proceed with migration without backup: {e}"
|
890
|
+
)
|
891
|
+
|
892
|
+
# Apply migrations sequentially
|
893
|
+
for version in range(current_version + 1, target_version + 1):
|
894
|
+
if version in MIGRATIONS:
|
895
|
+
try:
|
896
|
+
self._apply_migration(version, MIGRATIONS[version])
|
897
|
+
except Exception as e:
|
898
|
+
error_msg = (
|
899
|
+
f"Migration to version {version} failed: {e}\n"
|
900
|
+
f"Database backup is available at: {backup_path}\n"
|
901
|
+
f"To rollback, restore the backup:\n"
|
902
|
+
f" cp {backup_path} {self.xdg.database_path}"
|
903
|
+
)
|
904
|
+
LOG.error(error_msg)
|
905
|
+
raise StateManagerError(error_msg)
|
906
|
+
|
907
|
+
LOG.info(
|
908
|
+
f"Database migration completed successfully to version {
|
909
|
+
target_version}"
|
910
|
+
)
|
911
|
+
|
912
|
+
def _apply_migration(self, version: int, migration_func: callable) -> None:
|
913
|
+
"""
|
914
|
+
Apply a single database migration.
|
915
|
+
|
916
|
+
Args:
|
917
|
+
version: Target schema version
|
918
|
+
migration_func: Migration function to execute
|
919
|
+
|
920
|
+
Raises:
|
921
|
+
StateManagerError: If migration fails
|
922
|
+
"""
|
923
|
+
LOG.info(f"Applying migration to version {version}")
|
924
|
+
|
925
|
+
try:
|
926
|
+
with self._get_connection() as conn:
|
927
|
+
# Start transaction
|
928
|
+
conn.execute("BEGIN IMMEDIATE")
|
929
|
+
|
930
|
+
try:
|
931
|
+
# Execute migration function
|
932
|
+
migration_func(conn)
|
933
|
+
|
934
|
+
# Update schema version
|
935
|
+
conn.execute(
|
936
|
+
"""
|
937
|
+
INSERT INTO schema_version (version, description, applied_at)
|
938
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
939
|
+
""",
|
940
|
+
(version, f"Migration to version {version}"),
|
941
|
+
)
|
942
|
+
|
943
|
+
# Commit transaction
|
944
|
+
conn.commit()
|
945
|
+
LOG.info(
|
946
|
+
f"Migration to version {
|
947
|
+
version} completed successfully"
|
948
|
+
)
|
949
|
+
|
950
|
+
except Exception as e:
|
951
|
+
# Rollback on error
|
952
|
+
conn.rollback()
|
953
|
+
raise StateManagerError(
|
954
|
+
f"Migration to version {version} failed: {e}"
|
955
|
+
)
|
956
|
+
|
957
|
+
except Exception as e:
|
958
|
+
raise StateManagerError(f"Failed to apply migration: {e}")
|