From f5edbe7f4f1a73414cb4585341af1776eb7b234d Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Thu, 7 May 2026 19:21:26 +0530 Subject: [PATCH 01/10] Enable Container bvt tests and port some static google container tests --- base/images/images.toml | 22 +- .../container-base/test_container_bvt.py | 345 +++++++++++++ .../container-base/test_container_static.py | 158 ++++++ base/images/tests/cases/test_os_release.py | 5 + base/images/tests/cases/test_packages.py | 2 + base/images/tests/conftest.py | 117 +++++ base/images/tests/utils/container_runtime.py | 461 ++++++++++++++++++ base/images/tests/utils/pytest_plugin.py | 23 +- 8 files changed, 1130 insertions(+), 3 deletions(-) create mode 100644 base/images/tests/cases/container-base/test_container_bvt.py create mode 100644 base/images/tests/cases/container-base/test_container_static.py create mode 100644 base/images/tests/utils/container_runtime.py diff --git a/base/images/images.toml b/base/images/images.toml index a7e87f5055e..57a5c4ea073 100644 --- a/base/images/images.toml +++ b/base/images/images.toml @@ -12,7 +12,10 @@ runtime-package-management = true [images.container-base] description = "Container Base Image" definition = { type = "kiwi", path = "container-base/container-base.kiwi", profile = "core" } -tests.test-suites = [{ name = "static-image-checks" }] +tests.test-suites = [ + { name = "static-image-checks" }, + { name = "container-bvt-tests" }, +] [images.container-base.capabilities] machine-bootable = false @@ -62,3 +65,20 @@ extra-args = [ "--image-name", "{image-name}", "--capabilities", "{capabilities}", ] + +# Container Business Validation Tests (BVT) +[test-suites.container-bvt-tests] +type = "pytest" +description = "Container BVT: runtime validation of system resources, networking, filesystem, and package management" + +[test-suites.container-bvt-tests.pytest] +working-dir = "tests" +install = "pyproject" +test-paths = ["cases/container-base/test_container_bvt.py"] +extra-args = [ + "--image-path", "{image-path}", + "--image-name", "{image-name}", + "--capabilities", "{capabilities}", + "-m", "runtime_container_tests", + "-v", "-s" +] diff --git a/base/images/tests/cases/container-base/test_container_bvt.py b/base/images/tests/cases/container-base/test_container_bvt.py new file mode 100644 index 00000000000..afc70448ec2 --- /dev/null +++ b/base/images/tests/cases/container-base/test_container_bvt.py @@ -0,0 +1,345 @@ +# SPDX-License-Identifier: MIT +"""Container BVT (Build Verification Tests). + +All tests run inside a live container via podman exec and are marked @runtime_container_tests. +Tests are designed to be resilient to minimal container images — missing optional +tools are reported but do not fail the test unless they are essential. +""" + +from __future__ import annotations + +import json +import time + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _cmd_ok(ssh_exec, cmd: str, timeout: int = 10) -> tuple[bool, str]: + """Run cmd; return (success, stdout). Never raises.""" + try: + result = ssh_exec(cmd, timeout=timeout) + return result.returncode == 0, result.stdout.strip() + except Exception: + return False, "" + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container") +def test_bvt_system_footprint(ssh_exec) -> None: + """BVT: Memory, disk, CPU, and process footprint metrics.""" + # Memory + ok, out = _cmd_ok(ssh_exec, "free -m | awk '/^Mem:/ {print $2, $3}'") + assert ok, "free command failed — procps-ng may be missing" + parts = out.split() + assert len(parts) == 2, f"Unexpected free output: {out!r}" + total_mb = int(parts[0]) + assert total_mb > 0, f"Invalid total memory: {total_mb} MB" + print(f"Memory: total={parts[0]} MB, used={parts[1]} MB") + + # Disk + ok, out = _cmd_ok(ssh_exec, "df -h / | awk 'NR==2 {print $2, $3, $4}'") + assert ok, "df command failed" + print(f"Disk (total used avail): {out}") + + # CPU + ok, out = _cmd_ok(ssh_exec, "nproc") + assert ok, "nproc failed" + assert int(out) > 0, f"Unexpected CPU count: {out}" + print(f"CPU cores: {out}") + + # Process count + ok, out = _cmd_ok(ssh_exec, "ps aux --no-headers | wc -l") + assert ok, "ps failed — procps-ng may be missing" + count = int(out) + assert count >= 1, f"Too few processes: {count}" + print(f"Running processes: {count}") + + # Package count (optional — rpm may be absent in distroless builds) + ok, out = _cmd_ok(ssh_exec, "rpm -qa | wc -l", timeout=15) + if ok: + print(f"Installed packages: {out}") + else: + print("rpm not available — skipping package count") + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container") +def test_bvt_logging(ssh_exec) -> None: + """BVT: System logging via logger and log file/journal access.""" + tag = f"bvt_{int(time.time())}" + message = f"BVT log test entry {tag}" + + ok, _ = _cmd_ok(ssh_exec, f"logger -t bvt_test '{message}'", timeout=10) + assert ok, "logger command failed — util-linux may be missing" + + # Find the entry: try journalctl, then /var/log/messages, then /var/log/syslog + found = False + ok, out = _cmd_ok(ssh_exec, f"journalctl --no-pager --since '1 minute ago' 2>/dev/null | grep '{tag}' || true", timeout=15) + if ok and tag in out: + found = True + + if not found: + for logfile in ("/var/log/messages", "/var/log/syslog"): + ok, out = _cmd_ok(ssh_exec, f"tail -50 {logfile} 2>/dev/null | grep '{tag}' || true") + if ok and tag in out: + found = True + break + + # Log entry visibility depends on journald/syslog being wired up in the container. + if found: + print(f"Log entry verified in system logs: {message}") + else: + print(f"WARNING: log entry not immediately visible (journald may not be running in container): {message}") + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container") +def test_bvt_mathematical_computing(ssh_exec) -> None: + """BVT: Shell arithmetic, bc floating-point, and python3 math.""" + # Shell integer arithmetic (always available via bash) + ok, out = _cmd_ok(ssh_exec, "echo $((2 + 2))") + assert ok and out == "4", f"Basic arithmetic failed: {out!r}" + + ok, out = _cmd_ok(ssh_exec, "echo $((123 * 456))") + assert ok and out == str(123 * 456), f"Multiplication failed: {out!r}" + + # bc (optional) + ok, out = _cmd_ok(ssh_exec, "echo 'scale=2; 22/7' | bc", timeout=10) + if ok: + val = float(out) + assert 3.1 < val < 3.2, f"bc pi approximation out of range: {val}" + print(f"bc: 22/7 = {val}") + else: + print("bc not available — skipping floating-point test") + + # python3 (optional) + py_cmd = r"""python3 -c "import math; print(f'{math.pi:.6f}'); print(f'{math.sqrt(16):.1f}'); print('OK')" """ + ok, out = _cmd_ok(ssh_exec, py_cmd, timeout=15) + if ok and "OK" in out: + assert "3.14159" in out, f"Unexpected pi: {out}" + assert "4.0" in out, f"Unexpected sqrt(16): {out}" + print(f"python3 math verified: {out.splitlines()[0]}") + else: + print("python3 not available — skipping Python math test") + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container") +def test_bvt_networking(ssh_exec) -> None: + """BVT: Network interfaces, loopback ping, hostname, and routing.""" + # Network interfaces + ok, out = _cmd_ok(ssh_exec, "ip addr show", timeout=10) + assert ok, "ip command failed — iproute2 may be missing" + inet_lines = [l.strip() for l in out.splitlines() if "inet " in l and "scope" in l] + assert len(inet_lines) >= 1, f"No inet addresses found:\n{out}" + print(f"Network interfaces ({len(inet_lines)} addresses): {inet_lines}") + + # Loopback ping + ok, out = _cmd_ok(ssh_exec, "ping -c 2 -W 3 127.0.0.1", timeout=15) + assert ok, f"Loopback ping failed — iputils may be missing: {out}" + + # Hostname + ok, out = _cmd_ok(ssh_exec, "hostname") + assert ok and len(out) > 0, f"hostname command failed or returned empty: {out!r}" + print(f"Hostname: {out}") + + # Routing table (informational) + ok, out = _cmd_ok(ssh_exec, "ip route show", timeout=10) + if ok: + print(f"Routing table present, default route: {'default' in out}") + + # DNS config (informational) + ok, out = _cmd_ok(ssh_exec, "cat /etc/resolv.conf") + if ok: + print(f"DNS resolvers configured: {'nameserver' in out}") + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container") +def test_bvt_external_connectivity(ssh_exec) -> None: + """BVT: External DNS resolution and optional HTTP connectivity.""" + domains = ["google.com", "microsoft.com", "github.com"] + + ok_nslookup, _ = _cmd_ok(ssh_exec, "which nslookup", timeout=5) + if ok_nslookup: + resolved = sum( + 1 for domain in domains + for ok, out in [_cmd_ok(ssh_exec, f"nslookup {domain}", timeout=10)] + if ok and "NXDOMAIN" not in out + ) + print(f"DNS resolved {resolved}/{len(domains)} domains") + assert resolved >= 1, f"All DNS resolutions failed for {domains}" + else: + print("nslookup not available — skipping DNS resolution test") + + # HTTP connectivity (optional) + ok_curl, _ = _cmd_ok(ssh_exec, "which curl", timeout=5) + if ok_curl: + ok, out = _cmd_ok(ssh_exec, "curl -s --connect-timeout 5 --max-time 10 http://httpbin.org/get", timeout=15) + if ok and '"origin"' in out: + print("External HTTP connectivity verified") + else: + print("External HTTP test skipped or blocked by network policy") + else: + print("curl not available — skipping HTTP connectivity test") + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container") +def test_bvt_filesystem_operations(ssh_exec) -> None: + """BVT: File create, read, chmod, and delete in /tmp.""" + content = "Azure Linux BVT filesystem test" + path = "/tmp/bvt_test_file.txt" + + ok, _ = _cmd_ok(ssh_exec, f"echo '{content}' > {path}") + assert ok, f"File creation failed: {path}" + + ok, out = _cmd_ok(ssh_exec, f"cat {path}") + assert ok and content in out, f"File content mismatch: {out!r}" + + ok, out = _cmd_ok(ssh_exec, f"chmod 644 {path} && stat -c '%a' {path}") + assert ok and "644" in out, f"chmod/stat failed: {out!r}" + + ok, _ = _cmd_ok(ssh_exec, f"rm {path}") + assert ok, "File deletion failed" + + for sysfile in ("/etc/os-release", "/proc/version"): + ok, _ = _cmd_ok(ssh_exec, f"test -r {sysfile}") + assert ok, f"Cannot read required system file: {sysfile}" + + print("Filesystem operations verified") + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container") +def test_bvt_process_management(ssh_exec) -> None: + """BVT: Background process start, list, and kill.""" + ok, _ = _cmd_ok(ssh_exec, "sleep 60 &", timeout=5) + assert ok, "Failed to start background process" + + ok, out = _cmd_ok(ssh_exec, "ps aux | grep '[s]leep 60'", timeout=10) + assert ok and "sleep 60" in out, f"Background process not found in ps: {out}" + + ok, _ = _cmd_ok(ssh_exec, "pkill -f 'sleep 60'", timeout=10) + assert ok, "pkill failed" + + ok, out = _cmd_ok(ssh_exec, "ps aux | grep '[s]leep 60' || true") + assert "sleep 60" not in out, "Process still running after pkill" + print("Process management verified") + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container") +def test_bvt_user_management(ssh_exec) -> None: + """BVT: Create, verify, switch to, and delete a transient test user.""" + user = "bvt_testuser" + + ok, out = _cmd_ok(ssh_exec, f"useradd -m {user}", timeout=15) + assert ok, f"useradd failed — shadow-utils may be missing: {out}" + + ok, out = _cmd_ok(ssh_exec, f"id {user}") + assert ok and user in out, f"User not found: {out}" + + ok, _ = _cmd_ok(ssh_exec, f"test -d /home/{user}") + assert ok, f"Home directory /home/{user} not created" + + # Verify user is in passwd file (su may not work in minimal containers without TTY) + ok, out = _cmd_ok(ssh_exec, f"grep '^{user}:' /etc/passwd") + assert ok, f"User not in passwd file: {user}" + + ok, _ = _cmd_ok(ssh_exec, f"userdel -r {user}", timeout=15) + assert ok, "userdel failed" + print(f"User management verified for: {user}") + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container", "runtime-package-management") +def test_bvt_package_management(ssh_exec) -> None: + """BVT: Package manager (tdnf/dnf) cache refresh, list, and info.""" + ok_tdnf, _ = _cmd_ok(ssh_exec, "which tdnf", timeout=5) + pm = "tdnf" if ok_tdnf else "dnf" + + ok, out = _cmd_ok(ssh_exec, f"{pm} makecache", timeout=60) + assert ok, f"{pm} makecache failed: {out}" + + ok, out = _cmd_ok(ssh_exec, f"{pm} list --installed | head -20", timeout=30) + assert ok, f"Package listing failed: {out}" + assert any(kw in out.lower() for kw in ("azure", "bash", "filesystem")), \ + f"Expected Azure Linux packages not found in listing: {out[:200]}" + + ok, out = _cmd_ok(ssh_exec, f"{pm} info bash", timeout=15) + assert ok, f"Package info for bash failed: {out}" + print(f"Package management verified via {pm}") + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container") +def test_bvt_environment_variables(ssh_exec, container_info: dict) -> None: + """BVT: Essential environment variables and custom variable export.""" + ok, out = _cmd_ok(ssh_exec, "env") + assert ok, "env command failed" + assert "PATH=" in out, "Required env var PATH not set" + print([l for l in out.splitlines() if l.startswith("PATH=")][0]) + + ok, out = _cmd_ok(ssh_exec, "export BVT_VAR=azure_linux_bvt && echo $BVT_VAR") + assert ok and "azure_linux_bvt" in out, f"Custom env var not set: {out!r}" + + ok, out = _cmd_ok(ssh_exec, "echo $HOSTNAME") + assert ok and len(out) > 0, "HOSTNAME is empty" + print(f"Container HOSTNAME: {out}") + + +@pytest.mark.runtime_container_tests +@pytest.mark.require_capability("container") +def test_bvt_container_health_summary(ssh_exec, container_info: dict) -> None: + """BVT: Aggregated health summary — OS info, memory, processes, packages.""" + health: dict = { + "container_name": container_info["container_name"], + "container_ip": container_info["ip_address"], + "timestamp": int(time.time()), + } + + ok, out = _cmd_ok(ssh_exec, "grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d '\"'") + assert ok and out, "Could not read /etc/os-release" + health["os"] = out + + ok, out = _cmd_ok(ssh_exec, "uname -r") + if ok: + health["kernel"] = out + + ok, out = _cmd_ok(ssh_exec, "uptime -s 2>/dev/null || uptime") + if ok: + health["uptime"] = out + + ok, out = _cmd_ok(ssh_exec, "free -m | awk '/^Mem:/ {print $2}'") + if ok: + health["memory_total_mb"] = int(out) + + ok, out = _cmd_ok(ssh_exec, "ps aux --no-headers | wc -l") + if ok: + health["process_count"] = int(out) + + ok, out = _cmd_ok(ssh_exec, "rpm -qa | wc -l", timeout=15) + if ok: + health["package_count"] = int(out) + + assert health.get("container_name"), "Missing container name" + assert health.get("container_ip"), "Missing container IP" + assert health.get("os"), "Missing OS information" + assert health.get("memory_total_mb", 0) > 0, "Invalid memory reading" + assert health.get("process_count", 0) >= 1, "No processes detected" + + print("\n" + "=" * 60) + print("CONTAINER BVT HEALTH SUMMARY") + print("=" * 60) + print(json.dumps(health, indent=2)) + print("=" * 60) diff --git a/base/images/tests/cases/container-base/test_container_static.py b/base/images/tests/cases/container-base/test_container_static.py new file mode 100644 index 00000000000..ea705bd0b15 --- /dev/null +++ b/base/images/tests/cases/container-base/test_container_static.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: MIT +"""Ported Azure Linux Container Structure Test (CST) suite. + +These are static filesystem and metadata assertions ported from the Google +Container Structure Test YAML format to pytest. They validate core container +image properties without requiring a running container runtime. + +Mapped from: base/images/tests/cst/azl4_container_static_test.yaml +""" + +from __future__ import annotations + +import re +import pytest +from pathlib import Path + + +# ============================================================================ +# File Existence Tests (7 checks) +# ============================================================================ + + +@pytest.mark.static_container_test +def test_file_exists_root(rootfs: Path) -> None: + """Root directory must exist.""" + assert rootfs.exists(), "Root directory does not exist" + + +@pytest.mark.static_container_test +def test_file_exists_bin_date(rootfs: Path) -> None: + """Date binary must exist and be executable by owner.""" + path = rootfs / "bin" / "date" + assert path.exists(), f"File {path} does not exist" + # Check executable by owner (user x bit) + mode = path.stat().st_mode + assert mode & 0o100, f"File {path} is not executable by owner" + + +@pytest.mark.static_container_test +def test_file_exists_bin_bash(rootfs: Path) -> None: + """Bash must exist.""" + path = rootfs / "bin" / "bash" + assert path.exists(), f"File {path} does not exist" + + +@pytest.mark.static_container_test +def test_file_exists_etc_os_release(rootfs: Path) -> None: + """os-release must exist.""" + path = rootfs / "etc" / "os-release" + assert path.exists(), f"File {path} does not exist" + + +@pytest.mark.static_container_test +def test_file_exists_etc_passwd(rootfs: Path) -> None: + """passwd file must exist.""" + path = rootfs / "etc" / "passwd" + assert path.exists(), f"File {path} does not exist" + + +@pytest.mark.static_container_test +def test_file_exists_etc_dnf_dnf_conf(rootfs: Path) -> None: + """DNF config must exist.""" + path = rootfs / "etc" / "dnf" / "dnf.conf" + assert path.exists(), f"File {path} does not exist" + + +@pytest.mark.static_container_test +def test_file_not_exists_etc_dummy(rootfs: Path) -> None: + """Dummy file should not exist (negative test).""" + path = rootfs / "etc" / "dummy" + assert not path.exists(), f"File {path} should not exist" + + +# ============================================================================ +# File Content Tests (4 checks) +# ============================================================================ + + +@pytest.mark.static_container_test +def test_content_os_release_id(rootfs: Path, os_release: dict[str, str]) -> None: + """os-release must contain Azure Linux ID.""" + assert os_release.get("ID") == "azurelinux", \ + f"Expected ID=azurelinux, got {os_release.get('ID')}" + + +@pytest.mark.static_container_test +def test_content_os_release_version(rootfs: Path, os_release: dict[str, str]) -> None: + """os-release must contain correct version.""" + # Match pattern: VERSION_ID=4.0 (exact) + version_id = os_release.get("VERSION_ID") + assert version_id == "4.0", \ + f"Expected VERSION_ID=4.0, got {version_id}" + + +@pytest.mark.static_container_test +def test_content_os_release_variant(rootfs: Path, os_release: dict[str, str]) -> None: + """os-release must identify as container variant.""" + variant_id = os_release.get("VARIANT_ID") + assert variant_id == "container", \ + f"Expected VARIANT_ID=container, got {variant_id}" + + +@pytest.mark.static_container_test +def test_content_passwd_root_entry(rootfs: Path) -> None: + """passwd file must contain root user entry.""" + path = rootfs / "etc" / "passwd" + content = path.read_text() + # Pattern: root:x:0:0:Super User:/root:/bin/bash or similar + pattern = r"^root:x:0:0:.*:/root:/bin/bash" + assert re.search(pattern, content, re.MULTILINE), \ + f"Root entry not found in passwd file matching pattern {pattern}" + + +# ============================================================================ +# License Tests (2 checks) +# ============================================================================ + + +@pytest.mark.static_container_test +def test_license_bash_exists(rootfs: Path) -> None: + """Bash license file must exist.""" + path = rootfs / "usr" / "share" / "licenses" / "bash" / "COPYING" + assert path.exists(), f"License file {path} does not exist" + + +@pytest.mark.static_container_test +def test_license_coreutils_single_exists(rootfs: Path) -> None: + """coreutils-single license file must exist.""" + path = rootfs / "usr" / "share" / "licenses" / "coreutils-single" / "COPYING" + assert path.exists(), f"License file {path} does not exist" + + +# ============================================================================ +# Metadata Test (1 check) +# ============================================================================ + + +@pytest.mark.static_container_test +def test_metadata_entrypoint(rootfs: Path) -> None: + """Metadata validation: cmd=['/bin/bash'], workdir='/', user='root'. + + Note: This test validates the image structure is set up correctly. + Actual metadata (entrypoint, workdir, user) would be verified when + running the container. Here we just verify core structure is correct. + """ + # Verify bash exists (required for entrypoint) + bash_path = rootfs / "bin" / "bash" + assert bash_path.exists(), "bash not found for entrypoint" + + # Verify root user exists + passwd_path = rootfs / "etc" / "passwd" + content = passwd_path.read_text() + assert re.search(r"^root:", content, re.MULTILINE), \ + "root user not found in passwd" + + # Verify home directory exists + root_home = rootfs / "root" + assert root_home.exists(), "root home directory does not exist" diff --git a/base/images/tests/cases/test_os_release.py b/base/images/tests/cases/test_os_release.py index 518b0dcc16d..365b456386b 100644 --- a/base/images/tests/cases/test_os_release.py +++ b/base/images/tests/cases/test_os_release.py @@ -3,16 +3,21 @@ from __future__ import annotations +import pytest + +@pytest.mark.static_container_test def test_os_release_has_required_keys(os_release: dict[str, str]) -> None: """os-release must contain the distro-identifying keys.""" for key in ("NAME", "ID", "VERSION_ID"): assert key in os_release, f"Missing required key: {key}" +@pytest.mark.static_container_test def test_os_release_id(os_release: dict[str, str]) -> None: assert os_release.get("ID") == "azurelinux" +@pytest.mark.static_container_test def test_os_release_version(os_release: dict[str, str]) -> None: assert os_release.get("VERSION_ID") == "4.0" diff --git a/base/images/tests/cases/test_packages.py b/base/images/tests/cases/test_packages.py index 464dfb7ec7f..3e57019400f 100644 --- a/base/images/tests/cases/test_packages.py +++ b/base/images/tests/cases/test_packages.py @@ -24,12 +24,14 @@ } +@pytest.mark.static_container_test @pytest.mark.require_capability("runtime-package-management") def test_required_packages_installed(installed_packages: set[str]) -> None: missing = REQUIRED_PACKAGES - installed_packages assert not missing, f"Required packages missing: {sorted(missing)}" +@pytest.mark.static_container_test @pytest.mark.require_capability("runtime-package-management") @pytest.mark.parametrize("pkg", sorted(BLOCKLISTED_PACKAGES)) def test_blocklisted_package_absent( diff --git a/base/images/tests/conftest.py b/base/images/tests/conftest.py index 91823ae3223..d61527b6514 100644 --- a/base/images/tests/conftest.py +++ b/base/images/tests/conftest.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging +import os import shutil import subprocess import tempfile @@ -24,6 +25,11 @@ unmount_vm_image, ) from utils.parsers import parse_os_release, query_rpm_packages +from utils.container_runtime import ( + ContainerExecInstance, + create_container_with_exec, + destroy_exec_container, +) from utils.pytest_plugin import ( derive_image_type_from_capabilities, detect_image_type, @@ -208,3 +214,114 @@ def partition_table( for p in disk_info.partitions: logger.debug(" %s: type=%s mount=%s size=%d", p.device, p.type, p.mountpoint, p.size_bytes) return disk_info.partitions + + +# --------------------------------------------------------------------------- +# Dynamic Container Testing Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def running_container( + image_path: Path, image_type: str, image_name: str | None, workdir: Path, request: pytest.FixtureRequest +) -> ContainerExecInstance | None: + """Running container instance with exec access — session fixture with cleanup. + + Uses fast podman exec instead of SSH for better performance. + Only creates container for runtime container tests. + """ + if image_type != "container": + pytest.skip("running_container only applicable to container images") + + # Check if any tests being run require a live runtime container. + has_runtime_container_tests = any( + item.get_closest_marker("runtime_container_tests") is not None + for item in request.session.items + ) if hasattr(request, 'session') else False + + if not has_runtime_container_tests: + pytest.skip("running_container only for runtime container tests") + + logger.info("Creating running container for runtime container tests") + container = create_container_with_exec( + image_path, + workdir, + container_name=f"azl-test-{image_name or 'container'}", + image_name=image_name + ) + + try: + yield container + finally: + logger.info("Cleaning up running container") + destroy_exec_container(container) + + +@pytest.fixture +def container_exec(running_container: ContainerExecInstance): + """Execute commands in running container via podman exec (fast with on-demand packages).""" + def _exec(command: str, timeout: int = 60, check: bool = True) -> subprocess.CompletedProcess[str]: + """Execute command via exec with automatic package installation.""" + from utils.container_runtime import ( + exec_container_command_with_fallback, + detect_required_packages + ) + + # Detect packages that might be needed + required_packages = detect_required_packages(command) + + return exec_container_command_with_fallback( + running_container, command, required_packages, timeout, check + ) + return _exec + + +@pytest.fixture +def ssh_exec(running_container: ContainerExecInstance): + """Legacy SSH exec fixture - now uses exec with smart package installation. + + Note: This is kept for backward compatibility but uses exec instead of SSH. + For new tests, prefer using container_exec directly. + """ + def _exec(command: str, timeout: int = 60) -> subprocess.CompletedProcess[str]: + """Execute command via exec with automatic package installation.""" + from utils.container_runtime import ( + exec_container_command_with_fallback, + detect_required_packages + ) + + # Detect packages that might be needed + required_packages = detect_required_packages(command) + + return exec_container_command_with_fallback( + running_container, command, required_packages, timeout, check=True + ) + return _exec + + +@pytest.fixture +def container_info(running_container: ContainerExecInstance) -> dict[str, str]: + """Container runtime information.""" + # Get the container IP address for compatibility with SSH-based tests + try: + from utils.container_runtime import exec_container_command_raw + ip_result = exec_container_command_raw( + running_container.container_name, + ["hostname", "-i"], + timeout=5 + ) + container_ip = ip_result.stdout.strip() if ip_result.returncode == 0 else "127.0.0.1" + except Exception: + container_ip = "127.0.0.1" + + return { + "container_id": running_container.container_id, + "container_name": running_container.container_name, + "image_ref": running_container.image_ref, + # Compatibility fields for SSH-style tests + "ip_address": container_ip, + "ssh_port": "22", # Not applicable for exec but needed for compatibility + "ssh_user": "root", + } + + diff --git a/base/images/tests/utils/container_runtime.py b/base/images/tests/utils/container_runtime.py new file mode 100644 index 00000000000..15730544346 --- /dev/null +++ b/base/images/tests/utils/container_runtime.py @@ -0,0 +1,461 @@ +# SPDX-License-Identifier: MIT +"""Container runtime orchestration for dynamic testing. + +Provides fixtures and utilities for creating running containers with exec access +for dynamic integration testing. +""" + +from __future__ import annotations + +import logging +import subprocess +import time +from pathlib import Path +from typing import NamedTuple + +from .tools import NativeTool + +logger = logging.getLogger(__name__) + +# Container runtime tools +PODMAN = NativeTool( + name="podman", + package_hint="podman", + reason="create and manage test containers", + when="dynamic-container-tests", +) + +class ContainerExecInstance(NamedTuple): + """Running container instance with exec access (faster alternative to SSH).""" + container_id: str + container_name: str + image_ref: str + + +class ContainerRuntimeError(Exception): + """Container runtime operation failed.""" + pass + + +def _run_container_cmd(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + """Run container command with proper error handling.""" + logger.info("Container runtime: %s", " ".join(cmd)) + result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) + if result.returncode != 0: + logger.error( + "Container command failed (rc=%d): %s\nstdout: %s\nstderr: %s", + result.returncode, + " ".join(cmd), + result.stdout, + result.stderr, + ) + raise ContainerRuntimeError( + f"Container command failed: {' '.join(cmd)}\n" + f"Error: {result.stderr}" + ) + return result + + +def _get_image_reference(image_path: Path) -> str: + """Get container image reference from path or direct reference.""" + image_str = str(image_path) + + # If it's already a container reference (contains :), use directly + if ":" in image_str and not image_str.startswith("/"): + return image_str + + # If it's an OCI archive file, load it first + if image_path.exists() and image_path.suffix in (".tar", ".gz", ".xz"): + logger.info("Loading image archive: %s", image_path) + load_cmd = [PODMAN.name, "load", "-i", str(image_path)] + load_result = _run_container_cmd(load_cmd) + + # Get all images sorted by creation date (most recent first) + images_result = _run_container_cmd([ + PODMAN.name, "images", "--format", "{{.Repository}}:{{.Tag}}", + "--sort", "created" + ]) + loaded_images = [line.strip() for line in images_result.stdout.strip().split('\n') if line.strip()] + if loaded_images: + return loaded_images[0] # Use the most recently created image + else: + raise ContainerRuntimeError(f"No image loaded from {image_path}") + + # Assume it's a direct image reference + return image_str + + +def create_container_with_exec( + image_path: Path, + workdir: Path, + container_name: str | None = None, + image_name: str | None = None, +) -> ContainerExecInstance: + """Create running container with exec access (fast alternative to SSH). + + This is much faster than SSH-based containers as it: + - Skips SSH key generation + - Skips openssh-server installation + - Skips SSH daemon setup and connectivity testing + - Uses direct podman exec for command execution + + Args: + image_path: Path to image file or image reference + workdir: Working directory (unused but kept for compatibility) + container_name: Container name (auto-generated if None) + image_name: Image name for logging (derived if None) + """ + + # Generate container name if not provided + if container_name is None: + timestamp = int(time.time()) + import random + random_suffix = random.randint(1000, 9999) + container_name = f"azl-test-{timestamp}-{random_suffix}" + + # Container names can be reused across test suites; clear any stale + # package-install cache from prior container instances with same name. + _installed_packages_cache.pop(container_name, None) + + # Get image reference (load if needed) + image_ref = _get_image_reference(image_path) + logger.info("Creating exec container %s from image %s", container_name, image_ref) + + # Create and start container - much simpler than SSH version + run_cmd = [ + PODMAN.name, "run", "-d", + "--name", container_name, + "--replace", # Replace existing container with same name + "--tmpfs", "/run", + "--tmpfs", "/tmp", + image_ref, + "sleep", "infinity" # Keep container running indefinitely + ] + + result = _run_container_cmd(run_cmd) + container_id = result.stdout.strip() + + # Wait briefly for container to start + logger.info("Waiting for exec container %s to start...", container_name) + time.sleep(2) # Much shorter wait than SSH setup + + # Test basic exec access + test_result = exec_container_command_raw(container_name, ["echo", "container-ready"]) + if test_result.returncode != 0 or "container-ready" not in test_result.stdout: + raise ContainerRuntimeError(f"Container exec test failed for {container_name}") + + # Pre-warm the dnf metadata cache so subsequent installs are fast. + # This is non-fatal: if it times out the installs will just be slow. + try: + logger.info("Pre-warming dnf metadata cache") + exec_container_command_raw(container_name, ["dnf", "makecache"], timeout=300) + logger.info("dnf metadata cache warmed") + except subprocess.TimeoutExpired: + logger.warning("dnf makecache timed out — installs may be slow") + + # Pre-install essential packages only if any are actually missing. + # Checking binaries first avoids a dnf metadata refresh when the + # image already ships these packages. + essential = { + "procps-ng": "free", + "util-linux": "logger", + "gawk": "awk", + "which": "which", + "hostname": "hostname", + "python3": "python3", + } + missing = [ + pkg for pkg, cmd in essential.items() + if exec_container_command_raw(container_name, ["which", cmd], timeout=5).returncode != 0 + ] + if missing: + logger.info("Installing missing essential packages: %s", missing) + try: + install_result = exec_container_command_raw( + container_name, + ["dnf", "install", "-y"] + missing, + timeout=300, + ) + if install_result.returncode == 0: + logger.info("Essential packages installed successfully") + else: + logger.warning("Some essential packages may not have installed: %s", install_result.stderr) + except subprocess.TimeoutExpired: + logger.warning( + "Preinstall timed out after 300s — packages will be installed on-demand per test" + ) + else: + logger.info("All essential packages already present — skipping dnf install") + + logger.info( + "Exec container ready: %s (ID: %s)", + container_name, container_id[:12] + ) + + return ContainerExecInstance( + container_id=container_id, + container_name=container_name, + image_ref=image_ref, + ) + + +def exec_container_command_raw( + container_name: str, + command: list[str], + timeout: int = 60, + **kwargs: object +) -> subprocess.CompletedProcess[str]: + """Execute command in container via podman exec (raw version).""" + exec_cmd = [PODMAN.name, "exec", container_name] + command + + logger.debug("Container exec: %s", " ".join(command)) + return subprocess.run( + exec_cmd, + capture_output=True, + text=True, + timeout=timeout, + **kwargs + ) + + +# Global cache to track installed packages per container +_installed_packages_cache: dict[str, set[str]] = {} + +def ensure_packages_in_container(container_name: str, packages: list[str]) -> bool: + """Ensure packages are installed in the container. Install if missing.""" + if not packages: + return True + + # Initialize cache for this container + if container_name not in _installed_packages_cache: + _installed_packages_cache[container_name] = set() + + # Filter out packages we've already tried installing + packages_to_install = [ + pkg for pkg in packages + if pkg not in _installed_packages_cache[container_name] + ] + + if not packages_to_install: + return True # All packages already processed + + # Test commands for each package to see if they're available + package_commands = { + "procps-ng": "free", + "gawk": "awk", + "util-linux": "logger", + "bc": "bc", + "bind-utils": "nslookup", + "iproute": "ip", + "iproute2": "ip", + "iputils": "ping", + "shadow-utils": "useradd", + "python3": "python3", + "which": "which", + "hostname": "hostname", + "systemd": "systemctl", + } + + missing_packages = [] + for package in packages_to_install: + command = package_commands.get(package, package) + test_result = exec_container_command_raw( + container_name, ["which", command], timeout=3 + ) + + if test_result.returncode != 0: + missing_packages.append(package) + + # Install all missing packages in a single command with shorter timeout and retry + if missing_packages: + logger.info("Installing packages %s in container %s", missing_packages, container_name) + + # First attempt with shorter timeout + install_result = exec_container_command_raw( + container_name, + ["dnf", "install", "-y"] + missing_packages, + timeout=90 + ) + + # If first attempt failed due to timeout, try individual packages + if install_result.returncode != 0: + logger.warning("Bulk install failed, trying individual packages") + success_count = 0 + for package in missing_packages: + individual_result = exec_container_command_raw( + container_name, + ["dnf", "install", "-y", package], + timeout=60 + ) + if individual_result.returncode == 0: + success_count += 1 + + # Consider it successful if most packages installed + install_success = success_count >= len(missing_packages) * 0.7 + else: + install_success = True + + # Mark all packages as processed (even if installation failed) + _installed_packages_cache[container_name].update(packages_to_install) + + return install_success + else: + # Mark as processed since all were available + _installed_packages_cache[container_name].update(packages_to_install) + return True + + +def ensure_package_in_container(container_name: str, package: str) -> bool: + """Ensure a package is installed in the container. Install if missing.""" + return ensure_packages_in_container(container_name, [package]) + + +def detect_required_packages(command: str) -> list[str]: + """Detect what packages might be needed for a command.""" + command_to_package = { + "free": "procps-ng", + "ps": "procps-ng", + "awk": "gawk", + "logger": "util-linux", + "bc": "bc", + "nslookup": "bind-utils", + "ip": "iproute", + "ping": "iputils", + "useradd": "shadow-utils", + "systemctl": "systemd", + "python3": "python3", + "python": "python3", + "which": "which", + "hostname": "hostname", + "whereis": "which", + "uptime": "procps-ng", + "top": "procps-ng", + } + + packages = [] + for cmd, pkg in command_to_package.items(): + if cmd in command: + packages.append(pkg) + + return packages + + +def exec_container_command_with_fallback( + container: ContainerExecInstance, + command: str, + required_packages: list[str] | None = None, + timeout: int = 60, + check: bool = True +) -> subprocess.CompletedProcess[str]: + """Execute command in container, installing packages if needed.""" + + # First try the command as-is + result = exec_container_command_raw( + container.container_name, + ["bash", "-c", command], + timeout=timeout + ) + + # If command failed due to missing binary or missing target binary for + # `which `, install required packages and retry once. + should_install = ( + required_packages + and ( + (result.returncode == 127 and "command not found" in result.stderr) + or ( + result.returncode == 1 + and command.strip().startswith("which ") + and "no " in result.stderr + ) + ) + ) + + if should_install: + + logger.info("Command failed, installing required packages: %s", required_packages) + + # Install all required packages in one operation + ensure_packages_in_container(container.container_name, required_packages) + + # Retry the command + result = exec_container_command_raw( + container.container_name, + ["bash", "-c", command], + timeout=timeout + ) + + if check and result.returncode != 0: + logger.error( + "Container exec failed (rc=%d): %s\nstdout: %s\nstderr: %s", + result.returncode, command, result.stdout, result.stderr + ) + raise ContainerRuntimeError( + f"Container exec failed: {command}\n" + f"Exit code: {result.returncode}\n" + f"Error: {result.stderr}" + ) + + return result + + +def exec_container_command( + container: ContainerExecInstance, + command: str, + timeout: int = 60, + check: bool = True +) -> subprocess.CompletedProcess[str]: + """Execute command in container via podman exec. + + Args: + container: Container instance + command: Shell command to execute + timeout: Command timeout in seconds + check: Raise exception on non-zero exit code + + Returns: + Completed process with stdout/stderr + """ + result = exec_container_command_raw( + container.container_name, + ["bash", "-c", command], + timeout=timeout + ) + + if check and result.returncode != 0: + logger.error( + "Container exec failed (rc=%d): %s\nstdout: %s\nstderr: %s", + result.returncode, command, result.stdout, result.stderr + ) + raise ContainerRuntimeError( + f"Container exec failed: {command}\n" + f"Exit code: {result.returncode}\n" + f"Error: {result.stderr}" + ) + + return result + + +def destroy_exec_container(container: ContainerExecInstance) -> None: + """Stop and remove exec container.""" + logger.info("Destroying exec container %s", container.container_name) + + # Clear package cache for this container name to avoid stale state when + # a new container reuses the same name in a later suite. + _installed_packages_cache.pop(container.container_name, None) + + # Stop container + try: + _run_container_cmd([ + PODMAN.name, "stop", "-t", "10", container.container_name + ]) + except ContainerRuntimeError: + logger.warning("Failed to stop container gracefully") + + # Remove container + try: + _run_container_cmd([ + PODMAN.name, "rm", "-f", container.container_name + ]) + except ContainerRuntimeError: + logger.warning("Failed to remove container") + diff --git a/base/images/tests/utils/pytest_plugin.py b/base/images/tests/utils/pytest_plugin.py index 2528fba187a..bcbf1b292cd 100644 --- a/base/images/tests/utils/pytest_plugin.py +++ b/base/images/tests/utils/pytest_plugin.py @@ -108,6 +108,18 @@ def pytest_configure(config) -> None: # type: ignore[no-untyped-def] "markers", "image(name): only run this test when --image-name matches", ) + config.addinivalue_line( + "markers", + "static: static filesystem tests without container runtime", + ) + config.addinivalue_line( + "markers", + "static_container_test: portable static container structure tests", + ) + config.addinivalue_line( + "markers", + "runtime_container_tests: tests requiring a running container runtime", + ) from utils.tools import check_tools @@ -142,8 +154,15 @@ def pytest_runtest_setup(item: pytest.Item) -> None: caps = parse_capabilities(item.config.getoption("--capabilities", default=None)) for marker in item.iter_markers("require_capability"): required = marker.args[0] if marker.args else None - if required and required not in caps: - pytest.skip(f"requires capability '{required}' (not in: {sorted(caps)})") + if required: + # Handle both single capability strings and lists of capabilities + if isinstance(required, list): + missing = [cap for cap in required if cap not in caps] + if missing: + pytest.skip(f"requires capabilities {missing} (not in: {sorted(caps)})") + else: + if required not in caps: + pytest.skip(f"requires capability '{required}' (not in: {sorted(caps)})") # image: skip if --image-name doesn't match. image_name = item.config.getoption("--image-name", default=None) From 9579df13d1509ad8b526328c676fc45ea6ac0490 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Tue, 12 May 2026 23:51:42 +0530 Subject: [PATCH 02/10] Handle PR comments --- .../runtime/test_container_bvt.py | 447 ++++++++++++++++++ .../container-base/static/test_container.py | 127 +++++ .../cases/container-base/test_container.py | 20 - .../container-base/test_container_bvt.py | 345 -------------- .../container-base/test_container_static.py | 158 ------- base/images/tests/conftest.py | 128 ++--- base/images/tests/utils/container_runtime.py | 263 +---------- base/images/tests/utils/pytest_plugin.py | 31 +- 8 files changed, 673 insertions(+), 846 deletions(-) create mode 100644 base/images/tests/cases/container-base/runtime/test_container_bvt.py create mode 100644 base/images/tests/cases/container-base/static/test_container.py delete mode 100644 base/images/tests/cases/container-base/test_container.py delete mode 100644 base/images/tests/cases/container-base/test_container_bvt.py delete mode 100644 base/images/tests/cases/container-base/test_container_static.py diff --git a/base/images/tests/cases/container-base/runtime/test_container_bvt.py b/base/images/tests/cases/container-base/runtime/test_container_bvt.py new file mode 100644 index 00000000000..ca2e7c178e0 --- /dev/null +++ b/base/images/tests/cases/container-base/runtime/test_container_bvt.py @@ -0,0 +1,447 @@ +# SPDX-License-Identifier: MIT +"""Container BVT (Build Verification Tests). + +All tests run inside a live container via podman exec +""" + +from __future__ import annotations + +import json +import time + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _cmd_ok(container_exec, cmd: str, timeout: int = 10) -> tuple[bool, str]: + """Run cmd; return (success, stdout). Never raises.""" + try: + result = container_exec(cmd, timeout=timeout) + return result.returncode == 0, result.stdout.strip() + except Exception: + return False, "" + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.requires_pkg("procps-ng", "gawk") +def test_bvt_system_footprint(container_exec) -> None: + """BVT: Memory, disk, CPU, and process footprint metrics.""" + # Memory + ok, out = _cmd_ok(container_exec, "free -m | awk '/^Mem:/ {print $2, $3}'") + assert ok, "free command failed — procps-ng may be missing" + parts = out.split() + assert len(parts) == 2, f"Unexpected free output: {out!r}" + total_mb = int(parts[0]) + assert total_mb > 0, f"Invalid total memory: {total_mb} MB" + print(f"Memory: total={parts[0]} MB, used={parts[1]} MB") + + # Disk + ok, out = _cmd_ok(container_exec, "df -h / | awk 'NR==2 {print $2, $3, $4}'") + assert ok, "df command failed" + print(f"Disk (total used avail): {out}") + + # CPU + ok, out = _cmd_ok(container_exec, "nproc") + assert ok, "nproc failed" + assert int(out) > 0, f"Unexpected CPU count: {out}" + print(f"CPU cores: {out}") + + # Process count + ok, out = _cmd_ok(container_exec, "ps aux --no-headers | wc -l") + assert ok, "ps failed — procps-ng may be missing" + count = int(out) + assert count >= 1, f"Too few processes: {count}" + print(f"Running processes: {count}") + + # Package count (optional — rpm may be absent in distroless builds) + ok, out = _cmd_ok(container_exec, "rpm -qa | wc -l", timeout=15) + if ok: + print(f"Installed packages: {out}") + else: + print("rpm not available — skipping package count") + + +@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.requires_pkg("util-linux") +def test_bvt_logging(container_exec) -> None: + """BVT: System logging via logger and log file/journal access.""" + tag = f"bvt_{int(time.time())}" + message = f"BVT log test entry {tag}" + + ok, _ = _cmd_ok(container_exec, f"logger -t bvt_test '{message}'", timeout=10) + assert ok, "logger command failed — util-linux may be missing" + + # Find the entry: try journalctl, then /var/log/messages, then /var/log/syslog + found = False + ok, out = _cmd_ok(container_exec, f"journalctl --no-pager --since '1 minute ago' 2>/dev/null | grep '{tag}' || true", timeout=15) + if ok and tag in out: + found = True + + if not found: + for logfile in ("/var/log/messages", "/var/log/syslog"): + ok, out = _cmd_ok(container_exec, f"tail -50 {logfile} 2>/dev/null | grep '{tag}' || true") + if ok and tag in out: + found = True + break + + # Log entry visibility depends on journald/syslog being wired up in the container. + if found: + print(f"Log entry verified in system logs: {message}") + else: + print(f"WARNING: log entry not immediately visible (journald may not be running in container): {message}") + + +@pytest.mark.require_capability("container") +def test_bvt_mathematical_computing(container_exec) -> None: + """BVT: Shell arithmetic, bc floating-point, and python3 math.""" + # Shell integer arithmetic (always available via bash) + ok, out = _cmd_ok(container_exec, "echo $((2 + 2))") + assert ok and out == "4", f"Basic arithmetic failed: {out!r}" + + ok, out = _cmd_ok(container_exec, "echo $((123 * 456))") + assert ok and out == str(123 * 456), f"Multiplication failed: {out!r}" + + # bc (optional) + ok, out = _cmd_ok(container_exec, "echo 'scale=2; 22/7' | bc", timeout=10) + if ok: + val = float(out) + assert 3.1 < val < 3.2, f"bc pi approximation out of range: {val}" + print(f"bc: 22/7 = {val}") + else: + print("bc not available — skipping floating-point test") + + # python3 (optional) + py_cmd = r"""python3 -c "import math; print(f'{math.pi:.6f}'); print(f'{math.sqrt(16):.1f}'); print('OK')" """ + ok, out = _cmd_ok(container_exec, py_cmd, timeout=15) + if ok and "OK" in out: + assert "3.14159" in out, f"Unexpected pi: {out}" + assert "4.0" in out, f"Unexpected sqrt(16): {out}" + print(f"python3 math verified: {out.splitlines()[0]}") + else: + print("python3 not available — skipping Python math test") + + +@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.requires_pkg("iproute", "iputils", "hostname") +def test_bvt_networking(container_exec) -> None: + """BVT: Network interfaces, loopback ping, hostname, and routing.""" + # Network interfaces + ok, out = _cmd_ok(container_exec, "ip addr show", timeout=10) + assert ok, "ip command failed — iproute2 may be missing" + inet_lines = [l.strip() for l in out.splitlines() if "inet " in l and "scope" in l] + assert len(inet_lines) >= 1, f"No inet addresses found:\n{out}" + print(f"Network interfaces ({len(inet_lines)} addresses): {inet_lines}") + + # Loopback presence (the inet 127.0.0.1 line above already proves loopback + # exists). ping is informational only: rootless podman with slirp4netns + # often blocks ICMP regardless of iputils being installed. + assert any("127.0.0.1" in l for l in inet_lines), "Loopback interface missing" + ok, out = _cmd_ok(container_exec, "ping -c 2 -W 3 127.0.0.1", timeout=15) + if ok: + print("Loopback ping succeeded") + else: + print(f"Loopback ping not available (likely rootless ICMP restriction): {out[:120]}") + + # Hostname (hostname package was installed via requires_pkg) + ok, out = _cmd_ok(container_exec, "hostname") + assert ok, f"hostname command failed: {out!r}" + print(f"Hostname: {out or '(empty - no --hostname set)'}") + + # Routing table (informational) + ok, out = _cmd_ok(container_exec, "ip route show", timeout=10) + if ok: + print(f"Routing table present, default route: {'default' in out}") + + # DNS config (informational) + ok, out = _cmd_ok(container_exec, "cat /etc/resolv.conf") + if ok: + print(f"DNS resolvers configured: {'nameserver' in out}") + + +@pytest.mark.require_capability("container") +def test_bvt_external_connectivity(container_exec) -> None: + """BVT: External DNS resolution and optional HTTP connectivity.""" + domains = ["google.com", "microsoft.com", "github.com"] + + ok_nslookup, _ = _cmd_ok(container_exec, "which nslookup", timeout=5) + if ok_nslookup: + resolved = sum( + 1 for domain in domains + for ok, out in [_cmd_ok(container_exec, f"nslookup {domain}", timeout=10)] + if ok and "NXDOMAIN" not in out + ) + print(f"DNS resolved {resolved}/{len(domains)} domains") + assert resolved >= 1, f"All DNS resolutions failed for {domains}" + else: + print("nslookup not available — skipping DNS resolution test") + + # HTTP connectivity (optional) + ok_curl, _ = _cmd_ok(container_exec, "which curl", timeout=5) + if ok_curl: + ok, out = _cmd_ok(container_exec, "curl -s --connect-timeout 5 --max-time 10 http://httpbin.org/get", timeout=15) + if ok and '"origin"' in out: + print("External HTTP connectivity verified") + else: + print("External HTTP test skipped or blocked by network policy") + else: + print("curl not available — skipping HTTP connectivity test") + + +@pytest.mark.require_capability("container") +def test_bvt_filesystem_operations(container_exec) -> None: + """BVT: File create, read, chmod, and delete in /tmp.""" + content = "Azure Linux BVT filesystem test" + path = "/tmp/bvt_test_file.txt" + + ok, _ = _cmd_ok(container_exec, f"echo '{content}' > {path}") + assert ok, f"File creation failed: {path}" + + ok, out = _cmd_ok(container_exec, f"cat {path}") + assert ok and content in out, f"File content mismatch: {out!r}" + + ok, out = _cmd_ok(container_exec, f"chmod 644 {path} && stat -c '%a' {path}") + assert ok and "644" in out, f"chmod/stat failed: {out!r}" + + ok, _ = _cmd_ok(container_exec, f"rm {path}") + assert ok, "File deletion failed" + + for sysfile in ("/etc/os-release", "/proc/version"): + ok, _ = _cmd_ok(container_exec, f"test -r {sysfile}") + assert ok, f"Cannot read required system file: {sysfile}" + + print("Filesystem operations verified") + + +@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.requires_pkg("procps-ng") +def test_bvt_process_management(container_exec) -> None: + """BVT: Background process start, list, and kill.""" + ok, _ = _cmd_ok(container_exec, "sleep 60 &", timeout=5) + assert ok, "Failed to start background process" + + ok, out = _cmd_ok(container_exec, "ps aux | grep '[s]leep 60'", timeout=10) + assert ok and "sleep 60" in out, f"Background process not found in ps: {out}" + + ok, _ = _cmd_ok(container_exec, "pkill -f 'sleep 60'", timeout=10) + assert ok, "pkill failed" + + ok, out = _cmd_ok(container_exec, "ps aux | grep '[s]leep 60' || true") + assert "sleep 60" not in out, "Process still running after pkill" + print("Process management verified") + + +@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.requires_pkg("shadow-utils") +def test_bvt_user_management(container_exec) -> None: + """BVT: Create, verify, switch to, and delete a transient test user.""" + user = "bvt_testuser" + + ok, out = _cmd_ok(container_exec, f"useradd -m {user}", timeout=15) + assert ok, f"useradd failed — shadow-utils may be missing: {out}" + + ok, out = _cmd_ok(container_exec, f"id {user}") + assert ok and user in out, f"User not found: {out}" + + ok, _ = _cmd_ok(container_exec, f"test -d /home/{user}") + assert ok, f"Home directory /home/{user} not created" + + # Verify user is in passwd file (su may not work in minimal containers without TTY) + ok, out = _cmd_ok(container_exec, f"grep '^{user}:' /etc/passwd") + assert ok, f"User not in passwd file: {user}" + + ok, _ = _cmd_ok(container_exec, f"userdel -r {user}", timeout=15) + assert ok, "userdel failed" + print(f"User management verified for: {user}") + + +@pytest.mark.require_capability("container", "runtime-package-management") +def test_bvt_package_management(container_exec) -> None: + """BVT: Package manager (tdnf/dnf) cache refresh, list, and info.""" + ok_tdnf, _ = _cmd_ok(container_exec, "which tdnf", timeout=5) + pm = "tdnf" if ok_tdnf else "dnf" + + ok, out = _cmd_ok(container_exec, f"{pm} makecache", timeout=60) + assert ok, f"{pm} makecache failed: {out}" + + ok, out = _cmd_ok(container_exec, f"{pm} list --installed | head -20", timeout=30) + assert ok, f"Package listing failed: {out}" + assert any(kw in out.lower() for kw in ("azure", "bash", "filesystem")), \ + f"Expected Azure Linux packages not found in listing: {out[:200]}" + + ok, out = _cmd_ok(container_exec, f"{pm} info bash", timeout=15) + assert ok, f"Package info for bash failed: {out}" + print(f"Package management verified via {pm}") + + +@pytest.mark.require_capability("container") +def test_bvt_environment_variables(container_exec, container_info: dict) -> None: + """BVT: Essential environment variables and custom variable export.""" + ok, out = _cmd_ok(container_exec, "env") + assert ok, "env command failed" + assert "PATH=" in out, "Required env var PATH not set" + print([l for l in out.splitlines() if l.startswith("PATH=")][0]) + + ok, out = _cmd_ok(container_exec, "export BVT_VAR=azure_linux_bvt && echo $BVT_VAR") + assert ok and "azure_linux_bvt" in out, f"Custom env var not set: {out!r}" + + ok, out = _cmd_ok(container_exec, "echo $HOSTNAME") + assert ok and len(out) > 0, "HOSTNAME is empty" + print(f"Container HOSTNAME: {out}") + + +@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.requires_pkg("procps-ng") +def test_bvt_container_health_summary(container_exec, container_info: dict) -> None: + """BVT: Aggregated health summary — OS info, memory, processes, packages.""" + health: dict = { + "container_name": container_info["container_name"], + "container_ip": container_info["ip_address"], + "timestamp": int(time.time()), + } + + ok, out = _cmd_ok(container_exec, "grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d '\"'") + assert ok and out, "Could not read /etc/os-release" + health["os"] = out + + ok, out = _cmd_ok(container_exec, "uname -r") + if ok: + health["kernel"] = out + + ok, out = _cmd_ok(container_exec, "uptime -s 2>/dev/null || uptime") + if ok: + health["uptime"] = out + + # Memory: read /proc/meminfo directly with pure shell (no awk dependency) + ok, out = _cmd_ok(container_exec, "grep '^MemTotal:' /proc/meminfo") + if ok and out: + # Format: "MemTotal: 16384000 kB" + parts = out.split() + if len(parts) >= 2 and parts[1].isdigit(): + health["memory_total_mb"] = int(parts[1]) // 1024 + + ok, out = _cmd_ok(container_exec, "ps aux --no-headers | wc -l") + if ok: + health["process_count"] = int(out) + + ok, out = _cmd_ok(container_exec, "rpm -qa | wc -l", timeout=15) + if ok: + health["package_count"] = int(out) + + assert health.get("container_name"), "Missing container name" + assert health.get("container_ip"), "Missing container IP" + assert health.get("os"), "Missing OS information" + assert health.get("memory_total_mb", 0) > 0, "Invalid memory reading" + assert health.get("process_count", 0) >= 1, "No processes detected" + + print("\n" + "=" * 60) + print("CONTAINER BVT HEALTH SUMMARY") + print("=" * 60) + print(json.dumps(health, indent=2)) + print("=" * 60) + + +# --------------------------------------------------------------------------- +# Ports of legacy CBL-Mariner ContainerBase BVT tests +# --------------------------------------------------------------------------- + +# First 50 digits after "3." of pi — used to verify high-precision arithmetic. +_PI_PREFIX_50 = "14159265358979323846264338327950288419716939937510" + + +@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.requires_pkg("python3") +def test_bvt_pi_to_1000_places(container_exec) -> None: + """BVT: High-precision pi to 1000 digits via python3 ``decimal``. + + Ports the legacy "Pi to 1000 places". Uses Machin's formula in the + container's Python to avoid extra package dependencies. + """ + py_cmd = "python3 <<'PYEOF'\n" + ( + "from decimal import Decimal, getcontext\n" + "getcontext().prec = 1010\n" + "def atan(x):\n" + " s = Decimal(0); t = x; n = Decimal(1); sign = 1\n" + " x2 = x * x\n" + " while t / n != 0:\n" + " s += sign * t / n\n" + " t *= x2; n += 2; sign = -sign\n" + " return s\n" + "pi = 16 * atan(Decimal(1) / 5) - 4 * atan(Decimal(1) / 239)\n" + "print(str(pi)[:1002])\n" + ) + "PYEOF\n" + ok, out = _cmd_ok(container_exec, py_cmd, timeout=60) + assert ok, f"python3 pi computation failed — python3 may be missing: {out!r}" + # Output is "3." + 1000 digits + assert out.startswith("3."), f"Unexpected pi output prefix: {out[:20]!r}" + digits = out[2:] + assert len(digits) >= 1000, f"Got only {len(digits)} digits of pi" + assert digits.startswith(_PI_PREFIX_50), \ + f"Pi digits incorrect at the start: got {digits[:50]!r}" + print(f"Pi to 1000 places verified (first 50 digits: {digits[:50]})") + + +@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.requires_pkg("python3") +def test_bvt_pi_repeated_iterations(container_exec) -> None: + """BVT: Compute pi to 1000 places repeatedly (CPU stress + consistency). + + Ports the legacy "Pi to N x 1000 places" — runs the calculation 10 times + and asserts every iteration yields the same prefix. + """ + py_cmd = "python3 <<'PYEOF'\n" + ( + "from decimal import Decimal, getcontext\n" + "getcontext().prec = 1010\n" + "def atan(x):\n" + " s = Decimal(0); t = x; n = Decimal(1); sign = 1\n" + " x2 = x * x\n" + " while t / n != 0:\n" + " s += sign * t / n\n" + " t *= x2; n += 2; sign = -sign\n" + " return s\n" + "results = set()\n" + "for _ in range(10):\n" + " results.add(str(16 * atan(Decimal(1) / 5) - 4 * atan(Decimal(1) / 239))[:52])\n" + "print(len(results), next(iter(results)))\n" + ) + "PYEOF\n" + ok, out = _cmd_ok(container_exec, py_cmd, timeout=120) + assert ok, f"python3 repeated pi computation failed: {out!r}" + parts = out.split(maxsplit=1) + assert len(parts) == 2, f"Unexpected output: {out!r}" + unique_count, sample = int(parts[0]), parts[1] + assert unique_count == 1, f"Pi values diverged across iterations: {unique_count} distinct values" + assert sample.startswith("3."), f"Bad pi sample: {sample!r}" + assert sample[2:].startswith(_PI_PREFIX_50), f"Pi prefix wrong: {sample!r}" + print(f"Pi to 1000 places consistent across 10 iterations: {sample[:52]}") + + +@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.requires_pkg("curl") +def test_bvt_sustained_http_fetch(container_exec) -> None: + """BVT: Sustained external HTTP — 50 sequential fetches, ≥90% success. + + Ports the legacy "Core Networking Test" (50-iteration page fetch). + """ + iterations = 50 + url = "http://httpbin.org/get" + # One bash loop is far cheaper than 50 podman exec invocations. + cmd = ( + f"i=0; ok=0; while [ $i -lt {iterations} ]; do " + f"curl -s -o /dev/null -w '%{{http_code}}\\n' " + f"--connect-timeout 5 --max-time 10 {url} | " + f"grep -q '^2' && ok=$((ok+1)); i=$((i+1)); done; echo $ok" + ) + ok, out = _cmd_ok(container_exec, cmd, timeout=iterations * 12) + assert ok, f"Sustained fetch loop failed: {out!r}" + success = int(out.strip().splitlines()[-1]) + success_rate = success / iterations + print(f"Sustained HTTP fetch: {success}/{iterations} succeeded ({success_rate:.0%})") + assert success_rate >= 0.9, \ + f"HTTP success rate {success_rate:.0%} below 90% threshold ({success}/{iterations})" diff --git a/base/images/tests/cases/container-base/static/test_container.py b/base/images/tests/cases/container-base/static/test_container.py new file mode 100644 index 00000000000..dda6db52807 --- /dev/null +++ b/base/images/tests/cases/container-base/static/test_container.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import os +import re +import pytest +from pathlib import Path + +# Default upper-bound for the unpacked container rootfs (MiB). +# Ported from the legacy "Memory Usage Check" / "Container Size Max Check" +# in the CBL-Mariner ContainerBase BVT. Override via env var +# AZL_CONTAINER_MAX_SIZE_MB to adjust the gate per build flavor. +_DEFAULT_MAX_ROOTFS_MB = 250 + + + +def test_no_kernel_modules(rootfs: Path) -> None: + """Container images must not ship kernel modules.""" + for modules_dir in ( + rootfs / "lib" / "modules", + rootfs / "usr" / "lib" / "modules", + ): + if modules_dir.exists(): + versions = list(modules_dir.iterdir()) + assert not versions, ( + f"Container image has kernel modules under {modules_dir}: " + f"{[v.name for v in versions]}" + ) + +def test_file_exists_root(rootfs: Path) -> None: + """Root directory must exist.""" + assert rootfs.exists(), "Root directory does not exist" + + +def test_file_exists_bin_date(rootfs: Path) -> None: + """Date binary must exist and be executable by owner.""" + path = rootfs / "bin" / "date" + assert path.exists(), f"File {path} does not exist" + # Check executable by owner (user x bit) + mode = path.stat().st_mode + assert mode & 0o100, f"File {path} is not executable by owner" + + +def test_file_exists_bin_bash(rootfs: Path) -> None: + """Bash must exist.""" + path = rootfs / "bin" / "bash" + assert path.exists(), f"File {path} does not exist" + + +def test_file_exists_etc_dnf_dnf_conf(rootfs: Path) -> None: + """DNF config must exist.""" + path = rootfs / "etc" / "dnf" / "dnf.conf" + assert path.exists(), f"File {path} does not exist" + + +def test_file_not_exists_etc_dummy(rootfs: Path) -> None: + """Dummy file should not exist (negative test).""" + path = rootfs / "etc" / "dummy" + assert not path.exists(), f"File {path} should not exist" + + +def test_os_release(rootfs: Path, os_release: dict[str, str]) -> None: + """os-release must exist and identify the container variant. + + ID and VERSION_ID are validated globally in cases/test_os_release.py; + here we only assert the container-specific VARIANT_ID. + """ + path = rootfs / "etc" / "os-release" + assert path.exists(), f"File {path} does not exist" + assert os_release.get("VARIANT_ID") == "container", \ + f"Expected VARIANT_ID=container, got {os_release.get('VARIANT_ID')}" + + +def test_passwd(rootfs: Path) -> None: + """passwd must exist and contain a valid root entry.""" + path = rootfs / "etc" / "passwd" + assert path.exists(), f"File {path} does not exist" + content = path.read_text() + # Pattern: root:x:0:0:Super User:/root:/bin/bash or similar + pattern = r"^root:x:0:0:.*:/root:/bin/bash" + assert re.search(pattern, content, re.MULTILINE), \ + f"Root entry not found in passwd file matching pattern {pattern}" + + +def test_license_bash_exists(rootfs: Path) -> None: + """Bash license file must exist.""" + path = rootfs / "usr" / "share" / "licenses" / "bash" / "COPYING" + assert path.exists(), f"License file {path} does not exist" + + +def test_license_coreutils_single_exists(rootfs: Path) -> None: + """coreutils-single license file must exist.""" + path = rootfs / "usr" / "share" / "licenses" / "coreutils-single" / "COPYING" + assert path.exists(), f"License file {path} does not exist" + + +def test_root_home_dir_exists(rootfs: Path) -> None: + """Root user home directory must exist.""" + root_home = rootfs / "root" + assert root_home.exists(), "root home directory does not exist" + + +def test_container_rootfs_size(rootfs: Path) -> None: + """Container rootfs footprint must stay within the configured max. + + Ports the legacy ContainerBase BVT "Memory Usage Check" / + "Container Size Max Check": walks the extracted rootfs and asserts + the total on-disk size is under AZL_CONTAINER_MAX_SIZE_MB (MiB). + """ + max_mb = int(os.environ.get("AZL_CONTAINER_MAX_SIZE_MB", _DEFAULT_MAX_ROOTFS_MB)) + + total_bytes = 0 + for dirpath, _dirnames, filenames in os.walk(rootfs, followlinks=False): + for name in filenames: + fp = Path(dirpath) / name + try: + # lstat: don't follow symlinks, don't double-count targets + total_bytes += fp.lstat().st_size + except (FileNotFoundError, PermissionError): + continue + + total_mb = total_bytes / (1024 * 1024) + print(f"Container rootfs size: {total_mb:.1f} MiB (limit {max_mb} MiB)") + assert total_mb <= max_mb, ( + f"Container rootfs is {total_mb:.1f} MiB, exceeds limit of {max_mb} MiB" + ) diff --git a/base/images/tests/cases/container-base/test_container.py b/base/images/tests/cases/container-base/test_container.py deleted file mode 100644 index 61a6fd5c43e..00000000000 --- a/base/images/tests/cases/container-base/test_container.py +++ /dev/null @@ -1,20 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Container-specific validation tests.""" - -from __future__ import annotations - -from pathlib import Path - - -def test_no_kernel_modules(rootfs: Path) -> None: - """Container images must not ship kernel modules.""" - for modules_dir in ( - rootfs / "lib" / "modules", - rootfs / "usr" / "lib" / "modules", - ): - if modules_dir.exists(): - versions = list(modules_dir.iterdir()) - assert not versions, ( - f"Container image has kernel modules under {modules_dir}: " - f"{[v.name for v in versions]}" - ) diff --git a/base/images/tests/cases/container-base/test_container_bvt.py b/base/images/tests/cases/container-base/test_container_bvt.py deleted file mode 100644 index afc70448ec2..00000000000 --- a/base/images/tests/cases/container-base/test_container_bvt.py +++ /dev/null @@ -1,345 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Container BVT (Build Verification Tests). - -All tests run inside a live container via podman exec and are marked @runtime_container_tests. -Tests are designed to be resilient to minimal container images — missing optional -tools are reported but do not fail the test unless they are essential. -""" - -from __future__ import annotations - -import json -import time - -import pytest - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _cmd_ok(ssh_exec, cmd: str, timeout: int = 10) -> tuple[bool, str]: - """Run cmd; return (success, stdout). Never raises.""" - try: - result = ssh_exec(cmd, timeout=timeout) - return result.returncode == 0, result.stdout.strip() - except Exception: - return False, "" - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container") -def test_bvt_system_footprint(ssh_exec) -> None: - """BVT: Memory, disk, CPU, and process footprint metrics.""" - # Memory - ok, out = _cmd_ok(ssh_exec, "free -m | awk '/^Mem:/ {print $2, $3}'") - assert ok, "free command failed — procps-ng may be missing" - parts = out.split() - assert len(parts) == 2, f"Unexpected free output: {out!r}" - total_mb = int(parts[0]) - assert total_mb > 0, f"Invalid total memory: {total_mb} MB" - print(f"Memory: total={parts[0]} MB, used={parts[1]} MB") - - # Disk - ok, out = _cmd_ok(ssh_exec, "df -h / | awk 'NR==2 {print $2, $3, $4}'") - assert ok, "df command failed" - print(f"Disk (total used avail): {out}") - - # CPU - ok, out = _cmd_ok(ssh_exec, "nproc") - assert ok, "nproc failed" - assert int(out) > 0, f"Unexpected CPU count: {out}" - print(f"CPU cores: {out}") - - # Process count - ok, out = _cmd_ok(ssh_exec, "ps aux --no-headers | wc -l") - assert ok, "ps failed — procps-ng may be missing" - count = int(out) - assert count >= 1, f"Too few processes: {count}" - print(f"Running processes: {count}") - - # Package count (optional — rpm may be absent in distroless builds) - ok, out = _cmd_ok(ssh_exec, "rpm -qa | wc -l", timeout=15) - if ok: - print(f"Installed packages: {out}") - else: - print("rpm not available — skipping package count") - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container") -def test_bvt_logging(ssh_exec) -> None: - """BVT: System logging via logger and log file/journal access.""" - tag = f"bvt_{int(time.time())}" - message = f"BVT log test entry {tag}" - - ok, _ = _cmd_ok(ssh_exec, f"logger -t bvt_test '{message}'", timeout=10) - assert ok, "logger command failed — util-linux may be missing" - - # Find the entry: try journalctl, then /var/log/messages, then /var/log/syslog - found = False - ok, out = _cmd_ok(ssh_exec, f"journalctl --no-pager --since '1 minute ago' 2>/dev/null | grep '{tag}' || true", timeout=15) - if ok and tag in out: - found = True - - if not found: - for logfile in ("/var/log/messages", "/var/log/syslog"): - ok, out = _cmd_ok(ssh_exec, f"tail -50 {logfile} 2>/dev/null | grep '{tag}' || true") - if ok and tag in out: - found = True - break - - # Log entry visibility depends on journald/syslog being wired up in the container. - if found: - print(f"Log entry verified in system logs: {message}") - else: - print(f"WARNING: log entry not immediately visible (journald may not be running in container): {message}") - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container") -def test_bvt_mathematical_computing(ssh_exec) -> None: - """BVT: Shell arithmetic, bc floating-point, and python3 math.""" - # Shell integer arithmetic (always available via bash) - ok, out = _cmd_ok(ssh_exec, "echo $((2 + 2))") - assert ok and out == "4", f"Basic arithmetic failed: {out!r}" - - ok, out = _cmd_ok(ssh_exec, "echo $((123 * 456))") - assert ok and out == str(123 * 456), f"Multiplication failed: {out!r}" - - # bc (optional) - ok, out = _cmd_ok(ssh_exec, "echo 'scale=2; 22/7' | bc", timeout=10) - if ok: - val = float(out) - assert 3.1 < val < 3.2, f"bc pi approximation out of range: {val}" - print(f"bc: 22/7 = {val}") - else: - print("bc not available — skipping floating-point test") - - # python3 (optional) - py_cmd = r"""python3 -c "import math; print(f'{math.pi:.6f}'); print(f'{math.sqrt(16):.1f}'); print('OK')" """ - ok, out = _cmd_ok(ssh_exec, py_cmd, timeout=15) - if ok and "OK" in out: - assert "3.14159" in out, f"Unexpected pi: {out}" - assert "4.0" in out, f"Unexpected sqrt(16): {out}" - print(f"python3 math verified: {out.splitlines()[0]}") - else: - print("python3 not available — skipping Python math test") - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container") -def test_bvt_networking(ssh_exec) -> None: - """BVT: Network interfaces, loopback ping, hostname, and routing.""" - # Network interfaces - ok, out = _cmd_ok(ssh_exec, "ip addr show", timeout=10) - assert ok, "ip command failed — iproute2 may be missing" - inet_lines = [l.strip() for l in out.splitlines() if "inet " in l and "scope" in l] - assert len(inet_lines) >= 1, f"No inet addresses found:\n{out}" - print(f"Network interfaces ({len(inet_lines)} addresses): {inet_lines}") - - # Loopback ping - ok, out = _cmd_ok(ssh_exec, "ping -c 2 -W 3 127.0.0.1", timeout=15) - assert ok, f"Loopback ping failed — iputils may be missing: {out}" - - # Hostname - ok, out = _cmd_ok(ssh_exec, "hostname") - assert ok and len(out) > 0, f"hostname command failed or returned empty: {out!r}" - print(f"Hostname: {out}") - - # Routing table (informational) - ok, out = _cmd_ok(ssh_exec, "ip route show", timeout=10) - if ok: - print(f"Routing table present, default route: {'default' in out}") - - # DNS config (informational) - ok, out = _cmd_ok(ssh_exec, "cat /etc/resolv.conf") - if ok: - print(f"DNS resolvers configured: {'nameserver' in out}") - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container") -def test_bvt_external_connectivity(ssh_exec) -> None: - """BVT: External DNS resolution and optional HTTP connectivity.""" - domains = ["google.com", "microsoft.com", "github.com"] - - ok_nslookup, _ = _cmd_ok(ssh_exec, "which nslookup", timeout=5) - if ok_nslookup: - resolved = sum( - 1 for domain in domains - for ok, out in [_cmd_ok(ssh_exec, f"nslookup {domain}", timeout=10)] - if ok and "NXDOMAIN" not in out - ) - print(f"DNS resolved {resolved}/{len(domains)} domains") - assert resolved >= 1, f"All DNS resolutions failed for {domains}" - else: - print("nslookup not available — skipping DNS resolution test") - - # HTTP connectivity (optional) - ok_curl, _ = _cmd_ok(ssh_exec, "which curl", timeout=5) - if ok_curl: - ok, out = _cmd_ok(ssh_exec, "curl -s --connect-timeout 5 --max-time 10 http://httpbin.org/get", timeout=15) - if ok and '"origin"' in out: - print("External HTTP connectivity verified") - else: - print("External HTTP test skipped or blocked by network policy") - else: - print("curl not available — skipping HTTP connectivity test") - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container") -def test_bvt_filesystem_operations(ssh_exec) -> None: - """BVT: File create, read, chmod, and delete in /tmp.""" - content = "Azure Linux BVT filesystem test" - path = "/tmp/bvt_test_file.txt" - - ok, _ = _cmd_ok(ssh_exec, f"echo '{content}' > {path}") - assert ok, f"File creation failed: {path}" - - ok, out = _cmd_ok(ssh_exec, f"cat {path}") - assert ok and content in out, f"File content mismatch: {out!r}" - - ok, out = _cmd_ok(ssh_exec, f"chmod 644 {path} && stat -c '%a' {path}") - assert ok and "644" in out, f"chmod/stat failed: {out!r}" - - ok, _ = _cmd_ok(ssh_exec, f"rm {path}") - assert ok, "File deletion failed" - - for sysfile in ("/etc/os-release", "/proc/version"): - ok, _ = _cmd_ok(ssh_exec, f"test -r {sysfile}") - assert ok, f"Cannot read required system file: {sysfile}" - - print("Filesystem operations verified") - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container") -def test_bvt_process_management(ssh_exec) -> None: - """BVT: Background process start, list, and kill.""" - ok, _ = _cmd_ok(ssh_exec, "sleep 60 &", timeout=5) - assert ok, "Failed to start background process" - - ok, out = _cmd_ok(ssh_exec, "ps aux | grep '[s]leep 60'", timeout=10) - assert ok and "sleep 60" in out, f"Background process not found in ps: {out}" - - ok, _ = _cmd_ok(ssh_exec, "pkill -f 'sleep 60'", timeout=10) - assert ok, "pkill failed" - - ok, out = _cmd_ok(ssh_exec, "ps aux | grep '[s]leep 60' || true") - assert "sleep 60" not in out, "Process still running after pkill" - print("Process management verified") - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container") -def test_bvt_user_management(ssh_exec) -> None: - """BVT: Create, verify, switch to, and delete a transient test user.""" - user = "bvt_testuser" - - ok, out = _cmd_ok(ssh_exec, f"useradd -m {user}", timeout=15) - assert ok, f"useradd failed — shadow-utils may be missing: {out}" - - ok, out = _cmd_ok(ssh_exec, f"id {user}") - assert ok and user in out, f"User not found: {out}" - - ok, _ = _cmd_ok(ssh_exec, f"test -d /home/{user}") - assert ok, f"Home directory /home/{user} not created" - - # Verify user is in passwd file (su may not work in minimal containers without TTY) - ok, out = _cmd_ok(ssh_exec, f"grep '^{user}:' /etc/passwd") - assert ok, f"User not in passwd file: {user}" - - ok, _ = _cmd_ok(ssh_exec, f"userdel -r {user}", timeout=15) - assert ok, "userdel failed" - print(f"User management verified for: {user}") - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container", "runtime-package-management") -def test_bvt_package_management(ssh_exec) -> None: - """BVT: Package manager (tdnf/dnf) cache refresh, list, and info.""" - ok_tdnf, _ = _cmd_ok(ssh_exec, "which tdnf", timeout=5) - pm = "tdnf" if ok_tdnf else "dnf" - - ok, out = _cmd_ok(ssh_exec, f"{pm} makecache", timeout=60) - assert ok, f"{pm} makecache failed: {out}" - - ok, out = _cmd_ok(ssh_exec, f"{pm} list --installed | head -20", timeout=30) - assert ok, f"Package listing failed: {out}" - assert any(kw in out.lower() for kw in ("azure", "bash", "filesystem")), \ - f"Expected Azure Linux packages not found in listing: {out[:200]}" - - ok, out = _cmd_ok(ssh_exec, f"{pm} info bash", timeout=15) - assert ok, f"Package info for bash failed: {out}" - print(f"Package management verified via {pm}") - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container") -def test_bvt_environment_variables(ssh_exec, container_info: dict) -> None: - """BVT: Essential environment variables and custom variable export.""" - ok, out = _cmd_ok(ssh_exec, "env") - assert ok, "env command failed" - assert "PATH=" in out, "Required env var PATH not set" - print([l for l in out.splitlines() if l.startswith("PATH=")][0]) - - ok, out = _cmd_ok(ssh_exec, "export BVT_VAR=azure_linux_bvt && echo $BVT_VAR") - assert ok and "azure_linux_bvt" in out, f"Custom env var not set: {out!r}" - - ok, out = _cmd_ok(ssh_exec, "echo $HOSTNAME") - assert ok and len(out) > 0, "HOSTNAME is empty" - print(f"Container HOSTNAME: {out}") - - -@pytest.mark.runtime_container_tests -@pytest.mark.require_capability("container") -def test_bvt_container_health_summary(ssh_exec, container_info: dict) -> None: - """BVT: Aggregated health summary — OS info, memory, processes, packages.""" - health: dict = { - "container_name": container_info["container_name"], - "container_ip": container_info["ip_address"], - "timestamp": int(time.time()), - } - - ok, out = _cmd_ok(ssh_exec, "grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d '\"'") - assert ok and out, "Could not read /etc/os-release" - health["os"] = out - - ok, out = _cmd_ok(ssh_exec, "uname -r") - if ok: - health["kernel"] = out - - ok, out = _cmd_ok(ssh_exec, "uptime -s 2>/dev/null || uptime") - if ok: - health["uptime"] = out - - ok, out = _cmd_ok(ssh_exec, "free -m | awk '/^Mem:/ {print $2}'") - if ok: - health["memory_total_mb"] = int(out) - - ok, out = _cmd_ok(ssh_exec, "ps aux --no-headers | wc -l") - if ok: - health["process_count"] = int(out) - - ok, out = _cmd_ok(ssh_exec, "rpm -qa | wc -l", timeout=15) - if ok: - health["package_count"] = int(out) - - assert health.get("container_name"), "Missing container name" - assert health.get("container_ip"), "Missing container IP" - assert health.get("os"), "Missing OS information" - assert health.get("memory_total_mb", 0) > 0, "Invalid memory reading" - assert health.get("process_count", 0) >= 1, "No processes detected" - - print("\n" + "=" * 60) - print("CONTAINER BVT HEALTH SUMMARY") - print("=" * 60) - print(json.dumps(health, indent=2)) - print("=" * 60) diff --git a/base/images/tests/cases/container-base/test_container_static.py b/base/images/tests/cases/container-base/test_container_static.py deleted file mode 100644 index ea705bd0b15..00000000000 --- a/base/images/tests/cases/container-base/test_container_static.py +++ /dev/null @@ -1,158 +0,0 @@ -# SPDX-License-Identifier: MIT -"""Ported Azure Linux Container Structure Test (CST) suite. - -These are static filesystem and metadata assertions ported from the Google -Container Structure Test YAML format to pytest. They validate core container -image properties without requiring a running container runtime. - -Mapped from: base/images/tests/cst/azl4_container_static_test.yaml -""" - -from __future__ import annotations - -import re -import pytest -from pathlib import Path - - -# ============================================================================ -# File Existence Tests (7 checks) -# ============================================================================ - - -@pytest.mark.static_container_test -def test_file_exists_root(rootfs: Path) -> None: - """Root directory must exist.""" - assert rootfs.exists(), "Root directory does not exist" - - -@pytest.mark.static_container_test -def test_file_exists_bin_date(rootfs: Path) -> None: - """Date binary must exist and be executable by owner.""" - path = rootfs / "bin" / "date" - assert path.exists(), f"File {path} does not exist" - # Check executable by owner (user x bit) - mode = path.stat().st_mode - assert mode & 0o100, f"File {path} is not executable by owner" - - -@pytest.mark.static_container_test -def test_file_exists_bin_bash(rootfs: Path) -> None: - """Bash must exist.""" - path = rootfs / "bin" / "bash" - assert path.exists(), f"File {path} does not exist" - - -@pytest.mark.static_container_test -def test_file_exists_etc_os_release(rootfs: Path) -> None: - """os-release must exist.""" - path = rootfs / "etc" / "os-release" - assert path.exists(), f"File {path} does not exist" - - -@pytest.mark.static_container_test -def test_file_exists_etc_passwd(rootfs: Path) -> None: - """passwd file must exist.""" - path = rootfs / "etc" / "passwd" - assert path.exists(), f"File {path} does not exist" - - -@pytest.mark.static_container_test -def test_file_exists_etc_dnf_dnf_conf(rootfs: Path) -> None: - """DNF config must exist.""" - path = rootfs / "etc" / "dnf" / "dnf.conf" - assert path.exists(), f"File {path} does not exist" - - -@pytest.mark.static_container_test -def test_file_not_exists_etc_dummy(rootfs: Path) -> None: - """Dummy file should not exist (negative test).""" - path = rootfs / "etc" / "dummy" - assert not path.exists(), f"File {path} should not exist" - - -# ============================================================================ -# File Content Tests (4 checks) -# ============================================================================ - - -@pytest.mark.static_container_test -def test_content_os_release_id(rootfs: Path, os_release: dict[str, str]) -> None: - """os-release must contain Azure Linux ID.""" - assert os_release.get("ID") == "azurelinux", \ - f"Expected ID=azurelinux, got {os_release.get('ID')}" - - -@pytest.mark.static_container_test -def test_content_os_release_version(rootfs: Path, os_release: dict[str, str]) -> None: - """os-release must contain correct version.""" - # Match pattern: VERSION_ID=4.0 (exact) - version_id = os_release.get("VERSION_ID") - assert version_id == "4.0", \ - f"Expected VERSION_ID=4.0, got {version_id}" - - -@pytest.mark.static_container_test -def test_content_os_release_variant(rootfs: Path, os_release: dict[str, str]) -> None: - """os-release must identify as container variant.""" - variant_id = os_release.get("VARIANT_ID") - assert variant_id == "container", \ - f"Expected VARIANT_ID=container, got {variant_id}" - - -@pytest.mark.static_container_test -def test_content_passwd_root_entry(rootfs: Path) -> None: - """passwd file must contain root user entry.""" - path = rootfs / "etc" / "passwd" - content = path.read_text() - # Pattern: root:x:0:0:Super User:/root:/bin/bash or similar - pattern = r"^root:x:0:0:.*:/root:/bin/bash" - assert re.search(pattern, content, re.MULTILINE), \ - f"Root entry not found in passwd file matching pattern {pattern}" - - -# ============================================================================ -# License Tests (2 checks) -# ============================================================================ - - -@pytest.mark.static_container_test -def test_license_bash_exists(rootfs: Path) -> None: - """Bash license file must exist.""" - path = rootfs / "usr" / "share" / "licenses" / "bash" / "COPYING" - assert path.exists(), f"License file {path} does not exist" - - -@pytest.mark.static_container_test -def test_license_coreutils_single_exists(rootfs: Path) -> None: - """coreutils-single license file must exist.""" - path = rootfs / "usr" / "share" / "licenses" / "coreutils-single" / "COPYING" - assert path.exists(), f"License file {path} does not exist" - - -# ============================================================================ -# Metadata Test (1 check) -# ============================================================================ - - -@pytest.mark.static_container_test -def test_metadata_entrypoint(rootfs: Path) -> None: - """Metadata validation: cmd=['/bin/bash'], workdir='/', user='root'. - - Note: This test validates the image structure is set up correctly. - Actual metadata (entrypoint, workdir, user) would be verified when - running the container. Here we just verify core structure is correct. - """ - # Verify bash exists (required for entrypoint) - bash_path = rootfs / "bin" / "bash" - assert bash_path.exists(), "bash not found for entrypoint" - - # Verify root user exists - passwd_path = rootfs / "etc" / "passwd" - content = passwd_path.read_text() - assert re.search(r"^root:", content, re.MULTILINE), \ - "root user not found in passwd" - - # Verify home directory exists - root_home = rootfs / "root" - assert root_home.exists(), "root home directory does not exist" diff --git a/base/images/tests/conftest.py b/base/images/tests/conftest.py index d61527b6514..b213898177c 100644 --- a/base/images/tests/conftest.py +++ b/base/images/tests/conftest.py @@ -221,107 +221,113 @@ def partition_table( # --------------------------------------------------------------------------- -@pytest.fixture(scope="session") +@pytest.fixture def running_container( image_path: Path, image_type: str, image_name: str | None, workdir: Path, request: pytest.FixtureRequest ) -> ContainerExecInstance | None: - """Running container instance with exec access — session fixture with cleanup. - - Uses fast podman exec instead of SSH for better performance. - Only creates container for runtime container tests. + """Fresh container per test, with exec access and guaranteed teardown. + + A new container is started for every runtime test and destroyed on + teardown so tests cannot leak state to one another (installed + packages, created users, processes, files). The container name is + auto-generated to avoid collisions when tests run in parallel. """ if image_type != "container": pytest.skip("running_container only applicable to container images") - - # Check if any tests being run require a live runtime container. - has_runtime_container_tests = any( - item.get_closest_marker("runtime_container_tests") is not None - for item in request.session.items - ) if hasattr(request, 'session') else False - - if not has_runtime_container_tests: + + # Only spin up a container for tests that actually need one. + if not request.node.get_closest_marker("runtime_container_tests"): pytest.skip("running_container only for runtime container tests") - - logger.info("Creating running container for runtime container tests") + + logger.info("Creating fresh container for test %s", request.node.name) container = create_container_with_exec( - image_path, - workdir, - container_name=f"azl-test-{image_name or 'container'}", - image_name=image_name + image_path, + workdir, + container_name=None, # auto-generate a unique name per test + image_name=image_name, ) - + try: yield container finally: - logger.info("Cleaning up running container") + logger.info("Destroying container for test %s", request.node.name) destroy_exec_container(container) @pytest.fixture def container_exec(running_container: ContainerExecInstance): - """Execute commands in running container via podman exec (fast with on-demand packages).""" - def _exec(command: str, timeout: int = 60, check: bool = True) -> subprocess.CompletedProcess[str]: - """Execute command via exec with automatic package installation.""" - from utils.container_runtime import ( - exec_container_command_with_fallback, - detect_required_packages - ) - - # Detect packages that might be needed - required_packages = detect_required_packages(command) - - return exec_container_command_with_fallback( - running_container, command, required_packages, timeout, check + """Execute a shell command in the running container via ``podman exec``. + + Returns a callable ``(command: str, timeout: int = 60) -> + subprocess.CompletedProcess[str]``. The framework does **not** install + packages or mutate the container in any way — tests see the image as + shipped. If a test needs a binary that the image does not ship, it + must declare the dependency itself (and either skip or fail loudly). + """ + from utils.container_runtime import exec_container_command_raw + + def _exec(command: str, timeout: int = 60) -> subprocess.CompletedProcess[str]: + return exec_container_command_raw( + running_container.container_name, + ["bash", "-c", command], + timeout=timeout, ) return _exec -@pytest.fixture -def ssh_exec(running_container: ContainerExecInstance): - """Legacy SSH exec fixture - now uses exec with smart package installation. - - Note: This is kept for backward compatibility but uses exec instead of SSH. - For new tests, prefer using container_exec directly. +@pytest.fixture(autouse=True) +def _apply_requires_pkg(request: pytest.FixtureRequest) -> None: + """Honor ``@pytest.mark.requires_pkg(...)`` on runtime tests. + + Collects all packages declared by ``requires_pkg`` markers on the + test (multiple markers stack), installs them once via ``dnf`` in + the live container, and skips the test on installation failure. + + No-op for tests that don't carry the marker or don't request + ``container_exec`` (e.g. static rootfs tests). """ - def _exec(command: str, timeout: int = 60) -> subprocess.CompletedProcess[str]: - """Execute command via exec with automatic package installation.""" - from utils.container_runtime import ( - exec_container_command_with_fallback, - detect_required_packages - ) - - # Detect packages that might be needed - required_packages = detect_required_packages(command) - - return exec_container_command_with_fallback( - running_container, command, required_packages, timeout, check=True + markers = list(request.node.iter_markers("requires_pkg")) + if not markers: + return + if "container_exec" not in request.fixturenames: + pytest.skip("requires_pkg marker is only valid for runtime container tests") + + pkgs: list[str] = [] + for m in markers: + pkgs.extend(m.args) + if not pkgs: + return + + container_exec = request.getfixturevalue("container_exec") + cmd = "dnf install -y " + " ".join(pkgs) + logger.info("requires_pkg: installing %s", pkgs) + result = container_exec(cmd, timeout=300) + if result.returncode != 0: + pytest.skip( + f"requires_pkg: failed to install {pkgs} (rc={result.returncode}): " + f"{result.stderr.strip()[:200]}" ) - return _exec @pytest.fixture def container_info(running_container: ContainerExecInstance) -> dict[str, str]: - """Container runtime information.""" - # Get the container IP address for compatibility with SSH-based tests + """Container runtime metadata (id, name, image, IP).""" try: from utils.container_runtime import exec_container_command_raw ip_result = exec_container_command_raw( - running_container.container_name, - ["hostname", "-i"], + running_container.container_name, + ["hostname", "-i"], timeout=5 ) container_ip = ip_result.stdout.strip() if ip_result.returncode == 0 else "127.0.0.1" except Exception: container_ip = "127.0.0.1" - + return { "container_id": running_container.container_id, "container_name": running_container.container_name, "image_ref": running_container.image_ref, - # Compatibility fields for SSH-style tests "ip_address": container_ip, - "ssh_port": "22", # Not applicable for exec but needed for compatibility - "ssh_user": "root", } diff --git a/base/images/tests/utils/container_runtime.py b/base/images/tests/utils/container_runtime.py index 15730544346..e9f5159b6dc 100644 --- a/base/images/tests/utils/container_runtime.py +++ b/base/images/tests/utils/container_runtime.py @@ -26,7 +26,7 @@ ) class ContainerExecInstance(NamedTuple): - """Running container instance with exec access (faster alternative to SSH).""" + """Running container instance with podman exec access.""" container_id: str container_name: str image_ref: str @@ -90,22 +90,20 @@ def create_container_with_exec( workdir: Path, container_name: str | None = None, image_name: str | None = None, + image_ref: str | None = None, ) -> ContainerExecInstance: - """Create running container with exec access (fast alternative to SSH). - - This is much faster than SSH-based containers as it: - - Skips SSH key generation - - Skips openssh-server installation - - Skips SSH daemon setup and connectivity testing - - Uses direct podman exec for command execution - + """Create a running container with podman exec access. + Args: image_path: Path to image file or image reference workdir: Working directory (unused but kept for compatibility) container_name: Container name (auto-generated if None) image_name: Image name for logging (derived if None) + image_ref: Pre-resolved image reference (skips ``podman load``). + When provided, ``image_path`` is not touched. Use this to + amortize the ~10s tarball load across many container creates. """ - + # Generate container name if not provided if container_name is None: timestamp = int(time.time()) @@ -113,15 +111,13 @@ def create_container_with_exec( random_suffix = random.randint(1000, 9999) container_name = f"azl-test-{timestamp}-{random_suffix}" - # Container names can be reused across test suites; clear any stale - # package-install cache from prior container instances with same name. - _installed_packages_cache.pop(container_name, None) - - # Get image reference (load if needed) - image_ref = _get_image_reference(image_path) + # Get image reference (load if needed) — but skip if caller already + # resolved one for us (e.g. session-scoped fixture). + if image_ref is None: + image_ref = _get_image_reference(image_path) logger.info("Creating exec container %s from image %s", container_name, image_ref) - # Create and start container - much simpler than SSH version + # Create and start container run_cmd = [ PODMAN.name, "run", "-d", "--name", container_name, @@ -137,61 +133,18 @@ def create_container_with_exec( # Wait briefly for container to start logger.info("Waiting for exec container %s to start...", container_name) - time.sleep(2) # Much shorter wait than SSH setup + time.sleep(2) # Test basic exec access test_result = exec_container_command_raw(container_name, ["echo", "container-ready"]) if test_result.returncode != 0 or "container-ready" not in test_result.stdout: raise ContainerRuntimeError(f"Container exec test failed for {container_name}") - - # Pre-warm the dnf metadata cache so subsequent installs are fast. - # This is non-fatal: if it times out the installs will just be slow. - try: - logger.info("Pre-warming dnf metadata cache") - exec_container_command_raw(container_name, ["dnf", "makecache"], timeout=300) - logger.info("dnf metadata cache warmed") - except subprocess.TimeoutExpired: - logger.warning("dnf makecache timed out — installs may be slow") - # Pre-install essential packages only if any are actually missing. - # Checking binaries first avoids a dnf metadata refresh when the - # image already ships these packages. - essential = { - "procps-ng": "free", - "util-linux": "logger", - "gawk": "awk", - "which": "which", - "hostname": "hostname", - "python3": "python3", - } - missing = [ - pkg for pkg, cmd in essential.items() - if exec_container_command_raw(container_name, ["which", cmd], timeout=5).returncode != 0 - ] - if missing: - logger.info("Installing missing essential packages: %s", missing) - try: - install_result = exec_container_command_raw( - container_name, - ["dnf", "install", "-y"] + missing, - timeout=300, - ) - if install_result.returncode == 0: - logger.info("Essential packages installed successfully") - else: - logger.warning("Some essential packages may not have installed: %s", install_result.stderr) - except subprocess.TimeoutExpired: - logger.warning( - "Preinstall timed out after 300s — packages will be installed on-demand per test" - ) - else: - logger.info("All essential packages already present — skipping dnf install") - logger.info( - "Exec container ready: %s (ID: %s)", + "Exec container ready: %s (ID: %s)", container_name, container_id[:12] ) - + return ContainerExecInstance( container_id=container_id, container_name=container_name, @@ -218,186 +171,6 @@ def exec_container_command_raw( ) -# Global cache to track installed packages per container -_installed_packages_cache: dict[str, set[str]] = {} - -def ensure_packages_in_container(container_name: str, packages: list[str]) -> bool: - """Ensure packages are installed in the container. Install if missing.""" - if not packages: - return True - - # Initialize cache for this container - if container_name not in _installed_packages_cache: - _installed_packages_cache[container_name] = set() - - # Filter out packages we've already tried installing - packages_to_install = [ - pkg for pkg in packages - if pkg not in _installed_packages_cache[container_name] - ] - - if not packages_to_install: - return True # All packages already processed - - # Test commands for each package to see if they're available - package_commands = { - "procps-ng": "free", - "gawk": "awk", - "util-linux": "logger", - "bc": "bc", - "bind-utils": "nslookup", - "iproute": "ip", - "iproute2": "ip", - "iputils": "ping", - "shadow-utils": "useradd", - "python3": "python3", - "which": "which", - "hostname": "hostname", - "systemd": "systemctl", - } - - missing_packages = [] - for package in packages_to_install: - command = package_commands.get(package, package) - test_result = exec_container_command_raw( - container_name, ["which", command], timeout=3 - ) - - if test_result.returncode != 0: - missing_packages.append(package) - - # Install all missing packages in a single command with shorter timeout and retry - if missing_packages: - logger.info("Installing packages %s in container %s", missing_packages, container_name) - - # First attempt with shorter timeout - install_result = exec_container_command_raw( - container_name, - ["dnf", "install", "-y"] + missing_packages, - timeout=90 - ) - - # If first attempt failed due to timeout, try individual packages - if install_result.returncode != 0: - logger.warning("Bulk install failed, trying individual packages") - success_count = 0 - for package in missing_packages: - individual_result = exec_container_command_raw( - container_name, - ["dnf", "install", "-y", package], - timeout=60 - ) - if individual_result.returncode == 0: - success_count += 1 - - # Consider it successful if most packages installed - install_success = success_count >= len(missing_packages) * 0.7 - else: - install_success = True - - # Mark all packages as processed (even if installation failed) - _installed_packages_cache[container_name].update(packages_to_install) - - return install_success - else: - # Mark as processed since all were available - _installed_packages_cache[container_name].update(packages_to_install) - return True - - -def ensure_package_in_container(container_name: str, package: str) -> bool: - """Ensure a package is installed in the container. Install if missing.""" - return ensure_packages_in_container(container_name, [package]) - - -def detect_required_packages(command: str) -> list[str]: - """Detect what packages might be needed for a command.""" - command_to_package = { - "free": "procps-ng", - "ps": "procps-ng", - "awk": "gawk", - "logger": "util-linux", - "bc": "bc", - "nslookup": "bind-utils", - "ip": "iproute", - "ping": "iputils", - "useradd": "shadow-utils", - "systemctl": "systemd", - "python3": "python3", - "python": "python3", - "which": "which", - "hostname": "hostname", - "whereis": "which", - "uptime": "procps-ng", - "top": "procps-ng", - } - - packages = [] - for cmd, pkg in command_to_package.items(): - if cmd in command: - packages.append(pkg) - - return packages - - -def exec_container_command_with_fallback( - container: ContainerExecInstance, - command: str, - required_packages: list[str] | None = None, - timeout: int = 60, - check: bool = True -) -> subprocess.CompletedProcess[str]: - """Execute command in container, installing packages if needed.""" - - # First try the command as-is - result = exec_container_command_raw( - container.container_name, - ["bash", "-c", command], - timeout=timeout - ) - - # If command failed due to missing binary or missing target binary for - # `which `, install required packages and retry once. - should_install = ( - required_packages - and ( - (result.returncode == 127 and "command not found" in result.stderr) - or ( - result.returncode == 1 - and command.strip().startswith("which ") - and "no " in result.stderr - ) - ) - ) - - if should_install: - - logger.info("Command failed, installing required packages: %s", required_packages) - - # Install all required packages in one operation - ensure_packages_in_container(container.container_name, required_packages) - - # Retry the command - result = exec_container_command_raw( - container.container_name, - ["bash", "-c", command], - timeout=timeout - ) - - if check and result.returncode != 0: - logger.error( - "Container exec failed (rc=%d): %s\nstdout: %s\nstderr: %s", - result.returncode, command, result.stdout, result.stderr - ) - raise ContainerRuntimeError( - f"Container exec failed: {command}\n" - f"Exit code: {result.returncode}\n" - f"Error: {result.stderr}" - ) - - return result - - def exec_container_command( container: ContainerExecInstance, command: str, @@ -439,10 +212,6 @@ def destroy_exec_container(container: ContainerExecInstance) -> None: """Stop and remove exec container.""" logger.info("Destroying exec container %s", container.container_name) - # Clear package cache for this container name to avoid stale state when - # a new container reuses the same name in a later suite. - _installed_packages_cache.pop(container.container_name, None) - # Stop container try: _run_container_cmd([ diff --git a/base/images/tests/utils/pytest_plugin.py b/base/images/tests/utils/pytest_plugin.py index bcbf1b292cd..de1adf1329d 100644 --- a/base/images/tests/utils/pytest_plugin.py +++ b/base/images/tests/utils/pytest_plugin.py @@ -109,16 +109,17 @@ def pytest_configure(config) -> None: # type: ignore[no-untyped-def] "image(name): only run this test when --image-name matches", ) config.addinivalue_line( - "markers", - "static: static filesystem tests without container runtime", + "markers", + "runtime_container_tests: test requires a live container via podman exec", ) config.addinivalue_line( "markers", - "static_container_test: portable static container structure tests", + "requires_pkg(*names): preinstall the named packages in the live container " + "before the test runs; skip the test if installation fails", ) config.addinivalue_line( "markers", - "runtime_container_tests: tests requiring a running container runtime", + "static_container_test: test inspects a mounted container rootfs (no live container)", ) from utils.tools import check_tools @@ -147,22 +148,14 @@ def pytest_configure(config) -> None: # type: ignore[no-untyped-def] "Run 'uv run python -m utils.tools' for a full status check." ) - def pytest_runtest_setup(item: pytest.Item) -> None: """Skip tests whose marks are not satisfied.""" # require_capability: skip if image doesn't have the required capability. caps = parse_capabilities(item.config.getoption("--capabilities", default=None)) for marker in item.iter_markers("require_capability"): required = marker.args[0] if marker.args else None - if required: - # Handle both single capability strings and lists of capabilities - if isinstance(required, list): - missing = [cap for cap in required if cap not in caps] - if missing: - pytest.skip(f"requires capabilities {missing} (not in: {sorted(caps)})") - else: - if required not in caps: - pytest.skip(f"requires capability '{required}' (not in: {sorted(caps)})") + if required and required not in caps: + pytest.skip(f"requires capability '{required}' (not in: {sorted(caps)})") # image: skip if --image-name doesn't match. image_name = item.config.getoption("--image-name", default=None) @@ -171,7 +164,6 @@ def pytest_runtest_setup(item: pytest.Item) -> None: if expected and image_name != expected: pytest.skip(f"test is specific to image '{expected}' (running: '{image_name}')") - def pytest_collection_modifyitems(config, items) -> None: # type: ignore[no-untyped-def] """Auto-apply ``@pytest.mark.image("")`` to tests under ``cases//``. @@ -198,3 +190,12 @@ def pytest_collection_modifyitems(config, items) -> None: # type: ignore[no-unt if cases_idx + 2 < len(parts): image_dir = parts[cases_idx + 1] item.add_marker(pytest.mark.image(image_dir)) + + # Auto-apply runtime/static markers based on cases/// + # subdir so individual tests don't need to repeat the marker. + if cases_idx + 3 < len(parts): + kind = parts[cases_idx + 2] + if kind == "runtime": + item.add_marker(pytest.mark.runtime_container_tests) + elif kind == "static": + item.add_marker(pytest.mark.static_container_test) From ab036d0e6f15ab46c06b881d8f29c4bbc4d0d1e5 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Wed, 13 May 2026 00:08:30 +0530 Subject: [PATCH 03/10] Remove annotations of static & runtime tests --- base/images/tests/cases/test_os_release.py | 3 --- base/images/tests/cases/test_packages.py | 2 -- base/images/tests/utils/pytest_plugin.py | 17 ----------------- 3 files changed, 22 deletions(-) diff --git a/base/images/tests/cases/test_os_release.py b/base/images/tests/cases/test_os_release.py index 365b456386b..aa54c3cd0f3 100644 --- a/base/images/tests/cases/test_os_release.py +++ b/base/images/tests/cases/test_os_release.py @@ -6,18 +6,15 @@ import pytest -@pytest.mark.static_container_test def test_os_release_has_required_keys(os_release: dict[str, str]) -> None: """os-release must contain the distro-identifying keys.""" for key in ("NAME", "ID", "VERSION_ID"): assert key in os_release, f"Missing required key: {key}" -@pytest.mark.static_container_test def test_os_release_id(os_release: dict[str, str]) -> None: assert os_release.get("ID") == "azurelinux" -@pytest.mark.static_container_test def test_os_release_version(os_release: dict[str, str]) -> None: assert os_release.get("VERSION_ID") == "4.0" diff --git a/base/images/tests/cases/test_packages.py b/base/images/tests/cases/test_packages.py index 3e57019400f..464dfb7ec7f 100644 --- a/base/images/tests/cases/test_packages.py +++ b/base/images/tests/cases/test_packages.py @@ -24,14 +24,12 @@ } -@pytest.mark.static_container_test @pytest.mark.require_capability("runtime-package-management") def test_required_packages_installed(installed_packages: set[str]) -> None: missing = REQUIRED_PACKAGES - installed_packages assert not missing, f"Required packages missing: {sorted(missing)}" -@pytest.mark.static_container_test @pytest.mark.require_capability("runtime-package-management") @pytest.mark.parametrize("pkg", sorted(BLOCKLISTED_PACKAGES)) def test_blocklisted_package_absent( diff --git a/base/images/tests/utils/pytest_plugin.py b/base/images/tests/utils/pytest_plugin.py index de1adf1329d..a9394856c05 100644 --- a/base/images/tests/utils/pytest_plugin.py +++ b/base/images/tests/utils/pytest_plugin.py @@ -108,19 +108,11 @@ def pytest_configure(config) -> None: # type: ignore[no-untyped-def] "markers", "image(name): only run this test when --image-name matches", ) - config.addinivalue_line( - "markers", - "runtime_container_tests: test requires a live container via podman exec", - ) config.addinivalue_line( "markers", "requires_pkg(*names): preinstall the named packages in the live container " "before the test runs; skip the test if installation fails", ) - config.addinivalue_line( - "markers", - "static_container_test: test inspects a mounted container rootfs (no live container)", - ) from utils.tools import check_tools @@ -190,12 +182,3 @@ def pytest_collection_modifyitems(config, items) -> None: # type: ignore[no-unt if cases_idx + 2 < len(parts): image_dir = parts[cases_idx + 1] item.add_marker(pytest.mark.image(image_dir)) - - # Auto-apply runtime/static markers based on cases/// - # subdir so individual tests don't need to repeat the marker. - if cases_idx + 3 < len(parts): - kind = parts[cases_idx + 2] - if kind == "runtime": - item.add_marker(pytest.mark.runtime_container_tests) - elif kind == "static": - item.add_marker(pytest.mark.static_container_test) From 32d92d5e8bf1519703b1276126b4627b6c65f5ea Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Wed, 13 May 2026 00:25:24 +0530 Subject: [PATCH 04/10] Fix multi-arg @require_capability handling, auto-mark cases/*/runtime/ tests with runtime_container_tests + runtime-package-management --- base/images/tests/utils/pytest_plugin.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/base/images/tests/utils/pytest_plugin.py b/base/images/tests/utils/pytest_plugin.py index a9394856c05..293a077edba 100644 --- a/base/images/tests/utils/pytest_plugin.py +++ b/base/images/tests/utils/pytest_plugin.py @@ -113,6 +113,11 @@ def pytest_configure(config) -> None: # type: ignore[no-untyped-def] "requires_pkg(*names): preinstall the named packages in the live container " "before the test runs; skip the test if installation fails", ) + config.addinivalue_line( + "markers", + "runtime_container_tests: auto-applied to tests under cases/*/runtime/; " + "triggers a fresh container to be started for the test", + ) from utils.tools import check_tools @@ -145,9 +150,9 @@ def pytest_runtest_setup(item: pytest.Item) -> None: # require_capability: skip if image doesn't have the required capability. caps = parse_capabilities(item.config.getoption("--capabilities", default=None)) for marker in item.iter_markers("require_capability"): - required = marker.args[0] if marker.args else None - if required and required not in caps: - pytest.skip(f"requires capability '{required}' (not in: {sorted(caps)})") + for required in marker.args: + if required and required not in caps: + pytest.skip(f"requires capability '{required}' (not in: {sorted(caps)})") # image: skip if --image-name doesn't match. image_name = item.config.getoption("--image-name", default=None) @@ -182,3 +187,13 @@ def pytest_collection_modifyitems(config, items) -> None: # type: ignore[no-unt if cases_idx + 2 < len(parts): image_dir = parts[cases_idx + 1] item.add_marker(pytest.mark.image(image_dir)) + + # Auto-apply `runtime_container_tests` marker to any test that + # lives under a `runtime/` subdirectory anywhere below `cases/`. + # This keeps the runtime/static split a pure directory convention, + # so tests don't need to repeat the marker by hand. Runtime tests + # also implicitly require the `runtime-package-management` capability + # (they need to mutate the live container), so gate them on it here. + if "runtime" in parts[cases_idx:]: + item.add_marker(pytest.mark.runtime_container_tests) + item.add_marker(pytest.mark.require_capability("runtime-package-management")) From 2298bddf5a7e1e52b4b1d22ec90fe3e33a36ea81 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Wed, 13 May 2026 01:01:36 +0530 Subject: [PATCH 05/10] Handle copilot suggestion --- base/images/images.toml | 3 +- .../runtime/test_container_bvt.py | 33 +++-- .../container-base/static/test_container.py | 1 - base/images/tests/cases/test_os_release.py | 3 - base/images/tests/utils/container_runtime.py | 118 ++++++++++++++---- base/images/tests/utils/pytest_plugin.py | 12 +- 6 files changed, 123 insertions(+), 47 deletions(-) diff --git a/base/images/images.toml b/base/images/images.toml index 57a5c4ea073..4920d395462 100644 --- a/base/images/images.toml +++ b/base/images/images.toml @@ -74,11 +74,10 @@ description = "Container BVT: runtime validation of system resources, networking [test-suites.container-bvt-tests.pytest] working-dir = "tests" install = "pyproject" -test-paths = ["cases/container-base/test_container_bvt.py"] +test-paths = ["cases/container-base/runtime/test_container_bvt.py"] extra-args = [ "--image-path", "{image-path}", "--image-name", "{image-name}", "--capabilities", "{capabilities}", - "-m", "runtime_container_tests", "-v", "-s" ] diff --git a/base/images/tests/cases/container-base/runtime/test_container_bvt.py b/base/images/tests/cases/container-base/runtime/test_container_bvt.py index ca2e7c178e0..c111bc49fc1 100644 --- a/base/images/tests/cases/container-base/runtime/test_container_bvt.py +++ b/base/images/tests/cases/container-base/runtime/test_container_bvt.py @@ -9,9 +9,6 @@ import json import time -import pytest - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -30,7 +27,8 @@ def _cmd_ok(container_exec, cmd: str, timeout: int = 10) -> tuple[bool, str]: # --------------------------------------------------------------------------- -@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.require_capability("container") +@pytest.mark.require_capability("runtime-package-management") @pytest.mark.requires_pkg("procps-ng", "gawk") def test_bvt_system_footprint(container_exec) -> None: """BVT: Memory, disk, CPU, and process footprint metrics.""" @@ -69,7 +67,8 @@ def test_bvt_system_footprint(container_exec) -> None: print("rpm not available — skipping package count") -@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.require_capability("container") +@pytest.mark.require_capability("runtime-package-management") @pytest.mark.requires_pkg("util-linux") def test_bvt_logging(container_exec) -> None: """BVT: System logging via logger and log file/journal access.""" @@ -129,7 +128,8 @@ def test_bvt_mathematical_computing(container_exec) -> None: print("python3 not available — skipping Python math test") -@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.require_capability("container") +@pytest.mark.require_capability("runtime-package-management") @pytest.mark.requires_pkg("iproute", "iputils", "hostname") def test_bvt_networking(container_exec) -> None: """BVT: Network interfaces, loopback ping, hostname, and routing.""" @@ -220,7 +220,8 @@ def test_bvt_filesystem_operations(container_exec) -> None: print("Filesystem operations verified") -@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.require_capability("container") +@pytest.mark.require_capability("runtime-package-management") @pytest.mark.requires_pkg("procps-ng") def test_bvt_process_management(container_exec) -> None: """BVT: Background process start, list, and kill.""" @@ -238,7 +239,8 @@ def test_bvt_process_management(container_exec) -> None: print("Process management verified") -@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.require_capability("container") +@pytest.mark.require_capability("runtime-package-management") @pytest.mark.requires_pkg("shadow-utils") def test_bvt_user_management(container_exec) -> None: """BVT: Create, verify, switch to, and delete a transient test user.""" @@ -262,7 +264,8 @@ def test_bvt_user_management(container_exec) -> None: print(f"User management verified for: {user}") -@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.require_capability("container") +@pytest.mark.require_capability("runtime-package-management") def test_bvt_package_management(container_exec) -> None: """BVT: Package manager (tdnf/dnf) cache refresh, list, and info.""" ok_tdnf, _ = _cmd_ok(container_exec, "which tdnf", timeout=5) @@ -297,7 +300,8 @@ def test_bvt_environment_variables(container_exec, container_info: dict) -> None print(f"Container HOSTNAME: {out}") -@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.require_capability("container") +@pytest.mark.require_capability("runtime-package-management") @pytest.mark.requires_pkg("procps-ng") def test_bvt_container_health_summary(container_exec, container_info: dict) -> None: """BVT: Aggregated health summary — OS info, memory, processes, packages.""" @@ -356,7 +360,8 @@ def test_bvt_container_health_summary(container_exec, container_info: dict) -> N _PI_PREFIX_50 = "14159265358979323846264338327950288419716939937510" -@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.require_capability("container") +@pytest.mark.require_capability("runtime-package-management") @pytest.mark.requires_pkg("python3") def test_bvt_pi_to_1000_places(container_exec) -> None: """BVT: High-precision pi to 1000 digits via python3 ``decimal``. @@ -388,7 +393,8 @@ def test_bvt_pi_to_1000_places(container_exec) -> None: print(f"Pi to 1000 places verified (first 50 digits: {digits[:50]})") -@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.require_capability("container") +@pytest.mark.require_capability("runtime-package-management") @pytest.mark.requires_pkg("python3") def test_bvt_pi_repeated_iterations(container_exec) -> None: """BVT: Compute pi to 1000 places repeatedly (CPU stress + consistency). @@ -422,7 +428,8 @@ def test_bvt_pi_repeated_iterations(container_exec) -> None: print(f"Pi to 1000 places consistent across 10 iterations: {sample[:52]}") -@pytest.mark.require_capability("container", "runtime-package-management") +@pytest.mark.require_capability("container") +@pytest.mark.require_capability("runtime-package-management") @pytest.mark.requires_pkg("curl") def test_bvt_sustained_http_fetch(container_exec) -> None: """BVT: Sustained external HTTP — 50 sequential fetches, ≥90% success. diff --git a/base/images/tests/cases/container-base/static/test_container.py b/base/images/tests/cases/container-base/static/test_container.py index dda6db52807..36baf2fed64 100644 --- a/base/images/tests/cases/container-base/static/test_container.py +++ b/base/images/tests/cases/container-base/static/test_container.py @@ -4,7 +4,6 @@ import os import re -import pytest from pathlib import Path # Default upper-bound for the unpacked container rootfs (MiB). diff --git a/base/images/tests/cases/test_os_release.py b/base/images/tests/cases/test_os_release.py index aa54c3cd0f3..518124ccf05 100644 --- a/base/images/tests/cases/test_os_release.py +++ b/base/images/tests/cases/test_os_release.py @@ -3,9 +3,6 @@ from __future__ import annotations -import pytest - - def test_os_release_has_required_keys(os_release: dict[str, str]) -> None: """os-release must contain the distro-identifying keys.""" for key in ("NAME", "ID", "VERSION_ID"): diff --git a/base/images/tests/utils/container_runtime.py b/base/images/tests/utils/container_runtime.py index e9f5159b6dc..5d216f70a5f 100644 --- a/base/images/tests/utils/container_runtime.py +++ b/base/images/tests/utils/container_runtime.py @@ -22,7 +22,7 @@ name="podman", package_hint="podman", reason="create and manage test containers", - when="dynamic-container-tests", + when="container", ) class ContainerExecInstance(NamedTuple): @@ -56,31 +56,72 @@ def _run_container_cmd(cmd: list[str], **kwargs: object) -> subprocess.Completed return result +def _parse_loaded_images(load_stdout: str) -> list[str]: + """Extract image references from ``podman load`` stdout. + + podman/docker print one or more lines of the form:: + + Loaded image: localhost/foo:bar + Loaded image(s): localhost/foo:bar,localhost/baz:qux + + We accept both forms, split comma-separated lists, and return the + references in the order they were reported. This is race-free — + unlike scanning ``podman images --sort created``, it cannot be + perturbed by other processes loading or pulling images concurrently. + """ + refs: list[str] = [] + for line in load_stdout.splitlines(): + line = line.strip() + # Match "Loaded image:" and "Loaded image(s):" (case-insensitive). + lowered = line.lower() + for prefix in ("loaded image(s):", "loaded image:"): + if lowered.startswith(prefix): + payload = line[len(prefix):].strip() + # Some versions emit comma-separated lists on one line. + for ref in payload.split(","): + ref = ref.strip() + if ref: + refs.append(ref) + break + return refs + + def _get_image_reference(image_path: Path) -> str: """Get container image reference from path or direct reference.""" image_str = str(image_path) - + # If it's already a container reference (contains :), use directly if ":" in image_str and not image_str.startswith("/"): return image_str - + # If it's an OCI archive file, load it first if image_path.exists() and image_path.suffix in (".tar", ".gz", ".xz"): logger.info("Loading image archive: %s", image_path) load_cmd = [PODMAN.name, "load", "-i", str(image_path)] load_result = _run_container_cmd(load_cmd) - - # Get all images sorted by creation date (most recent first) - images_result = _run_container_cmd([ - PODMAN.name, "images", "--format", "{{.Repository}}:{{.Tag}}", - "--sort", "created" - ]) - loaded_images = [line.strip() for line in images_result.stdout.strip().split('\n') if line.strip()] - if loaded_images: - return loaded_images[0] # Use the most recently created image - else: - raise ContainerRuntimeError(f"No image loaded from {image_path}") - + + # Parse the actual reference(s) reported by `podman load` rather + # than picking "the most recently created image", which is racy + # under parallel test runs or a busy dev machine where another + # process may have just pulled/loaded an image. + loaded_images = _parse_loaded_images(load_result.stdout) + if not loaded_images: + # Some podman versions write the "Loaded image:" line to + # stderr; fall back to that before giving up. + loaded_images = _parse_loaded_images(load_result.stderr) + if not loaded_images: + raise ContainerRuntimeError( + f"Could not determine image reference from `podman load` output " + f"for {image_path}.\nstdout: {load_result.stdout!r}\n" + f"stderr: {load_result.stderr!r}" + ) + if len(loaded_images) > 1: + logger.info( + "Archive %s contained %d images; using the first: %s (others: %s)", + image_path, len(loaded_images), loaded_images[0], loaded_images[1:], + ) + return loaded_images[0] + # Assume it's a direct image reference return image_str @@ -130,15 +171,44 @@ def create_container_with_exec( result = _run_container_cmd(run_cmd) container_id = result.stdout.strip() - - # Wait briefly for container to start - logger.info("Waiting for exec container %s to start...", container_name) - time.sleep(2) - - # Test basic exec access - test_result = exec_container_command_raw(container_name, ["echo", "container-ready"]) - if test_result.returncode != 0 or "container-ready" not in test_result.stdout: - raise ContainerRuntimeError(f"Container exec test failed for {container_name}") + + # Wait briefly for container to start, then verify exec access. + # If anything goes wrong from here on we MUST tear the container + # down — otherwise a flaky readiness probe leaks containers in CI + # and on dev machines, and subsequent runs fail with name conflicts + # or resource exhaustion. + try: + logger.info("Waiting for exec container %s to start...", container_name) + time.sleep(2) + + test_result = exec_container_command_raw( + container_name, ["echo", "container-ready"] + ) + if test_result.returncode != 0 or "container-ready" not in test_result.stdout: + raise ContainerRuntimeError( + f"Container exec test failed for {container_name} " + f"(rc={test_result.returncode}, stdout={test_result.stdout!r}, " + f"stderr={test_result.stderr!r})" + ) + except BaseException: + # Best-effort cleanup; never let cleanup errors mask the original. + # BaseException covers KeyboardInterrupt / SystemExit too — we + # still want the container removed when the user Ctrl-C's mid-probe. + logger.warning( + "Readiness probe failed for %s; removing leaked container", + container_name, + ) + try: + subprocess.run( + [PODMAN.name, "rm", "-f", container_name], + capture_output=True, text=True, timeout=30, + ) + except Exception as cleanup_exc: # pragma: no cover + logger.warning( + "Failed to remove leaked container %s: %s", + container_name, cleanup_exc, + ) + raise logger.info( "Exec container ready: %s (ID: %s)", diff --git a/base/images/tests/utils/pytest_plugin.py b/base/images/tests/utils/pytest_plugin.py index 293a077edba..1bc383cd923 100644 --- a/base/images/tests/utils/pytest_plugin.py +++ b/base/images/tests/utils/pytest_plugin.py @@ -102,7 +102,9 @@ def pytest_configure(config) -> None: # type: ignore[no-untyped-def] """Register markers and fail fast if required native tools are missing.""" config.addinivalue_line( "markers", - "require_capability(name): skip test unless the image has the named capability", + "require_capability(name): skip test unless the image has the named " + "capability. Each marker takes exactly one capability; stack the " + "decorator to require several.", ) config.addinivalue_line( "markers", @@ -148,11 +150,13 @@ def pytest_configure(config) -> None: # type: ignore[no-untyped-def] def pytest_runtest_setup(item: pytest.Item) -> None: """Skip tests whose marks are not satisfied.""" # require_capability: skip if image doesn't have the required capability. + # Each marker takes exactly one capability name; stack the decorator + # multiple times to require several capabilities on a single test. caps = parse_capabilities(item.config.getoption("--capabilities", default=None)) for marker in item.iter_markers("require_capability"): - for required in marker.args: - if required and required not in caps: - pytest.skip(f"requires capability '{required}' (not in: {sorted(caps)})") + required = marker.args[0] if marker.args else None + if required and required not in caps: + pytest.skip(f"requires capability '{required}' (not in: {sorted(caps)})") # image: skip if --image-name doesn't match. image_name = item.config.getoption("--image-name", default=None) From dfa7d0ab27712ede253dc466c22037e16ea9ac0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 19:48:46 +0000 Subject: [PATCH 06/10] fix: exclude runtime_container_tests from static-image-checks suite Agent-Logs-Url: https://github.com/microsoft/azurelinux/sessions/c4ffcb16-3c13-4f4d-86e1-2e089c5576a1 Co-authored-by: bhagyapathak <13693535+bhagyapathak@users.noreply.github.com> --- base/images/images.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/base/images/images.toml b/base/images/images.toml index 4920d395462..ad7b7d9c50c 100644 --- a/base/images/images.toml +++ b/base/images/images.toml @@ -64,6 +64,7 @@ extra-args = [ "--image-path", "{image-path}", "--image-name", "{image-name}", "--capabilities", "{capabilities}", + "-m", "not runtime_container_tests", ] # Container Business Validation Tests (BVT) From 58796180a155eec23507dfeec1535fc3025cb391 Mon Sep 17 00:00:00 2001 From: Bhagyashri Date: Wed, 13 May 2026 01:19:45 +0530 Subject: [PATCH 07/10] Handle copilot suggestion iteration 2 --- .../runtime/test_container_bvt.py | 3 ++ base/images/tests/conftest.py | 34 ++++++++++++------- base/images/tests/utils/pytest_plugin.py | 8 ++--- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/base/images/tests/cases/container-base/runtime/test_container_bvt.py b/base/images/tests/cases/container-base/runtime/test_container_bvt.py index c111bc49fc1..3d9e9fc467b 100644 --- a/base/images/tests/cases/container-base/runtime/test_container_bvt.py +++ b/base/images/tests/cases/container-base/runtime/test_container_bvt.py @@ -9,6 +9,9 @@ import json import time +import pytest + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/base/images/tests/conftest.py b/base/images/tests/conftest.py index b213898177c..777b1d77ce6 100644 --- a/base/images/tests/conftest.py +++ b/base/images/tests/conftest.py @@ -9,7 +9,6 @@ from __future__ import annotations import logging -import os import shutil import subprocess import tempfile @@ -227,18 +226,22 @@ def running_container( ) -> ContainerExecInstance | None: """Fresh container per test, with exec access and guaranteed teardown. - A new container is started for every runtime test and destroyed on - teardown so tests cannot leak state to one another (installed - packages, created users, processes, files). The container name is - auto-generated to avoid collisions when tests run in parallel. + A new container is started for every test that requests this + fixture (directly or via ``container_exec`` / ``container_info``) + and destroyed on teardown so tests cannot leak state to one another + (installed packages, created users, processes, files). The container + name is auto-generated to avoid collisions when tests run in + parallel. + + Container startup is gated on **fixture usage**, not on a marker: + requesting the fixture is itself the signal that the test needs a + live container. The only hard gate is image type — for non-container + images the fixture skips so VM-only sessions don't try to start + podman. """ if image_type != "container": pytest.skip("running_container only applicable to container images") - # Only spin up a container for tests that actually need one. - if not request.node.get_closest_marker("runtime_container_tests"): - pytest.skip("running_container only for runtime container tests") - logger.info("Creating fresh container for test %s", request.node.name) container = create_container_with_exec( image_path, @@ -259,10 +262,15 @@ def container_exec(running_container: ContainerExecInstance): """Execute a shell command in the running container via ``podman exec``. Returns a callable ``(command: str, timeout: int = 60) -> - subprocess.CompletedProcess[str]``. The framework does **not** install - packages or mutate the container in any way — tests see the image as - shipped. If a test needs a binary that the image does not ship, it - must declare the dependency itself (and either skip or fail loudly). + subprocess.CompletedProcess[str]``. + + The container starts as-shipped, but tests can opt-in to dependency + installation by declaring ``@pytest.mark.requires_pkg("pkg", ...)`` + on the test function. The autouse ``_apply_requires_pkg`` fixture + (below) installs those packages via ``dnf install -y`` in this same + container before the test body runs, and skips the test if the + install fails. Tests with no ``requires_pkg`` marker see the image + exactly as shipped — the container is never mutated implicitly. """ from utils.container_runtime import exec_container_command_raw diff --git a/base/images/tests/utils/pytest_plugin.py b/base/images/tests/utils/pytest_plugin.py index 1bc383cd923..97a58a6f2f6 100644 --- a/base/images/tests/utils/pytest_plugin.py +++ b/base/images/tests/utils/pytest_plugin.py @@ -194,10 +194,8 @@ def pytest_collection_modifyitems(config, items) -> None: # type: ignore[no-unt # Auto-apply `runtime_container_tests` marker to any test that # lives under a `runtime/` subdirectory anywhere below `cases/`. - # This keeps the runtime/static split a pure directory convention, - # so tests don't need to repeat the marker by hand. Runtime tests - # also implicitly require the `runtime-package-management` capability - # (they need to mutate the live container), so gate them on it here. + # This is purely for `-m runtime_container_tests` filtering; the + # `running_container` fixture itself triggers container startup + # on fixture usage, not on this marker. if "runtime" in parts[cases_idx:]: item.add_marker(pytest.mark.runtime_container_tests) - item.add_marker(pytest.mark.require_capability("runtime-package-management")) From a7867fc61a606f0d97e8940bb92d2dc12ef0fb68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 19:56:57 +0000 Subject: [PATCH 08/10] fix: make test_bvt_sustained_http_fetch configurable and skip when network is restricted Agent-Logs-Url: https://github.com/microsoft/azurelinux/sessions/5a6f8871-2c4d-409b-86ad-2f2607b38e11 Co-authored-by: bhagyapathak <13693535+bhagyapathak@users.noreply.github.com> --- .../runtime/test_container_bvt.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/base/images/tests/cases/container-base/runtime/test_container_bvt.py b/base/images/tests/cases/container-base/runtime/test_container_bvt.py index 3d9e9fc467b..ab66697f421 100644 --- a/base/images/tests/cases/container-base/runtime/test_container_bvt.py +++ b/base/images/tests/cases/container-base/runtime/test_container_bvt.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import os import time import pytest @@ -438,9 +439,26 @@ def test_bvt_sustained_http_fetch(container_exec) -> None: """BVT: Sustained external HTTP — 50 sequential fetches, ≥90% success. Ports the legacy "Core Networking Test" (50-iteration page fetch). + + The target URL defaults to ``http://httpbin.org/get`` but can be + overridden by setting the ``BVT_HTTP_ENDPOINT`` environment variable + on the host before running the test suite. If the first probe to the + endpoint fails (e.g. outbound networking is blocked in the CI + environment), the test is skipped rather than failed so that + unrelated image-quality issues are not masked by network policy. """ iterations = 50 - url = "http://httpbin.org/get" + url = os.environ.get("BVT_HTTP_ENDPOINT", "http://httpbin.org/get") + + # Probe once first; skip the whole test if outbound networking is blocked. + ok, _ = _cmd_ok( + container_exec, + f"curl -s -o /dev/null -w '%{{http_code}}' --connect-timeout 5 --max-time 10 {url}", + timeout=15, + ) + if not ok: + pytest.skip(f"Outbound HTTP to {url!r} is unreachable — skipping sustained fetch test") + # One bash loop is far cheaper than 50 podman exec invocations. cmd = ( f"i=0; ok=0; while [ $i -lt {iterations} ]; do " From 9d99f4ccc1ec14f373ee0d122e87fc696eec7d8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 20:03:22 +0000 Subject: [PATCH 09/10] fix: address review 4275221016 - marker description, image ref caching, subprocess timeout Agent-Logs-Url: https://github.com/microsoft/azurelinux/sessions/66b748da-ab1a-49bd-a6de-a5c86adc4061 Co-authored-by: bhagyapathak <13693535+bhagyapathak@users.noreply.github.com> --- base/images/tests/conftest.py | 19 ++++++++++++++++++- base/images/tests/utils/container_runtime.py | 13 ++++++++++--- base/images/tests/utils/pytest_plugin.py | 3 ++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/base/images/tests/conftest.py b/base/images/tests/conftest.py index 777b1d77ce6..784497d3fb9 100644 --- a/base/images/tests/conftest.py +++ b/base/images/tests/conftest.py @@ -28,6 +28,7 @@ ContainerExecInstance, create_container_with_exec, destroy_exec_container, + _get_image_reference, ) from utils.pytest_plugin import ( derive_image_type_from_capabilities, @@ -220,9 +221,24 @@ def partition_table( # --------------------------------------------------------------------------- +@pytest.fixture(scope="session") +def _container_image_ref(image_path: Path, image_type: str) -> str | None: + """Load the container image once per session and cache the reference. + + This avoids calling ``podman load -i `` for every test when + ``--image-path`` points at an OCI tar archive. For non-container + sessions the fixture returns ``None`` immediately (no podman call). + """ + if image_type != "container": + return None + return _get_image_reference(image_path) + + @pytest.fixture def running_container( - image_path: Path, image_type: str, image_name: str | None, workdir: Path, request: pytest.FixtureRequest + image_path: Path, image_type: str, image_name: str | None, workdir: Path, + _container_image_ref: str | None, + request: pytest.FixtureRequest ) -> ContainerExecInstance | None: """Fresh container per test, with exec access and guaranteed teardown. @@ -248,6 +264,7 @@ def running_container( workdir, container_name=None, # auto-generate a unique name per test image_name=image_name, + image_ref=_container_image_ref, # pre-loaded once per session; avoids repeated podman load ) try: diff --git a/base/images/tests/utils/container_runtime.py b/base/images/tests/utils/container_runtime.py index 5d216f70a5f..a2a1ed64143 100644 --- a/base/images/tests/utils/container_runtime.py +++ b/base/images/tests/utils/container_runtime.py @@ -37,10 +37,17 @@ class ContainerRuntimeError(Exception): pass -def _run_container_cmd(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: - """Run container command with proper error handling.""" +def _run_container_cmd(cmd: list[str], timeout: int = 300, **kwargs: object) -> subprocess.CompletedProcess[str]: + """Run container command with proper error handling. + + Args: + cmd: Command and arguments to run. + timeout: Maximum seconds to wait (default 300). Prevents operations + like ``podman load`` or ``podman run`` from hanging indefinitely + in CI when storage or network layers stall. + """ logger.info("Container runtime: %s", " ".join(cmd)) - result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kwargs) if result.returncode != 0: logger.error( "Container command failed (rc=%d): %s\nstdout: %s\nstderr: %s", diff --git a/base/images/tests/utils/pytest_plugin.py b/base/images/tests/utils/pytest_plugin.py index 97a58a6f2f6..9cdb0291738 100644 --- a/base/images/tests/utils/pytest_plugin.py +++ b/base/images/tests/utils/pytest_plugin.py @@ -118,7 +118,8 @@ def pytest_configure(config) -> None: # type: ignore[no-untyped-def] config.addinivalue_line( "markers", "runtime_container_tests: auto-applied to tests under cases/*/runtime/; " - "triggers a fresh container to be started for the test", + "used for -m runtime_container_tests filtering. Container startup is " + "driven by fixture usage (running_container), not by this marker.", ) from utils.tools import check_tools From 743b470211e98c06171947be34b0349e00c4e6c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 20:17:49 +0000 Subject: [PATCH 10/10] fix: address review 4275818337 - public API, return type, pkg mgr detection, unused params, env validation Agent-Logs-Url: https://github.com/microsoft/azurelinux/sessions/310e39f7-537e-488a-9b12-f763068ffefc Co-authored-by: bhagyapathak <13693535+bhagyapathak@users.noreply.github.com> --- .../container-base/static/test_container.py | 11 ++++++- base/images/tests/conftest.py | 33 ++++++++++++++----- base/images/tests/utils/container_runtime.py | 26 ++++++++++----- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/base/images/tests/cases/container-base/static/test_container.py b/base/images/tests/cases/container-base/static/test_container.py index 36baf2fed64..68642fa5745 100644 --- a/base/images/tests/cases/container-base/static/test_container.py +++ b/base/images/tests/cases/container-base/static/test_container.py @@ -6,6 +6,8 @@ import re from pathlib import Path +import pytest + # Default upper-bound for the unpacked container rootfs (MiB). # Ported from the legacy "Memory Usage Check" / "Container Size Max Check" # in the CBL-Mariner ContainerBase BVT. Override via env var @@ -107,7 +109,14 @@ def test_container_rootfs_size(rootfs: Path) -> None: "Container Size Max Check": walks the extracted rootfs and asserts the total on-disk size is under AZL_CONTAINER_MAX_SIZE_MB (MiB). """ - max_mb = int(os.environ.get("AZL_CONTAINER_MAX_SIZE_MB", _DEFAULT_MAX_ROOTFS_MB)) + raw = os.environ.get("AZL_CONTAINER_MAX_SIZE_MB", str(_DEFAULT_MAX_ROOTFS_MB)) + try: + max_mb = int(raw) + except ValueError: + pytest.fail( + f"AZL_CONTAINER_MAX_SIZE_MB must be an integer (got {raw!r}). " + "Set it to the maximum allowed rootfs size in MiB." + ) total_bytes = 0 for dirpath, _dirnames, filenames in os.walk(rootfs, followlinks=False): diff --git a/base/images/tests/conftest.py b/base/images/tests/conftest.py index 784497d3fb9..b21e81949d3 100644 --- a/base/images/tests/conftest.py +++ b/base/images/tests/conftest.py @@ -28,7 +28,7 @@ ContainerExecInstance, create_container_with_exec, destroy_exec_container, - _get_image_reference, + resolve_image_reference, ) from utils.pytest_plugin import ( derive_image_type_from_capabilities, @@ -231,7 +231,7 @@ def _container_image_ref(image_path: Path, image_type: str) -> str | None: """ if image_type != "container": return None - return _get_image_reference(image_path) + return resolve_image_reference(image_path) @pytest.fixture @@ -239,7 +239,7 @@ def running_container( image_path: Path, image_type: str, image_name: str | None, workdir: Path, _container_image_ref: str | None, request: pytest.FixtureRequest -) -> ContainerExecInstance | None: +) -> ContainerExecInstance: """Fresh container per test, with exec access and guaranteed teardown. A new container is started for every test that requests this @@ -261,7 +261,6 @@ def running_container( logger.info("Creating fresh container for test %s", request.node.name) container = create_container_with_exec( image_path, - workdir, container_name=None, # auto-generate a unique name per test image_name=image_name, image_ref=_container_image_ref, # pre-loaded once per session; avoids repeated podman load @@ -284,7 +283,7 @@ def container_exec(running_container: ContainerExecInstance): The container starts as-shipped, but tests can opt-in to dependency installation by declaring ``@pytest.mark.requires_pkg("pkg", ...)`` on the test function. The autouse ``_apply_requires_pkg`` fixture - (below) installs those packages via ``dnf install -y`` in this same + (below) installs those packages via ``tdnf`` or ``dnf`` in this same container before the test body runs, and skips the test if the install fails. Tests with no ``requires_pkg`` marker see the image exactly as shipped — the container is never mutated implicitly. @@ -305,8 +304,9 @@ def _apply_requires_pkg(request: pytest.FixtureRequest) -> None: """Honor ``@pytest.mark.requires_pkg(...)`` on runtime tests. Collects all packages declared by ``requires_pkg`` markers on the - test (multiple markers stack), installs them once via ``dnf`` in - the live container, and skips the test on installation failure. + test (multiple markers stack), installs them once via ``tdnf`` + (preferred) or ``dnf`` in the live container, and skips the test on + installation failure or when neither package manager is found. No-op for tests that don't carry the marker or don't request ``container_exec`` (e.g. static rootfs tests). @@ -324,12 +324,27 @@ def _apply_requires_pkg(request: pytest.FixtureRequest) -> None: return container_exec = request.getfixturevalue("container_exec") - cmd = "dnf install -y " + " ".join(pkgs) + + # Detect which package manager is available in this container. + # AZL images may ship tdnf, dnf, or both; prefer tdnf when present. + pkg_mgr: str | None = None + for candidate in ("tdnf", "dnf"): + probe = container_exec(f"command -v {candidate!s}", timeout=10) + if probe.returncode == 0: + pkg_mgr = candidate + break + if pkg_mgr is None: + pytest.skip( + f"requires_pkg: neither tdnf nor dnf found in container; " + f"cannot install {pkgs!r}" + ) + + cmd = f"{pkg_mgr} install -y " + " ".join(pkgs) logger.info("requires_pkg: installing %s", pkgs) result = container_exec(cmd, timeout=300) if result.returncode != 0: pytest.skip( - f"requires_pkg: failed to install {pkgs} (rc={result.returncode}): " + f"requires_pkg: failed to install {pkgs!r} (rc={result.returncode}): " f"{result.stderr.strip()[:200]}" ) diff --git a/base/images/tests/utils/container_runtime.py b/base/images/tests/utils/container_runtime.py index a2a1ed64143..5c1bba432a8 100644 --- a/base/images/tests/utils/container_runtime.py +++ b/base/images/tests/utils/container_runtime.py @@ -93,8 +93,13 @@ def _parse_loaded_images(load_stdout: str) -> list[str]: return refs -def _get_image_reference(image_path: Path) -> str: - """Get container image reference from path or direct reference.""" +def resolve_image_reference(image_path: Path) -> str: + """Return a podman-usable image reference for *image_path*. + + If *image_path* looks like an already-resolved image reference (contains + ``:``) the value is returned as-is. Otherwise the file is loaded via + ``podman load`` and the reference reported by podman is returned. + """ image_str = str(image_path) # If it's already a container reference (contains :), use directly @@ -135,7 +140,6 @@ def _get_image_reference(image_path: Path) -> str: def create_container_with_exec( image_path: Path, - workdir: Path, container_name: str | None = None, image_name: str | None = None, image_ref: str | None = None, @@ -143,10 +147,10 @@ def create_container_with_exec( """Create a running container with podman exec access. Args: - image_path: Path to image file or image reference - workdir: Working directory (unused but kept for compatibility) - container_name: Container name (auto-generated if None) - image_name: Image name for logging (derived if None) + image_path: Path to image file or image reference. Ignored when + *image_ref* is provided. + container_name: Container name (auto-generated if ``None``). + image_name: Human-readable image name used in log messages. image_ref: Pre-resolved image reference (skips ``podman load``). When provided, ``image_path`` is not touched. Use this to amortize the ~10s tarball load across many container creates. @@ -162,8 +166,12 @@ def create_container_with_exec( # Get image reference (load if needed) — but skip if caller already # resolved one for us (e.g. session-scoped fixture). if image_ref is None: - image_ref = _get_image_reference(image_path) - logger.info("Creating exec container %s from image %s", container_name, image_ref) + image_ref = resolve_image_reference(image_path) + logger.info( + "Creating exec container %s from image %s%s", + container_name, image_ref, + f" ({image_name})" if image_name else "", + ) # Create and start container run_cmd = [