From 46fa50c7506c95f29a722c74bcab52b1badbd45a Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sun, 1 Nov 2026 16:25:04 -0500 Subject: [PATCH 1/2] feat(test): Kubernetes backend runs CIT and compose stacks as pods dbuild test --backend k8s runs the same shell/port/health/screenshot/ command tests against a cluster from the kubeconfig in the environment. Pod annotations carry the org.freebsd.jail.* settings from the compose metadata; image labels come from crictl. A compose stack becomes one host-networked pod, or a pod plus a ClusterIP Service per service when the services publish ports; stack-only repos run as a synthetic stack variant. kubectl output with invalid UTF-8 no longer aborts a test. --- dbuild/cli.py | 6 +- dbuild/container_backend.py | 192 ++++++++++++- dbuild/kube.py | 553 ++++++++++++++++++++++++++++++++++++ dbuild/test.py | 92 ++++-- docs/dbuild.1 | 2 +- tests/test_kube.py | 148 ++++++++++ tests/test_test.py | 130 +++++++++ 7 files changed, 1099 insertions(+), 24 deletions(-) create mode 100644 dbuild/kube.py create mode 100644 tests/test_kube.py diff --git a/dbuild/cli.py b/dbuild/cli.py index b747029..7436984 100644 --- a/dbuild/cli.py +++ b/dbuild/cli.py @@ -152,9 +152,11 @@ def _make_parser() -> argparse.ArgumentParser: ) test_parser.add_argument( "--backend", - choices=["podman", "appjail", "all"], + choices=["podman", "appjail", "k8s", "all"], default="all", - help="which backend(s) to test with: podman, appjail, or all (default: all)", + help="which backend(s) to test with: podman, appjail, k8s, or all " + "(default: all = podman and, where configured, appjail; k8s only " + "when asked, on a cluster node, against the registry's image)", ) test_parser.add_argument( "--puid", diff --git a/dbuild/container_backend.py b/dbuild/container_backend.py index b5d842a..77fc168 100644 --- a/dbuild/container_backend.py +++ b/dbuild/container_backend.py @@ -8,6 +8,7 @@ from __future__ import annotations +import os import shutil import subprocess from abc import ABC, abstractmethod @@ -63,6 +64,20 @@ def running(self, cname: str, *, quiet: bool = False) -> bool: def stop(self, cname: str) -> None: """Stop and remove the container/jail.""" + def labels(self, image_ref: str) -> dict[str, str]: + """The image's OCI labels (port, healthcheck, jail annotations).""" + return podman.inspect_labels(image_ref) + + def run_oneshot( + self, + image_ref: str, + command: list[str] | None, + *, + annotations: dict[str, str], + ) -> tuple[int, str]: + """Run the image to completion (command mode): ``(rc, output)``.""" + return podman.run_oneshot(image_ref, command, annotations=annotations) + class PodmanBackend(ContainerBackend): name = "podman" @@ -160,8 +175,183 @@ def stop(self, cname: str) -> None: aj.jail_destroy(cname) +class KubernetesBackend(ContainerBackend): + """One pod per test on the cluster this node belongs to (``kubectl``). + + Tests the registry's image: the node pulls it, nothing is built here. + """ + + name = "k8s" + + def __init__(self) -> None: + self._stacks: set[str] = set() # cnames started as stacks (one pod, many containers) + # cnames started as a pod per service: cname -> (main pod, host port -> container port) + self._split: dict[str, tuple[str, dict[int, int]]] = {} + self._split_ports: dict[str, tuple[dict[str, str], dict[str, dict[int, int]]]] = {} + + @classmethod + def available(cls) -> bool: + from dbuild import kube + return kube.available() + + def start( + self, + cname: str, + image_ref: str, + *, + annotations: dict[str, str], + env: dict[str, str] | None = None, + volumes: list[str] | None = None, + ) -> None: + from dbuild import kube + if volumes: + raise NotImplementedError( + "k8s backend does not support volumes " + "(used by the --puid test); run --puid with --backend podman", + ) + kube.ensure_namespace() + name = kube.pod_name(cname) + kube.delete(name) + log.info(f"Pod: {kube.NAMESPACE}/{name}") + kube.apply(kube.pod_spec(name, image_ref, annotations=annotations, env=env)) + kube.wait_started(name) + + def get_ip(self, cname: str) -> str: + from dbuild import kube + if cname in self._split: + return kube.pod_ip(self._split[cname][0]) + return kube.pod_ip(kube.pod_name(cname)) + + def logs(self, cname: str, *, quiet: bool = False) -> str: + from dbuild import kube + if cname in self._split: + return kube.stack_logs_all(kube.pod_name(cname), quiet=quiet) + if cname in self._stacks: + return kube.stack_logs(kube.pod_name(cname), quiet=quiet) + return kube.logs(kube.pod_name(cname), quiet=quiet) + + def exec_in(self, cname: str, cmd: list[str]) -> subprocess.CompletedProcess: + from dbuild import kube + if cname in self._split: + return kube.exec_in(self._split[cname][0], cmd, first=True) + return kube.exec_in(kube.pod_name(cname), cmd, first=cname in self._stacks) + + def running(self, cname: str, *, quiet: bool = False) -> bool: + from dbuild import kube + if cname in self._split: + return kube.stack_running_all(kube.pod_name(cname)) + if cname in self._stacks: + return kube.stack_running(kube.pod_name(cname)) + return kube.running(kube.pod_name(cname)) + + def stop(self, cname: str) -> None: + from dbuild import kube + if cname in self._split: + kube.delete_stack(kube.pod_name(cname)) + del self._split[cname] + return + kube.delete(kube.pod_name(cname)) + self._stacks.discard(cname) + + def stack_port(self, cname: str, port: int | None) -> int | None: + """The port to probe on get_ip's pod: for a pod-per-service stack the + config's port is a published (host) port, and the probe goes to the + pod that publishes it, at its container port.""" + if port is None or cname not in self._split: + return port + return self._split[cname][1].get(port, port) + + def labels(self, image_ref: str) -> dict[str, str]: + from dbuild import kube + return kube.image_labels(image_ref) + + def start_stack( + self, cname: str, repo_dir: str, compose_file: str, + image_map: dict[str, str] | None = None, + ) -> None: + """A compose stack as one pod: a container per service, the stack's + ``.env`` (else ``example.env``) for its variables, hostPath data dirs + under the node's scratch root. *image_map* maps the compose file's + local ``:build`` ref to the registry image under test.""" + import yaml + + from dbuild import kube + with open(compose_file) as fh: + compose = yaml.safe_load(fh) or {} + env, env_files = kube.stack_env(repo_dir) + name = kube.pod_name(cname) + kube.ensure_namespace() + kube.delete(name) + kube.delete_stack(name) + if kube.shares_host_network(compose): + spec, images = kube.stack_spec( + name, compose, env=env, env_files=env_files, data_root=f"/var/tmp/cit/{name}", + image_map=image_map, + ) + for svc, image in images.items(): + log.info(f" {svc}: {image}") + log.info(f"Pod: {kube.NAMESPACE}/{name} ({len(images)} containers, one network)") + kube.apply(spec) + kube.wait_started(name) + self._stacks.add(cname) + return + specs, images, ports = kube.stack_specs_per_service( + name, compose, env=env, env_files=env_files, data_root=f"/var/tmp/cit/{name}", + image_map=image_map, + ) + for svc, image in images.items(): + pub = ", ".join(f"{h}->{c}" for h, c in ports[svc].items()) or "no published port" + log.info(f" {svc}: {image} ({pub})") + log.info(f"Pods: {kube.NAMESPACE}/{name}-* ({len(images)} pods, a Service per name)") + kube.apply_all(specs) + for pod in [sp["metadata"]["name"] for sp in specs if sp["kind"] == "Pod"]: + kube.wait_started(pod) + # The pod the checks talk to: the one publishing the config's port + # (resolved in stack_port); until a port is asked for, the first. + main = kube.pod_name(f"{name}-{next(iter(images))}") + by_host: dict[int, int] = {} + self._split[cname] = (main, by_host) + self._split_ports = {cname: (images, ports)} + + def choose_main(self, cname: str, port: int | None) -> None: + """Pick the pod that publishes *port* as the one the checks probe.""" + from dbuild import kube + if cname not in self._split or port is None: + return + images, ports = self._split_ports.get(cname, ({}, {})) + name = kube.pod_name(cname) + for svc, mapping in ports.items(): + if port in mapping: + self._split[cname] = (kube.pod_name(f"{name}-{svc}"), dict(mapping)) + log.info(f"Probing {svc} (publishes {port} -> {mapping[port]})") + return + log.warn(f"No service publishes port {port}; probing {next(iter(images), '?')}") + + def run_oneshot( + self, + image_ref: str, + command: list[str] | None, + *, + annotations: dict[str, str], + ) -> tuple[int, str]: + from dbuild import kube + kube.ensure_namespace() + name = kube.pod_name(f"cit-{os.getpid()}-oneshot") + kube.delete(name) + kube.apply(kube.pod_spec(name, image_ref, annotations=annotations, args=command)) + try: + kube.wait_started(name) + rc = kube.wait_finished(name, timeout=600) + output = kube.logs(name, quiet=True) + if rc is None: + return 124, output + "\n(still running after 600s)" + return rc, output + finally: + kube.delete(name) + + #: Ordered list of all available backends (podman first = default) -_BACKENDS: list[type[ContainerBackend]] = [PodmanBackend, AppJailBackend] +_BACKENDS: list[type[ContainerBackend]] = [PodmanBackend, AppJailBackend, KubernetesBackend] def get_backend(name: str) -> ContainerBackend: diff --git a/dbuild/kube.py b/dbuild/kube.py new file mode 100644 index 0000000..f9fe8ce --- /dev/null +++ b/dbuild/kube.py @@ -0,0 +1,553 @@ +"""kubectl and crictl wrappers for the Kubernetes CIT backend. + +The CIT runs on a cluster node: ``kubectl`` talks to the API, ``crictl`` to +the node's CRI-O for image labels (pulling the image on the way, so the pod's +own pull is a no-op). Each test is one pod in the ``cit`` namespace. Pods are +never restarted by the kubelet (``restartPolicy: Never``): a crash is a +failed pod with its log intact, not a restart loop hidden behind "Running". +""" + +from __future__ import annotations + +import contextlib +import json +import os +import re +import subprocess +import time + +from dbuild import log + +NAMESPACE = "cit" + +#: How long a pod may stay Pending (the image pull, mostly) before start fails. +PENDING_TIMEOUT = 900 + + +class KubeError(Exception): + """A kubectl/crictl command failed.""" + + def __init__(self, cmd: list[str], returncode: int, stderr: str) -> None: + self.cmd = cmd + self.returncode = returncode + self.stderr = stderr + super().__init__(f"{' '.join(cmd)} failed ({returncode}): {stderr}") + + +def _run( + cmd: list[str], + *, + check: bool = True, + quiet: bool = False, + stdin: str | None = None, +) -> subprocess.CompletedProcess[str]: + if not quiet: + log.info(f"$ {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, errors="replace", input=stdin) + if check and result.returncode != 0: + raise KubeError(cmd, result.returncode, (result.stderr or "").strip()) + return result + + +def kubectl(*args: str, **kw) -> subprocess.CompletedProcess[str]: + return _run(["kubectl", "-n", NAMESPACE, *args], **kw) + + +def pod_name(cname: str) -> str: + """A DNS label for a CIT container name: lowercase, [a-z0-9-], 63 max.""" + name = re.sub(r"[^a-z0-9-]+", "-", cname.lower()).strip("-") + return name[:63].rstrip("-") + + +def ensure_namespace() -> None: + res = _run(["kubectl", "get", "ns", NAMESPACE], check=False, quiet=True) + if res.returncode != 0: + _run(["kubectl", "create", "ns", NAMESPACE]) + + +def pod_spec( + name: str, + image_ref: str, + *, + annotations: dict[str, str] | None = None, + env: dict[str, str] | None = None, + args: list[str] | None = None, + restart: str = "Never", +) -> dict: + """The pod for one CIT run: one container, the image's own entrypoint. + + Pod annotations reach the OCI runtime as they are (CRI-O filters only + its own experimental keys), so ``org.freebsd.jail.*`` lands in ocijail + the same way ``podman run --annotation`` delivers it. No securityContext: + the container starts as root and drops privileges itself, today's + contract. + """ + container: dict = { + "name": "app", + "image": image_ref, + "imagePullPolicy": "IfNotPresent", + } + if env: + container["env"] = [{"name": k, "value": str(v)} for k, v in env.items()] + if args: + container["args"] = list(args) + spec = { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": name, + "namespace": NAMESPACE, + "labels": {"app.kubernetes.io/managed-by": "dbuild-cit"}, + "annotations": {k: str(v) for k, v in (annotations or {}).items()}, + }, + "spec": { + "restartPolicy": restart, + "terminationGracePeriodSeconds": 30, + "containers": [container], + }, + } + return spec + + +def apply(spec: dict) -> None: + kubectl("apply", "-f", "-", stdin=json.dumps(spec)) + + +def get_pod(name: str) -> dict | None: + res = kubectl("get", "pod", name, "-o", "json", check=False, quiet=True) + if res.returncode != 0 or not res.stdout.strip(): + return None + try: + return json.loads(res.stdout) + except json.JSONDecodeError: + return None + + +def _container_state(pod: dict) -> tuple[str, str]: + """(state, reason) of the pod's one container: waiting/running/terminated.""" + for cs in (pod.get("status") or {}).get("containerStatuses") or []: + state = cs.get("state") or {} + for key in ("running", "terminated", "waiting"): + if key in state: + return key, (state[key] or {}).get("reason", "") + return "", "" + + +def wait_started(name: str, timeout: int = PENDING_TIMEOUT) -> None: + """Wait until the pod's container has started (or already finished). + + Raises when the pull or the start fails for good (ErrImagePull, + ImagePullBackOff, CreateContainerError...) or the timeout passes. + """ + deadline = time.monotonic() + timeout + last = "" + while time.monotonic() < deadline: + pod = get_pod(name) + if pod is not None: + state, reason = _container_state(pod) + if state in ("running", "terminated") and not _any_waiting(pod): + return + phase = (pod.get("status") or {}).get("phase", "") + if reason in ("ErrImagePull", "ImagePullBackOff", "CreateContainerError", + "CreateContainerConfigError", "InvalidImageName"): + msg = _waiting_message(pod) + raise RuntimeError(f"pod {name} cannot start: {reason} {msg}".strip()) + cur = f"{phase}/{reason or state or '-'}" + if cur != last: + log.info(f"Pod {name}: {cur}") + last = cur + time.sleep(3) + raise RuntimeError(f"pod {name} still not started after {timeout}s") + + +def _any_waiting(pod: dict) -> bool: + """A container of the pod (a stack's sidecar) is still being created.""" + for cs in (pod.get("status") or {}).get("containerStatuses") or []: + if "waiting" in (cs.get("state") or {}): + return True + return False + + +def _waiting_message(pod: dict) -> str: + for cs in (pod.get("status") or {}).get("containerStatuses") or []: + waiting = (cs.get("state") or {}).get("waiting") or {} + if waiting.get("message"): + return waiting["message"] + return "" + + +def running(name: str) -> bool: + pod = get_pod(name) + if pod is None: + return False + state, _ = _container_state(pod) + return state == "running" + + +def exit_code(name: str) -> int | None: + pod = get_pod(name) + if pod is None: + return None + for cs in (pod.get("status") or {}).get("containerStatuses") or []: + term = (cs.get("state") or {}).get("terminated") + if term is not None: + return int(term.get("exitCode", -1)) + return None + + +def pod_ip(name: str) -> str: + pod = get_pod(name) + if pod is None: + return "" + return ((pod.get("status") or {}).get("podIP")) or "" + + +def logs(name: str, *, quiet: bool = False) -> str: + res = kubectl("logs", name, "--container", "app", check=False, quiet=quiet) + return (res.stdout or "") + (res.stderr if res.returncode != 0 else "") + + +def exec_in(name: str, cmd: list[str], *, first: bool = False) -> subprocess.CompletedProcess[str]: + """Run in the pod's ``app`` container, or (first) in its first container.""" + target = [] if first else ["--container", "app"] + return kubectl("exec", name, *target, "--", *cmd, check=False, quiet=True) + + +def delete(name: str) -> None: + """Remove the pod now. Forced: a pod can hang in Terminating after CRI-O + has already removed everything (seen on the FreeBSD stack), and the CIT + needs the name back.""" + kubectl("delete", "pod", name, "--ignore-not-found", "--force", + "--grace-period=0", "--wait=false", check=False) + + +def wait_finished(name: str, timeout: int) -> int | None: + """Wait for a one-shot pod to end; its exit code, or None on timeout.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + code = exit_code(name) + if code is not None: + return code + time.sleep(2) + return None + + +def image_labels(image_ref: str) -> dict[str, str]: + """The image's OCI labels, through the node's CRI-O (pulling it first).""" + _run(["crictl", "pull", image_ref]) + res = _run(["crictl", "inspecti", "-o", "json", image_ref], check=False, quiet=True) + if res.returncode != 0 or not res.stdout.strip(): + return {} + try: + data = json.loads(res.stdout) + except json.JSONDecodeError: + return {} + labels = (((data.get("info") or {}).get("imageSpec") or {}).get("config") or {}).get("Labels") + return labels if isinstance(labels, dict) else {} + + +def available() -> bool: + """kubectl present and a kubeconfig to talk with.""" + from shutil import which + if which("kubectl") is None: + return False + cfg = os.environ.get("KUBECONFIG") or os.path.expanduser("~/.kube/config") + return os.path.exists(cfg) + + +# ── Compose stacks ─────────────────────────────────────────────────── +# +# A daemonless stack is a compose file whose services share the host's +# network (network_mode: host) and find each other on localhost. The pod is +# that same shape: one pod, one container per service, one network +# namespace, so the compose file's localhost addresses hold as written. +# Bind mounts become hostPath dirs under a scratch root; named volumes too. + +_VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}") + + +def interpolate(text: str, env: dict[str, str]) -> str: + """Compose-style ``${VAR}`` / ``${VAR:-default}`` substitution.""" + def sub(m: re.Match) -> str: + val = env.get(m.group(1)) + if val is None or (val == "" and m.group(0).startswith("${" + m.group(1) + ":-")): + return m.group(2) or "" + return val + return _VAR.sub(sub, text) + + +def parse_env_file(text: str) -> dict[str, str]: + """KEY=value lines of a .env; comments and blanks skipped, quotes stripped.""" + env: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + v = v.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1] + env[k.strip()] = v + return env + + +def _service_env(svc: dict, env: dict[str, str], env_files: dict[str, dict[str, str]]) -> dict[str, str]: + out: dict[str, str] = {} + for f in svc.get("env_file") or []: + name = f if isinstance(f, str) else f.get("path", "") + out.update(env_files.get(name, {})) + raw = svc.get("environment") or {} + if isinstance(raw, list): + for item in raw: + k, _, v = str(item).partition("=") + out[k] = v + else: + out.update({k: "" if v is None else str(v) for k, v in raw.items()}) + return {k: interpolate(v, env) for k, v in out.items()} + + +def _mount_name(container_path: str) -> str: + return pod_name("m-" + container_path.strip("/").replace("/", "-")) or "m-root" + + +def _service_container( + svc_name: str, + svc: dict, + *, + env: dict[str, str], + env_files: dict[str, dict[str, str]], + data_root: str, + image_map: dict[str, str], +) -> tuple[dict, list[dict], str]: + """A service's container spec, its hostPath volumes, and its image.""" + image = interpolate(str(svc.get("image", "")), env) + image = image_map.get(image, image) + cname = pod_name(svc_name) + container: dict = { + "name": cname, + "image": image, + "imagePullPolicy": "IfNotPresent", + } + svc_env = _service_env(svc, env, env_files) + if svc_env: + container["env"] = [{"name": k, "value": v} for k, v in svc_env.items()] + if svc.get("command"): + cmd = svc["command"] + container["args"] = cmd if isinstance(cmd, list) else cmd.split() + mounts = [] + volumes = [] + for vol in svc.get("volumes") or []: + if isinstance(vol, dict): + src, dst, ro = vol.get("source", ""), vol.get("target", ""), bool(vol.get("read_only")) + else: + parts = interpolate(str(vol), env).split(":") + if len(parts) < 2: + continue + src, dst = parts[0], parts[1] + ro = len(parts) > 2 and "ro" in parts[2].split(",") + src = interpolate(src, env) + if src == "/etc/localtime": + continue # the node's clock reaches the pod anyway + if src.startswith("/"): + host = f"{data_root}/{cname}{src}" if src.startswith("/containers") or src.startswith("/path") else f"{data_root}/{cname}/{src.strip('/').replace('/', '-')}" + else: + host = f"{data_root}/vol-{pod_name(src)}" + vname = pod_name(f"{cname}-{_mount_name(dst)}") + volumes.append({"name": vname, "hostPath": {"path": host, "type": "DirectoryOrCreate"}}) + mount = {"name": vname, "mountPath": dst} + if ro: + mount["readOnly"] = True + mounts.append(mount) + if mounts: + container["volumeMounts"] = mounts + return container, volumes, image + + +def _pod(name: str, containers: list[dict], volumes: list[dict], annotations: dict[str, str], stack: str) -> dict: + spec = { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": name, + "namespace": NAMESPACE, + "labels": {"app.kubernetes.io/managed-by": "dbuild-cit", "dbuild-cit/stack": stack}, + "annotations": annotations, + }, + "spec": { + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 60, + "containers": containers, + }, + } + if volumes: + spec["spec"]["volumes"] = volumes + return spec + + +def published_ports(svc: dict, env: dict[str, str]) -> dict[int, int]: + """host port -> container port from a service's ``ports:`` list.""" + out: dict[int, int] = {} + for item in svc.get("ports") or []: + if isinstance(item, dict): + with contextlib.suppress(TypeError, ValueError): + out[int(item.get("published", item.get("target")))] = int(item.get("target")) + continue + text = interpolate(str(item), env).split("/")[0] + parts = text.split(":") + try: + if len(parts) == 1: + out[int(parts[0])] = int(parts[0]) + else: + out[int(parts[-2])] = int(parts[-1]) + except ValueError: + continue + return out + + +def stack_spec( + name: str, + compose: dict, + *, + env: dict[str, str], + env_files: dict[str, dict[str, str]] | None = None, + data_root: str, + image_map: dict[str, str] | None = None, +) -> tuple[dict, dict[str, str]]: + """One pod for a host-networked compose stack, and each service's image. + + Services under a ``profiles:`` key are left out (compose starts them + only when the profile is asked for). *image_map* rewrites image refs: + the CIT compose file names the local ``:build`` tag, which on a cluster + node is the registry's image of the variant under test. Returns the + spec and a map of service name to image ref. + """ + env_files = env_files or {} + image_map = image_map or {} + containers = [] + volumes = [] + images: dict[str, str] = {} + annotations: dict[str, str] = {} + for svc_name, svc in (compose.get("services") or {}).items(): + if svc.get("profiles"): + continue + container, vols, image = _service_container( + svc_name, svc, env=env, env_files=env_files, data_root=data_root, image_map=image_map, + ) + images[svc_name] = image + containers.append(container) + volumes += vols + for k, v in (svc.get("annotations") or {}).items(): + annotations[str(k)] = str(v) + return _pod(name, containers, volumes, annotations, name), images + + +def shares_host_network(compose: dict) -> bool: + """Every service of the stack runs on the host's network (localhost + between them), so one pod with a shared network namespace is its shape.""" + svcs = [s for s in (compose.get("services") or {}).values() if not s.get("profiles")] + return bool(svcs) and all(s.get("network_mode") == "host" for s in svcs) + + +def stack_specs_per_service( + name: str, + compose: dict, + *, + env: dict[str, str], + env_files: dict[str, dict[str, str]] | None = None, + data_root: str, + image_map: dict[str, str] | None = None, +) -> tuple[list[dict], dict[str, str], dict[str, dict[int, int]]]: + """A pod and a Service per compose service, for stacks whose services + have networks of their own (bridge networking, published ports): the + Service carries the compose name, so ``db:5432`` resolves as written. + Returns the specs (pods and services), service -> image, and + service -> {host port: container port}. + """ + env_files = env_files or {} + image_map = image_map or {} + specs: list[dict] = [] + images: dict[str, str] = {} + ports: dict[str, dict[int, int]] = {} + for svc_name, svc in (compose.get("services") or {}).items(): + if svc.get("profiles"): + continue + container, vols, image = _service_container( + svc_name, svc, env=env, env_files=env_files, data_root=data_root, image_map=image_map, + ) + images[svc_name] = image + ports[svc_name] = published_ports(svc, env) + annotations = {str(k): str(v) for k, v in (svc.get("annotations") or {}).items()} + pod = _pod(pod_name(f"{name}-{svc_name}"), [container], vols, annotations, name) + pod["metadata"]["labels"]["dbuild-cit/service"] = pod_name(svc_name) + pod["spec"]["hostname"] = pod_name(svc_name) + specs.append(pod) + cports = sorted(set(ports[svc_name].values())) + if cports: + specs.append({ + "apiVersion": "v1", + "kind": "Service", + "metadata": {"name": pod_name(svc_name), "namespace": NAMESPACE, + "labels": {"app.kubernetes.io/managed-by": "dbuild-cit", "dbuild-cit/stack": name}}, + "spec": { + "selector": {"dbuild-cit/service": pod_name(svc_name), "dbuild-cit/stack": name}, + "ports": [{"name": f"p{p}", "port": p, "targetPort": p} for p in cports], + }, + }) + return specs, images, ports + + +def apply_all(specs: list[dict]) -> None: + kubectl("apply", "-f", "-", stdin=json.dumps({"apiVersion": "v1", "kind": "List", "items": specs})) + + +def delete_stack(stack: str) -> None: + """Remove every pod and Service of a stack, forced (see delete).""" + kubectl("delete", "pod,service", "-l", f"dbuild-cit/stack={stack}", "--ignore-not-found", + "--force", "--grace-period=0", "--wait=false", check=False) + + +def stack_pods(stack: str) -> list[str]: + res = kubectl("get", "pods", "-l", f"dbuild-cit/stack={stack}", "-o", "name", check=False, quiet=True) + return [line.split("/", 1)[1] for line in (res.stdout or "").split() if "/" in line] + + +def stack_logs_all(stack: str, *, quiet: bool = False) -> str: + res = kubectl("logs", "-l", f"dbuild-cit/stack={stack}", "--all-containers", "--prefix", + "--tail=-1", check=False, quiet=quiet) + return (res.stdout or "") + (res.stderr if res.returncode != 0 else "") + + +def stack_running_all(stack: str) -> bool: + pods = stack_pods(stack) + return bool(pods) and all(stack_running(p) for p in pods) + + +def stack_env(repo_dir: str) -> tuple[dict[str, str], dict[str, dict[str, str]]]: + """The stack's variables: ``.env`` if the repo has one, else ``example.env`` + (what the README tells a user to copy). Also every env_file by name.""" + env: dict[str, str] = {} + files: dict[str, dict[str, str]] = {} + for fname in ("example.env", ".env"): + path = os.path.join(repo_dir, fname) + if os.path.exists(path): + with open(path) as fh: + parsed = parse_env_file(fh.read()) + files[fname] = parsed + env.update(parsed) + if ".env" not in files and "example.env" in files: + files[".env"] = files["example.env"] + return env, files + + +def stack_logs(name: str, *, quiet: bool = False) -> str: + res = kubectl("logs", name, "--all-containers", "--prefix", check=False, quiet=quiet) + return (res.stdout or "") + (res.stderr if res.returncode != 0 else "") + + +def stack_running(name: str) -> bool: + """Every container of the pod is running (none waiting or terminated).""" + pod = get_pod(name) + if pod is None: + return False + statuses = (pod.get("status") or {}).get("containerStatuses") or [] + return bool(statuses) and all("running" in (cs.get("state") or {}) for cs in statuses) diff --git a/dbuild/test.py b/dbuild/test.py index 8d00283..f371703 100644 --- a/dbuild/test.py +++ b/dbuild/test.py @@ -30,7 +30,12 @@ from dbuild import log, podman from dbuild.config import AppTestConfig, Config, Variant, variant_filter_matches -from dbuild.container_backend import AppJailBackend, ContainerBackend, PodmanBackend +from dbuild.container_backend import ( + AppJailBackend, + ContainerBackend, + KubernetesBackend, + PodmanBackend, +) # ── Cleanup registry (survives SIGTERM) ─────────────────────────────── @@ -92,12 +97,15 @@ def _emergency_cleanup(*_args) -> None: ) _CRASH_LOOP_PATTERN = re.compile(r"Service '.*' crashed") +#: The one "variant" of a stack-only repo (no image of its own), k8s backend. +_STACK_TAG = "stack" + # ── Label reading ───────────────────────────────────────────────────── -def _read_labels(image_ref: str) -> dict: +def _read_labels(image_ref: str, backend: ContainerBackend | None = None) -> dict: """Read OCI labels from an image and extract CIT-relevant values.""" - labels = podman.inspect_labels(image_ref) + labels = backend.labels(image_ref) if backend else podman.inspect_labels(image_ref) port_str = labels.get("io.daemonless.port") health_raw = labels.get("io.daemonless.healthcheck-url") @@ -337,6 +345,7 @@ def _test_command( test: AppTestConfig, *, annotations: dict[str, str], + backend: ContainerBackend | None = None, ) -> bool: """Run a one-shot image to completion; check exit code (+ optional regex). @@ -345,7 +354,9 @@ def _test_command( """ desc = " ".join(test.command) if test.command else "(image default CMD)" log.info(f"Command mode: running entrypoint with {desc}") - rc, output = podman.run_oneshot(image_ref, test.command, annotations=annotations) + if backend is None: + backend = PodmanBackend() + rc, output = backend.run_oneshot(image_ref, test.command, annotations=annotations) for line in output.splitlines()[-20:]: log.info(f" {line}") log.info(f"Exit code: {rc} (expected {test.expect_exit})") @@ -741,7 +752,30 @@ def _test_variant( log.step(f"Testing :{variant.tag}") - build_ref = _resolve_image_ref(cfg, variant.tag) + # -- Select backend -- + if force_backend == "appjail": + if not AppJailBackend.available(): + log.error("--backend appjail requested but appjail is not installed") + return 1 + backend: ContainerBackend = AppJailBackend() + elif force_backend == "k8s": + if not KubernetesBackend.available(): + log.error("--backend k8s requested but kubectl (with a kubeconfig) is not here") + return 1 + backend = KubernetesBackend() + elif force_backend == "podman": + backend = PodmanBackend() + else: # auto + backend = AppJailBackend() if (_appjail_cit_applicable(cfg) and AppJailBackend.available()) else PodmanBackend() + + stack_only = variant.tag == _STACK_TAG + if force_backend == "k8s": + # Nothing is built on a cluster node: the test is of the registry's + # image, which the node pulls. A stack-only repo has no image of its + # own: its compose file names every image. + build_ref = "(stack)" if stack_only else f"{cfg.full_image}:{variant.tag}" + else: + build_ref = _resolve_image_ref(cfg, variant.tag) if build_ref is None: log.error( f"No image found: tried build-{variant.tag} and {variant.tag} " @@ -751,9 +785,19 @@ def _test_variant( log.info(f"Image: {build_ref}") # -- Merge config: labels + config overrides -- - # AppJail backend bypasses compose — it uses Director instead - compose_mode = test.compose and force_backend != "appjail" + # AppJail bypasses compose (Director runs the stack); on k8s the image is + # tested alone too, so a stack's main service runs without its sidecars. + compose_mode = test.compose and force_backend not in ("appjail", "k8s") + # On k8s the stack is one pod with a container per service; the checks + # then run against that pod like against a single container. + k8s_stack = bool(test.compose) and force_backend == "k8s" compose_file: Path | None = None + if k8s_stack: + compose_file = _find_compose_file(repo_dir) + if not compose_file: + log.error("compose: true but no compose.yaml found") + return 1 + log.info(f"Stack: {compose_file}") if compose_mode: if not shutil.which("podman-compose"): @@ -768,8 +812,12 @@ def _test_variant( build_tag = f"{cfg.full_image}:build" podman.tag(build_ref, build_tag) label_info: dict = {"port": None, "health": None, "jail_annotations": {}} + elif k8s_stack: + # The stack's images carry the labels, not one image: the config says + # the port and health path, as in compose mode. + label_info = {"port": None, "health": None, "jail_annotations": {}} else: - label_info = _read_labels(build_ref) + label_info = _read_labels(build_ref, backend) # Merge: config overrides > labels > defaults port = test.port or label_info["port"] @@ -810,7 +858,7 @@ def _test_variant( log.error("command mode is podman-only (it runs the image to completion)") return 1 cmd_results = {"command": "skip"} - passed = _test_command(build_ref, test, annotations=annotations) + passed = _test_command(build_ref, test, annotations=annotations, backend=backend) cmd_results["command"] = "pass" if passed else "fail" if out is not None: out.update(cmd_results) @@ -848,17 +896,6 @@ def _test_variant( } rc = 1 # assume failure; set to 0 on success - # -- Select backend -- - if force_backend == "appjail": - if not AppJailBackend.available(): - log.error("--backend appjail requested but appjail is not installed") - return 1 - backend: ContainerBackend = AppJailBackend() - elif force_backend == "podman": - backend = PodmanBackend() - else: # auto - backend = AppJailBackend() if (_appjail_cit_applicable(cfg) and AppJailBackend.available()) else PodmanBackend() - # PUID check only works on the podman backend, non-compose (needs env/volume). puid_run = puid_enabled and not compose_mode and isinstance(backend, PodmanBackend) if puid_enabled and not puid_run: @@ -892,6 +929,16 @@ def _test_variant( if compose_mode: assert compose_file is not None podman.compose_up(str(compose_file)) + elif k8s_stack: + assert compose_file is not None and isinstance(backend, KubernetesBackend) + backend.start_stack( + cname, str(repo_dir), str(compose_file), + image_map={} if stack_only else {f"{cfg.full_image}:build": build_ref}, + ) + # A stack with a pod per service: the config's port is the + # published one; probe the pod behind it at its container port. + backend.choose_main(cname, port) + port = backend.stack_port(cname, port) else: backend.start( cname, build_ref, annotations=annotations, @@ -1314,6 +1361,11 @@ def run(cfg: Config, args: argparse.Namespace) -> int: v for v in cfg.variants if variant_filter_matches(v.tag, variant_filter) ] + # A stack-only repo (type: stack) builds nothing, so it has no variants + # to test: on k8s its compose file is the thing under test, as one pod. + if not cfg.variants and cfg.test.compose and backends == ["k8s"]: + log.info("Stack-only repo: testing its compose file as one pod") + matching = [Variant(tag=_STACK_TAG)] for force_backend in backends: if len(backends) > 1: diff --git a/docs/dbuild.1 b/docs/dbuild.1 index 79708d8..3b03b2b 100644 --- a/docs/dbuild.1 +++ b/docs/dbuild.1 @@ -363,7 +363,7 @@ filter to one or more variants by tag (comma-separated, e.g. pkg,pkg-latest) write test result JSON to FILE .TP \fB--backend\fR -which backend(s) to test with: podman, appjail, or all (default: all) +which backend(s) to test with: podman, appjail, k8s, or all (default: all = podman and, where configured, appjail). k8s runs only when asked, on a cluster node, one pod per test against the registry's image .TP \fB--puid\fR enable/disable the PUID/PGID re-chown check for this run, overriding the `cit: puid:` config (e.g. --puid=false). Bare --puid forces it on; default follows config (on). Podman only. diff --git a/tests/test_kube.py b/tests/test_kube.py new file mode 100644 index 0000000..4b22219 --- /dev/null +++ b/tests/test_kube.py @@ -0,0 +1,148 @@ +"""The Kubernetes CIT backend's pure parts: pod names, pod specs, pod state.""" + +import unittest + +from dbuild import kube + + +class TestPodName(unittest.TestCase): + def test_cit_name_is_a_dns_label(self): + self.assertEqual(kube.pod_name("cit-4242-nginx-php-fpm"), "cit-4242-nginx-php-fpm") + self.assertEqual(kube.pod_name("cit-1-Foo_Bar.baz"), "cit-1-foo-bar-baz") + self.assertLessEqual(len(kube.pod_name("cit-1-" + "x" * 80)), 63) + + +class TestPodSpec(unittest.TestCase): + def test_annotations_env_and_args_land_where_kubelet_reads_them(self): + spec = kube.pod_spec( + "cit-1-postgres", "ghcr.io/daemonless/postgres:latest", + annotations={"org.freebsd.jail.allow.sysvipc": "true"}, + env={"POSTGRES_PASSWORD": "x", "PUID": 1000}, + args=["--version"], + ) + self.assertEqual(spec["metadata"]["namespace"], kube.NAMESPACE) + self.assertEqual(spec["metadata"]["annotations"], {"org.freebsd.jail.allow.sysvipc": "true"}) + self.assertEqual(spec["spec"]["restartPolicy"], "Never") + c = spec["spec"]["containers"][0] + self.assertEqual(c["image"], "ghcr.io/daemonless/postgres:latest") + self.assertEqual(c["env"], [{"name": "POSTGRES_PASSWORD", "value": "x"}, {"name": "PUID", "value": "1000"}]) + self.assertEqual(c["args"], ["--version"]) + self.assertNotIn("securityContext", c) + + def test_bare_spec_has_no_env_or_args(self): + c = kube.pod_spec("cit-1-redis", "ghcr.io/daemonless/redis:latest")["spec"]["containers"][0] + self.assertNotIn("env", c) + self.assertNotIn("args", c) + + +class TestContainerState(unittest.TestCase): + def pod(self, state): + return {"status": {"phase": "Running", "containerStatuses": [{"state": state}]}} + + def test_states(self): + self.assertEqual(kube._container_state(self.pod({"running": {}})), ("running", "")) + self.assertEqual(kube._container_state(self.pod({"waiting": {"reason": "ImagePullBackOff"}})), ("waiting", "ImagePullBackOff")) + self.assertEqual(kube._container_state(self.pod({"terminated": {"exitCode": 3}})), ("terminated", "")) + self.assertEqual(kube._container_state({"status": {}}), ("", "")) + + +class TestInterpolate(unittest.TestCase): + def test_compose_forms(self): + env = {"TZ": "Europe/Oslo", "EMPTY": ""} + self.assertEqual(kube.interpolate("${TZ}", env), "Europe/Oslo") + self.assertEqual(kube.interpolate("${MISSING:-UTC}", env), "UTC") + self.assertEqual(kube.interpolate("${EMPTY:-x}", env), "x") + self.assertEqual(kube.interpolate("${EMPTY-x}", env), "") + self.assertEqual(kube.interpolate("redis://${HOST:-localhost}:6379", env), "redis://localhost:6379") + self.assertEqual(kube.interpolate("${MISSING}", env), "") + + def test_env_file(self): + self.assertEqual(kube.parse_env_file("# c\nA=1\nB='two'\n\nC=\"3\"\nnoeq\n"), {"A": "1", "B": "two", "C": "3"}) + + +STACK = { + "services": { + "paperless-ngx": { + "image": "ghcr.io/daemonless/paperless-ngx:${TAG:-latest}", + "network_mode": "host", + "environment": ["PUID=1000", "PAPERLESS_REDIS=redis://${REDIS_HOSTNAME:-localhost}:6379"], + "env_file": [".env"], + "depends_on": ["redis"], + "volumes": ["${CONFIG_LOCATION}:/config", "/etc/localtime:/etc/localtime:ro"], + "ports": ["8000:8000"], + }, + "redis": { + "image": "ghcr.io/daemonless/redis:latest", + "network_mode": "host", + "annotations": {"org.freebsd.jail.allow.sysvipc": "true"}, + "volumes": [{"type": "bind", "source": "${REDIS_DATA_LOCATION}", "target": "/config"}], + }, + "proxy": {"image": "x", "profiles": ["proxy"]}, + } +} + + +class TestStackSpec(unittest.TestCase): + def test_one_pod_with_a_container_per_service(self): + env = {"CONFIG_LOCATION": "/containers/paperless-ngx/config", "REDIS_DATA_LOCATION": "/containers/paperless-ngx/redis", "SECRET": "s"} + spec, images = kube.stack_spec( + "cit-1-paperless-ngx", STACK, env=env, env_files={".env": env}, data_root="/var/tmp/cit", + image_map={"ghcr.io/daemonless/paperless-ngx:latest": "ghcr.io/daemonless/paperless-ngx:2.20"}, + ) + self.assertEqual(images, {"paperless-ngx": "ghcr.io/daemonless/paperless-ngx:2.20", "redis": "ghcr.io/daemonless/redis:latest"}) + names = [c["name"] for c in spec["spec"]["containers"]] + self.assertEqual(names, ["paperless-ngx", "redis"]) + self.assertEqual(spec["spec"]["restartPolicy"], "Always") + self.assertEqual(spec["metadata"]["annotations"], {"org.freebsd.jail.allow.sysvipc": "true"}) + app = spec["spec"]["containers"][0] + env_map = {e["name"]: e["value"] for e in app["env"]} + self.assertEqual(env_map["PAPERLESS_REDIS"], "redis://localhost:6379") + self.assertEqual(env_map["SECRET"], "s") + self.assertEqual(env_map["PUID"], "1000") + self.assertEqual([m["mountPath"] for m in app["volumeMounts"]], ["/config"]) + paths = {v["name"]: v["hostPath"]["path"] for v in spec["spec"]["volumes"]} + self.assertEqual(paths[app["volumeMounts"][0]["name"]], "/var/tmp/cit/paperless-ngx/containers/paperless-ngx/config") + redis = spec["spec"]["containers"][1] + self.assertEqual(redis["volumeMounts"][0]["mountPath"], "/config") + self.assertTrue(all(v["hostPath"]["type"] == "DirectoryOrCreate" for v in spec["spec"]["volumes"])) + + +BRIDGED = { + "services": { + "photocraft": {"image": "ghcr.io/daemonless/photocraft:latest", "ports": ["8080:80"]}, + "vectorcraft": {"image": "ghcr.io/daemonless/vectorcraft:latest", "ports": ["8081:80"], + "annotations": {"org.freebsd.jail.allow.mlock": "true"}}, + "db": {"image": "ghcr.io/daemonless/postgres:latest", "volumes": ["dbdata:/var/lib/postgresql/data"]}, + } +} + + +class TestStackShapes(unittest.TestCase): + def test_host_network_is_one_pod_and_bridged_is_not(self): + self.assertTrue(kube.shares_host_network(STACK)) + self.assertFalse(kube.shares_host_network(BRIDGED)) + self.assertFalse(kube.shares_host_network({"services": {}})) + + def test_published_ports(self): + self.assertEqual(kube.published_ports({"ports": ["8080:80", "9000", "127.0.0.1:8443:443/tcp"]}, {}), {8080: 80, 9000: 9000, 8443: 443}) + self.assertEqual(kube.published_ports({"ports": [{"published": 8100, "target": 80}]}, {}), {8100: 80}) + self.assertEqual(kube.published_ports({"ports": ["${WEB_PORT:-8200}:80"]}, {}), {8200: 80}) + + def test_pod_and_service_per_bridged_service(self): + specs, _images, ports = kube.stack_specs_per_service("cit-1-crafting-apps", BRIDGED, env={}, data_root="/var/tmp/cit") + kinds = [(sp["kind"], sp["metadata"]["name"]) for sp in specs] + self.assertEqual(kinds, [ + ("Pod", "cit-1-crafting-apps-photocraft"), ("Service", "photocraft"), + ("Pod", "cit-1-crafting-apps-vectorcraft"), ("Service", "vectorcraft"), + ("Pod", "cit-1-crafting-apps-db"), + ]) + self.assertEqual(ports["photocraft"], {8080: 80}) + self.assertEqual(ports["db"], {}) + pod = specs[2] + self.assertEqual(pod["metadata"]["annotations"], {"org.freebsd.jail.allow.mlock": "true"}) + self.assertEqual(pod["metadata"]["labels"]["dbuild-cit/stack"], "cit-1-crafting-apps") + self.assertEqual(pod["spec"]["hostname"], "vectorcraft") + svc = specs[3] + self.assertEqual(svc["spec"]["selector"], {"dbuild-cit/service": "vectorcraft", "dbuild-cit/stack": "cit-1-crafting-apps"}) + self.assertEqual(svc["spec"]["ports"], [{"name": "p80", "port": 80, "targetPort": 80}]) + self.assertEqual(specs[4]["spec"]["volumes"][0]["hostPath"]["path"], "/var/tmp/cit/vol-dbdata") diff --git a/tests/test_test.py b/tests/test_test.py index c23adba..73e20ac 100644 --- a/tests/test_test.py +++ b/tests/test_test.py @@ -689,6 +689,136 @@ def test_diagnose_port_not_listening(self): unittest.main() +class TestKubernetesBackendSelection(unittest.TestCase): + """`--backend k8s` runs k8s alone, never as part of `all`, and refuses to + run where kubectl has no cluster to talk to.""" + + def _run(self, backend_arg, available): + from dbuild.config import _parse_appjail + cfg = MagicMock() + cfg.metadata.appjail = _parse_appjail({}) + cfg.test.puid = False + cfg.variants = [MagicMock(tag="latest", puid=None)] + args = MagicMock(variant=None, json_output=None, backend=backend_arg, puid=None) + seen = [] + + def fake_test_variant(cfg, variant, test, **kw): + seen.append(kw["force_backend"]) + return 0 + + with patch("dbuild.ci.detect") as detect, \ + patch.object(cit.AppJailBackend, "available", return_value=True), \ + patch.object(cit.KubernetesBackend, "available", return_value=available), \ + patch.object(cit, "_test_variant", side_effect=fake_test_variant): + detect.return_value.should_skip.return_value = False + cit.run(cfg, args) + return seen + + def test_all_leaves_k8s_out(self): + self.assertEqual(self._run("all", True), ["podman", "appjail"]) + + def test_k8s_alone_when_asked(self): + self.assertEqual(self._run("k8s", True), ["k8s"]) + + def test_variant_refuses_without_a_cluster(self): + cfg = MagicMock() + cfg.full_image = "ghcr.io/daemonless/redis" + variant = MagicMock(tag="latest") + test = MagicMock(compose=False) + with patch.object(cit.KubernetesBackend, "available", return_value=False): + self.assertEqual(cit._test_variant(cfg, variant, test, force_backend="k8s"), 1) + + def test_stack_only_repo_runs_as_one_pod_on_k8s(self): + from dbuild.config import _parse_appjail + cfg = MagicMock() + cfg.metadata.appjail = _parse_appjail({}) + cfg.test.puid = False + cfg.test.compose = True + cfg.variants = [] + args = MagicMock(variant=None, json_output=None, backend="k8s", puid=None) + seen = [] + + def fake_test_variant(cfg, variant, test, **kw): + seen.append((kw["force_backend"], variant.tag)) + return 0 + + with patch("dbuild.ci.detect") as detect, \ + patch.object(cit.KubernetesBackend, "available", return_value=True), \ + patch.object(cit, "_test_variant", side_effect=fake_test_variant): + detect.return_value.should_skip.return_value = False + self.assertEqual(cit.run(cfg, args), 0) + self.assertEqual(seen, [("k8s", "stack")]) + + def test_stack_only_variant_reads_no_labels_and_maps_no_image(self): + cfg = MagicMock() + cfg.full_image = "ghcr.io/daemonless/immich" + cfg.image = "immich" + variant = MagicMock(tag="stack") + test = MagicMock(compose=True, mode="health", port=2283, health="/api/server/ping", https=False, + annotations=[], ready=None, wait=10, puid_ignore=[]) + with patch.object(cit.KubernetesBackend, "available", return_value=True), \ + patch.object(cit, "_read_labels") as labels, \ + patch.object(cit, "_find_baseline", return_value=None), \ + patch.object(cit, "_find_compose_file", return_value=Path("compose.yaml")), \ + patch.object(cit, "_resolve_mode", return_value="health"), \ + patch.object(cit.KubernetesBackend, "start_stack") as start_stack, \ + patch.object(cit.KubernetesBackend, "stop"), \ + patch.object(cit, "_functional_checks", return_value=0): + self.assertEqual(cit._test_variant(cfg, variant, test, force_backend="k8s"), 0) + labels.assert_not_called() + self.assertEqual(start_stack.call_args.kwargs["image_map"], {}) + + def test_k8s_tests_the_registry_image_and_starts_the_stack_as_a_pod(self): + cfg = MagicMock() + cfg.full_image = "ghcr.io/daemonless/paperless-ngx" + cfg.image = "paperless-ngx" + variant = MagicMock(tag="latest") + test = MagicMock(compose=True, mode="shell", port=None, health=None, https=False, + annotations=[], ready=None, wait=10, puid_ignore=[]) + with patch.object(cit.KubernetesBackend, "available", return_value=True), \ + patch.object(cit, "_read_labels") as labels, \ + patch.object(cit, "_find_baseline", return_value=None), \ + patch.object(cit, "_find_compose_file", return_value=Path("compose.yaml")), \ + patch.object(cit, "_resolve_mode", return_value="shell"), \ + patch.object(cit.KubernetesBackend, "start") as start, \ + patch.object(cit.KubernetesBackend, "start_stack") as start_stack, \ + patch.object(cit.KubernetesBackend, "stop"), \ + patch.object(cit, "_functional_checks", return_value=0): + self.assertEqual(cit._test_variant(cfg, variant, test, force_backend="k8s"), 0) + labels.assert_not_called() # a stack's port and health come from its config + start.assert_not_called() + start_stack.assert_called_once() + self.assertEqual(start_stack.call_args.args[2], "compose.yaml") + self.assertEqual( + start_stack.call_args.kwargs["image_map"], + {"ghcr.io/daemonless/paperless-ngx:build": "ghcr.io/daemonless/paperless-ngx:latest"}, + ) + + def test_k8s_single_image_reads_its_labels_from_the_registry_image(self): + cfg = MagicMock() + cfg.full_image = "ghcr.io/daemonless/redis" + cfg.image = "redis" + variant = MagicMock(tag="latest") + test = MagicMock(compose=False, mode="port", port=6379, health=None, https=False, + annotations=[], ready=None, wait=10, puid_ignore=[]) + seen = {} + + def fake_labels(ref, backend=None): + seen["ref"], seen["backend"] = ref, backend + return {"port": None, "health": None, "jail_annotations": {}} + + with patch.object(cit.KubernetesBackend, "available", return_value=True), \ + patch.object(cit, "_read_labels", side_effect=fake_labels), \ + patch.object(cit, "_find_baseline", return_value=None), \ + patch.object(cit, "_resolve_mode", return_value="port"), \ + patch.object(cit.KubernetesBackend, "start"), \ + patch.object(cit.KubernetesBackend, "stop"), \ + patch.object(cit, "_functional_checks", return_value=0): + self.assertEqual(cit._test_variant(cfg, variant, test, force_backend="k8s"), 0) + self.assertEqual(seen["ref"], "ghcr.io/daemonless/redis:latest") + self.assertIsInstance(seen["backend"], cit.KubernetesBackend) + + class TestAppJailBackendSelection(unittest.TestCase): """`dbuild test` runs the appjail backend for every image that has AppJail on, including the default (no appjail key) and `appjail: true`, which From f3d18f3374e4caf2862e195c223a61a0efcf8ac2 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sun, 1 Nov 2026 16:25:09 -0500 Subject: [PATCH 2/2] fix(screenshot): Kill leftover chromium helpers after a capture Selenium's quit() leaves helper processes behind on FreeBSD, and a timeout(1) around the run then waits for them, so a screenshot test could take 25 minutes instead of 2. Give each capture its own profile directory and kill anything still holding it on the way out. --- dbuild/screenshot.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/dbuild/screenshot.py b/dbuild/screenshot.py index 077ab8c..4ba09d9 100644 --- a/dbuild/screenshot.py +++ b/dbuild/screenshot.py @@ -7,8 +7,13 @@ from __future__ import annotations +import contextlib import os +import shutil +import signal +import subprocess import sys +import tempfile import time from urllib.parse import urlsplit @@ -87,6 +92,12 @@ def capture(url: str, output: str, timeout: int = 30, min_wait: int = 0, secure_ options.add_argument(f"--unsafely-treat-insecure-origin-as-secure={origin}") if CHROME_BIN: options.binary_location = CHROME_BIN + # A profile dir of our own, so the browser's processes can be found and + # killed after quit(): chromedriver's own quit leaves chromium's helper + # processes (gpu, network, renderers) behind on FreeBSD, and they pile + # up -- a reaper such as timeout(1) then waits on them for good. + profile = tempfile.mkdtemp(prefix="dbuild-chrome-") + options.add_argument(f"--user-data-dir={profile}") service = Service(executable_path=CHROMEDRIVER_BIN) driver = webdriver.Chrome(service=service, options=options) @@ -140,3 +151,16 @@ def capture(url: str, output: str, timeout: int = 30, min_wait: int = 0, secure_ return False finally: driver.quit() + _kill_leftovers(profile) + shutil.rmtree(profile, ignore_errors=True) + + +def _kill_leftovers(profile: str) -> None: + """Kill chromium processes still running on *profile* after quit().""" + try: + out = subprocess.run(["pgrep", "-f", profile], capture_output=True, text=True).stdout + except OSError: + return + for pid in out.split(): + with contextlib.suppress(OSError, ValueError): + os.kill(int(pid), signal.SIGKILL)