maqet 0.0.1.3__py3-none-any.whl → 0.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. maqet/__init__.py +50 -6
  2. maqet/__main__.py +96 -0
  3. maqet/__version__.py +3 -0
  4. maqet/api/__init__.py +35 -0
  5. maqet/api/decorators.py +184 -0
  6. maqet/api/metadata.py +147 -0
  7. maqet/api/registry.py +182 -0
  8. maqet/cli.py +71 -0
  9. maqet/config/__init__.py +26 -0
  10. maqet/config/merger.py +237 -0
  11. maqet/config/parser.py +198 -0
  12. maqet/config/validators.py +519 -0
  13. maqet/config_handlers.py +684 -0
  14. maqet/constants.py +200 -0
  15. maqet/exceptions.py +226 -0
  16. maqet/formatters.py +294 -0
  17. maqet/generators/__init__.py +12 -0
  18. maqet/generators/base_generator.py +101 -0
  19. maqet/generators/cli_generator.py +635 -0
  20. maqet/generators/python_generator.py +247 -0
  21. maqet/generators/rest_generator.py +58 -0
  22. maqet/handlers/__init__.py +12 -0
  23. maqet/handlers/base.py +108 -0
  24. maqet/handlers/init.py +147 -0
  25. maqet/handlers/stage.py +196 -0
  26. maqet/ipc/__init__.py +29 -0
  27. maqet/ipc/retry.py +265 -0
  28. maqet/ipc/runner_client.py +285 -0
  29. maqet/ipc/unix_socket_server.py +239 -0
  30. maqet/logger.py +160 -55
  31. maqet/machine.py +884 -0
  32. maqet/managers/__init__.py +7 -0
  33. maqet/managers/qmp_manager.py +333 -0
  34. maqet/managers/snapshot_coordinator.py +327 -0
  35. maqet/managers/vm_manager.py +683 -0
  36. maqet/maqet.py +1120 -0
  37. maqet/os_interactions.py +46 -0
  38. maqet/process_spawner.py +395 -0
  39. maqet/qemu_args.py +76 -0
  40. maqet/qmp/__init__.py +10 -0
  41. maqet/qmp/commands.py +92 -0
  42. maqet/qmp/keyboard.py +311 -0
  43. maqet/qmp/qmp.py +17 -0
  44. maqet/snapshot.py +473 -0
  45. maqet/state.py +958 -0
  46. maqet/storage.py +702 -162
  47. maqet/validation/__init__.py +9 -0
  48. maqet/validation/config_validator.py +170 -0
  49. maqet/vm_runner.py +523 -0
  50. maqet-0.0.5.dist-info/METADATA +237 -0
  51. maqet-0.0.5.dist-info/RECORD +55 -0
  52. {maqet-0.0.1.3.dist-info → maqet-0.0.5.dist-info}/WHEEL +1 -1
  53. maqet-0.0.5.dist-info/entry_points.txt +2 -0
  54. maqet-0.0.5.dist-info/licenses/LICENSE +21 -0
  55. {maqet-0.0.1.3.dist-info → maqet-0.0.5.dist-info}/top_level.txt +0 -1
  56. maqet/core.py +0 -395
  57. maqet/functions.py +0 -104
  58. maqet-0.0.1.3.dist-info/METADATA +0 -104
  59. maqet-0.0.1.3.dist-info/RECORD +0 -33
  60. qemu/machine/__init__.py +0 -36
  61. qemu/machine/console_socket.py +0 -142
  62. qemu/machine/machine.py +0 -954
  63. qemu/machine/py.typed +0 -0
  64. qemu/machine/qtest.py +0 -191
  65. qemu/qmp/__init__.py +0 -59
  66. qemu/qmp/error.py +0 -50
  67. qemu/qmp/events.py +0 -717
  68. qemu/qmp/legacy.py +0 -319
  69. qemu/qmp/message.py +0 -209
  70. qemu/qmp/models.py +0 -146
  71. qemu/qmp/protocol.py +0 -1057
  72. qemu/qmp/py.typed +0 -0
  73. qemu/qmp/qmp_client.py +0 -655
  74. qemu/qmp/qmp_shell.py +0 -618
  75. qemu/qmp/qmp_tui.py +0 -655
  76. qemu/qmp/util.py +0 -219
  77. qemu/utils/__init__.py +0 -162
  78. qemu/utils/accel.py +0 -84
  79. qemu/utils/py.typed +0 -0
  80. qemu/utils/qemu_ga_client.py +0 -323
  81. qemu/utils/qom.py +0 -273
  82. qemu/utils/qom_common.py +0 -175
  83. qemu/utils/qom_fuse.py +0 -207
maqet/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}")