92 lines
2.9 KiB
Python
92 lines
2.9 KiB
Python
"""Small, testable wrappers around required host-level operations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .config import Settings
|
|
from .errors import CommandError, UvmError
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CommandResult:
|
|
args: tuple[str, ...]
|
|
returncode: int
|
|
stdout: str = ""
|
|
stderr: str = ""
|
|
|
|
|
|
class CommandRunner:
|
|
"""Execute host commands while keeping command construction testable."""
|
|
|
|
def __init__(self, emit: Callable[[str], None] | None = print) -> None:
|
|
self._emit = emit
|
|
|
|
def run(
|
|
self,
|
|
command: Sequence[str | Path],
|
|
*,
|
|
check: bool = True,
|
|
capture: bool = False,
|
|
input_text: str | None = None,
|
|
sensitive: bool = False,
|
|
timeout: float | None = None,
|
|
) -> CommandResult:
|
|
args = tuple(str(part) for part in command)
|
|
if self._emit is not None:
|
|
self._emit(f"+ {shlex.join(args)}")
|
|
|
|
try:
|
|
completed = subprocess.run(
|
|
args,
|
|
check=False,
|
|
text=True,
|
|
input=input_text,
|
|
stdout=subprocess.PIPE if capture else None,
|
|
stderr=subprocess.PIPE if capture else None,
|
|
timeout=timeout,
|
|
)
|
|
except FileNotFoundError as error:
|
|
raise CommandError(f"required command was not found: {args[0]}") from error
|
|
except OSError as error:
|
|
raise CommandError(f"could not run {args[0]}: {error}") from error
|
|
except subprocess.TimeoutExpired as error:
|
|
raise CommandError(f"command timed out: {shlex.join(args)}") from error
|
|
|
|
result = CommandResult(
|
|
args=args,
|
|
returncode=completed.returncode,
|
|
stdout=completed.stdout or "",
|
|
stderr=completed.stderr or "",
|
|
)
|
|
if check and result.returncode != 0:
|
|
detail = "" if sensitive else result.stderr.strip() or result.stdout.strip()
|
|
suffix = f": {detail}" if detail else ""
|
|
raise CommandError(
|
|
f"command failed ({result.returncode}): {shlex.join(args)}{suffix}"
|
|
)
|
|
return result
|
|
|
|
|
|
def require_root() -> None:
|
|
if os.geteuid() != 0:
|
|
raise UvmError("this command needs root. Run it with sudo.")
|
|
|
|
|
|
def check_kvm() -> None:
|
|
kvm = Path("/dev/kvm")
|
|
if not kvm.exists():
|
|
raise UvmError("/dev/kvm does not exist. Enable hardware virtualization/KVM first.")
|
|
if not os.access(kvm, os.R_OK | os.W_OK):
|
|
raise UvmError("no read/write access to /dev/kvm. Run as root or grant KVM access.")
|
|
|
|
|
|
def ensure_data_directories(settings: Settings) -> None:
|
|
for path in (settings.base, settings.bin_dir, settings.images_dir, settings.vms_dir):
|
|
path.mkdir(parents=True, exist_ok=True)
|