10 changed files with 608 additions and 39 deletions
@ -0,0 +1,227 @@ |
|||||
|
import textwrap |
||||
|
import test_quadlet # noqa: F401 |
||||
|
import pytest |
||||
|
import subprocess |
||||
|
import tempfile |
||||
|
from pathlib import Path |
||||
|
|
||||
|
@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 [ "secure", "ping" ] |
||||
|
|
||||
|
# Extra files to inject into the FCOS image for the tests in this file. |
||||
|
@pytest.fixture(scope="module") |
||||
|
def fcos_extra_files(pebble_acme_server, top_level_domain) -> dict: |
||||
|
""" |
||||
|
Extra files to inject into the FCOS VM image. |
||||
|
""" |
||||
|
|
||||
|
files = { |
||||
|
# Exposes the Traefik ping endpoint to localhost. |
||||
|
"/etc/quadlets/traefik/conf.d/ping.yaml": ( |
||||
|
textwrap.dedent(f"""\ |
||||
|
http: |
||||
|
routers: |
||||
|
traefik-ping: |
||||
|
rule: Host(`ping`) || Host(`ping.{top_level_domain}`) |
||||
|
entryPoints: |
||||
|
- http |
||||
|
service: "ping@internal" |
||||
|
middlewares: |
||||
|
- localhost-only |
||||
|
middlewares: |
||||
|
localhost-only: |
||||
|
ipAllowList: |
||||
|
sourceRange: |
||||
|
- "127.0.0.1/32" |
||||
|
"""), |
||||
|
10001, |
||||
|
10000, |
||||
|
0o644, |
||||
|
), |
||||
|
# Exposes the ping endpoint to the outside world over HTTPS only |
||||
|
"/etc/quadlets/traefik/conf.d/secure.yaml": ( |
||||
|
textwrap.dedent(f"""\ |
||||
|
http: |
||||
|
routers: |
||||
|
secure: |
||||
|
rule: Host(`secure.{top_level_domain}`) |
||||
|
entryPoints: |
||||
|
- https |
||||
|
service: "ping@internal" |
||||
|
tls: |
||||
|
certResolver: le |
||||
|
"""), |
||||
|
10001, |
||||
|
10000, |
||||
|
0o644, |
||||
|
), |
||||
|
"/etc/quadlets/traefik/pebble.pem": ( |
||||
|
pebble_acme_server['ca_cert'], |
||||
|
10001, |
||||
|
10000, |
||||
|
0o644, |
||||
|
), |
||||
|
"/etc/quadlets/traefik/traefik.yaml": ( |
||||
|
textwrap.dedent(f"""\ |
||||
|
api: |
||||
|
dashboard: false |
||||
|
debug: false |
||||
|
ping: |
||||
|
manualRouting: true |
||||
|
|
||||
|
log: |
||||
|
level: "INFO" |
||||
|
|
||||
|
accesslog: false |
||||
|
|
||||
|
global: |
||||
|
sendanonymoususage: false |
||||
|
checknewversion: false |
||||
|
|
||||
|
entryPoints: |
||||
|
http: |
||||
|
address: ":80" |
||||
|
https: |
||||
|
address: ":443" |
||||
|
|
||||
|
certificatesResolvers: |
||||
|
le: |
||||
|
acme: |
||||
|
email: "traefik@pytest.example.test" |
||||
|
caServer: "{pebble_acme_server['directory_url']}" |
||||
|
caCertificates: "/etc/traefik/pebble.pem" |
||||
|
keyType: "EC384" |
||||
|
httpChallenge: |
||||
|
entryPoint: http |
||||
|
storage: "/var/lib/traefik/acme.json" |
||||
|
|
||||
|
providers: |
||||
|
file: |
||||
|
directory: /etc/traefik/conf.d/ |
||||
|
watch: true |
||||
|
"""), |
||||
|
10001, |
||||
|
10000, |
||||
|
0o644, |
||||
|
), |
||||
|
} |
||||
|
|
||||
|
return files |
||||
|
|
||||
|
""" |
||||
|
Verify that the Traefik Quadlet is correctly installed and configured on a fresh VM boot. |
||||
|
""" |
||||
|
class TestTraefikQuadlet(test_quadlet.TestQuadlet): |
||||
|
expected_services = [ |
||||
|
{ "name": "traefik.target", "state": "active", "exists": True }, |
||||
|
{ "name": "traefik.service", "state": "active", "exists": True }, |
||||
|
] |
||||
|
|
||||
|
expected_sockets = [ |
||||
|
{ "uri": "tcp://127.0.0.1:80", "state": "listening" }, |
||||
|
{ "uri": "tcp://127.0.0.1:443", "state": "listening" }, |
||||
|
] |
||||
|
|
||||
|
expected_ports = [ |
||||
|
{ "number": 80, "protocol": "tcp", "state": "open" }, |
||||
|
{ "number": 443, "protocol": "tcp", "state": "open" }, |
||||
|
{ "number": 22, "protocol": "tcp", "state": "open" }, |
||||
|
] |
||||
|
|
||||
|
expected_files = [ |
||||
|
{ "path": "/var/lib/quadlets/traefik", "type": "directory", "owner": "traefik", "group": "itix-svc", "mode": 0o755 }, |
||||
|
{ "path": "/etc/quadlets/traefik", "type": "directory", "owner": "traefik", "group": "itix-svc", "mode": 0o755 }, |
||||
|
{ "path": "/etc/quadlets/traefik/traefik.yaml", "type": "file", "owner": "traefik", "group": "itix-svc", "mode": 0o644 }, |
||||
|
] |
||||
|
|
||||
|
expected_podman_images = [ |
||||
|
{ "name": "docker.io/library/traefik", "tag": "v3.4", "state": "present" }, |
||||
|
] |
||||
|
|
||||
|
expected_podman_containers = [ |
||||
|
{ "name": "traefik", "state": "present", "pid1": { "owner": "10001", "group": "10000" } }, |
||||
|
] |
||||
|
|
||||
|
expected_main_service = "traefik.target" |
||||
|
expected_main_service_timeout = 300 |
||||
|
|
||||
|
def test_clean_traefik_state(self, fcos_host, keep): |
||||
|
if keep: |
||||
|
# Stop the traefik.target to ensure a clean state for the tests, but only if --keep is set because otherwise the VM is not reused across runs and is already in a clean state. |
||||
|
result = fcos_host.run("systemctl stop traefik.target") |
||||
|
assert result.rc == 0, f"Failed to stop traefik.target: {result.stderr}" |
||||
|
fcos_host.run("rm -rf /var/lib/quadlets/traefik/acme.json") |
||||
|
result = fcos_host.run("systemctl start traefik.target") |
||||
|
assert result.rc == 0, f"Failed to start traefik.target: {result.stderr}" |
||||
|
else: |
||||
|
pytest.skip("Skipping clean Traefik state test because --keep is not set.") |
||||
|
|
||||
|
@pytest.mark.flaky(reruns=6, reruns_delay=5) |
||||
|
def test_traefik_ping_localhost(self, fcos_host): |
||||
|
"""Traefik must respond to the ping endpoint with HTTP 200.""" |
||||
|
result = fcos_host.run("curl -sSf -o /dev/null -w '%{http_code}' -H 'Host: ping' http://127.0.0.1/") |
||||
|
assert result.rc == 0, f"curl failed with exit code {result.rc}: {result.stderr}" |
||||
|
assert result.stdout.strip() == "200", f"Expected HTTP 200 from ping endpoint, got: {result.stdout.strip()}" |
||||
|
|
||||
|
def test_traefik_ping_external(self, fcos_vm, top_level_domain): |
||||
|
"""Traefik must NOT respond to the ping endpoint outside localhost.""" |
||||
|
result = subprocess.run( |
||||
|
[ |
||||
|
"curl", |
||||
|
"-sSf", |
||||
|
"-o", "/dev/null", |
||||
|
"--resolve", f"ping.{top_level_domain}:80:{fcos_vm.ip}", |
||||
|
"-w", "%{http_code}", |
||||
|
f"http://ping.{top_level_domain}/" |
||||
|
], |
||||
|
check=False, |
||||
|
capture_output=True, |
||||
|
) |
||||
|
assert result.returncode == 22, f"curl failed with exit code {result.returncode}: {result.stderr}" |
||||
|
assert int(result.stdout.strip()) == 403, f"Expected HTTP 403 from ping endpoint, got: {result.stdout.strip()}" |
||||
|
|
||||
|
@pytest.mark.flaky(reruns=12, reruns_delay=5) |
||||
|
def test_traefik_tls(self, fcos_vm, pebble_acme_server, top_level_domain): |
||||
|
"""Traefik must respond to the secure endpoint with HTTP 200.""" |
||||
|
|
||||
|
# On the host running pytest, create a temporary dir in /tmp and write the Pebble CA certificate in the pebble.pem file. |
||||
|
tmpdir = tempfile.TemporaryDirectory(delete=True) |
||||
|
d = Path(tmpdir.name) |
||||
|
pebble_ca_bundle_path = d / "pebble.pem" |
||||
|
pebble_ca_bundle_path.write_text(pebble_acme_server['ca_bundle']) |
||||
|
|
||||
|
result = subprocess.run( |
||||
|
[ |
||||
|
"curl", |
||||
|
"-sSf", |
||||
|
"-o", "/dev/null", |
||||
|
"--cacert", str(pebble_ca_bundle_path), |
||||
|
"--resolve", f"secure.{top_level_domain}:443:{fcos_vm.ip}", |
||||
|
"-w", "%{http_code}", |
||||
|
f"https://secure.{top_level_domain}/" |
||||
|
], |
||||
|
check=False, |
||||
|
capture_output=True, |
||||
|
) |
||||
|
assert result.returncode == 0, f"curl failed with exit code {result.returncode}: {result.stderr}" |
||||
|
assert int(result.stdout.strip()) == 200, f"Expected HTTP 200 from ping endpoint, got: {result.stdout.strip()}" |
||||
|
|
||||
|
def test_traefik_restart(self, fcos_host): |
||||
|
"""Restarting traefik.target must keep Traefik running and the ping endpoint must still respond.""" |
||||
|
result = fcos_host.run("systemctl restart traefik.target") |
||||
|
assert result.rc == 0, f"Failed to restart traefik.target: {result.stderr}" |
||||
|
|
||||
|
# Wait for traefik.target to become active again after the restart |
||||
|
self.wait_for_service(fcos_host, "traefik.target", timeout=120) |
||||
|
|
||||
|
# traefik.service must still be running after the restart |
||||
|
self.check_expected_services(fcos_host, [ |
||||
|
{ "name": "traefik.service", "state": "active", "exists": True }, |
||||
|
]) |
||||
|
|
||||
|
# Ping endpoint must still respond after the restart |
||||
|
result = fcos_host.run("curl -sSf -H 'Host: ping' http://127.0.0.1/") |
||||
|
assert result.rc == 0, f"curl failed after restart: {result.stderr}" |
||||
|
|
||||
@ -0,0 +1,64 @@ |
|||||
|
import subprocess |
||||
|
|
||||
|
class DNSServer: |
||||
|
""" |
||||
|
Manages the libvirt network configuration related to DNS. |
||||
|
""" |
||||
|
|
||||
|
def __init__(self, network: str = "default", persistent: bool = False) -> None: |
||||
|
""" |
||||
|
Args: |
||||
|
network: The libvirt network name to configure DNS for. |
||||
|
persistent: Whether to keep the DNS configuration persistent. |
||||
|
""" |
||||
|
self.network = network |
||||
|
self.persistent = persistent |
||||
|
self.domain = None |
||||
|
|
||||
|
def set_domain(self, domain: str) -> None: |
||||
|
"""Set the domain for the DNS server.""" |
||||
|
self.domain = domain |
||||
|
|
||||
|
def add_host(self, ip: str, hostnames: list[str]) -> None: |
||||
|
"""Adds a host to the DNS server.""" |
||||
|
|
||||
|
xml = f'<host ip="{ip}">' |
||||
|
for hostname in hostnames: |
||||
|
fqdn = f"{hostname}.{self.domain}" if self.domain else hostname |
||||
|
xml += f'<hostname>{fqdn}</hostname>' |
||||
|
xml += '</host>' |
||||
|
result = subprocess.run( |
||||
|
[ |
||||
|
"virsh", "net-update", self.network, "add-last", "dns-host", xml, "--live", |
||||
|
] + (["--config"] if self.persistent else []), |
||||
|
capture_output=True, |
||||
|
timeout=10, |
||||
|
check = True, |
||||
|
) |
||||
|
|
||||
|
def remove_host(self, ip: str) -> None: |
||||
|
"""Removes a host from the DNS server.""" |
||||
|
|
||||
|
xml = f'<host ip="{ip}"/>' |
||||
|
result = subprocess.run( |
||||
|
[ |
||||
|
"virsh", "net-update", self.network, "delete", "dns-host", xml, "--live" |
||||
|
] + (["--config"] if self.persistent else []), |
||||
|
capture_output=True, |
||||
|
timeout=10, |
||||
|
check = True, |
||||
|
) |
||||
|
|
||||
|
def cleanup(self) -> None: |
||||
|
"""Resets the libvirt network configuration to its default state by destroying and restarting the network.""" |
||||
|
|
||||
|
if not self.persistent: |
||||
|
for cmd in [ "net-destroy", "net-start" ]: |
||||
|
result = subprocess.run( |
||||
|
[ |
||||
|
"virsh", cmd, self.network |
||||
|
], |
||||
|
capture_output=True, |
||||
|
timeout=10, |
||||
|
check = True, |
||||
|
) |
||||
Loading…
Reference in new issue