Add integration tests based on VM
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create and validate the one approved M9 VirtualBox VM; never start it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable, Sequence
|
||||
import uuid
|
||||
|
||||
import guard
|
||||
|
||||
|
||||
LAB_ROOT = guard.LAB_ROOT
|
||||
ISO_PATH = LAB_ROOT / guard.EXPECTED_ISO
|
||||
VM_NAME = guard.TARGET_NAME
|
||||
OS_TYPE = "Debian13_64"
|
||||
DISK_SIZE_MIB = 8192
|
||||
MEMORY_MIB = 1024
|
||||
CPUS = 1
|
||||
MAX_CAPTURE = 1024 * 1024
|
||||
TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
class CreateError(Exception):
|
||||
"""Creation could not safely complete; partial target is retained."""
|
||||
|
||||
|
||||
Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
|
||||
|
||||
|
||||
def run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
||||
process = subprocess.Popen(
|
||||
list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C"},
|
||||
)
|
||||
assert process.stdout is not None and process.stderr is not None
|
||||
output = {process.stdout: bytearray(), process.stderr: bytearray()}
|
||||
selector = selectors.DefaultSelector()
|
||||
for stream in output:
|
||||
os.set_blocking(stream.fileno(), False)
|
||||
selector.register(stream, selectors.EVENT_READ)
|
||||
deadline = time.monotonic() + TIMEOUT_SECONDS
|
||||
try:
|
||||
while selector.get_map():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise subprocess.TimeoutExpired(argv, TIMEOUT_SECONDS)
|
||||
for key, _ in selector.select(remaining):
|
||||
stream = key.fileobj
|
||||
chunk = os.read(stream.fileno(), min(65536, MAX_CAPTURE + 1 - len(output[stream])))
|
||||
if not chunk:
|
||||
selector.unregister(stream)
|
||||
continue
|
||||
output[stream].extend(chunk)
|
||||
if len(output[stream]) > MAX_CAPTURE:
|
||||
raise CreateError("VBoxManage output exceeded the creation limit")
|
||||
returncode = process.wait(timeout=max(0.0, deadline - time.monotonic()))
|
||||
except (OSError, subprocess.TimeoutExpired, CreateError):
|
||||
process.kill()
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
selector.close()
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
return subprocess.CompletedProcess(
|
||||
argv, returncode,
|
||||
bytes(output[process.stdout]).decode("utf-8", errors="replace"),
|
||||
bytes(output[process.stderr]).decode("utf-8", errors="replace"),
|
||||
)
|
||||
|
||||
|
||||
def _call(runner: Runner, argv: Sequence[str], *, created_uuid: str | None = None) -> str:
|
||||
try:
|
||||
result = runner(tuple(argv))
|
||||
except (OSError, subprocess.TimeoutExpired, CreateError) as exc:
|
||||
suffix = f"; retained partial VM {VM_NAME} ({created_uuid})" if created_uuid else ""
|
||||
raise CreateError(f"bounded VBoxManage call failed: {argv[1]}{suffix}") from exc
|
||||
if len(result.stdout) > MAX_CAPTURE or len(result.stderr) > MAX_CAPTURE:
|
||||
raise CreateError("VBoxManage output exceeded the creation limit")
|
||||
if result.returncode != 0:
|
||||
suffix = f"; retained partial VM {VM_NAME} ({created_uuid})" if created_uuid else ""
|
||||
raise CreateError(f"VBoxManage {argv[1]} failed with status {result.returncode}{suffix}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _check_lab(lab_root: Path, iso_path: Path, owner_uid: int) -> None:
|
||||
try:
|
||||
root_info = lab_root.lstat()
|
||||
except OSError as exc:
|
||||
raise CreateError("private lab root is unavailable") from exc
|
||||
if (not stat.S_ISDIR(root_info.st_mode) or stat.S_ISLNK(root_info.st_mode)
|
||||
or root_info.st_uid != owner_uid or stat.S_IMODE(root_info.st_mode) != 0o700):
|
||||
raise CreateError("lab root must be a real current-user directory with mode 0700")
|
||||
if iso_path.parent.resolve(strict=True) != lab_root.resolve(strict=True):
|
||||
raise CreateError("installer ISO must be directly inside the exact lab root")
|
||||
try:
|
||||
iso_info = iso_path.lstat()
|
||||
except OSError as exc:
|
||||
raise CreateError("pinned installer ISO is unavailable") from exc
|
||||
if not stat.S_ISREG(iso_info.st_mode) or stat.S_ISLNK(iso_info.st_mode):
|
||||
raise CreateError("installer ISO must be a regular non-symlink file")
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with iso_path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
except OSError as exc:
|
||||
raise CreateError("pinned installer ISO cannot be read") from exc
|
||||
if digest.hexdigest() != guard.EXPECTED_ISO_SHA256:
|
||||
raise CreateError("installer ISO SHA-256 does not match the pinned digest")
|
||||
for path, description in (
|
||||
(lab_root / guard.IDENTITY_FILE, "target identity manifest"),
|
||||
(lab_root / VM_NAME, "exact target VM path"),
|
||||
):
|
||||
try:
|
||||
path.lstat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
raise CreateError(f"could not inspect {description}; refusing creation") from exc
|
||||
if description == "target identity manifest":
|
||||
raise CreateError(f"{description} already exists; refusing overwrite")
|
||||
raise CreateError(f"{description} already exists; refusing creation")
|
||||
|
||||
|
||||
def _check_absent(runner: Runner) -> None:
|
||||
output = _call(runner, ("VBoxManage", "list", "vms"))
|
||||
names: set[str] = set()
|
||||
uuids: set[str] = set()
|
||||
for line in output.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line)
|
||||
if not match:
|
||||
raise CreateError("malformed VBoxManage inventory; refusing creation")
|
||||
name, raw_uuid = match.groups()
|
||||
try:
|
||||
vm_uuid = str(uuid.UUID(raw_uuid))
|
||||
except ValueError as exc:
|
||||
raise CreateError("malformed VBoxManage inventory UUID; refusing creation") from exc
|
||||
if name in names or vm_uuid in uuids:
|
||||
raise CreateError("duplicate VBoxManage inventory identity; refusing creation")
|
||||
names.add(name)
|
||||
uuids.add(vm_uuid)
|
||||
if VM_NAME in names:
|
||||
raise CreateError("exact target name is already registered; refusing creation")
|
||||
|
||||
|
||||
def _write_manifest(lab_root: Path, target_uuid: str, owner_uid: int) -> None:
|
||||
marker = f"{VM_NAME}:{target_uuid}"
|
||||
data = {
|
||||
"schema": 1,
|
||||
"name": VM_NAME,
|
||||
"owner_uid": owner_uid,
|
||||
"uuid": target_uuid,
|
||||
"host_marker": marker,
|
||||
}
|
||||
path = lab_root / guard.IDENTITY_FILE
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(path, flags, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as destination:
|
||||
os.fchmod(destination.fileno(), 0o600)
|
||||
json.dump(data, destination, sort_keys=True)
|
||||
destination.write("\n")
|
||||
destination.flush()
|
||||
os.fsync(destination.fileno())
|
||||
except OSError as exc:
|
||||
raise CreateError(f"could not write identity manifest; retained partial VM {VM_NAME} ({target_uuid})") from exc
|
||||
|
||||
|
||||
def create_target(
|
||||
*, lab_root: Path = LAB_ROOT,
|
||||
iso_path: Path = ISO_PATH,
|
||||
owner_uid: int | None = None,
|
||||
runner: Runner = run_command,
|
||||
) -> str:
|
||||
"""Preflight, create/configure the exact VM, and run the read-only guard."""
|
||||
owner_uid = os.getuid() if owner_uid is None else owner_uid
|
||||
try:
|
||||
_check_lab(lab_root, iso_path, owner_uid)
|
||||
_check_absent(runner)
|
||||
except OSError as exc:
|
||||
raise CreateError("creation preflight could not inspect the lab root or ISO") from exc
|
||||
|
||||
create_output = _call(runner, (
|
||||
"VBoxManage", "createvm", "--name", VM_NAME, "--ostype", OS_TYPE,
|
||||
"--basefolder", str(lab_root), "--register",
|
||||
))
|
||||
created_match = re.search(r"(?m)^UUID:\s*([0-9A-Fa-f-]{36})\s*$", create_output)
|
||||
target_uuid: str | None = None
|
||||
if created_match:
|
||||
try:
|
||||
target_uuid = str(uuid.UUID(created_match.group(1)))
|
||||
except ValueError:
|
||||
target_uuid = None
|
||||
try:
|
||||
info_text = _call(runner, (
|
||||
"VBoxManage", "showvminfo", target_uuid or VM_NAME, "--machinereadable",
|
||||
), created_uuid=target_uuid)
|
||||
info = guard._machine_values(info_text)
|
||||
if info.get("name") != VM_NAME or "UUID" not in info:
|
||||
raise CreateError(f"created VM identity is unreadable; retained partial VM {VM_NAME} ({target_uuid or 'UUID unavailable'})")
|
||||
reported_uuid = str(uuid.UUID(info["UUID"]))
|
||||
if target_uuid is not None and target_uuid != reported_uuid:
|
||||
raise CreateError(f"created VM UUID changed unexpectedly; retained partial VM {VM_NAME} ({target_uuid})")
|
||||
target_uuid = reported_uuid
|
||||
expected_cfg = lab_root / VM_NAME / f"{VM_NAME}.vbox"
|
||||
if Path(info.get("CfgFile", "")) != expected_cfg:
|
||||
raise CreateError(f"created VM path is unexpected; retained partial VM {VM_NAME} ({target_uuid})")
|
||||
|
||||
_call(runner, (
|
||||
"VBoxManage", "modifyvm", target_uuid,
|
||||
"--memory", str(MEMORY_MIB), "--cpus", str(CPUS),
|
||||
"--nic1", "nat", "--nic2", "none", "--nic3", "none", "--nic4", "none",
|
||||
"--nic5", "none", "--nic6", "none", "--nic7", "none", "--nic8", "none",
|
||||
"--audio", "none", "--usb", "off",
|
||||
"--boot1", "dvd", "--boot2", "disk", "--boot3", "none", "--boot4", "none",
|
||||
), created_uuid=target_uuid)
|
||||
disk = lab_root / VM_NAME / f"{VM_NAME}.vdi"
|
||||
_call(runner, (
|
||||
"VBoxManage", "createmedium", "disk", "--filename", str(disk),
|
||||
"--size", str(DISK_SIZE_MIB), "--format", "VDI", "--variant", "Standard",
|
||||
), created_uuid=target_uuid)
|
||||
_call(runner, (
|
||||
"VBoxManage", "storagectl", target_uuid, "--name", "SATA",
|
||||
"--add", "sata", "--controller", "IntelAhci",
|
||||
), created_uuid=target_uuid)
|
||||
_call(runner, (
|
||||
"VBoxManage", "storageattach", target_uuid, "--storagectl", "SATA",
|
||||
"--port", "0", "--device", "0", "--type", "hdd", "--medium", str(disk),
|
||||
), created_uuid=target_uuid)
|
||||
_call(runner, (
|
||||
"VBoxManage", "storageattach", target_uuid, "--storagectl", "SATA",
|
||||
"--port", "1", "--device", "0", "--type", "dvddrive", "--medium", str(iso_path),
|
||||
), created_uuid=target_uuid)
|
||||
_write_manifest(lab_root, target_uuid, owner_uid)
|
||||
_call(runner, (
|
||||
"VBoxManage", "modifyvm", target_uuid,
|
||||
"--description", f"{VM_NAME}:{target_uuid}",
|
||||
), created_uuid=target_uuid)
|
||||
try:
|
||||
guard.inspect_target(lab_root=lab_root, owner_uid=owner_uid, runner=runner)
|
||||
except (guard.GuardError, OSError) as exc:
|
||||
raise CreateError(f"final read-only guard failed; retained partial VM {VM_NAME} ({target_uuid})") from exc
|
||||
return target_uuid
|
||||
except CreateError as exc:
|
||||
if "retained partial" in str(exc):
|
||||
raise
|
||||
raise CreateError(f"{exc}; retained partial VM {VM_NAME} ({locals().get('target_uuid', 'UUID unavailable')})") from exc
|
||||
except (ValueError, KeyError, OSError) as exc:
|
||||
raise CreateError(f"creation identity/configuration failed; retained partial VM {VM_NAME} ({locals().get('target_uuid', 'UUID unavailable')})") from exc
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.parse_args(argv)
|
||||
try:
|
||||
target_uuid = create_target()
|
||||
except CreateError as exc:
|
||||
print(f"VM creation refused/stopped: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"Created and guarded {VM_NAME} ({target_uuid}); VM remains powered off")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user