Files
k7/tests/integration/test_docker.py
T
G 13fafe0997 Release 0.4.0
Per-key node pins, k7d-fc pause/resume/exec, and HA-soak fixes. Playbook
pins k7d 0.7.0. GitHub .deb, Launchpad PPA, and PyPI k7-sdk are 0.4.0.
2026-09-19 23:22:14 +02:00

689 lines
29 KiB
Python

"""Integration tests for first-class ``--docker``.
k7d runs dockerd as a guest agent service (no sidecar, no privileged).
Kata backends use a privileged docker-vehicle container + block graph.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import shlex
import subprocess
import time
import uuid
from pathlib import Path
import pytest
from k7.core.core import K7Core
from k7.core.docker import VEHICLE_CONTAINER_NAME
from k7.core.models import SandboxConfig
pytestmark = pytest.mark.integration
_K7D_DISKS = Path("/var/lib/k7d/disks")
def _wait_all_containers_ready(sandbox_name: str, namespace: str, timeout: int = 300) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
result = subprocess.run(
[
"k3s",
"kubectl",
"get",
"pods",
"-n",
namespace,
"-l",
f"app={sandbox_name}",
"-o",
"json",
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
time.sleep(2)
continue
try:
data = json.loads(result.stdout)
items = data.get("items", [])
for pod in items:
if pod.get("status", {}).get("phase") != "Running":
continue
statuses = pod.get("status", {}).get("containerStatuses", [])
if statuses and all(s.get("ready") for s in statuses):
return
except (json.JSONDecodeError, KeyError, IndexError):
pass
time.sleep(2)
raise TimeoutError(f"Not all containers ready for {sandbox_name} within {timeout}s")
# Classic builder uses dockerd's pull path (CA bundle in the payload).
# Default BuildKit metadata fetch does not (CHALLENGES #13).
_DOCKER_BUILD = "DOCKER_BUILDKIT=0 docker build"
_LOCAL_NODE = os.uname().nodename
async def _wait_docker_ready(k7_core: K7Core, name: str, namespace: str, timeout: int = 120) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
result = await k7_core.exec_command(name, "docker info >/dev/null 2>&1 && echo ok", namespace=namespace)
if "ok" in (result.stdout or ""):
return
await asyncio.sleep(3)
raise TimeoutError(f"Docker daemon not ready in {name} within {timeout}s")
async def _sh(k7_core: K7Core, name: str, namespace: str, command: str, timeout: int = 180) -> str:
"""Run a command in the sandbox and require a zero guest exit code.
Kata CRI exec returns after the first stdout chunk and kills the rest
of the script (CHALLENGES #13). Launch with no streaming stdout, then
poll a done-file over short execs.
"""
token = uuid.uuid4().hex[:12]
out = f"/tmp/k7-{token}.out"
ec = f"/tmp/k7-{token}.ec"
launch = f"trap '' HUP; rm -f {ec} {out}; ( sh -c {shlex.quote(command)} >{out} 2>&1; echo $? >{ec} ) &"
await k7_core.exec_command(name, launch, namespace=namespace)
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
poll = f"[ -f {ec} ] && echo __K7_EC:$(cat {ec})"
result = await k7_core.exec_command(name, poll, namespace=namespace)
last = (result.stdout or "") + (f"\n{result.stderr}" if result.stderr else "")
match = re.search(r"__K7_EC:(\d+)", last)
if match:
code = int(match.group(1))
body_res = await k7_core.exec_command(name, f"tail -c 8000 {out}; rm -f {out} {ec}", namespace=namespace)
body = (body_res.stdout or "").strip("\n")
assert code == 0, f"{command!r} exited {code}:\n{body}"
return body
await asyncio.sleep(2)
raise AssertionError(f"timeout waiting for {command!r}: {last!r}")
async def _wait_nginx_via_docker_exec(k7_core: K7Core, name: str, namespace: str, timeout: int = 90) -> str:
"""Probe the inner nginx through ``docker exec``, not CRI localhost.
``docker run -p 8080:80`` publishes in the guest host netns (k7d
agent's wget). ``k7 exec`` is the CRI container netns.
"""
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
result = await k7_core.exec_command(
name,
"docker exec web wget -qO- http://127.0.0.1:80 2>&1 | head -c 200",
namespace=namespace,
)
last = result.stdout or ""
if "nginx" in last.lower() or "html" in last.lower() or "Welcome" in last:
return last
await asyncio.sleep(2)
raise TimeoutError(f"nginx did not serve in {name} within {timeout}s: {last!r}")
def _assert_overlay2(docker_info: str) -> None:
"""Overlay2 is the graph driver; vfs on that line is a ready failure."""
assert "Storage Driver: overlay2" in docker_info, docker_info
driver_line = docker_info.split("Storage Driver:")[-1].splitlines()[0].lower()
assert "vfs" not in driver_line, f"storage driver line is vfs-shaped: {driver_line!r}"
def _k7d_docker_disk_names() -> set[str]:
if not _K7D_DISKS.is_dir():
return set()
return {p.name for p in _K7D_DISKS.iterdir() if "docker" in p.name}
async def _wait_k7d_docker_disks_gone(before: set[str], timeout: float = 60.0) -> None:
"""k7 delete returns when K8s objects are gone; k7d unlinks the graph later.
``delete_sandbox`` does not wait for VM teardown (API latency must not
couple to shim Delete). Poll here; a leftover after ``timeout`` is a leak.
"""
started = time.time()
leftover: set[str] = set()
while time.time() - started < timeout:
leftover = _k7d_docker_disk_names() - before
if not leftover:
return
await asyncio.sleep(1)
raise AssertionError(f"leaked k7d docker volume images after {time.time() - started:.1f}s: {leftover}")
def _pod_annotations(name: str, namespace: str) -> dict[str, str]:
result = subprocess.run(
[
"k3s",
"kubectl",
"get",
"pods",
"-n",
namespace,
"-l",
f"app={name}",
"-o",
"json",
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
items = json.loads(result.stdout).get("items", [])
assert items, f"no pod for {name}"
return items[0].get("metadata", {}).get("annotations") or {}
# ===========================================================================
# k7d — first-class --docker
# ===========================================================================
@pytest.mark.k7d
class TestDockerK7d:
backend = "k7d"
def _name(self, stem: str) -> str:
return stem if self.backend == "k7d" else f"{stem}-fc"
async def test_docker_engine_cli_path_sharing_not_privileged(self, k7_core: K7Core, test_namespace: str):
"""Overlay2, hello-world, compose/buildx, -v, CapBnd, graph disk."""
name = self._name("docker-engine-k7d")
before_disks = _k7d_docker_disk_names()
cfg = SandboxConfig(
name=name,
image="ubuntu:24.04",
namespace=test_namespace,
backend=self.backend,
docker=True,
node_name=_LOCAL_NODE,
limits={"memory": "3Gi", "cpu": "2"},
)
result = await k7_core.create_sandbox(cfg)
assert result.success, f"create failed: {result.error}"
try:
_wait_all_containers_ready(name, test_namespace)
await _wait_docker_ready(k7_core, name, test_namespace)
ann = _pod_annotations(name, test_namespace)
assert ann.get("k7d.katakate.org/docker") == "true"
assert "k7.katakate.org/sidecar" not in ann
if self.backend == "k7d-fc":
pod = _pod_json(name, test_namespace)
assert pod.get("spec", {}).get("runtimeClassName") == "k7-fc"
info = await k7_core.exec_command(name, "docker info", namespace=test_namespace)
assert info.exit_code == 0, info.stderr
_assert_overlay2(info.stdout)
during_disks = _k7d_docker_disk_names() - before_disks
assert during_disks, "k7d docker graph disk missing from /var/lib/k7d/disks (graph would be tmpfs)"
hello = await k7_core.exec_command(name, "docker run --rm hello-world", namespace=test_namespace)
assert hello.exit_code == 0, hello.stderr
assert "Hello from Docker!" in hello.stdout
compose = await k7_core.exec_command(name, "docker compose version", namespace=test_namespace)
assert compose.exit_code == 0, compose.stderr
buildx = await k7_core.exec_command(name, "docker buildx version", namespace=test_namespace)
assert buildx.exit_code == 0, buildx.stderr
await k7_core.exec_command(name, "mkdir -p /tmp/x && echo f > /tmp/x/f", namespace=test_namespace)
share = await k7_core.exec_command(
name, "docker run --rm -v /tmp/x:/x alpine:3.21 ls /x", namespace=test_namespace
)
assert share.exit_code == 0, share.stderr
assert "f" in share.stdout
cap = await k7_core.exec_command(name, "grep '^CapBnd:' /proc/self/status", namespace=test_namespace)
assert cap.exit_code == 0, cap.stderr
capbnd = cap.stdout.split()[-1].lower()
assert capbnd != "000001ffffffffff", f"sandbox CapBnd is full: {capbnd}"
finally:
await k7_core.delete_sandbox(name, namespace=test_namespace)
await _wait_k7d_docker_disks_gone(before_disks)
async def test_docker_build_and_compose(self, k7_core: K7Core, test_namespace: str):
"""docker build + compose of a one-service fixture."""
name = self._name("docker-build-k7d")
cfg = SandboxConfig(
name=name,
image="ubuntu:24.04",
namespace=test_namespace,
backend=self.backend,
docker=True,
node_name=_LOCAL_NODE,
limits={"memory": "3Gi", "cpu": "2"},
)
result = await k7_core.create_sandbox(cfg)
assert result.success, f"create failed: {result.error}"
try:
_wait_all_containers_ready(name, test_namespace)
await _wait_docker_ready(k7_core, name, test_namespace)
await _sh(
k7_core,
name,
test_namespace,
"mkdir -p /tmp/app && "
"printf 'FROM alpine:3.21\\nCMD echo built-ok\\n' > /tmp/app/Dockerfile && "
"printf 'services:\\n app:\\n image: myapp\\n command: echo compose-ok\\n' "
"> /tmp/app/compose.yaml",
)
build = await _sh(
k7_core,
name,
test_namespace,
f"cd /tmp/app && {_DOCKER_BUILD} -t myapp .",
)
assert (
"Successfully tagged" in build
or "Successfully built" in build
or "naming to docker.io/library/myapp" in build
), build
run = await _sh(k7_core, name, test_namespace, "docker run --rm myapp")
assert "built-ok" in run
compose = await _sh(
k7_core,
name,
test_namespace,
"cd /tmp/app && DOCKER_BUILDKIT=0 docker compose run --rm app",
)
assert "compose-ok" in compose
finally:
await k7_core.delete_sandbox(name, namespace=test_namespace)
async def test_docker_fork_keeps_running_inner_container(self, k7_core: K7Core, test_namespace: str):
"""k7 fork of --docker keeps a running inner container; not a sidecar reject."""
source = self._name("docker-fork-src")
fork_name = self._name("docker-fork-dst")
cfg = SandboxConfig(
name=source,
image="ubuntu:24.04",
namespace=test_namespace,
backend=self.backend,
docker=True,
node_name=os.uname().nodename,
limits={"memory": "3Gi", "cpu": "2"},
)
result = await k7_core.create_sandbox(cfg)
assert result.success, f"create failed: {result.error}"
try:
_wait_all_containers_ready(source, test_namespace)
await _wait_docker_ready(k7_core, source, test_namespace)
await _sh(
k7_core,
source,
test_namespace,
"docker run -d --name web -p 8080:80 nginx:1.27-alpine",
)
await _wait_nginx_via_docker_exec(k7_core, source, test_namespace)
fork_result = await k7_core.fork_sandbox(source, fork_name, namespace=test_namespace)
assert fork_result.success, f"fork failed (must not trip sidecar reject): {fork_result.error}"
assert "sidecar" not in (fork_result.error or "").lower()
_wait_all_containers_ready(fork_name, test_namespace, timeout=180)
await _wait_docker_ready(k7_core, fork_name, test_namespace)
child_info = await k7_core.exec_command(fork_name, "docker info", namespace=test_namespace)
assert child_info.exit_code == 0, child_info.stderr
_assert_overlay2(child_info.stdout)
ps = await _sh(
k7_core,
fork_name,
test_namespace,
"docker ps --format '{{.Names}}'",
)
assert "web" in ps
child = await _wait_nginx_via_docker_exec(k7_core, fork_name, test_namespace)
assert "Welcome to nginx" in child or "nginx" in child.lower() or "html" in child.lower()
parent = await _wait_nginx_via_docker_exec(k7_core, source, test_namespace)
assert "nginx" in parent.lower() or "html" in parent.lower() or "Welcome" in parent
finally:
await k7_core.delete_sandbox(fork_name, namespace=test_namespace)
await k7_core.delete_sandbox(source, namespace=test_namespace)
@pytest.mark.k7d
class TestDockerK7dFc(TestDockerK7d):
"""The full --docker suite on RuntimeClass k7-fc."""
backend = "k7d-fc"
@pytest.fixture(autouse=True)
def _require_k7_fc(self):
result = subprocess.run(
["k3s", "kubectl", "get", "runtimeclass", "k7-fc"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
pytest.skip("RuntimeClass k7-fc not registered")
def _pod_json(name: str, namespace: str) -> dict:
result = subprocess.run(
["k3s", "kubectl", "get", "pods", "-n", namespace, "-l", f"app={name}", "-o", "json"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
items = json.loads(result.stdout).get("items", [])
assert items, f"no pod for {name}"
return items[0]
def _sandbox_security_context(name: str, namespace: str) -> dict:
pod = _pod_json(name, namespace)
sandbox = next(c for c in pod["spec"]["containers"] if c["name"] == "sandbox")
return sandbox.get("securityContext") or {}
def _privileged_container_names(name: str, namespace: str) -> list[str]:
pod = _pod_json(name, namespace)
names = []
for c in pod["spec"]["containers"]:
if (c.get("securityContext") or {}).get("privileged"):
names.append(c["name"])
return names
async def _assert_overlay2_engine_cli_share(k7_core: K7Core, name: str, namespace: str) -> None:
info = await _sh(k7_core, name, namespace, "docker info -f '{{.Driver}} {{.DriverStatus}}'")
assert "overlay2" in info, info
assert "vfs" not in info.lower(), f"vfs storage driver: {info}"
hello = await _sh(k7_core, name, namespace, "docker run --rm hello-world")
assert "Hello from Docker!" in hello
compose = await _sh(k7_core, name, namespace, "docker compose version")
assert "v" in compose.lower() or "compose" in compose.lower()
buildx = await _sh(k7_core, name, namespace, "docker buildx version")
assert "buildx" in buildx.lower() or "github.com/docker/buildx" in buildx.lower()
await _sh(k7_core, name, namespace, "mkdir -p /tmp/x && echo f > /tmp/x/f")
share = await _sh(k7_core, name, namespace, "docker run --rm -v /tmp/x:/x alpine:3.21 ls /x")
assert "f" in share
async def _assert_build_and_compose(k7_core: K7Core, name: str, namespace: str) -> None:
await _sh(
k7_core,
name,
namespace,
"mkdir -p /tmp/app && "
"printf 'FROM alpine:3.21\\nCMD echo built-ok\\n' > /tmp/app/Dockerfile && "
"printf 'services:\\n app:\\n image: myapp\\n command: echo compose-ok\\n' "
"> /tmp/app/compose.yaml",
)
build = await _sh(k7_core, name, namespace, f"cd /tmp/app && {_DOCKER_BUILD} -t myapp .")
assert (
"Successfully tagged" in build or "Successfully built" in build or "naming to docker.io/library/myapp" in build
), build
run = await _sh(k7_core, name, namespace, "docker run --rm myapp")
assert "built-ok" in run
compose = await _sh(
k7_core,
name,
namespace,
"cd /tmp/app && DOCKER_BUILDKIT=0 docker compose run --rm app",
)
assert "compose-ok" in compose
def _remaining_pvcs(namespace: str, prefix: str) -> list[str]:
pvc_check = subprocess.run(
["k3s", "kubectl", "get", "pvc", "-n", namespace, "-o", "jsonpath={.items[*].metadata.name}"],
capture_output=True,
text=True,
check=False,
)
return [p for p in pvc_check.stdout.strip().split() if p.startswith(prefix)]
def _remaining_configmaps(namespace: str, prefix: str) -> list[str]:
check = subprocess.run(
["k3s", "kubectl", "get", "cm", "-n", namespace, "-o", "jsonpath={.items[*].metadata.name}"],
capture_output=True,
text=True,
check=False,
)
return [p for p in check.stdout.strip().split() if p.startswith(prefix)]
class _DockerKataMixin:
backend: str
limits = {"memory": "3Gi", "cpu": "2"}
def _cfg(self, name: str, namespace: str, **kwargs) -> SandboxConfig:
return SandboxConfig(
name=name,
image="ubuntu:24.04",
namespace=namespace,
backend=self.backend,
docker=True,
limits=dict(self.limits),
**kwargs,
)
async def test_docker_engine_cli_path_sharing(self, k7_core: K7Core, test_namespace: str):
"""Overlay2, hello-world, compose/buildx, -v."""
name = f"docker-engine-{self.backend.split('-')[1][:2]}"
result = await k7_core.create_sandbox(self._cfg(name, test_namespace))
assert result.success, f"create failed: {result.error}"
try:
_wait_all_containers_ready(name, test_namespace)
await _wait_docker_ready(k7_core, name, test_namespace)
ann = _pod_annotations(name, test_namespace)
assert ann.get("k7.katakate.org/docker") == "true"
assert "k7.katakate.org/sidecar" not in ann
await _assert_overlay2_engine_cli_share(k7_core, name, test_namespace)
finally:
await k7_core.delete_sandbox(name, namespace=test_namespace)
async def test_docker_build_and_compose(self, k7_core: K7Core, test_namespace: str):
"""docker build + compose of a one-service fixture."""
name = f"docker-build-{self.backend.split('-')[1][:2]}"
result = await k7_core.create_sandbox(self._cfg(name, test_namespace))
assert result.success, f"create failed: {result.error}"
try:
_wait_all_containers_ready(name, test_namespace)
await _wait_docker_ready(k7_core, name, test_namespace)
await _assert_build_and_compose(k7_core, name, test_namespace)
finally:
await k7_core.delete_sandbox(name, namespace=test_namespace)
async def test_sandbox_security_context_unchanged(self, k7_core: K7Core, test_namespace: str):
"""--docker does not change the sandbox security context."""
docker_name = f"docker-sec-{self.backend.split('-')[1][:2]}"
plain_name = f"plain-sec-{self.backend.split('-')[1][:2]}"
docker_cfg = self._cfg(docker_name, test_namespace)
plain = SandboxConfig(
name=plain_name,
image="ubuntu:24.04",
namespace=test_namespace,
backend=self.backend,
docker=False,
limits=dict(self.limits),
)
docker_res = await k7_core.create_sandbox(docker_cfg)
assert docker_res.success, docker_res.error
try:
plain_res = await k7_core.create_sandbox(plain)
assert plain_res.success, plain_res.error
try:
_wait_all_containers_ready(docker_name, test_namespace)
_wait_all_containers_ready(plain_name, test_namespace)
assert _sandbox_security_context(docker_name, test_namespace) == _sandbox_security_context(
plain_name, test_namespace
)
priv = _privileged_container_names(docker_name, test_namespace)
if self.backend == "kata-qemu-longhorn":
assert "sandbox" in priv
assert VEHICLE_CONTAINER_NAME in priv
assert priv.count(VEHICLE_CONTAINER_NAME) == 1
else:
assert priv == [VEHICLE_CONTAINER_NAME]
finally:
await k7_core.delete_sandbox(plain_name, namespace=test_namespace)
finally:
await k7_core.delete_sandbox(docker_name, namespace=test_namespace)
async def test_delete_leaves_no_docker_resources(self, k7_core: K7Core, test_namespace: str):
"""Delete leaves no docker resources behind."""
name = f"docker-clean-{self.backend.split('-')[1][:2]}"
result = await k7_core.create_sandbox(self._cfg(name, test_namespace))
assert result.success, result.error
_wait_all_containers_ready(name, test_namespace)
await k7_core.delete_sandbox(name, namespace=test_namespace)
await asyncio.sleep(5)
leftover_pvc = _remaining_pvcs(test_namespace, name)
leftover_cm = _remaining_configmaps(test_namespace, name)
assert not leftover_pvc, f"leaked PVCs: {leftover_pvc}"
assert not leftover_cm, f"leaked ConfigMaps: {leftover_cm}"
@pytest.mark.qemu
class TestDockerKQL(_DockerKataMixin):
backend = "kata-qemu-longhorn"
async def test_pause_resume_keeps_image(self, k7_core: K7Core, test_namespace: str):
"""Pause/resume keeps the pulled image."""
name = "docker-pr-ql"
result = await k7_core.create_sandbox(self._cfg(name, test_namespace))
assert result.success, result.error
try:
_wait_all_containers_ready(name, test_namespace)
await _wait_docker_ready(k7_core, name, test_namespace)
await _sh(k7_core, name, test_namespace, "docker pull alpine:3.21")
pause = await k7_core.pause_sandbox(name, namespace=test_namespace)
assert pause.success, pause.error
await asyncio.sleep(5)
resume = await k7_core.resume_sandbox(name, namespace=test_namespace)
assert resume.success, resume.error
_wait_all_containers_ready(name, test_namespace, timeout=180)
await _wait_docker_ready(k7_core, name, test_namespace)
has = await _sh(k7_core, name, test_namespace, "docker image inspect alpine:3.21 >/dev/null && echo yes")
assert "yes" in has
run = await _sh(k7_core, name, test_namespace, "docker run --rm alpine:3.21 echo survived-pause")
assert "survived-pause" in run
finally:
await k7_core.delete_sandbox(name, namespace=test_namespace)
async def test_fork_clones_both_pvcs(self, k7_core: K7Core, test_namespace: str):
"""Fork clones both the root and the docker graph PVC."""
source = "docker-fork-src-ql"
fork_name = "docker-fork-dst-ql"
result = await k7_core.create_sandbox(self._cfg(source, test_namespace))
assert result.success, result.error
try:
_wait_all_containers_ready(source, test_namespace)
await _wait_docker_ready(k7_core, source, test_namespace)
await _sh(k7_core, source, test_namespace, "docker pull alpine:3.21")
fork = await k7_core.fork_sandbox(source, fork_name, namespace=test_namespace)
assert fork.success, fork.error
# Same 600s bound as test_qemu.test_fork_clones_data: on HA
# longhorn_replicas=3 the cloned volumes hydrate before first
# attach ("not ready for workloads"). 240s is a false timeout.
_wait_all_containers_ready(fork_name, test_namespace, timeout=600)
await _wait_docker_ready(k7_core, fork_name, test_namespace)
has = await _sh(
k7_core, fork_name, test_namespace, "docker image inspect alpine:3.21 >/dev/null && echo yes"
)
assert "yes" in has
run = await _sh(k7_core, fork_name, test_namespace, "docker run --rm alpine:3.21 echo fork-works")
assert "fork-works" in run
parent = await _sh(k7_core, source, test_namespace, "docker run --rm alpine:3.21 echo parent-ok")
assert "parent-ok" in parent
pvcs = _remaining_pvcs(test_namespace, "")
assert f"{source}-docker-lh" in pvcs
assert f"{fork_name}-docker-lh" in pvcs
assert f"{source}-root-lh" in pvcs
assert f"{fork_name}-root-lh" in pvcs
finally:
await k7_core.delete_sandbox(fork_name, namespace=test_namespace)
await k7_core.delete_sandbox(source, namespace=test_namespace)
async def test_snapshot_restore_keeps_image(self, k7_core: K7Core, test_namespace: str):
"""Snapshot + restore keeps the pulled image."""
source = "docker-snap-src-ql"
restored = "docker-snap-dst-ql"
snap = "docker-snap-ql"
result = await k7_core.create_sandbox(self._cfg(source, test_namespace))
assert result.success, result.error
try:
_wait_all_containers_ready(source, test_namespace)
await _wait_docker_ready(k7_core, source, test_namespace)
await _sh(k7_core, source, test_namespace, "docker pull alpine:3.21")
snap_res = await k7_core.create_snapshot(source, snap, namespace=test_namespace)
assert snap_res.success, snap_res.error
restore = await k7_core.restore_sandbox(snap, restored, namespace=test_namespace)
assert restore.success, restore.error
_wait_all_containers_ready(restored, test_namespace, timeout=240)
await _wait_docker_ready(k7_core, restored, test_namespace)
has = await _sh(
k7_core, restored, test_namespace, "docker image inspect alpine:3.21 >/dev/null && echo yes"
)
assert "yes" in has
finally:
await k7_core.delete_sandbox(restored, namespace=test_namespace)
await k7_core.delete_sandbox(source, namespace=test_namespace)
await k7_core.delete_snapshot(snap, namespace=test_namespace)
@pytest.mark.firecracker
class TestDockerKFD(_DockerKataMixin):
backend = "kata-firecracker-devmapper"
async def test_graph_is_ephemeral_block_fork_rejected(self, k7_core: K7Core, test_namespace: str):
"""The graph is an ephemeral block device, so fork is rejected."""
name = "docker-ephem-kfd"
result = await k7_core.create_sandbox(self._cfg(name, test_namespace))
assert result.success, result.error
try:
_wait_all_containers_ready(name, test_namespace)
await _wait_docker_ready(k7_core, name, test_namespace)
mnt = await k7_core.exec_command(
name,
"findmnt -no FSTYPE,SOURCE /var/lib/docker || cat /proc/mounts",
namespace=test_namespace,
container="docker-vehicle",
)
text = (mnt.stdout or "") + (mnt.stderr or "")
assert "ext4" in text.lower() or "ext4" in text
assert "virtiofs" not in text.lower()
assert "tmpfs" not in text.split("/var/lib/docker")[-1].splitlines()[0].lower()
fork = await k7_core.fork_sandbox(name, "docker-kfd-fork", namespace=test_namespace)
assert not fork.success
assert "cannot be cloned" in (fork.error or "").lower()
finally:
before = subprocess.run(["lvs", "--noheadings", "-o", "lv_name", "kata-vg"], capture_output=True, text=True)
await k7_core.delete_sandbox(name, namespace=test_namespace)
await asyncio.sleep(8)
leftover_pvc = _remaining_pvcs(test_namespace, name)
leftover_cm = _remaining_configmaps(test_namespace, name)
assert not leftover_pvc, f"leaked PVCs: {leftover_pvc}"
assert not leftover_cm, f"leaked ConfigMaps: {leftover_cm}"
after = subprocess.run(["lvs", "--noheadings", "-o", "lv_name", "kata-vg"], capture_output=True, text=True)
# thin-pool remains; docker graph LVs must be gone.
leftover_lvs = [
ln.strip()
for ln in (after.stdout or "").splitlines()
if ln.strip() and ln.strip() != "thin-pool" and name.replace("_", "-") in ln
]
assert not leftover_lvs, f"leaked LVs: {leftover_lvs} (before={before.stdout!r})"