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.
 
 
 
 
 

369 lines
16 KiB

"""Pytest fixtures for the Podman Quadlets cookbooks.
Prerequisites:
- Must run as root (KVM/libvirt access).
- The Fedora CoreOS base QCOW2 image must be present at /var/lib/libvirt/images/library/fedora-coreos.qcow2.
Run ``coreos-installer download -p qemu -f qcow2.xz -d -C /var/lib/libvirt/images/library/`` to fetch it.
- fcos-test.ign for the cookbook is built on demand by ``make butane`` if it is missing.
- The rootful Podman socket must be enabled (systemctl enable --now podman.socket) for the
pebble_acme_server fixture to start a Pebble ACME container via Testcontainers.
"""
import json
import os
import re
import shutil
import socket as _socket
import subprocess
import sys
import textwrap
import time
from pathlib import Path
import urllib.request
import ssl
import pytest
import testinfra
from fcos_vm import FCOSVirtualMachine, ensure_fcos_ign # noqa: E402
from dns_server import DNSServer # noqa: E402
# Persistent directory used when --keep is active.
_KEEP_VM_CACHE_DIR = Path.home() / ".cache" / "pytest"
@pytest.fixture(scope="session")
def libvirt_network() -> str:
"""The libvirt network name to use."""
return "default"
@pytest.fixture(scope="session")
def libvirt_network_if(libvirt_network: str) -> str:
"""The libvirt network interface to use."""
result = subprocess.run(
["virsh", "net-info", libvirt_network],
capture_output=True, text=True, check=True,
)
match = re.search(r"Bridge:\s+(\S+)", result.stdout)
if match:
return match.group(1)
raise RuntimeError(f"Could not find interface for libvirt network '{libvirt_network}'")
@pytest.fixture(scope="session")
def pebble_server_ip(libvirt_network_if: str) -> str:
"""IP Address of the Pebble ACME server."""
return _get_libvirt_bridge_ip(libvirt_network_if)
@pytest.fixture(scope="session")
def top_level_domain() -> str:
"""Top-level domain for the test environment."""
return "pytest.example.test"
@pytest.fixture(scope="session")
def dns_server_ip(libvirt_network_if: str) -> str:
"""IP Address of the DNS server."""
return _get_libvirt_bridge_ip(libvirt_network_if)
def _get_libvirt_bridge_ip(libvirt_network_if: str) -> str:
"""Return the IP of the host running pytest, as seen from the test VMs."""
result = subprocess.run(
["ip", "-4", "-j", "addr", "show", "scope", "global", "dev", libvirt_network_if],
capture_output=True, text=True, check=True,
)
ip_info = json.loads(result.stdout)
if ip_info and "addr_info" in ip_info[0] and ip_info[0]["addr_info"]:
return ip_info[0]["addr_info"][0]["local"]
raise RuntimeError(f"Could not find IP address for libvirt network interface '{libvirt_network_if}'")
def _wait_for_port(host: str, port: int, timeout: int = 30) -> None:
"""Wait for a TCP port to be open on the given host, or raise after timeout."""
timeout = 30
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with _socket.create_connection((host, port), timeout=1):
return
except OSError:
time.sleep(0.5)
raise TimeoutError(f"Port {host}:{port} not available after {timeout}s")
# You can pass --keep on the command line to keep the test VM alive after the test run and reuse it on the next run.
# Speeds up iteration: the VM is created once and never destroyed. The SSH key is stored persistently in ~/.cache/pytest.
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--keep",
action="store_true",
default=False,
help=(
"Keep the test VM alive after the test run and reuse it on the next run. "
"Speeds up iteration: the VM is created once and never destroyed. "
"The SSH key is stored persistently in "
f"{_KEEP_VM_CACHE_DIR}."
),
)
@pytest.fixture(scope="session")
def keep(request: pytest.FixtureRequest) -> bool:
"""True when --keep was passed on the command line."""
return request.config.getoption("--keep")
@pytest.fixture(scope="session")
def pebble_acme_server(tmp_path_factory: pytest.TempPathFactory, pebble_server_ip: str, dns_server_ip: str, keep: bool) -> dict:
"""Session-scoped Pebble ACME test server running in a Podman container.
Pebble is configured to validate HTTP-01 challenges on standard ports
(80/443) and binds to all host interfaces via host networking so it is
reachable from the libvirt test VMs.
The rootful Podman socket must be enabled before running the tests:
systemctl enable --now podman.socket
Yields a dict with:
- directory_url : ACME directory URL (https://<bridge_ip>:14000/dir)
- ca_cert : Pebble root CA certificate (PEM string), used to
authenticate Pebble's own TLS endpoint.
"""
from testcontainers.core.container import DockerContainer
# Point Testcontainers at the rootful Podman socket and disable Ryuk
# (Ryuk does not work reliably with Podman).
os.environ.setdefault("DOCKER_HOST", "unix:///run/podman/podman.sock")
os.environ.setdefault("TESTCONTAINERS_RYUK_DISABLED", "true")
if keep:
pebble_dir = Path("/srv/pebble")
pebble_dir.mkdir(parents=True, exist_ok=True)
else:
pebble_dir = tmp_path_factory.mktemp("pebble")
etc_dir = pebble_dir / "etc"
etc_dir.mkdir(exist_ok=True)
var_dir = pebble_dir / "var"
var_dir.mkdir(exist_ok=True)
ca_cert_path = var_dir / "ca.crt"
ca_key_path = var_dir / "ca.key"
server_key_path = var_dir / "server.key"
server_cert_path = var_dir / "server.crt"
# Generate a self-signed certificate for Pebble to use.
# The certificate's CN must match the host IP visible from the VM for TLS to work.
# The keys and certificates are reused across runs when --keep is set because those artefacts
# are injected into the VM and must remain consistent for the VM to be reusable.
if not (ca_cert_path.exists() and ca_key_path.exists()):
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", str(ca_key_path), "-out", str(ca_cert_path), "-days", "3650", "-noenc",
"-subj", "/CN=Pebble CA", "-addext", "basicConstraints=critical,CA:TRUE"], check=True, capture_output=True)
if not (var_dir / "server.csr").exists():
subprocess.run(["openssl", "req", "-newkey", "rsa:2048",
"-keyout", str(server_key_path), "-out", str(var_dir / "server.csr"), "-noenc",
"-subj", "/CN=localhost"], check=True, capture_output=True)
if not server_cert_path.exists():
(pebble_dir / "srv_ext.txt").write_text(f"basicConstraints=CA:FALSE\nsubjectAltName=IP:127.0.0.1,IP:{pebble_server_ip},DNS:localhost\n")
subprocess.run(["openssl", "x509", "-req",
"-in", str(var_dir / "server.csr"), "-CA", str(ca_cert_path), "-CAkey", str(ca_key_path), "-CAcreateserial",
"-out", str(server_cert_path), "-days", "365", "-extfile", str(pebble_dir / "srv_ext.txt")], check=True, capture_output=True)
# Write the Pebble configuration with standard challenge ports (80 / 443).
config_file = etc_dir / "pebble-config.json"
config_file.write_text(json.dumps({
"pebble": {
"listenAddress": "0.0.0.0:14000",
"managementListenAddress": "0.0.0.0:15000",
"certificate": "/test/certs/server.crt",
"privateKey": "/test/certs/server.key",
# Use standard ports to validate HTTP-01 & TLS-ALPN-01 challenges.
"httpPort": 80,
"httpsPort": 443,
"externalAccountBindingRequired": False,
"domainBlocklist": [],
}
}))
container = (
DockerContainer("ghcr.io/letsencrypt/pebble:latest")
.with_name("pebble-acme-server")
.with_env("PEBBLE_VA_NOSLEEP", "1")
.with_env("PEBBLE_WFE_NONCEREJECT", "0")
.with_command(f"-config /test/config/pebble-config.json -dnsserver {dns_server_ip}:53")
.with_volume_mapping(str(etc_dir), "/test/config", "ro,z")
.with_volume_mapping(str(var_dir), "/test/certs", "ro,z")
.with_kwargs(
network_mode="host",
)
)
with container:
_wait_for_port(pebble_server_ip, 14000)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
certs = {}
for name, path in [("root", "roots/0"), ("intermediate", "intermediates/0")]:
url = f"https://{pebble_server_ip}:15000/{path}"
with urllib.request.urlopen(url, context=ctx) as resp:
certs[name] = resp.read().decode()
data = {
# The directory URL is how ACME clients discover the available endpoints and must be provided to the tests.
"directory_url": f"https://{pebble_server_ip}:14000/dir",
# The CA certificate is needed by the tests to authenticate Pebble's TLS endpoint.
"ca_cert": ca_cert_path.read_text(),
# The CA bundle to trust the generated certificates.
"ca_bundle": certs["intermediate"] + certs["root"],
}
yield data # <-- tests run here with access to the Pebble ACME server
@pytest.fixture(scope="session")
def dns_server(libvirt_network: str, top_level_domain: str, keep: bool) -> DNSServer:
"""Session-scoped DNS server manager for the libvirt network."""
srv = DNSServer(network=libvirt_network, persistent=keep)
srv.set_domain(top_level_domain)
yield srv # <-- tests run here with access to the DNS server manager
srv.cleanup()
@pytest.fixture(scope="module")
def fcos_extra_files(request: pytest.FixtureRequest) -> dict:
"""Extra files to inject into the FCOS VM image.
Defaults to the ``PYTEST_FCOS_EXTRA_FILES`` module-level dict (backward
compatible with existing test modules). Override this fixture in a test
module to provide dynamic content whose values depend on other fixtures
(e.g. files that embed the Pebble ACME server URL or CA certificate).
"""
return getattr(request.module, "PYTEST_FCOS_EXTRA_FILES", {})
@pytest.fixture(scope="session")
def test_ssh_key(
keep: bool,
tmp_path_factory: pytest.TempPathFactory,
) -> Path:
"""SSH key pair for VM access.
When --keep is set the key is stored persistently so that subsequent
runs can re-use the same VM without re-injecting a new key.
"""
if keep:
key_dir = _KEEP_VM_CACHE_DIR
key_dir.mkdir(parents=True, exist_ok=True)
key_path = key_dir / "id_ed25519"
if not key_path.exists():
subprocess.run(
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)],
check=True,
capture_output=True,
)
return key_path
key_dir = tmp_path_factory.mktemp("ssh-key")
key_path = key_dir / "id_ed25519"
subprocess.run(
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)],
check=True,
capture_output=True,
)
return key_path
@pytest.fixture(scope="session")
def test_ssh_pubkey(test_ssh_key: Path) -> str:
"""Public key string corresponding to test_ssh_key."""
return test_ssh_key.with_suffix(".pub").read_text().strip()
# The virtiofs is where important and persistent data are stored.
# We keep it for the entire test session.
@pytest.fixture(scope="package")
def virtiofs_dirs(request, keep: bool) -> list[tuple[Path, str]]:
"""VirtioFS host directories for the default test VM.
With --keep the directories are persistent so the VM can be reused across
test runs. Without it unique per-process paths are used and cleaned up
on teardown.
"""
cookbook_dir = Path(request.path).parent.parent
if keep:
d = Path("/srv") / f"fcos-test-{cookbook_dir.name}-dev"
else:
d = Path("/srv") / f"fcos-test-{cookbook_dir.name}-{os.getpid()}"
d.mkdir(parents=True, exist_ok=True)
yield [(d, "data",)] # <-- tests run here with access to the virtiofs directories
if not keep and d.exists():
shutil.rmtree(d)
# However, the VM itself is recreated for each test module to ensure a clean state.
@pytest.fixture(scope="module")
def fcos_host(fcos_vm: FCOSVirtualMachine, test_ssh_key: Path):
"""testinfra SSH host connected to the default FCOS VM."""
return testinfra.get_host(
f"ssh://root@{fcos_vm.ip}",
ssh_extra_args=(
f"-i {test_ssh_key}"
" -o StrictHostKeyChecking=no"
" -o UserKnownHostsFile=/dev/null"
),
)
# Default VM configuration (memory in MB, vCPUs, root disk size in GB, /var disk size in GB).
@pytest.fixture(scope="package")
def fcos_vm_config() -> tuple[int, int, int, int]:
"""Default VM configuration (memory in MB, vCPUs, root disk size in GB, /var disk size in GB)."""
return (4096, 2, 50, 100) # (memory in MB, vCPUs, disk size for / and /var in GB)
@pytest.fixture(scope="module")
def dns_names() -> list[str]:
"""List of DNS names to be resolved by the VM (e.g. for ACME challenges)."""
return [ ]
# Test VM are kept for the duration of a test module, backed with a persistent Virtiofs directory.
@pytest.fixture(scope="module")
def fcos_vm(
request, # Fixture that provides information about the requesting test function, class or module.
keep: bool, # Fixture passed from command line option --keep to determine whether to keep the VM after tests for debugging purposes.
fcos_vm_config: tuple[int, int, int, int], # Fixture that provides the VM configuration (memory in MB, vCPUs, root disk size in GB, /var disk size in GB).
test_ssh_key: Path, # Fixture that provides the path to the SSH private key to connect to the VM.
test_ssh_pubkey: str, # Fixture that provides the content of the SSH public key to inject into the VM for SSH access.
virtiofs_dirs: list[tuple[Path, str]], # Fixture that provides a list of tuples containing host directories and their corresponding target directories in the VM to be exposed via VirtioFS.
tmp_path_factory: pytest.TempPathFactory, # Fixture that provides a factory for creating temporary directories.
fcos_extra_files: dict, # Fixture that provides extra files to inject into the FCOS VM image (overridable per module).
dns_server: DNSServer, # Fixture that provides a DNS server manager to configure DNS entries for the test VMs.
dns_names: list[str], # Fixture that provides a list of DNS names to be resolved by the VM (e.g. for ACME challenges)
) -> FCOSVirtualMachine:
"""Running CoreOS VM with Quadlets installed.
With --keep the VM is reused across runs: it is created only if it
does not already exist and is never destroyed on teardown.
"""
module_name = request.module.__name__.split(".")[-1].replace("test_", "").replace("_", "-")
cookbook_dir = Path(request.path).parent.parent
pg_major = getattr(request.module, "PG_MAJOR_DEFAULT", 0)
vm = FCOSVirtualMachine(
cookbook_name=cookbook_dir.name,
instance_name=module_name,
keep=keep,
virtiofs_dirs=virtiofs_dirs,
vm_config = fcos_vm_config,
)
if not (keep and vm.exists()):
fcos_ign = ensure_fcos_ign(cookbook_dir)
vm.ignition.ignition_files.append(fcos_ign)
vm.ignition.extra_files.update(fcos_extra_files)
vm.ignition.ssh_key = test_ssh_pubkey
vm.create()
vm.wait_ip()
dns_server.add_host(vm.ip, [ vm.vm_name ] + dns_names)
vm.wait_ssh(ssh_key=test_ssh_key, timeout=300)
yield vm # <-- tests run here with access to the VM instance
if not keep:
vm.destroy()
dns_server.remove_host(vm.ip)