Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions dbuild/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
192 changes: 191 additions & 1 deletion dbuild/container_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import os
import shutil
import subprocess
from abc import ABC, abstractmethod
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading