diff --git a/base/images/images.toml b/base/images/images.toml index 37c6bb05f48..75b813a6380 100644 --- a/base/images/images.toml +++ b/base/images/images.toml @@ -56,7 +56,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 = "runtime-container-tests" }, +] [images.container-base.capabilities] machine-bootable = false @@ -67,7 +70,10 @@ runtime-package-management = true [images.container-base-dev] description = "Container Base Image (dev)" definition = { type = "kiwi", path = "container-base/container-base.kiwi", profile = "core-dev" } -tests.test-suites = [{ name = "static-image-checks" }] +tests.test-suites = [ + { name = "static-image-checks" }, + { name = "runtime-container-tests" }, +] [images.container-base-dev.capabilities] machine-bootable = false @@ -123,7 +129,7 @@ description = "Offline image validation (shared + image-specific tests)" [test-suites.static-image-checks.pytest] working-dir = "tests" install = "pyproject" -test-paths = ["cases/"] +test-paths = ["cases/static/"] # {capabilities} is substituted by azldev as a comma-separated list of # the names of capabilities set to `true` for the image (e.g. # "machine-bootable,systemd,runtime-package-management"). Names use @@ -135,3 +141,18 @@ extra-args = [ "--image-name", "{image-name}", "--capabilities", "{capabilities}", ] + +# Runtime container tests — validate live container behavior via podman. +[test-suites.runtime-container-tests] +type = "pytest" +description = "Runtime container validation (exec into live containers)" + +[test-suites.runtime-container-tests.pytest] +working-dir = "tests" +install = "pyproject" +test-paths = ["cases/runtime/"] +extra-args = [ + "--image-path", "{image-path}", + "--image-name", "{image-name}", + "--capabilities", "{capabilities}", +] diff --git a/base/images/tests/README.md b/base/images/tests/README.md index ea1202d6e68..99a805a4810 100644 --- a/base/images/tests/README.md +++ b/base/images/tests/README.md @@ -1,13 +1,13 @@ # Azure Linux Image Tests -Static validation framework for built Azure Linux images (VM and container). -Mounts images read-only and runs pytest tests against the filesystem -without booting. +Validation framework for built Azure Linux images (VM and container). +Includes both static (offline filesystem) and runtime (live container) +tests, all driven by pytest. ## How it gets invoked -These tests are wired into `azldev` via the `[test-suites.static-image-checks]` -table in `base/images/images.toml`, and referenced by each image's +These tests are wired into `azldev` via the `[test-suites.*]` tables +in `base/images/images.toml`, and referenced by each image's `tests.test-suites`. The standard entry point is: ```bash @@ -30,22 +30,41 @@ where present — to validate the dev variant.) `pyproject.toml`, and invokes pytest with the right `--image-path`, `--image-name`, and `--capabilities` arguments. +## Test suites + +| Suite | Description | Runs for | +|-------|-------------|----------| +| `static-image-checks` | Offline filesystem validation — mounts images read-only | All images | +| `runtime-container-tests` | Live container tests via `podman exec` | Container images | + ## Direct (manual) invocation ```bash cd base/images/tests -# VM image — shared + VM-specific tests -uv run pytest cases/ \ +# Static tests — VM image +uv run pytest cases/static/ \ --image-path /path/to/image.raw \ --image-name vm-base \ --capabilities machine-bootable,systemd,runtime-package-management -# Container image — shared + container-specific tests -uv run pytest cases/ \ +# Static tests — Container image +uv run pytest cases/static/ \ + --image-path /path/to/image.oci.tar.xz \ + --image-name container-base \ + --capabilities container,runtime-package-management + +# Runtime tests — Container image (requires podman socket) +uv run pytest cases/runtime/ \ --image-path /path/to/image.oci.tar.xz \ --image-name container-base \ --capabilities container,runtime-package-management + +# Runtime tests — from a registry reference +uv run pytest cases/runtime/ \ + --image-ref mcr.microsoft.com/azurelinux/base/core:4.0 \ + --image-name container-base \ + --capabilities container,runtime-package-management ``` Test selection follows standard pytest positional arguments. Tests @@ -67,12 +86,36 @@ System packages (not pip-installable): - **`libguestfs-tools`** + **`guestfs-tools`** — `guestmount`, `guestunmount`, `virt-inspector` (VM images) -- **`skopeo`** — OCI archive conversion (container images) -- **`umoci`** — OCI image unpacking (container images) -- **`buildah`** — cleanup of rootless umoci extracts (container images) +- **`skopeo`** — OCI archive conversion (container images, static tests) +- **`umoci`** — OCI image unpacking (container images, static tests) +- **`buildah`** — cleanup of rootless umoci extracts (container images, static tests) +- **`podman`** — container runtime for live tests (container images, runtime tests) - **`rpm`** — for `rpm --root` package queries - **`uv`** — Python project/package manager +For runtime tests, the **podman socket** must be active: +(ASK: Do we need this? podman-py is quite basic, maybe it's better without REST API and directly invoking podman CLI? The socket requirement is a bit of a pain, especially for WSL users. Then would need to have our own wrapper around podman CLI to handle the container orchestration logic currently in `container_runtime.py`. Or even docker SDK with moby might be more stable than podman-py?) +```bash +# With systemd (standard Linux) +systemctl --user start podman.socket + +# Without systemd (WSL, containers, CI) +podman system service --timeout=0 & +``` + +> **WSL users:** Rootless podman defaults to the systemd cgroup +> manager, but the REST API (used by podman-py) does not auto-fallback +> to cgroupfs like the CLI does. If container starts fail with +> `sd-bus call: Permission denied`, create +> `~/.config/containers/containers.conf`: +> +> ```toml +> [engine] +> cgroup_manager = "cgroupfs" +> ``` +> +> Then restart the podman socket. + `pytest_configure` does a preflight check and fails fast if any tool needed for the current `--image-type` is missing. @@ -82,30 +125,40 @@ needed for the current `--image-type` is missing. base/images/ ├── images.toml # Image registry + test-suite wiring └── tests/ - ├── pyproject.toml # uv project: pytest, plugin entry point - ├── conftest.py # Session fixtures + ├── pyproject.toml # uv project: pytest + podman deps + ├── conftest.py # Session fixtures (static + runtime) ├── utils/ # Helper package (not test-collected) │ ├── pytest_plugin.py # CLI options, markers, tool preflight + │ ├── container_runtime.py # Podman-py based container orchestration │ ├── extract.py # Image mounting / extraction │ ├── disk.py # virt-inspector → DiskInfo │ ├── parsers.py # File content parsers │ ├── types.py # Dataclasses │ └── tools.py # Native-tool registry └── cases/ # Test cases - ├── test_os_release.py # Shared: /etc/os-release - ├── test_packages.py # Shared: rpm-db checks (capability-gated) - ├── vm-base/ # VM-specific tests (auto-restricted to the vm-base family — vm-base, vm-base-dev) - │ ├── test_kernel.py - │ └── test_partitions.py - └── container-base/ # Container-specific tests (auto-restricted to the container-base family) - └── test_container.py + ├── static/ # Offline filesystem tests + │ ├── test_os_release.py # Shared: /etc/os-release + │ ├── test_packages.py # Shared: rpm-db checks (capability-gated) + │ ├── vm-base/ # VM-specific static tests + │ │ ├── test_kernel.py + │ │ └── test_partitions.py + │ └── container-base/ # Container-specific static tests + │ └── test_container.py + └── runtime/ # Live container tests (via podman exec) + └── container-base/ + ├── test_basic.py # Basic: shell access, DNS resolution + └── test_nginx/ # Dockerfile test example + ├── test_nginx.py # Test logic + ├── Dockerfile # Custom image (ARG BASE_IMAGE) + └── nginx.conf # Supporting files ``` ## Available fixtures | Fixture | Scope | Type | Description | |---------|-------|------|-------------| -| `image_path` | session | `Path` | From `--image-path` | +| `image_path` | session | `Path \| None` | From `--image-path` (None when `--image-ref` used) | +| `image_ref` | session | `str \| None` | From `--image-ref` (None when `--image-path` used) | | `image_name` | session | `str \| None` | From `--image-name` | | `image_type` | session | `str` | `"vm"` or `"container"` (explicit / capabilities / extension) | | `capabilities` | session | `set[str]` | Parsed `--capabilities` | @@ -115,20 +168,77 @@ base/images/ | `installed_packages` | session | `set[str]` | Installed RPM names (`rpm --root`) | | `disk_info` | session | `DiskInfo \| None` | VM only | | `partition_table` | session | `list[PartitionInfo]` | VM only — auto-skips on containers | +| `podman_client` | session | `PodmanClient \| None` | Podman API client; None for non-container images | +| `container_image_ref` | session | `str \| None` | Loaded image ID (cached); None for non-container | +| `running_container` | function | `ContainerInstance` | Fresh container per test — auto-skips on VMs | +| `container_exec` | function | callable | `(cmd) → ContainerExecResult` | ## Adding tests -- **Shared (every image):** add a `cases/test_.py`. Use +- **Shared static (every image):** add a `cases/static/test_.py`. Use `@pytest.mark.require_capability("…")` if the test only applies to images with a given capability. -- **Image-specific:** add `cases//test_.py`. Tests - in such subdirectories are **automatically** restricted to that +- **Image-specific static:** add `cases/static//test_.py`. + Tests in such subdirectories are **automatically** restricted to that image family (the plugin applies `@pytest.mark.image("")` during collection — no boilerplate per file or per subdir). The directory name is treated as a *family*: an `--image-name` matches the family if it equals the family exactly OR has the form - `-` (so `cases/vm-base/` runs for both `vm-base` - and `vm-base-dev`). + `-` (so `cases/static/vm-base/` runs for both + `vm-base` and `vm-base-dev`). +- **Shared runtime (every container):** add a `cases/runtime/test_.py`. + Use the `container_exec` fixture to run commands in a live container. + Tests under `cases/runtime/` are auto-marked with + `@pytest.mark.runtime_container_tests`. +- **Image-specific runtime:** add `cases/runtime//test_.py`. + +### Dockerfile-based runtime tests + +When a runtime test needs packages or config beyond what the base image +ships, give it its own directory with a `Dockerfile`: + +``` +cases/runtime/container-base/test_nginx/ + test_nginx.py # test logic + Dockerfile # builds on top of the image-under-test + nginx.conf # supporting files (COPY'd in Dockerfile) +``` + +The Dockerfile must use `ARG BASE_IMAGE` / `FROM ${BASE_IMAGE}` — the +framework injects the image-under-test automatically: + +```dockerfile +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +RUN dnf install -y nginx && dnf clean all +COPY nginx.conf /etc/nginx/nginx.conf +``` + +Mark tests with `@pytest.mark.dockerfile()` to trigger the build. The +marker optionally accepts a path relative to the test file's directory +(defaults to `Dockerfile` in the same directory): + +```python +# Auto-discovers Dockerfile in the same directory +@pytest.mark.dockerfile() +def test_nginx_config(container_exec): + result = container_exec("nginx -t 2>&1") + assert result.exit_code == 0 + +# Explicit path to a different Dockerfile +@pytest.mark.dockerfile("alt/Dockerfile.debug") +def test_debug_variant(container_exec): + ... +``` + +Built images are cached per session — multiple tests sharing the same +Dockerfile only trigger one build. + +> **Note:** All containers (plain and Dockerfile-based) run with +> `sleep infinity` as PID 1 — the Dockerfile's `CMD`/`ENTRYPOINT` is +> overridden. Tests that need a service should start it explicitly via +> `container_exec("nginx")`. This keeps behaviour predictable and +> ensures each test controls exactly what runs. ## Adding a native-tool dependency diff --git a/base/images/tests/cases/__init__.py b/base/images/tests/cases/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/base/images/tests/cases/container-base/__init__.py b/base/images/tests/cases/container-base/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/base/images/tests/cases/runtime/container-base/test_basic.py b/base/images/tests/cases/runtime/container-base/test_basic.py new file mode 100644 index 00000000000..84cc7c4234c --- /dev/null +++ b/base/images/tests/cases/runtime/container-base/test_basic.py @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: MIT +"""Basic runtime tests for container-base images. + +Validate fundamental container behavior by exec-ing commands into a +running container. Each test gets a fresh container instance via the +``container_exec`` fixture (see conftest.py). +""" + +from __future__ import annotations + + +def test_shell_accessible(container_exec) -> None: + """Container shell must be functional via exec.""" + result = container_exec("echo hello-from-container") + assert result.exit_code == 0, ( + f"Shell exec failed (exit_code={result.exit_code}): {result.output}" + ) + assert "hello-from-container" in result.output + + +def test_dns_resolution(container_exec) -> None: + """Container must be able to resolve localhost via DNS.""" + result = container_exec("getent hosts localhost") + assert result.exit_code == 0, ( + f"DNS resolution failed (exit_code={result.exit_code}): {result.output}" + ) + assert "localhost" in result.output diff --git a/base/images/tests/cases/runtime/container-base/test_nginx/Dockerfile b/base/images/tests/cases/runtime/container-base/test_nginx/Dockerfile new file mode 100644 index 00000000000..f986418a74a --- /dev/null +++ b/base/images/tests/cases/runtime/container-base/test_nginx/Dockerfile @@ -0,0 +1,5 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +RUN dnf install -y nginx && dnf clean all +COPY nginx.conf /etc/nginx/nginx.conf +EXPOSE 80 diff --git a/base/images/tests/cases/runtime/container-base/test_nginx/nginx.conf b/base/images/tests/cases/runtime/container-base/test_nginx/nginx.conf new file mode 100644 index 00000000000..9ba414e0f7c --- /dev/null +++ b/base/images/tests/cases/runtime/container-base/test_nginx/nginx.conf @@ -0,0 +1,27 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /run/nginx.pid; + +events { + worker_connections 128; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server { + listen 80; + server_name localhost; + + location / { + return 200 "azl-nginx-ok\n"; + add_header Content-Type text/plain; + } + + location /health { + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + } +} diff --git a/base/images/tests/cases/runtime/container-base/test_nginx/test_nginx.py b/base/images/tests/cases/runtime/container-base/test_nginx/test_nginx.py new file mode 100644 index 00000000000..09f8bbc9e87 --- /dev/null +++ b/base/images/tests/cases/runtime/container-base/test_nginx/test_nginx.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: MIT +"""Validate nginx works on the container-base image. + +Uses ``@pytest.mark.dockerfile()`` to build a custom image with +nginx installed on top of the image-under-test. +""" + +from __future__ import annotations + +import pytest + + +@pytest.mark.dockerfile() +def test_nginx_config_valid(container_exec) -> None: + """nginx configuration must pass validation.""" + result = container_exec("nginx -t 2>&1") + assert result.exit_code == 0, f"nginx -t failed: {result.output}" + assert "syntax is ok" in result.output + assert "test is successful" in result.output + + +@pytest.mark.dockerfile() +def test_nginx_serves_response(container_exec) -> None: + """nginx must start and serve HTTP responses.""" + # Start nginx in the background. + start = container_exec("nginx") + assert start.exit_code == 0, f"nginx failed to start: {start.output}" + + # Verify it responds on port 80. + result = container_exec("curl -sf http://localhost:80/") + assert result.exit_code == 0, f"curl failed: {result.output}" + assert "azl-nginx-ok" in result.output + + +@pytest.mark.dockerfile() +def test_nginx_health_endpoint(container_exec) -> None: + """nginx /health endpoint must return 200.""" + start = container_exec("nginx") + assert start.exit_code == 0, f"nginx failed to start: {start.output}" + + result = container_exec("curl -sf http://localhost:80/health") + assert result.exit_code == 0, f"health check failed: {result.output}" + assert "healthy" in result.output diff --git a/base/images/tests/cases/container-base/test_container.py b/base/images/tests/cases/static/container-base/test_container.py similarity index 100% rename from base/images/tests/cases/container-base/test_container.py rename to base/images/tests/cases/static/container-base/test_container.py diff --git a/base/images/tests/cases/test_os_release.py b/base/images/tests/cases/static/test_os_release.py similarity index 100% rename from base/images/tests/cases/test_os_release.py rename to base/images/tests/cases/static/test_os_release.py diff --git a/base/images/tests/cases/test_packages.py b/base/images/tests/cases/static/test_packages.py similarity index 100% rename from base/images/tests/cases/test_packages.py rename to base/images/tests/cases/static/test_packages.py diff --git a/base/images/tests/cases/vm-base/test_kernel.py b/base/images/tests/cases/static/vm-base/test_kernel.py similarity index 100% rename from base/images/tests/cases/vm-base/test_kernel.py rename to base/images/tests/cases/static/vm-base/test_kernel.py diff --git a/base/images/tests/cases/vm-base/test_partitions.py b/base/images/tests/cases/static/vm-base/test_partitions.py similarity index 100% rename from base/images/tests/cases/vm-base/test_partitions.py rename to base/images/tests/cases/static/vm-base/test_partitions.py diff --git a/base/images/tests/cases/vm-base/__init__.py b/base/images/tests/cases/vm-base/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/base/images/tests/conftest.py b/base/images/tests/conftest.py index 91823ae3223..748ffddc5d4 100644 --- a/base/images/tests/conftest.py +++ b/base/images/tests/conftest.py @@ -1,9 +1,9 @@ # SPDX-License-Identifier: MIT """Root conftest — fixtures for image validation. -CLI options (``--image-path``, ``--image-name``, ``--image-type``, -``--capabilities``, ``--workdir``) are registered in -:mod:`utils.pytest_plugin` (loaded early via entry point). +CLI options (``--image-path``, ``--image-ref``, ``--image-name``, +``--image-type``, ``--capabilities``, ``--workdir``) are registered +in :mod:`utils.pytest_plugin` (loaded early via entry point). """ from __future__ import annotations @@ -23,6 +23,14 @@ unmount_container_image, unmount_vm_image, ) +from utils.container_runtime import ( + build_image, + create_container, + destroy_container, + exec_in_container, + get_podman_client, + resolve_image_reference, +) from utils.parsers import parse_os_release, query_rpm_packages from utils.pytest_plugin import ( derive_image_type_from_capabilities, @@ -57,8 +65,12 @@ def capabilities(request: pytest.FixtureRequest) -> set[str]: @pytest.fixture(scope="session") -def image_path(request: pytest.FixtureRequest) -> Path: - p = Path(request.config.getoption("--image-path")).resolve() +def image_path(request: pytest.FixtureRequest) -> Path | None: + """Path to image artifact from ``--image-path``, or None if ``--image-ref`` is used.""" + raw = request.config.getoption("--image-path") + if not raw: + return None + p = Path(raw).resolve() logger.info("Image path: %s", p) if not p.exists(): pytest.fail(f"Image file does not exist: {p}") @@ -66,9 +78,19 @@ def image_path(request: pytest.FixtureRequest) -> Path: return p +@pytest.fixture(scope="session") +def image_ref(request: pytest.FixtureRequest) -> str | None: + """Image reference from ``--image-ref``, or None if ``--image-path`` is used.""" + ref = request.config.getoption("--image-ref") + if ref: + logger.info("Image ref: %s", ref) + return ref + + @pytest.fixture(scope="session") def image_type( - request: pytest.FixtureRequest, capabilities: set[str], image_path: Path, + request: pytest.FixtureRequest, capabilities: set[str], + image_path: Path | None, image_ref: str | None, ) -> str: """``'vm'`` or ``'container'`` — from ``--image-type``, capabilities, or file extension.""" explicit = request.config.getoption("--image-type") @@ -81,14 +103,21 @@ def image_type( logger.info("Image type (from capabilities): %s", from_caps) return from_caps - detected = detect_image_type(str(image_path)) - if detected is None: - pytest.fail( - f"Cannot detect image type from extension of {image_path.name}. " - "Pass --image-type or --capabilities explicitly." - ) - logger.info("Image type (auto-detected from extension): %s", detected) - return detected + # --image-ref implies container. + if image_ref: + logger.info("Image type (from --image-ref): container") + return "container" + + if image_path: + detected = detect_image_type(str(image_path)) + if detected is not None: + logger.info("Image type (auto-detected from extension): %s", detected) + return detected + + pytest.fail( + "Cannot detect image type. " + "Pass --image-type or --capabilities explicitly." + ) @pytest.fixture(scope="session") @@ -138,8 +167,14 @@ def workdir(request: pytest.FixtureRequest) -> Path: @pytest.fixture(scope="session") -def rootfs(image_path: Path, image_type: str, workdir: Path) -> Path: - """Mounted rootfs — session yield-fixture with cleanup.""" +def rootfs(image_path: Path | None, image_type: str, workdir: Path) -> Path: + """Mounted rootfs — session yield-fixture with cleanup. + + Requires ``--image-path`` (not ``--image-ref``); skips otherwise. + """ + if image_path is None: + pytest.skip("rootfs requires --image-path (not available with --image-ref)") + if image_type == "vm": mountpoint = workdir / "vm-rootfs" mountpoint.mkdir(parents=True, exist_ok=True) @@ -159,11 +194,13 @@ def rootfs(image_path: Path, image_type: str, workdir: Path) -> Path: @pytest.fixture(scope="session") -def disk_info(image_path: Path, image_type: str) -> DiskInfo | None: +def disk_info(image_path: Path | None, image_type: str) -> DiskInfo | None: """Partition/filesystem info — ``None`` for container images.""" if image_type != "vm": logger.debug("Skipping disk inspection (not a VM image)") return None + if image_path is None: + pytest.skip("disk_info requires --image-path") logger.info("Inspecting disk: %s", image_path) return inspect_disk(image_path) @@ -208,3 +245,104 @@ 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 + + +# --------------------------------------------------------------------------- +# Container runtime fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def podman_client(image_type: str): + """Session-scoped PodmanClient; skips for non-container images.""" + if image_type != "container": + yield None + return + + client = get_podman_client() + try: + yield client + finally: + client.close() + + +@pytest.fixture(scope="session") +def container_image_ref( + podman_client, image_path: Path | None, image_ref: str | None, + image_type: str, +) -> str | None: + """Resolve the container image once per session and cache the reference. + + - If ``--image-ref`` was given, returns it directly. + - If ``--image-path`` was given, loads the archive via ``podman load`` + and returns the resulting image ID. + - Returns ``None`` for non-container sessions. + """ + if image_type != "container": + return None + + return resolve_image_reference(podman_client, image_path=image_path, image_ref=image_ref) + + +@pytest.fixture +def running_container( + podman_client, image_type: str, + container_image_ref: str | None, request: pytest.FixtureRequest, +): + """Fresh container per test with guaranteed teardown. + + If marked with ``@pytest.mark.dockerfile()``, builds a custom + image from the specified Dockerfile first. Skips for non-container + images. + """ + if image_type != "container": + pytest.skip("running_container only applicable to container images") + + # Check for @pytest.mark.dockerfile() marker. + effective_image = container_image_ref + dockerfile_marker = request.node.get_closest_marker("dockerfile") + if dockerfile_marker is not None: + test_dir = Path(request.fspath).parent + if dockerfile_marker.args: + dockerfile_path = (test_dir / dockerfile_marker.args[0]).resolve() + else: + dockerfile_path = (test_dir / "Dockerfile").resolve() + + if not dockerfile_path.exists(): + pytest.fail( + f"Dockerfile not found: {dockerfile_path} " + f"(from @pytest.mark.dockerfile on {request.node.name})" + ) + + effective_image = build_image( + podman_client, dockerfile_path, container_image_ref, + ) + + logger.info("Creating container for test %s", request.node.name) + instance = create_container(podman_client, effective_image) + + try: + yield instance + finally: + logger.info("Destroying container for test %s", request.node.name) + destroy_container(podman_client, instance.container_name) + + +@pytest.fixture +def container_exec(podman_client, running_container): + """Callable to execute shell commands in the running test container. + + Usage:: + + def test_example(container_exec): + result = container_exec("echo hello") + assert result.exit_code == 0 + assert "hello" in result.output + """ + def _exec(command: str): + return exec_in_container( + podman_client, + running_container.container_name, + command, + ) + return _exec diff --git a/base/images/tests/pyproject.toml b/base/images/tests/pyproject.toml index 212913a71b8..1a9cfe7a9d2 100644 --- a/base/images/tests/pyproject.toml +++ b/base/images/tests/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "pytest>=8.0", + "podman>=5.0", ] [build-system] diff --git a/base/images/tests/utils/container_runtime.py b/base/images/tests/utils/container_runtime.py new file mode 100644 index 00000000000..7e224676e0d --- /dev/null +++ b/base/images/tests/utils/container_runtime.py @@ -0,0 +1,294 @@ +# SPDX-License-Identifier: MIT +"""Container runtime orchestration using podman-py. + +Provides utilities for creating running containers with exec access +for runtime integration testing. Uses the podman Python library +(podman-py) which communicates via the Podman REST API socket. + +The podman socket must be active before tests run. Start it with:: + + systemctl --user start podman.socket + # or: podman system service --timeout=0 & +""" + +from __future__ import annotations + +import logging +import os +import uuid +from pathlib import Path +from typing import NamedTuple + +from podman import PodmanClient +from podman.domain.containers import Container +from podman.errors import APIError, NotFound + +from .tools import NativeTool + +logger = logging.getLogger(__name__) + +# Register podman as a required native tool for container testing. +PODMAN = NativeTool( + name="podman", + package_hint="podman", + reason="container runtime for live container tests", + when="container", +) + + +class ContainerExecResult(NamedTuple): + """Result of executing a command inside a container.""" + exit_code: int + output: str + + +class ContainerInstance(NamedTuple): + """A running container managed by the test framework.""" + container_id: str + container_name: str + image_ref: str + + +class ContainerRuntimeError(Exception): + """Container runtime operation failed.""" + + +def _find_podman_socket() -> Path: + """Locate the podman socket, trying rootless first then rootful.""" + candidates: list[Path] = [] + + # Rootless paths. + xdg_runtime = os.environ.get("XDG_RUNTIME_DIR") + if xdg_runtime: + candidates.append(Path(xdg_runtime) / "podman" / "podman.sock") + candidates.append(Path(f"/run/user/{os.getuid()}/podman/podman.sock")) + + # Rootful path. + candidates.append(Path("/run/podman/podman.sock")) + + for sock in candidates: + if sock.exists(): + logger.debug("Found podman socket: %s", sock) + return sock + + tried = ", ".join(str(s) for s in candidates) + raise ContainerRuntimeError( + f"Podman socket not found. Tried: {tried}. " + "Start it with: systemctl --user start podman.socket " + "(rootless) or systemctl start podman.socket (rootful)" + ) + + +def get_podman_client() -> PodmanClient: + """Create a PodmanClient connected to the podman socket. + + Tries rootless first, then falls back to rootful. + Raises ContainerRuntimeError if the socket is unavailable. + """ + socket_path = _find_podman_socket() + uri = f"unix://{socket_path}" + logger.info("Connecting to podman at %s", uri) + + client = PodmanClient(base_url=uri) + try: + client.ping() + except APIError as exc: + raise ContainerRuntimeError( + f"Cannot connect to podman at {uri}: {exc}" + ) from exc + return client + + +def resolve_image_reference( + client: PodmanClient, + *, + image_path: Path | None = None, + image_ref: str | None = None, +) -> str: + """Resolve to a podman-usable image reference. + + Exactly one of *image_path* or *image_ref* must be provided. + + - **image_path**: a local archive file (``.tar``, ``.tar.xz``, etc.) + is loaded via ``podman load`` and the resulting image ID is returned. + - **image_ref**: an image reference (e.g. ``mcr.microsoft.com/azurelinux/base/core:4.0`` + or ``localhost/container-base:latest``). Pulled from the registry + if not already present locally. + + Returns: + Image ID (for archives) or the image reference string. + """ + if image_path and image_ref: + raise ContainerRuntimeError( + "image_path and image_ref are mutually exclusive" + ) + if not image_path and not image_ref: + raise ContainerRuntimeError( + "Either image_path or image_ref must be provided" + ) + + if image_ref: + # Ensure the image is available locally; pull if needed. + try: + client.images.get(image_ref) + logger.info("Image already present locally: %s", image_ref) + except NotFound: + logger.info("Pulling image: %s", image_ref) + client.images.pull(image_ref) + return image_ref + + # Load from archive file. + assert image_path is not None + logger.info("Loading image archive: %s", image_path) + with open(image_path, "rb") as f: + images = list(client.images.load(f)) + + if not images: + raise ContainerRuntimeError( + f"podman load returned no images for {image_path}" + ) + + image = images[0] + logger.info("Loaded image: %s", image.id[:12]) + return image.id + + +# Session-level cache for images built from Dockerfiles. +# Keyed by resolved Dockerfile path; avoids redundant builds when +# multiple tests share the same Dockerfile. +_built_image_cache: dict[str, str] = {} + + +def build_image( + client: PodmanClient, + dockerfile_path: Path, + base_image_ref: str, +) -> str: + """Build a container image from a Dockerfile. + + Injects the image-under-test as the ``BASE_IMAGE`` build arg. + Results are cached per session by Dockerfile path. + """ + cache_key = str(dockerfile_path) + if cache_key in _built_image_cache: + cached = _built_image_cache[cache_key] + logger.info("Using cached build for %s: %s", dockerfile_path.name, cached[:12]) + return cached + + context_dir = dockerfile_path.parent + logger.info( + "Building image from %s (base: %s, context: %s)", + dockerfile_path, base_image_ref[:12], context_dir, + ) + + image, _build_logs = client.images.build( + path=str(context_dir), + dockerfile=str(dockerfile_path), + buildargs={"BASE_IMAGE": base_image_ref}, + rm=True, + ) + + image_id = image.id + logger.info("Built image: %s", image_id[:12]) + _built_image_cache[cache_key] = image_id + return image_id + + +def create_container( + client: PodmanClient, + image_ref: str, + container_name: str | None = None, +) -> ContainerInstance: + """Create and start a container with exec access. + + The container runs ``sleep infinity`` to stay alive for the duration + of the test, allowing repeated ``exec`` calls. + + Args: + client: Active PodmanClient instance. + image_ref: Image ID or reference to run. + container_name: Optional name; auto-generated if None. + + Returns: + A ContainerInstance with the container's ID, name, and image ref. + """ + if container_name is None: + container_name = f"azl-test-{uuid.uuid4().hex[:12]}" + + logger.info("Creating container %s from image %s", container_name, image_ref[:12]) + + container: Container = client.containers.run( + image_ref, + command=["sleep", "infinity"], + name=container_name, + detach=True, + ) + + # Verify the container is running and exec works. + try: + container.reload() + if container.status != "running": + raise ContainerRuntimeError( + f"Container {container_name} is not running " + f"(status: {container.status})" + ) + + exit_code, output = container.exec_run(["echo", "ready"]) + if exit_code != 0: + raise ContainerRuntimeError( + f"Container exec readiness check failed for {container_name} " + f"(exit_code={exit_code}, output={output!r})" + ) + except BaseException: + # Clean up on any failure (including KeyboardInterrupt). + logger.warning("Readiness check failed; removing container %s", container_name) + try: + container.remove(force=True) + except Exception as cleanup_exc: + logger.warning("Failed to clean up container %s: %s", container_name, cleanup_exc) + raise + + logger.info("Container ready: %s (ID: %s)", container_name, container.id[:12]) + return ContainerInstance( + container_id=container.id, + container_name=container_name, + image_ref=image_ref, + ) + + +def exec_in_container( + client: PodmanClient, + container_name: str, + command: str, +) -> ContainerExecResult: + """Execute a shell command inside a running container. + + Args: + client: Active PodmanClient instance. + container_name: Name of the running container. + command: Shell command string to execute via ``bash -c``. + + Returns: + ContainerExecResult with exit_code and combined output. + """ + logger.debug("Container exec [%s]: %s", container_name, command) + container = client.containers.get(container_name) + exit_code, output = container.exec_run(["bash", "-c", command]) + output_str = output.decode("utf-8", errors="replace") if isinstance(output, bytes) else str(output) + return ContainerExecResult(exit_code=exit_code, output=output_str) + + +def destroy_container(client: PodmanClient, container_name: str) -> None: + """Kill and remove a container (best-effort, never raises).""" + logger.info("Destroying container %s", container_name) + try: + container = client.containers.get(container_name) + container.kill() + except (NotFound, APIError) as exc: + logger.debug("Container kill skipped (%s): %s", container_name, exc) + + try: + container = client.containers.get(container_name) + container.remove(force=True) + except (NotFound, APIError) as exc: + logger.debug("Container remove skipped (%s): %s", container_name, exc) diff --git a/base/images/tests/utils/pytest_plugin.py b/base/images/tests/utils/pytest_plugin.py index 6d3d26c4083..ddab7663a7e 100644 --- a/base/images/tests/utils/pytest_plugin.py +++ b/base/images/tests/utils/pytest_plugin.py @@ -60,8 +60,17 @@ def pytest_addoption(parser) -> None: # type: ignore[no-untyped-def] group = parser.getgroup("image", "Azure Linux image validation") group.addoption( "--image-path", - required=True, - help="Path to the built image artifact (VHD, raw, OCI tar.xz, etc.)", + default=None, + help="Path to the built image artifact (VHD, raw, OCI tar.xz, etc.). " + "Mutually exclusive with --image-ref.", + ) + group.addoption( + "--image-ref", + default=None, + help="Container image reference (e.g. 'mcr.microsoft.com/azurelinux/base/core:4.0' " + "or 'localhost/container-base:latest'). Podman will pull from the " + "registry if not already present locally. " + "Mutually exclusive with --image-path.", ) group.addoption( "--image-name", @@ -109,9 +118,35 @@ def pytest_configure(config) -> None: # type: ignore[no-untyped-def] "image(name): only run this test when --image-name matches the named image family " "(exact match, or a ``-`` image-name)", ) + config.addinivalue_line( + "markers", + "runtime_container_tests: auto-applied to tests under cases/runtime/; " + "used for -m filtering to separate static and runtime suites", + ) + config.addinivalue_line( + "markers", + 'dockerfile(path=None): build a custom image from a Dockerfile before ' + 'running the test. With no args, auto-discovers "Dockerfile" in the ' + "test file's directory. With an arg, uses that path relative to the " + "test file's directory. The image-under-test is injected as the " + "BASE_IMAGE build arg.", + ) from utils.tools import check_tools + # Validate that exactly one of --image-path or --image-ref is provided. + image_path_raw = config.getoption("--image-path", default=None) + image_ref_raw = config.getoption("--image-ref", default=None) + if image_path_raw and image_ref_raw: + raise pytest.UsageError( + "--image-path and --image-ref are mutually exclusive. " + "Provide one or the other." + ) + if not image_path_raw and not image_ref_raw: + raise pytest.UsageError( + "Either --image-path or --image-ref is required." + ) + # Determine image type early (before fixtures) so we only check # the tools that are actually needed for this run. image_type = config.getoption("--image-type", default=None) @@ -168,33 +203,56 @@ def pytest_runtest_setup(item: pytest.Item) -> None: def pytest_collection_modifyitems(config, items) -> None: # type: ignore[no-untyped-def] - """Auto-apply ``@pytest.mark.image("")`` to tests under ``cases//``. + """Auto-apply markers based on directory layout under ``cases/``. + + Layout convention (after restructure):: + + cases/ + static/ + test_*.py # shared static tests + /test_*.py # image-specific static + runtime/ + test_*.py # shared runtime tests + /test_*.py # image-specific runtime + + Auto-applied markers: - Convention: any test file inside ``cases//`` (at any - depth) is automatically restricted to images whose name belongs to - that family. The directory name is the family; an ``--image-name`` - matches the family if it equals the family exactly OR if it has the - form ``-`` (e.g. ``vm-base-dev`` matches the - ``vm-base`` family). See :func:`pytest_runtest_setup`. + - ``@pytest.mark.image("")`` on any test under + ``cases/static//`` or ``cases/runtime//`` so it + only runs when ``--image-name`` belongs to that family. - This keeps the routing rule co-located with the directory layout — - no per-subdir conftest, and no per-file ``pytestmark`` boilerplate - that contributors might forget to add. + - ``@pytest.mark.runtime_container_tests`` on any test under + ``cases/runtime/`` so static-only suites can exclude them with + ``-m "not runtime_container_tests"``. - Tests directly under ``cases/`` (no image subdir) get no marker - and run for every image. + Tests directly under ``cases/static/`` or ``cases/runtime/`` (no + image subdir) get no ``image`` marker and run for every image. """ from pathlib import Path for item in items: parts = Path(str(item.fspath)).parts - # Anchor on the right-most "cases" segment so the convention is - # robust against arbitrary parent directory names. + # Anchor on the right-most "cases" segment. try: cases_idx = len(parts) - 1 - parts[::-1].index("cases") except ValueError: continue - # Need at least cases//.py to derive a family name. - if cases_idx + 2 < len(parts): - image_dir = parts[cases_idx + 1] + + remaining = parts[cases_idx + 1:] + if not remaining: + continue + + # remaining[0] is "static" or "runtime"; remaining[1] (if present) + # is the image-family directory. + category = remaining[0] # "static" or "runtime" + + # Auto-apply runtime_container_tests marker. + if category == "runtime": + item.add_marker(pytest.mark.runtime_container_tests) + + # Auto-apply image() marker if there's an image-family subdir. + # e.g. cases/static/vm-base/test_kernel.py → image("vm-base") + # cases/runtime/container-base/test_foo.py → image("container-base") + if len(remaining) >= 3: # category + family_dir + file + image_dir = remaining[1] item.add_marker(pytest.mark.image(image_dir))