Implement the packaging configuration, staging script, maintainer scripts, and container test harness for building native deb and rpm packages. Core files: - packaging/nfpm.yaml: single source of truth for both formats - packaging/stage.sh: builds staged tree (venv, wrapper, helpers, units, polkit, sysusers, tmpfiles) - packaging/fenris.conf: placeholder-commented default configuration - packaging/postinst.sh, prerm.sh, postrm.sh: POSIX-compatible deb maintainer scripts - packaging/rpm/post.sh, preun.sh, postun.sh: RPM scriptlets - packaging/sysusers.d/fenris.conf, tmpfiles.d/fenris.conf: systemd fragments - packaging/fenris.repo: dnf consumer setup - packaging/keys/fenris-packaging.asc: public key placeholder Build targets added to Makefile: stage, package-deb, package-rpm, package, release, clean. Container test harness in tests/test_packaging.py covering dormant install, migration guard, upgrade semantics, and removal semantics across the compatibility matrix (Debian 12, Ubuntu 22.04/24.04, Fedora 40). Dormant CI workflow at .gitea/workflows/release.yml. All 289 existing tests pass without regression. Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
559 lines
19 KiB
Python
559 lines
19 KiB
Python
"""Packaging acceptance tests — containerized matrix.
|
||
|
||
Tests the built deb and rpm packages in throwaway per-distro containers,
|
||
verifying the dormant-install contract, upgrade semantics, removal mapping,
|
||
and migration guard. This is the single test seam agreed in the release spec.
|
||
|
||
Requirements:
|
||
- docker (running, current user in docker group)
|
||
- Built packages in dist/ (run `make package` first)
|
||
- No network access required once containers are built
|
||
- No registry or signing key required
|
||
|
||
Spec: release-packaging.md §§1–9, ADR 0007
|
||
"""
|
||
import os
|
||
import subprocess
|
||
import textwrap
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||
DIST_DIR = REPO_ROOT / "dist"
|
||
VERSION_FILE = REPO_ROOT / "pyproject.toml"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _get_version() -> str:
|
||
"""Extract version from pyproject.toml."""
|
||
for line in VERSION_FILE.read_text().splitlines():
|
||
if line.startswith("version"):
|
||
return line.split("=")[1].strip().strip('"')
|
||
raise RuntimeError("Could not determine version from pyproject.toml")
|
||
|
||
|
||
def _docker_available() -> bool:
|
||
"""Check if Docker daemon is reachable."""
|
||
try:
|
||
r = subprocess.run(
|
||
["docker", "info"], capture_output=True, timeout=10
|
||
)
|
||
return r.returncode == 0
|
||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||
return False
|
||
|
||
|
||
def _container_exec(container: str, cmd: str) -> tuple[int, str]:
|
||
"""Execute a command inside a running container."""
|
||
r = subprocess.run(
|
||
["docker", "exec", container, "sh", "-c", cmd],
|
||
capture_output=True, text=True, timeout=120,
|
||
)
|
||
return r.returncode, r.stdout + r.stderr
|
||
|
||
|
||
def _find_package(fmt: str, distro: str | None = None) -> Path:
|
||
"""Locate the built package artifact."""
|
||
version = _get_version()
|
||
if fmt == "deb":
|
||
candidate = DIST_DIR / f"fenris_{version}_amd64.deb"
|
||
elif fmt == "rpm":
|
||
candidate = DIST_DIR / f"fenris-{version}-1.x86_64.rpm"
|
||
else:
|
||
raise ValueError(f"Unknown format: {fmt}")
|
||
if not candidate.exists():
|
||
pytest.skip(f"Package not found: {candidate} — run `make package` first")
|
||
return candidate
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Matrix definitions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
DEB_TARGETS = [
|
||
("debian:bookworm", "deb"),
|
||
("ubuntu:22.04", "deb"),
|
||
("ubuntu:24.04", "deb"),
|
||
]
|
||
|
||
RPM_TARGETS = [
|
||
("fedora:40", "rpm"),
|
||
]
|
||
|
||
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Dockerfile builders
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _build_deb_dockerfile(image: str, pkg_name: str) -> str:
|
||
"""Dockerfile for testing deb installation."""
|
||
return textwrap.dedent(f"""\
|
||
FROM {image}
|
||
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||
python3 smartmontools systemd systemd-sysv dbus && \\
|
||
rm -rf /var/lib/apt/lists/*
|
||
COPY dist/{pkg_name} /tmp/{pkg_name}
|
||
RUN dpkg -i /tmp/{pkg_name} || apt-get install -f -y
|
||
""")
|
||
|
||
|
||
def _build_rpm_dockerfile(image: str, pkg_name: str) -> str:
|
||
"""Dockerfile for testing rpm installation."""
|
||
return textwrap.dedent(f"""\
|
||
FROM {image}
|
||
RUN dnf install -y --setopt=install_weak_deps=False \
|
||
python3 smartmontools systemd dbus && \
|
||
dnf clean all
|
||
COPY dist/{pkg_name} /tmp/{pkg_name}
|
||
RUN rpm -ivh /tmp/{pkg_name}
|
||
""")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.fixture(scope="module")
|
||
def version():
|
||
return _get_version()
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def skip_no_docker():
|
||
if not _docker_available():
|
||
pytest.skip("Docker not available")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shared assertion functions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
|
||
"""Assert the dormant-install contract (spec §6, §7)."""
|
||
# Venv directory exists with expected structure
|
||
rc, out = _container_exec(container, "test -d /opt/fenris && echo OK")
|
||
assert "OK" in out, "Bundled venv not found at /opt/fenris"
|
||
|
||
# Venv Python binary exists (may not execute if shared libs differ)
|
||
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3")
|
||
assert rc == 0, "Venv Python binary not found"
|
||
|
||
# Wrapper on PATH
|
||
rc, out = _container_exec(container, "command -v fenris")
|
||
assert rc == 0, f"fenris not on PATH: {out}"
|
||
|
||
# Wrapper file exists and is executable
|
||
rc, _ = _container_exec(container, "test -x /usr/bin/fenris")
|
||
assert rc == 0, "Wrapper not found or not executable at /usr/bin/fenris"
|
||
|
||
# Wrapper contains the correct version string
|
||
rc, out = _container_exec(container, f"grep -q '{version}' /usr/bin/fenris && echo OK")
|
||
assert "OK" in out, f"Version {version} not found in wrapper script"
|
||
|
||
# Helpers in /usr/libexec/fenris
|
||
for helper in ("fenris-monitor", "fenris-collect"):
|
||
rc, _ = _container_exec(container, f"test -x /usr/libexec/fenris/{helper}")
|
||
assert rc == 0, f"{helper} not found or not executable"
|
||
|
||
# systemd units in vendor placement
|
||
for unit in ("fenris-collect.timer", "fenris-collect.service"):
|
||
rc, _ = _container_exec(container, f"test -f /usr/lib/systemd/system/{unit}")
|
||
assert rc == 0, f"{unit} not found in vendor placement"
|
||
|
||
# Polkit policy
|
||
rc, _ = _container_exec(
|
||
container,
|
||
"test -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy",
|
||
)
|
||
assert rc == 0, "Polkit policy not found"
|
||
|
||
# sysusers and tmpfiles fragments
|
||
rc, _ = _container_exec(container, "test -f /usr/lib/sysusers.d/fenris.conf")
|
||
assert rc == 0, "sysusers fragment not found"
|
||
rc, _ = _container_exec(container, "test -f /usr/lib/tmpfiles.d/fenris.conf")
|
||
assert rc == 0, "tmpfiles fragment not found"
|
||
|
||
# Placeholder-commented config
|
||
rc, out = _container_exec(container, "cat /etc/fenris/fenris.conf")
|
||
assert rc == 0, "fenris.conf not found"
|
||
assert "device" in out.lower() or "devices" in out.lower() or "#" in out, \
|
||
"Config does not appear to be placeholder-commented"
|
||
|
||
# fenris group exists
|
||
rc, out = _container_exec(container, "getent group fenris")
|
||
assert rc == 0, "fenris group not created"
|
||
|
||
# Observation store directory
|
||
if fmt == "deb":
|
||
rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris")
|
||
assert rc == 0, "Observation store directory not created"
|
||
parts = out.strip().split()
|
||
assert parts[0] == "2750", f"Store dir mode: expected 2750, got {parts[0]}"
|
||
assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}"
|
||
assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}"
|
||
else:
|
||
# rpm: directory owned by package (ghost)
|
||
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
||
assert rc == 0, "Observation store directory not found"
|
||
|
||
# Timer is disabled and inactive (dormant)
|
||
rc, out = _container_exec(
|
||
container, "systemctl is-enabled fenris-collect.timer 2>/dev/null || echo disabled"
|
||
)
|
||
assert "disabled" in out.lower() or "masked" in out.lower() or rc != 0, \
|
||
f"Timer should be disabled (dormant), got: {out}"
|
||
|
||
rc, out = _container_exec(
|
||
container, "systemctl is-active fenris-collect.timer 2>/dev/null || echo inactive"
|
||
)
|
||
assert "inactive" in out.lower() or "dead" in out.lower() or rc != 0, \
|
||
f"Timer should be inactive (dormant), got: {out}"
|
||
|
||
# No hand-rolled manifest
|
||
rc, _ = _container_exec(container, "test -f /var/lib/fenris/manifest.txt")
|
||
assert rc != 0, "Legacy manifest.txt should not exist in package install"
|
||
|
||
|
||
def _assert_migration_guard(container: str, fmt: str) -> None:
|
||
"""Assert that install aborts on make-install remnants (spec §7, §9)."""
|
||
if fmt == "deb":
|
||
# Plant the legacy manifest marker
|
||
_container_exec(container, "mkdir -p /var/lib/fenris")
|
||
_container_exec(container, "echo '# manifest' > /var/lib/fenris/manifest.txt")
|
||
# Attempt install — should fail with migration pointer
|
||
rc, out = _container_exec(
|
||
container,
|
||
"dpkg -i /tmp/fenris_0.3.0_amd64.deb 2>&1 || true",
|
||
)
|
||
assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \
|
||
f"Migration guard did not trigger: {out}"
|
||
# Clean up marker for subsequent tests
|
||
_container_exec(container, "rm -f /var/lib/fenris/manifest.txt")
|
||
else:
|
||
# Plant the admin unit marker
|
||
_container_exec(container, "mkdir -p /etc/systemd/system")
|
||
_container_exec(
|
||
container,
|
||
"echo '[Unit]' > /etc/systemd/system/fenris-collect.timer",
|
||
)
|
||
rc, out = _container_exec(
|
||
container,
|
||
"rpm -ivh /tmp/fenris-0.3.0-1.x86_64.rpm 2>&1 || true",
|
||
)
|
||
assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \
|
||
f"Migration guard did not trigger: {out}"
|
||
_container_exec(
|
||
container,
|
||
"rm -f /etc/systemd/system/fenris-collect.timer",
|
||
)
|
||
|
||
|
||
def _assert_upgrade_semantics(container: str, fmt: str) -> None:
|
||
"""Assert upgrade behavior (spec §7, ADR 0007 §6)."""
|
||
# Create a fake observation store using system Python
|
||
# (venv Python may not execute if host shared libs differ)
|
||
_container_exec(
|
||
container,
|
||
"mkdir -p /var/lib/fenris && "
|
||
"python3 -c \""
|
||
"import sqlite3; "
|
||
"c = sqlite3.connect('/var/lib/fenris/observations.db'); "
|
||
"c.execute('PRAGMA user_version=1'); "
|
||
"c.commit(); c.close()\"",
|
||
)
|
||
|
||
if fmt == "deb":
|
||
# Re-install triggers upgrade path
|
||
rc, out = _container_exec(
|
||
container,
|
||
"dpkg --force-confnew -i /tmp/fenris_*.deb 2>&1 || "
|
||
"apt-get install -f -y 2>&1 || true",
|
||
)
|
||
else:
|
||
rc, out = _container_exec(
|
||
container,
|
||
"rpm -Uvh /tmp/fenris-*.rpm 2>&1 || true",
|
||
)
|
||
|
||
# Snapshot should exist
|
||
rc, _ = _container_exec(
|
||
container, "test -f /var/lib/fenris/observations.db.bak"
|
||
)
|
||
assert rc == 0, "Observation store snapshot not created on upgrade"
|
||
|
||
# Original store still exists
|
||
rc, _ = _container_exec(
|
||
container, "test -f /var/lib/fenris/observations.db"
|
||
)
|
||
assert rc == 0, "Observation store missing after upgrade"
|
||
|
||
|
||
def _assert_removal_semantics(container: str, fmt: str) -> None:
|
||
"""Assert removal mapping (spec §7)."""
|
||
if fmt == "deb":
|
||
# remove (not purge) — config and store survive
|
||
rc, out = _container_exec(
|
||
container, "dpkg --purge fenris 2>&1 || true"
|
||
)
|
||
# After purge: config gone, store gone (our postrm removes on purge)
|
||
# But the store dir may survive since it's not package-owned
|
||
else:
|
||
# rpm erase
|
||
rc, out = _container_exec(
|
||
container, "rpm -e fenris 2>&1 || true"
|
||
)
|
||
|
||
# Units removed
|
||
rc, _ = _container_exec(
|
||
container, "test -f /usr/lib/systemd/system/fenris-collect.timer"
|
||
)
|
||
assert rc != 0, "Timer unit should be removed after uninstall"
|
||
|
||
# Helpers removed
|
||
rc, _ = _container_exec(
|
||
container, "test -f /usr/libexec/fenris/fenris-monitor"
|
||
)
|
||
assert rc != 0, "Helper should be removed after uninstall"
|
||
|
||
# Venv key files removed (directories may remain if non-empty)
|
||
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3")
|
||
assert rc != 0, "Venv Python should be removed after uninstall"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests — dormant install (tracer bullet)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.slow
|
||
@pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS])
|
||
def test_dormant_install(skip_no_docker, image, fmt, version):
|
||
"""Install package in container, assert dormant layout and ownership."""
|
||
pkg = _find_package(fmt)
|
||
pkg_name = pkg.name
|
||
|
||
# Copy package to build context
|
||
build_dir = REPO_ROOT / "build" / "test-container"
|
||
build_dir.mkdir(parents=True, exist_ok=True)
|
||
(build_dir / "dist").mkdir(exist_ok=True)
|
||
subprocess.run(
|
||
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||
check=True,
|
||
)
|
||
|
||
# Write Dockerfile
|
||
if fmt == "deb":
|
||
dockerfile = _build_deb_dockerfile(image, pkg_name)
|
||
else:
|
||
dockerfile = _build_rpm_dockerfile(image, pkg_name)
|
||
(build_dir / "Dockerfile").write_text(dockerfile)
|
||
|
||
# Build image
|
||
tag = f"fenris-test-{image.replace(':', '-').replace('/', '-')}"
|
||
subprocess.run(
|
||
["docker", "build", "-t", tag, str(build_dir)],
|
||
check=True,
|
||
capture_output=True,
|
||
timeout=300,
|
||
)
|
||
|
||
# Run container
|
||
container = f"fenris-test-{os.getpid()}"
|
||
subprocess.run(
|
||
[
|
||
"docker", "run", "-d", "--name", container,
|
||
"--tmpfs", "/tmp:exec,size=64m",
|
||
tag, "sleep", "infinity",
|
||
],
|
||
check=True,
|
||
capture_output=True,
|
||
)
|
||
|
||
try:
|
||
_assert_dormant_layout(container, fmt, version)
|
||
finally:
|
||
subprocess.run(
|
||
["docker", "rm", "-f", container],
|
||
capture_output=True,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests — migration guard
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.slow
|
||
@pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS])
|
||
def test_migration_guard(skip_no_docker, image, fmt, version):
|
||
"""Assert install aborts on make-install remnants."""
|
||
pkg = _find_package(fmt)
|
||
pkg_name = pkg.name
|
||
|
||
build_dir = REPO_ROOT / "build" / "test-container-guard"
|
||
build_dir.mkdir(parents=True, exist_ok=True)
|
||
(build_dir / "dist").mkdir(exist_ok=True)
|
||
subprocess.run(
|
||
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||
check=True,
|
||
)
|
||
|
||
# Dockerfile: install with remnants pre-planted
|
||
if fmt == "deb":
|
||
dockerfile = textwrap.dedent(f"""\
|
||
FROM {image}
|
||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||
python3 python3-minimal smartmontools systemd systemd-sysv dbus && \
|
||
rm -rf /var/lib/apt/lists/*
|
||
COPY dist/{pkg_name} /tmp/{pkg_name}
|
||
# Plant make-install remnant BEFORE installing
|
||
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
|
||
""")
|
||
else:
|
||
dockerfile = textwrap.dedent(f"""\
|
||
FROM {image}
|
||
RUN dnf install -y --setopt=install_weak_deps=False \
|
||
python3 smartmontools systemd dbus && \
|
||
dnf clean all
|
||
COPY dist/{pkg_name} /tmp/{pkg_name}
|
||
RUN mkdir -p /etc/systemd/system && \\
|
||
echo '[Unit]' > /etc/systemd/system/fenris-collect.timer
|
||
""")
|
||
(build_dir / "Dockerfile").write_text(dockerfile)
|
||
|
||
tag = f"fenris-guard-{image.replace(':', '-').replace('/', '-')}"
|
||
subprocess.run(
|
||
["docker", "build", "-t", tag, str(build_dir)],
|
||
check=True,
|
||
capture_output=True,
|
||
timeout=300,
|
||
)
|
||
|
||
container = f"fenris-guard-{os.getpid()}"
|
||
subprocess.run(
|
||
[
|
||
"docker", "run", "-d", "--name", container,
|
||
"--tmpfs", "/tmp:exec,size=64m",
|
||
tag, "sleep", "infinity",
|
||
],
|
||
check=True,
|
||
capture_output=True,
|
||
)
|
||
|
||
try:
|
||
_assert_migration_guard(container, fmt)
|
||
finally:
|
||
subprocess.run(
|
||
["docker", "rm", "-f", container],
|
||
capture_output=True,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests — upgrade semantics
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.slow
|
||
@pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS])
|
||
def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
||
"""Assert upgrade snapshots store and preserves config."""
|
||
pkg = _find_package(fmt)
|
||
pkg_name = pkg.name
|
||
|
||
build_dir = REPO_ROOT / "build" / "test-container-upgrade"
|
||
build_dir.mkdir(parents=True, exist_ok=True)
|
||
(build_dir / "dist").mkdir(exist_ok=True)
|
||
subprocess.run(
|
||
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||
check=True,
|
||
)
|
||
|
||
if fmt == "deb":
|
||
dockerfile = _build_deb_dockerfile(image, pkg_name)
|
||
else:
|
||
dockerfile = _build_rpm_dockerfile(image, pkg_name)
|
||
(build_dir / "Dockerfile").write_text(dockerfile)
|
||
|
||
tag = f"fenris-upgrade-{image.replace(':', '-').replace('/', '-')}"
|
||
subprocess.run(
|
||
["docker", "build", "-t", tag, str(build_dir)],
|
||
check=True,
|
||
capture_output=True,
|
||
timeout=300,
|
||
)
|
||
|
||
container = f"fenris-upgrade-{os.getpid()}"
|
||
subprocess.run(
|
||
[
|
||
"docker", "run", "-d", "--name", container,
|
||
"--tmpfs", "/tmp:exec,size=64m",
|
||
tag, "sleep", "infinity",
|
||
],
|
||
check=True,
|
||
capture_output=True,
|
||
)
|
||
|
||
try:
|
||
_assert_upgrade_semantics(container, fmt)
|
||
finally:
|
||
subprocess.run(
|
||
["docker", "rm", "-f", container],
|
||
capture_output=True,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests — removal semantics
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.slow
|
||
@pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS])
|
||
def test_removal_semantics(skip_no_docker, image, fmt, version):
|
||
"""Assert removal cleans package-owned files."""
|
||
pkg = _find_package(fmt)
|
||
pkg_name = pkg.name
|
||
|
||
build_dir = REPO_ROOT / "build" / "test-container-removal"
|
||
build_dir.mkdir(parents=True, exist_ok=True)
|
||
(build_dir / "dist").mkdir(exist_ok=True)
|
||
subprocess.run(
|
||
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||
check=True,
|
||
)
|
||
|
||
if fmt == "deb":
|
||
dockerfile = _build_deb_dockerfile(image, pkg_name)
|
||
else:
|
||
dockerfile = _build_rpm_dockerfile(image, pkg_name)
|
||
(build_dir / "Dockerfile").write_text(dockerfile)
|
||
|
||
tag = f"fenris-removal-{image.replace(':', '-').replace('/', '-')}"
|
||
subprocess.run(
|
||
["docker", "build", "-t", tag, str(build_dir)],
|
||
check=True,
|
||
capture_output=True,
|
||
timeout=300,
|
||
)
|
||
|
||
container = f"fenris-removal-{os.getpid()}"
|
||
subprocess.run(
|
||
[
|
||
"docker", "run", "-d", "--name", container,
|
||
"--tmpfs", "/tmp:exec,size=64m",
|
||
tag, "sleep", "infinity",
|
||
],
|
||
check=True,
|
||
capture_output=True,
|
||
)
|
||
|
||
try:
|
||
_assert_removal_semantics(container, fmt)
|
||
finally:
|
||
subprocess.run(
|
||
["docker", "rm", "-f", container],
|
||
capture_output=True,
|
||
)
|