Collection of cookbooks for Podman Quadlets
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

384 lines
13 KiB

"""Fedora CoreOS VM lifecycle helpers for end-to-end testing.
Requires running as root (virt-install, virsh, qemu-img need root privileges).
Typical usage:
vm = FCOSVirtualMachine(
name="postgresql-abc123",
ignition_file=Path("/tmp/fcos-test.ign"),
virtiofs_dir=Path("/srv/fcos-test-postgresql-abc123"),
)
vm.create()
vm.wait_ssh(ssh_key=key_path)
vm.wait_for_service("postgresql.target", ssh_key=key_path)
# ... run tests ...
vm.destroy()
"""
import base64
import re
import shutil
import subprocess
import tempfile
import textwrap
import time
from pathlib import Path
LIBVIRT_IMAGES_DIR = Path("/var/lib/libvirt/images")
FCOS_BASE_IMAGE = LIBVIRT_IMAGES_DIR / "library" / "fedora-coreos.qcow2"
# Butane spec version — must match the project convention.
BUTANE_VERSION = "1.4.0"
def ensure_fcos_ign(cookbook_dir: Path) -> Path:
"""Return the path to fcos.ign, building it via ``make butane`` if absent."""
fcos_ign = cookbook_dir / "fcos.ign"
if not fcos_ign.exists():
subprocess.run(
["make", "-C", str(cookbook_dir), "butane"],
check=True,
)
return fcos_ign
def build_test_ignition(
base_ignition: Path,
ssh_pubkey: str,
output: Path,
config_env_overrides: dict[str, str] | None = None,
extra_files: dict[str, tuple[str, int]] | None = None,
) -> Path:
"""Build a test ignition file by overlaying the cookbook's fcos.ign.
The overlay:
- Merges the base cookbook ignition (fcos.ign).
- Adds the test SSH public key to the root user so the test runner can
SSH in (FCOS allows root login with keys via PermitRootLogin
prohibit-password).
- Optionally patches /etc/quadlets/postgresql/config.env via
``config_env_overrides`` (merged on top of whatever the base ignition
already sets).
- Optionally injects arbitrary extra files via ``extra_files``:
``{"/path/on/vm": ("file content", 0o644)}``.
Args:
base_ignition: Path to the pre-built fcos.ign for the cookbook.
ssh_pubkey: Ed25519 public key string to inject for root.
output: Destination path for the compiled test ignition.
config_env_overrides: Key/value pairs to override in config.env.
The full config.env is re-written with these values merged on
top of the defaults from the base ignition.
extra_files: Additional files to inject into the VM image.
Returns:
``output`` path.
"""
with tempfile.TemporaryDirectory() as _tmpdir:
d = Path(_tmpdir)
# butane resolves "local:" references relative to the directory passed
# via -d; copy the base ignition there.
shutil.copy(base_ignition, d / "base.ign")
# Build the storage.files section of the overlay.
storage_section = _build_storage_section(config_env_overrides, extra_files)
overlay_bu = textwrap.dedent(f"""\
variant: fcos
version: {BUTANE_VERSION}
ignition:
config:
merge:
- local: base.ign
passwd:
users:
- name: root
ssh_authorized_keys:
- {ssh_pubkey}
systemd:
units:
# Disable & mask zincati to avoid reboots during testing.
- name: zincati.service
enabled: false
mask: true
""")
if storage_section:
overlay_bu += storage_section
overlay_bu_path = d / "test-overlay.bu"
overlay_bu_path.write_text(overlay_bu)
subprocess.run(
[
"butane",
"--strict",
"-d", str(d),
"-o", str(output),
str(overlay_bu_path),
],
check=True,
)
return output
def _build_storage_section(
config_env_overrides: dict[str, str] | None,
extra_files: dict[str, tuple[str, int]] | None,
) -> str:
"""Return a Butane ``storage:`` YAML block (or empty string if nothing to inject)."""
files = []
if config_env_overrides:
content = "\n".join(f"{k}={v}" for k, v in config_env_overrides.items()) + "\n"
files.append(
_butane_file("/etc/quadlets/postgresql/config.env", content, 0o600)
)
if extra_files:
for path, (content, mode) in extra_files.items():
files.append(_butane_file(path, content, mode))
if not files:
return ""
joined = "\n".join(files)
return f"storage:\n files:\n{joined}\n"
def _butane_file(path: str, content: str, mode: int) -> str:
"""Return a Butane file entry using a base64 data URI (avoids YAML quoting)."""
b64 = base64.b64encode(content.encode()).decode()
return (
f" - path: {path}\n"
f" mode: {mode}\n"
f" contents:\n"
f' source: "data:text/plain;base64,{b64}"\n'
)
class FCOSVirtualMachine:
"""Manages a Fedora CoreOS KVM virtual machine for end-to-end testing.
All public methods are synchronous and raise on failure. The caller is
responsible for calling ``destroy()`` (typically from a pytest fixture
teardown).
"""
def __init__(self, name: str, ignition_file: Path, virtiofs_dir: Path) -> None:
"""
Args:
name: Short identifier appended to "fcos-test-" to form the
libvirt domain name. Keep it unique across parallel tests.
ignition_file: Path to the compiled Ignition (.ign) file.
virtiofs_dir: Host directory that will be exposed inside the VM
at /var/lib/virtiofs/data via VirtioFS.
"""
self.name = name
self.vm_name = f"fcos-test-{name}"
self.ignition_file = Path(ignition_file)
self.virtiofs_dir = Path(virtiofs_dir)
self._images_dir = LIBVIRT_IMAGES_DIR / self.vm_name
self._ip: str | None = None
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def create(self) -> None:
"""Create disk images and start the VM via virt-install."""
self._images_dir.mkdir(parents=True, exist_ok=True)
self.virtiofs_dir.mkdir(parents=True, exist_ok=True)
ign_dest = self._images_dir / "fcos.ign"
shutil.copy(self.ignition_file, ign_dest)
ign_dest.chmod(0o644)
# Root OS disk: copy from the shared base QCOW2 image.
root_qcow2 = self._images_dir / "root.qcow2"
shutil.copy(FCOS_BASE_IMAGE, root_qcow2)
# Secondary disk for /var (keeps OS and data separate, matches common.mk).
var_qcow2 = self._images_dir / "var.qcow2"
subprocess.run(
["qemu-img", "create", "-f", "qcow2", str(var_qcow2), "100G"],
check=True,
)
subprocess.run(
[
"virt-install",
f"--name={self.vm_name}",
"--import",
"--noautoconsole",
"--ram=4096",
"--vcpus=2",
"--os-variant=fedora-coreos-stable",
f"--disk=path={root_qcow2},format=qcow2,size=50",
f"--disk=path={var_qcow2},format=qcow2",
f"--qemu-commandline=-fw_cfg name=opt/com.coreos/config,file={ign_dest}",
"--network=network=default,model=virtio",
"--console=pty,target.type=virtio",
"--serial=pty",
"--graphics=none",
"--boot=uefi",
"--memorybacking=access.mode=shared,source.type=memfd",
(
f"--filesystem=type=mount,accessmode=passthrough,"
f"driver.type=virtiofs,driver.queue=1024,"
f"source.dir={self.virtiofs_dir},target.dir=data"
),
],
check=True,
)
def destroy(self) -> None:
"""Forcefully stop and delete the VM and all associated disk images."""
subprocess.run(["virsh", "destroy", self.vm_name], capture_output=True)
subprocess.run(
["virsh", "undefine", self.vm_name, "--nvram"],
capture_output=True,
)
if self._images_dir.exists():
shutil.rmtree(self._images_dir)
if self.virtiofs_dir.exists():
shutil.rmtree(self.virtiofs_dir)
# ------------------------------------------------------------------
# Readiness polling
# ------------------------------------------------------------------
def get_ip(self) -> str | None:
"""Return the VM's primary IPv4 address reported by virsh, or None."""
result = subprocess.run(
["virsh", "domifaddr", self.vm_name],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
match = re.search(r"(\d+\.\d+\.\d+\.\d+)", result.stdout)
return match.group(1) if match else None
@property
def ip(self) -> str:
if self._ip is None:
self._ip = self.get_ip()
if self._ip is None:
raise RuntimeError(f"VM {self.vm_name!r} has no IP address yet")
return self._ip
def wait_ssh(self, ssh_key: Path, timeout: int = 300) -> str:
"""Block until SSH is reachable. Returns the IP address.
Polls every 5 seconds until ``timeout`` seconds have elapsed.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
ip = self.get_ip()
if ip:
try:
result = subprocess.run(
[
"ssh",
"-i", str(ssh_key),
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=5",
"-o", "BatchMode=yes",
f"root@{ip}",
"true",
],
capture_output=True,
timeout=10,
)
if result.returncode == 0:
self._ip = ip
return ip
except subprocess.TimeoutExpired:
pass
time.sleep(5)
raise TimeoutError(
f"VM {self.vm_name!r} did not become SSH-ready within {timeout}s"
)
def wait_for_service(
self, service: str, ssh_key: Path, timeout: int = 120
) -> None:
"""Block until *service* reaches the ``active`` state."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = self.ssh_run(
f"systemctl is-active {service}", ssh_key, check=False
)
if result.stdout.strip() == "active":
return
time.sleep(5)
status = self.ssh_run(
f"systemctl status {service} --no-pager", ssh_key, check=False
)
raise TimeoutError(
f"Service {service!r} not active after {timeout}s:\n{status.stdout}"
)
def wait_for_unit_done(
self, service: str, ssh_key: Path, timeout: int = 120
) -> str:
"""Block until a oneshot service finishes (``inactive`` or ``failed``).
Returns:
The final state string: ``"inactive"`` on success, ``"failed"``
on failure.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = self.ssh_run(
f"systemctl is-active {service}", ssh_key, check=False
)
state = result.stdout.strip()
if state in ("inactive", "failed"):
return state
time.sleep(5)
raise TimeoutError(
f"Service {service!r} did not finish within {timeout}s"
)
# ------------------------------------------------------------------
# Remote execution
# ------------------------------------------------------------------
def ssh_run(
self,
command: str,
ssh_key: Path,
check: bool = True,
) -> subprocess.CompletedProcess:
"""Run a shell command in the VM via SSH.
Args:
command: Shell command string passed to the remote bash.
ssh_key: Path to the private key used for authentication.
check: If True (default), raise RuntimeError on non-zero exit.
Returns:
CompletedProcess with stdout/stderr as text.
"""
result = subprocess.run(
[
"ssh",
"-i", str(ssh_key),
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
f"root@{self.ip}",
command,
],
capture_output=True,
text=True,
)
if check and result.returncode != 0:
raise RuntimeError(
f"SSH command failed (exit {result.returncode}): {command!r}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
return result