__init__
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user