Add integration tests based on VM
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare the one pinned M9 VM for unattended installation; never start it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable, Sequence
|
||||
|
||||
import guard
|
||||
|
||||
|
||||
TARGET_UUID = "b14c850b-a21e-4a56-acca-785590f0c4ac"
|
||||
LAB_ROOT = guard.LAB_ROOT
|
||||
ISO_PATH = LAB_ROOT / guard.EXPECTED_ISO
|
||||
ISO_SHA256 = guard.EXPECTED_ISO_SHA256
|
||||
ADDITIONS_ISO = Path("/usr/share/virtualbox/VBoxGuestAdditions.iso")
|
||||
ADDITIONS_SHA256 = "4f51a073296de31cce53924860549149be5dc339f65dcd1dbf34fd7accefe8fb"
|
||||
PASSWORD_DIR = Path("/tmp/wga-m9-private")
|
||||
USER_PASSWORD_FILE = PASSWORD_DIR / "wga-password"
|
||||
ROOT_PASSWORD_FILE = PASSWORD_DIR / "root-password"
|
||||
MAX_CAPTURE = 1024 * 1024
|
||||
TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
class PrepareError(Exception):
|
||||
"""Preflight refused or unattended preparation stopped; output is redacted."""
|
||||
|
||||
|
||||
Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
|
||||
|
||||
|
||||
def run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
||||
"""Run bounded VBoxManage while retaining its potentially secret output privately."""
|
||||
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 PrepareError("VBoxManage output exceeded the preparation limit")
|
||||
returncode = process.wait(timeout=max(0.0, deadline - time.monotonic()))
|
||||
except (OSError, subprocess.TimeoutExpired, PrepareError):
|
||||
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 _check_private_file(path: Path, owner_uid: int) -> None:
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError as exc:
|
||||
raise PrepareError("private synthetic password file is unavailable") from exc
|
||||
if (not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode)
|
||||
or info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o600
|
||||
or info.st_size == 0):
|
||||
raise PrepareError("synthetic password files must be nonempty current-user files with mode 0600")
|
||||
|
||||
|
||||
def _check_private_credentials(owner_uid: int) -> None:
|
||||
try:
|
||||
info = PASSWORD_DIR.lstat()
|
||||
except OSError as exc:
|
||||
raise PrepareError("private synthetic password directory is unavailable") from exc
|
||||
if (not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode)
|
||||
or info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o700):
|
||||
raise PrepareError("synthetic password directory must be current-user-owned mode 0700")
|
||||
_check_private_file(USER_PASSWORD_FILE, owner_uid)
|
||||
_check_private_file(ROOT_PASSWORD_FILE, owner_uid)
|
||||
|
||||
|
||||
def _check_digest(path: Path, expected: str, label: str) -> None:
|
||||
try:
|
||||
info = path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
|
||||
raise PrepareError(f"{label} must be a regular non-symlink file")
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
except OSError as exc:
|
||||
raise PrepareError(f"{label} is unavailable") from exc
|
||||
if digest.hexdigest() != expected:
|
||||
raise PrepareError(f"{label} SHA-256 does not match the pinned value")
|
||||
|
||||
|
||||
def _validate_preinstall_state(info: dict[str, str]) -> None:
|
||||
if info.get("VMState") != "poweroff":
|
||||
raise PrepareError("exact target must be powered off before preparation")
|
||||
expected_disk = str((LAB_ROOT / guard.TARGET_NAME / f"{guard.TARGET_NAME}.vdi").resolve(strict=True))
|
||||
expected_iso = str(ISO_PATH.resolve(strict=True))
|
||||
attachments = {key: value for key, value in info.items()
|
||||
if re.fullmatch(r"SATA-[0-9]+-[0-9]+", key)}
|
||||
if (attachments.get("SATA-0-0") != expected_disk
|
||||
or attachments.get("SATA-1-0") != expected_iso):
|
||||
raise PrepareError("target media do not match the expected VDI and pinned installer ISO")
|
||||
extra_attachments = set(attachments) - {"SATA-0-0", "SATA-1-0"}
|
||||
if any(attachments[key] not in {"none", "emptydrive"} for key in extra_attachments):
|
||||
raise PrepareError("target media do not match the expected VDI and pinned installer ISO")
|
||||
|
||||
|
||||
def _arguments(*, dry_run: bool) -> tuple[str, ...]:
|
||||
args = [
|
||||
"VBoxManage", "unattended", "install", TARGET_UUID,
|
||||
f"--iso={ISO_PATH}",
|
||||
"--user=wga", "--full-user-name=WireGuard Admin Lab",
|
||||
f"--user-password-file={USER_PASSWORD_FILE}",
|
||||
f"--admin-password-file={ROOT_PASSWORD_FILE}",
|
||||
"--hostname=wga-m9-debian137.invalid", "--locale=en_US", "--time-zone=UTC",
|
||||
"--country=US", "--package-selection-adjustment=minimal",
|
||||
"--install-additions", f"--additions-iso={ADDITIONS_ISO}",
|
||||
"--start-vm=none",
|
||||
]
|
||||
if dry_run:
|
||||
args.append("--dry-run")
|
||||
return tuple(args)
|
||||
|
||||
|
||||
def _invoke(runner: Runner, argv: Sequence[str], stage: str) -> None:
|
||||
try:
|
||||
result = runner(argv)
|
||||
except PrepareError as exc:
|
||||
if str(exc) == "VBoxManage output exceeded the preparation limit":
|
||||
raise PrepareError(f"{stage} output exceeded the preparation limit; output was suppressed") from None
|
||||
raise PrepareError(f"{stage} failed or timed out; VBoxManage output was suppressed") from None
|
||||
except Exception:
|
||||
raise PrepareError(f"{stage} failed or timed out; VBoxManage output was suppressed") from None
|
||||
if result.returncode != 0:
|
||||
raise PrepareError(f"{stage} failed with status {result.returncode}; VBoxManage output was suppressed")
|
||||
if len(result.stdout) > MAX_CAPTURE or len(result.stderr) > MAX_CAPTURE:
|
||||
raise PrepareError(f"{stage} output exceeded the preparation limit; output was suppressed")
|
||||
|
||||
|
||||
def prepare_target(*, runner: Runner = run_command, owner_uid: int | None = None) -> str:
|
||||
owner_uid = os.getuid() if owner_uid is None else owner_uid
|
||||
_check_private_credentials(owner_uid)
|
||||
if guard.EXPECTED_ISO_SHA256 != ISO_SHA256:
|
||||
raise PrepareError("configured installer digest differs from the pinned digest")
|
||||
_check_digest(ISO_PATH, ISO_SHA256, "installer ISO")
|
||||
_check_digest(ADDITIONS_ISO, ADDITIONS_SHA256, "Guest Additions ISO")
|
||||
try:
|
||||
target_uuid, info = guard.inspect_target(runner=runner)
|
||||
except (guard.GuardError, OSError, subprocess.TimeoutExpired):
|
||||
raise PrepareError("read-only exact-target guard failed; refusing preparation") from None
|
||||
if target_uuid != TARGET_UUID:
|
||||
raise PrepareError("read-only guard returned an unexpected target UUID")
|
||||
_validate_preinstall_state(info)
|
||||
_invoke(runner, _arguments(dry_run=True), "unattended dry run")
|
||||
try:
|
||||
_invoke(runner, _arguments(dry_run=False), "unattended preparation")
|
||||
except PrepareError:
|
||||
raise PrepareError(
|
||||
f"unattended preparation stopped or partially completed for {TARGET_UUID}; "
|
||||
"do not rerun; perform separate read-only inspection before any separately reviewed start"
|
||||
) from None
|
||||
return TARGET_UUID
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
target_uuid = prepare_target()
|
||||
except PrepareError as exc:
|
||||
print(f"Unattended preparation refused/stopped: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(
|
||||
f"Unattended preparation completed for the pinned target ({target_uuid}); "
|
||||
"VM remains unstarted. Perform separate read-only media inspection before any separately reviewed start."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user