From 46c74a4faa6de95074c207a025f16f45b9f1bf47 Mon Sep 17 00:00:00 2001 From: Joseph Asbury Date: Thu, 17 Sep 2026 08:41:05 -0400 Subject: [PATCH 1/2] feat(docker): add Docker-in-Docker support for container image --- docs/tasks/container-images.md | 44 ++- .../docker-in-docker/Dockerfile | 174 ++++++++++++ .../data/generic/Dockerfile.j2 | 11 +- tests/test_docker_in_docker.py | 268 ++++++++++++++++++ tests/test_utils.py | 6 + 5 files changed, 492 insertions(+), 11 deletions(-) create mode 100644 src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/Dockerfile create mode 100644 tests/test_docker_in_docker.py diff --git a/docs/tasks/container-images.md b/docs/tasks/container-images.md index cff8450..308df50 100644 --- a/docs/tasks/container-images.md +++ b/docs/tasks/container-images.md @@ -77,7 +77,7 @@ COPY . /tmp/build/ # Export debug requirements when the project defines a debug dependency group RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }}{% for mount in UV_INDEX_SECRET_MOUNTS %} \ --mount={{ mount }}{% endfor %} \ - uv export --group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt + uv export --frozen --only-group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt {% endif %} # Build the wheel, caching the uv cache directory to speed up subsequent builds. @@ -172,10 +172,11 @@ LABEL git.commit=${GIT_COMMIT} # current package does not provide a console script, the entrypoint will default to `python` RUN echo "#!/bin/sh -{{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \ +exec {{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \ && chmod +x /pkg/entrypoint.sh -USER py +ARG RUNTIME_USER=py +USER ${RUNTIME_USER} {% if EXTENSION_CONTENT %} {{ EXTENSION_CONTENT }} @@ -193,13 +194,13 @@ COPY --from=builder /tmp/build /tmp/build RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} pip install --root-user-action=ignore -r /tmp/build/requirements-debug.txt -USER py +USER ${RUNTIME_USER} {% endif %} # Final (default) image: explicitly use runtime as the final target so debug is not used unless requested FROM runtime AS final -USER py +USER ${RUNTIME_USER} ``` ### Dependency image template @@ -310,7 +311,7 @@ RUN apt-get update \ USER py ``` -Extension files are concatenated in their configured order and inserted near the end of `runtime`, after the entrypoint is created and after `USER py`. An extension that needs elevated permissions must switch to `USER root`; it should normally restore `USER py` for the instructions that follow. `COPY` paths remain relative to the project-root build context. +Extension files are concatenated in their configured order and inserted near the end of `runtime`, after the entrypoint is created and after `USER ${RUNTIME_USER}` (which defaults to `py`). An extension that needs elevated permissions must switch to `USER root`; it should normally restore `USER ${RUNTIME_USER}` for the instructions that follow. Extensions that need to start as another user can redeclare `ARG RUNTIME_USER=root`; both the final and debug stages honor this argument. `COPY` paths remain relative to the project-root build context. Extension content is treated as raw Dockerfile syntax, not as a Jinja template. This keeps project extensions independent of private template variables used by `common-python-tasks`. @@ -327,6 +328,37 @@ Use an extension for additive runtime instructions. Use `CONTAINER_DOCKERFILE_HO | `COMMON_PYTHON_TASKS_DOCKER_PLAIN` | `1` when plain progress output is active, otherwise `0` | | `COMMON_PYTHON_TASKS_DOCKER_SINGLE_ARCH` | `1` for a single-architecture build, otherwise `0` | +### Docker-in-Docker + +The bundled `docker-in-docker` extension installs Docker CE, containerd, Buildx, and Compose from Docker's signed APT repository. Installation and startup code live in this package; image builds do not download scripts from the devcontainer feature repository. + +Initial support is limited to Debian Bookworm variants (`slim-bookworm` and `bookworm`) on `amd64` and `arm64`. The extension checks the actual distribution and architecture during the build and rejects everything else, including Alpine and Trixie. Select extensions independently for each image build; other images do not need to enable Docker-in-Docker. + +```sh +CONTAINER_PYTHON_VARIANT=slim-bookworm \ + CONTAINER_EXTENSIONS=docker-in-docker poe build-image +poe run-container --privileged +``` + +The image starts as root through Tini and the DinD supervisor. The supervisor prepares nested cgroups, starts a dedicated Docker daemon, and waits for `docker info` to succeed before launching the existing `/pkg/entrypoint.sh` as `py`. Application arguments and exit status are preserved. Signals reach the application, and shutdown stops the application before stopping Docker. Startup failure or loss of the daemon terminates the container with a nonzero status. `DIND_STARTUP_TIMEOUT` controls the readiness deadline in seconds (default `60`, accepted range `1`–`9999`). Each process group gets up to five seconds to stop before being killed; allow more than ten seconds for the outer container's stop timeout. + +Docker listens only on `unix:///var/run/docker.sock`. The image sets `DOCKER_HOST` accordingly, and startup clears Docker context and TLS environment overrides so the application uses its own daemon. Do not mount the host Docker socket. The `py` user belongs to the Docker group and can control the nested daemon; this is a privileged container, not a sandbox for untrusted workloads. + +The image declares volumes for `/var/lib/docker` and `/var/lib/containerd`. Docker creates anonymous volumes automatically. Use dedicated named volumes when state should survive container replacement, and never share them between concurrently running daemons. For example, when launching the built image directly, replace `your-image:tag` with its tag. + +```sh +docker run --rm --privileged --stop-timeout 20 \ + --mount source=my-app-docker,target=/var/lib/docker \ + --mount source=my-app-containerd,target=/var/lib/containerd \ + your-image:tag +``` + +For Compose, set `privileged: true`, `stop_grace_period: 20s`, and the equivalent volume mounts on the application service. Keep the image's entrypoint and startup user. `container-shell` and an explicit `--entrypoint` override bypass DinD startup; to inspect a running DinD container, use `docker exec -it --user py sh`. + +By default, the build installs the current stable packages available in Docker's repository. An optional bundle value pins the exact Docker Engine and CLI APT version. Include literal quotes around the version inside `CONTAINER_EXTENSIONS` so its epoch colon is not parsed as an extension separator. For example, `CONTAINER_EXTENSIONS='docker-in-docker="5:29.8.1-1~debian.12~bookworm"'` selects that version if available. This pins only Engine and CLI; containerd and CLI plugins still use the repository's current versions. + +Maintainers can run the opt-in integration tests with `CPT_DIND_INTEGRATION=1 poe test tests/test_docker_in_docker.py`. These tests require a running Docker daemon, privileged Linux containers, and network access for image and package downloads. They build the actual generated application Dockerfile and remove their test images, containers, and anonymous volumes afterward. + ## Supplying external dependencies Dependency images support artifacts that should be built separately from the application wheel, such as compiled tools or browser binaries. The dependency image must place its exported content under `/tmp/deps`; the generated application Dockerfile copies that directory into its `runtime` stage. diff --git a/src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/Dockerfile b/src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/Dockerfile new file mode 100644 index 0000000..9d69dbc --- /dev/null +++ b/src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/Dockerfile @@ -0,0 +1,174 @@ +# Docker-in-Docker currently supports Debian Bookworm on amd64 and arm64. +USER root + +# Optional exact APT version, including the epoch and distribution suffix. +ARG DIND_DOCKER_VERSION + +RUN <<'INSTALL_DIND' +set -eu +# shellcheck source=/dev/null +. /etc/os-release +if [ "$ID" != debian ] || [ "${VERSION_CODENAME:-}" != bookworm ]; then + echo >&2 'docker-in-docker requires Debian Bookworm; use slim-bookworm.' + exit 1 +fi +case "$(dpkg --print-architecture)" in + amd64 | arm64) ;; + *) + echo >&2 'docker-in-docker supports amd64 and arm64 only.' + exit 1 + ;; +esac +apt-get update +apt-get install -y --no-install-recommends \ + bash ca-certificates curl iptables tini util-linux +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/debian/gpg \ + -o /etc/apt/keyrings/docker.asc +chmod 0644 /etc/apt/keyrings/docker.asc +cat >/etc/apt/sources.list.d/docker.sources <&2 'docker-in-docker must start as root with --privileged.' + return 1 + fi + if [[ ! -w /sys/fs/cgroup ]]; then + echo >&2 'docker-in-docker needs writable cgroups; use --privileged.' + return 1 + fi + if [[ -d /sys/kernel/security ]] && + ! mountpoint -q /sys/kernel/security; then + mount -t securityfs none /sys/kernel/security + fi + _prepare_cgroups + if ! iptables -nL >/dev/null 2>&1 && + iptables-legacy -nL >/dev/null 2>&1; then + update-alternatives --set iptables /usr/sbin/iptables-legacy + update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy + fi + # Only remove our daemon's PID file, never unrelated processes' files. + rm -f /var/run/docker.pid +} + +_prepare_cgroups() { + [[ -f /sys/fs/cgroup/cgroup.controllers ]] || return 0 + mkdir -p /sys/fs/cgroup/cpt-init + local attempt pid controllers + for ((attempt = 0; attempt < 5; attempt++)); do + while read -r pid; do + # Processes can disappear while we move them. + echo "$pid" >/sys/fs/cgroup/cpt-init/cgroup.procs || true + done /sys/fs/cgroup/cgroup.subtree_control; then + return 0 + fi + sleep 0.1 + done + echo >&2 'docker-in-docker could not enable nested cgroups.' + return 1 +} + +_stop_group() { + local pid=$1 + [[ -n "$pid" ]] || return 0 + kill -TERM -- "-$pid" 2>/dev/null || true + local attempt + for ((attempt = 0; attempt < 50; attempt++)); do + kill -0 -- "-$pid" 2>/dev/null || break + sleep 0.1 + done + kill -KILL -- "-$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true +} + +_shutdown() { + trap '' TERM INT + _stop_group "$app_pid" + _stop_group "$daemon_pid" +} + +_wait_for_docker() { + local deadline=$((SECONDS + DIND_STARTUP_TIMEOUT)) + while ((SECONDS < deadline)); do + if ! kill -0 "$daemon_pid" 2>/dev/null; then + echo >&2 'docker-in-docker daemon exited during startup.' + return 1 + fi + if timeout 1 docker --host "$DOCKER_HOST" info >/dev/null 2>&1; then + return 0 + fi + sleep 0.2 + done + echo >&2 'docker-in-docker timed out waiting for the daemon.' + return 1 +} + +_main() { + if [[ ! ${DIND_STARTUP_TIMEOUT:-60} =~ ^[1-9][0-9]{0,3}$ ]]; then + echo >&2 'DIND_STARTUP_TIMEOUT must be an integer from 1 to 9999.' + return 1 + fi + export DIND_STARTUP_TIMEOUT=${DIND_STARTUP_TIMEOUT:-60} + export DOCKER_HOST=unix:///var/run/docker.sock + export container=docker + unset DOCKER_CONTEXT DOCKER_TLS_VERIFY DOCKER_CERT_PATH + _prepare_host + daemon_pid='' + app_pid='' + trap _shutdown EXIT + trap 'exit 143' TERM + trap 'exit 130' INT + # Keep daemon logs on stderr and expose only the local Unix socket. + setsid dockerd --host "$DOCKER_HOST" --group docker >&2 & + daemon_pid=$! + _wait_for_docker + setsid setpriv --reuid py --regid py --init-groups \ + env HOME="$(getent passwd py | cut -d: -f6)" USER=py LOGNAME=py \ + /pkg/application-entrypoint.sh "$@" & + app_pid=$! + local finished status=0 + wait -n -p finished "$daemon_pid" "$app_pid" || status=$? + if [[ "$finished" == "$daemon_pid" ]]; then + echo >&2 'docker-in-docker daemon exited while the app was running.' + return 1 + fi + return "$status" +} + +_main "$@" +DIND_ENTRYPOINT + +RUN mv /pkg/entrypoint.sh /pkg/application-entrypoint.sh \ + && printf '%s\n' '#!/bin/sh' \ + 'exec /usr/bin/tini -- /usr/local/bin/cpt-dind-supervisor "$@"' \ + >/pkg/entrypoint.sh \ + && chmod 0755 /pkg/entrypoint.sh + +ENV DOCKER_HOST=unix:///var/run/docker.sock +VOLUME ["/var/lib/docker", "/var/lib/containerd"] + +# The generic final and debug stages inherit this startup-user selection. +# The wrapper drops to py only for the application, after Docker is ready. +ARG RUNTIME_USER=root +USER ${RUNTIME_USER} diff --git a/src/common_python_tasks/data/generic/Dockerfile.j2 b/src/common_python_tasks/data/generic/Dockerfile.j2 index daba81d..2f5f748 100644 --- a/src/common_python_tasks/data/generic/Dockerfile.j2 +++ b/src/common_python_tasks/data/generic/Dockerfile.j2 @@ -40,7 +40,7 @@ COPY . /tmp/build/ # Export debug requirements when the project defines a debug dependency group RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }}{% for mount in UV_INDEX_SECRET_MOUNTS %} \ --mount={{ mount }}{% endfor %} \ - uv export --group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt + uv export --frozen --only-group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt {% endif %} # Build the wheel, caching the uv cache directory to speed up subsequent builds. @@ -133,10 +133,11 @@ LABEL git.commit=${GIT_COMMIT} # This entrypoint is deliberately not configurable via environment variables in order to # ensure that the container always uses the entrypoint selected at build time. If the # current package does not provide a console script, the entrypoint will default to `python` -RUN echo "#!/bin/sh\n\n{{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \ +RUN echo "#!/bin/sh\n\nexec {{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \ && chmod +x /pkg/entrypoint.sh -USER py +ARG RUNTIME_USER=py +USER ${RUNTIME_USER} {% if EXTENSION_CONTENT %} {{ EXTENSION_CONTENT }} @@ -154,10 +155,10 @@ COPY --from=builder /tmp/build /tmp/build RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} pip install --root-user-action=ignore -r /tmp/build/requirements-debug.txt -USER py +USER ${RUNTIME_USER} {% endif %} # Final (default) image: explicitly use runtime as the final target so debug is not used unless requested FROM runtime AS final -USER py +USER ${RUNTIME_USER} diff --git a/tests/test_docker_in_docker.py b/tests/test_docker_in_docker.py new file mode 100644 index 0000000..5611acb --- /dev/null +++ b/tests/test_docker_in_docker.py @@ -0,0 +1,268 @@ +import os +import subprocess +import time +import uuid + +import pytest + +from common_python_tasks.env import ( + parse_container_extensions, + resolve_extension_content, +) +from common_python_tasks.utils import load_data_file, render_template_text + + +def _extension(): + return load_data_file( + "docker-in-docker/Dockerfile", type_identifier="dockerfile_extensions" + )[1] + + +def test_dind_bundle_resolves_without_project_files(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("CONTAINER_EXTENSION_FILES", raising=False) + monkeypatch.setenv( + "CONTAINER_EXTENSIONS", 'docker-in-docker="5:29.8.1-1~debian.12~bookworm"' + ) + assert resolve_extension_content(parse_container_extensions()[0]) == _extension() + assert parse_container_extensions()[0]["args"] == "5:29.8.1-1~debian.12~bookworm" + + +@pytest.mark.parametrize("debug", [False, True]) +@pytest.mark.parametrize("extension", ["", "ARG RUNTIME_USER=root\nUSER root"]) +def test_derived_stages_preserve_runtime_user(debug, extension): + dockerfile = render_template_text( + load_data_file("Dockerfile.j2")[1], + {"EXTENSION_CONTENT": extension, "HAS_DEBUG_DEPS": debug}, + ) + runtime, final = dockerfile.split("FROM runtime AS final") + assert "ARG RUNTIME_USER=py" in runtime + assert "USER ${RUNTIME_USER}" in final + assert "USER py" not in dockerfile + if debug: + assert "USER ${RUNTIME_USER}" in runtime.split("FROM runtime AS debug")[1] + + +def test_application_entrypoint_executes_selected_command(): + assert "exec python" in render_template_text(load_data_file("Dockerfile.j2")[1], {}) + + +def test_dind_wraps_the_generated_entrypoint_without_redeclaring_it(): + extension = _extension() + assert "mv /pkg/entrypoint.sh /pkg/application-entrypoint.sh" in extension + assert "/pkg/application-entrypoint.sh" in extension + assert "\nENTRYPOINT " not in extension + + +def _docker(*args, check=True, timeout=180): + return subprocess.run( + ["docker", *args], + shell=False, + check=check, + capture_output=True, + text=True, + timeout=timeout, + ) + + +@pytest.fixture(scope="module") +def dind_image(tmp_path_factory): + """Build the generated application image for opt-in privileged tests. + + Args: + tmp_path_factory: Factory for isolated build contexts. + + Yields: + The temporary Docker image tag. + """ + if os.environ.get("CPT_DIND_INTEGRATION") != "1": + pytest.skip("Set CPT_DIND_INTEGRATION=1 to build and run privileged DinD tests") + context = tmp_path_factory.mktemp("dind-image") + (context / "pyproject.toml").write_text( + '[project]\nname = "dind-smoke"\nversion = "0.0.0"\n' + '[build-system]\nrequires = ["setuptools"]\n' + 'build-backend = "setuptools.build_meta"\n', + encoding="utf-8", + ) + (context / "dind_smoke").mkdir() + (context / "dind_smoke" / "__init__.py").touch() + (context / "Dockerfile").write_text( + render_template_text( + load_data_file("Dockerfile.j2")[1], {"EXTENSION_CONTENT": _extension()} + ), + encoding="utf-8", + ) + image = f"cpt-dind-test:{uuid.uuid4().hex}" + try: + result = _docker( + "build", + "--build-arg", + "PYTHON_VERSION=3.14", + "--build-arg", + "PYTHON_VARIANT=slim-bookworm", + "--build-arg", + "PACKAGE_NAME=dind_smoke", + "--tag", + image, + str(context), + check=False, + timeout=900, + ) + assert result.returncode == 0, result.stdout + result.stderr + yield image + finally: + _docker("image", "rm", image, check=False) + + +@pytest.fixture +def dind_container(dind_image): + """Reserve a container name and clean up its volumes after the test. + + Args: + dind_image: Image fixture whose lifetime includes container cleanup. + + Yields: + A unique container name. + """ + name = f"cpt-dind-test-{uuid.uuid4().hex}" + try: + yield name + finally: + _docker("rm", "--force", "--volumes", name, check=False) + + +def _run_app(image, name, code, *options): + return _docker("run", "--name", name, *options, image, "-c", code, check=False) + + +def test_dind_runs_nested_workload_as_py(dind_image, dind_container): + result = _run_app( + dind_image, + dind_container, + "import os, subprocess; assert os.getuid() == 1000; " + "subprocess.run(['docker', 'run', '--rm', 'alpine:3.22', " + "'sh', '-c', 'echo nested-ok'], check=True); " + "subprocess.run(['docker', 'buildx', 'version'], check=True); " + "subprocess.run(['docker', 'compose', 'version'], check=True)", + "--privileged", + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "nested-ok" in result.stdout + + +def test_dind_preserves_application_exit_code(dind_image, dind_container): + result = _run_app( + dind_image, dind_container, "import sys; sys.exit(42)", "--privileged" + ) + assert result.returncode == 42, result.stdout + result.stderr + + +@pytest.mark.parametrize("options", [[], ["--privileged", "--user", "py"]]) +def test_dind_rejects_missing_privileges(dind_image, dind_container, options): + result = _run_app(dind_image, dind_container, "print('app-started')", *options) + assert result.returncode != 0 + assert "--privileged" in result.stderr + assert "app-started" not in result.stdout + + +def test_dind_daemon_failure_prevents_application_start( + dind_image, dind_container, tmp_path +): + (tmp_path / "daemon.json").write_text('{"not-a-docker-option": true}') + result = _run_app( + dind_image, + dind_container, + "print('app-started')", + "--privileged", + "--volume", + f"{tmp_path / 'daemon.json'}:/etc/docker/daemon.json:ro", + ) + assert result.returncode != 0 + assert "daemon exited during startup" in result.stderr + assert "app-started" not in result.stdout + + +def test_dind_startup_timeout_prevents_application_start( + dind_image, dind_container, tmp_path +): + (tmp_path / "dockerd").write_text("#!/bin/sh\nexec sleep 300\n") + (tmp_path / "dockerd").chmod(0o755) + result = _run_app( + dind_image, + dind_container, + "print('app-started')", + "--privileged", + "--env", + "DIND_STARTUP_TIMEOUT=1", + "--volume", + f"{tmp_path / 'dockerd'}:/usr/bin/dockerd:ro", + ) + assert result.returncode != 0 + assert "timed out waiting for the daemon" in result.stderr + assert "app-started" not in result.stdout + + +def test_dind_rejects_unsupported_distribution(dind_image, tmp_path): + (tmp_path / "Dockerfile").write_text("FROM alpine:3.22\n" + _extension()) + result = _docker("build", str(tmp_path), check=False) + assert result.returncode != 0 + assert "requires Debian Bookworm" in result.stderr + + +def _wait_for_output(name, expected): + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + if expected in _docker("logs", name).stdout: + return + time.sleep(0.2) + pytest.fail(f"Container never produced {expected}: {_docker('logs', name).stderr}") + + +def test_dind_forwards_stop_and_can_restart(dind_image, dind_container): + _docker( + "run", + "--detach", + "--privileged", + "--name", + dind_container, + dind_image, + "-c", + "import signal, sys, time; " + "signal.signal(signal.SIGTERM, lambda *_: " + "(print('app-stopped', flush=True), sys.exit(0))); " + "print('app-ready', flush=True); time.sleep(300)", + ) + _wait_for_output(dind_container, "app-ready") + _docker("stop", "--time", "20", dind_container) + assert "app-stopped" in _docker("logs", dind_container).stdout + assert ( + _docker( + "inspect", "--format", "{{.State.ExitCode}}", dind_container + ).stdout.strip() + == "143" + ) + _docker("start", dind_container) + _wait_for_output(dind_container, "app-ready\napp-stopped\napp-ready") + _docker("exec", "--user", "py", dind_container, "docker", "info") + + +def test_dind_stops_application_when_daemon_exits(dind_image, dind_container): + _docker( + "run", + "--detach", + "--privileged", + "--name", + dind_container, + dind_image, + "-c", + "import time; print('app-ready'); time.sleep(300)", + ) + _wait_for_output(dind_container, "app-ready") + _docker( + "exec", dind_container, "sh", "-c", 'kill -TERM "$(cat /var/run/docker.pid)"' + ) + assert _docker("wait", dind_container, timeout=30).stdout.strip() == "1" + assert ( + "daemon exited while the app was running" + in _docker("logs", dind_container).stderr + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 3b3c4cf..f0f5f90 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -262,6 +262,12 @@ def test_dockerfile_templates_define_python_variant_argument(filename, stage): assert 'org.opencontainers.image.python.variant="${PYTHON_VARIANT}"' in contents +def test_application_dockerfile_exports_only_debug_dependencies(): + _, contents = load_data_file("Dockerfile.j2") + + assert "uv export --frozen --only-group debug" in contents + + def test_run_git_cliff_passes_args_and_capture_output_to_run_command(): with ( patch( From f2cb3b86b3105a5d15a257ffb9141b9367253ba8 Mon Sep 17 00:00:00 2001 From: Joseph Asbury Date: Thu, 17 Sep 2026 10:10:57 -0400 Subject: [PATCH 2/2] refactor(containers): package extension scripts as build contexts --- docs/tasks/container-images.md | 4 +- .../docker-in-docker/Dockerfile | 159 +----------------- .../docker-in-docker/install.sh | 39 +++++ .../docker-in-docker/supervisor.sh | 116 +++++++++++++ src/common_python_tasks/env.py | 34 ++++ src/common_python_tasks/tasks.py | 15 +- tests/test_build_image.py | 43 +++++ tests/test_docker_in_docker.py | 37 +++- tests/test_env.py | 35 ++++ 9 files changed, 326 insertions(+), 156 deletions(-) create mode 100644 src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/install.sh create mode 100644 src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/supervisor.sh diff --git a/docs/tasks/container-images.md b/docs/tasks/container-images.md index 308df50..6433c82 100644 --- a/docs/tasks/container-images.md +++ b/docs/tasks/container-images.md @@ -317,6 +317,8 @@ Extension content is treated as raw Dockerfile syntax, not as a Jinja template. `CONTAINER_EXTENSIONS` selects extension bundles shipped in the installed package's `data/dockerfile_extensions/` directory. Bundle names are colon-delimited and are applied after local extension files. A bundle may accept one value with `bundle=value`; that value is passed to the first `ARG` declared by the bundle that has not already been assigned to another extension. Arguments are ignored with a warning when the bundle declares no `ARG`. +A bundled extension can keep scripts and other supporting files beside its `Dockerfile`. The image builder exposes that directory as a BuildKit named context called `cpt-extension-`, with unsupported characters normalized to hyphens. The bundle can copy an asset with `COPY --from=cpt-extension-example script.sh /usr/local/bin/script` without embedding it in a heredoc. These managed contexts are added only to the application image build. + Use an extension for additive runtime instructions. Use `CONTAINER_DOCKERFILE_HOOK_PATH` only when a change must rewrite another part of the selected Dockerfile. The hook must be an executable host-side script; it receives a temporary copy of the selected Dockerfile as its first argument and must edit that file in place. The hook also receives the following context variables. | Variable | Meaning | @@ -330,7 +332,7 @@ Use an extension for additive runtime instructions. Use `CONTAINER_DOCKERFILE_HO ### Docker-in-Docker -The bundled `docker-in-docker` extension installs Docker CE, containerd, Buildx, and Compose from Docker's signed APT repository. Installation and startup code live in this package; image builds do not download scripts from the devcontainer feature repository. +The bundled `docker-in-docker` extension installs Docker CE, containerd, Buildx, and Compose from Docker's signed APT repository. Its installation and supervisor scripts are separate packaged files copied through the extension's named build context; image builds do not download scripts from the devcontainer feature repository. Initial support is limited to Debian Bookworm variants (`slim-bookworm` and `bookworm`) on `amd64` and `arm64`. The extension checks the actual distribution and architecture during the build and rejects everything else, including Alpine and Trixie. Select extensions independently for each image build; other images do not need to enable Docker-in-Docker. diff --git a/src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/Dockerfile b/src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/Dockerfile index 9d69dbc..1d3b664 100644 --- a/src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/Dockerfile +++ b/src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/Dockerfile @@ -4,160 +4,13 @@ USER root # Optional exact APT version, including the epoch and distribution suffix. ARG DIND_DOCKER_VERSION -RUN <<'INSTALL_DIND' -set -eu -# shellcheck source=/dev/null -. /etc/os-release -if [ "$ID" != debian ] || [ "${VERSION_CODENAME:-}" != bookworm ]; then - echo >&2 'docker-in-docker requires Debian Bookworm; use slim-bookworm.' - exit 1 -fi -case "$(dpkg --print-architecture)" in - amd64 | arm64) ;; - *) - echo >&2 'docker-in-docker supports amd64 and arm64 only.' - exit 1 - ;; -esac -apt-get update -apt-get install -y --no-install-recommends \ - bash ca-certificates curl iptables tini util-linux -install -m 0755 -d /etc/apt/keyrings -curl -fsSL https://download.docker.com/linux/debian/gpg \ - -o /etc/apt/keyrings/docker.asc -chmod 0644 /etc/apt/keyrings/docker.asc -cat >/etc/apt/sources.list.d/docker.sources <&2 'docker-in-docker must start as root with --privileged.' - return 1 - fi - if [[ ! -w /sys/fs/cgroup ]]; then - echo >&2 'docker-in-docker needs writable cgroups; use --privileged.' - return 1 - fi - if [[ -d /sys/kernel/security ]] && - ! mountpoint -q /sys/kernel/security; then - mount -t securityfs none /sys/kernel/security - fi - _prepare_cgroups - if ! iptables -nL >/dev/null 2>&1 && - iptables-legacy -nL >/dev/null 2>&1; then - update-alternatives --set iptables /usr/sbin/iptables-legacy - update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy - fi - # Only remove our daemon's PID file, never unrelated processes' files. - rm -f /var/run/docker.pid -} - -_prepare_cgroups() { - [[ -f /sys/fs/cgroup/cgroup.controllers ]] || return 0 - mkdir -p /sys/fs/cgroup/cpt-init - local attempt pid controllers - for ((attempt = 0; attempt < 5; attempt++)); do - while read -r pid; do - # Processes can disappear while we move them. - echo "$pid" >/sys/fs/cgroup/cpt-init/cgroup.procs || true - done /sys/fs/cgroup/cgroup.subtree_control; then - return 0 - fi - sleep 0.1 - done - echo >&2 'docker-in-docker could not enable nested cgroups.' - return 1 -} - -_stop_group() { - local pid=$1 - [[ -n "$pid" ]] || return 0 - kill -TERM -- "-$pid" 2>/dev/null || true - local attempt - for ((attempt = 0; attempt < 50; attempt++)); do - kill -0 -- "-$pid" 2>/dev/null || break - sleep 0.1 - done - kill -KILL -- "-$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true -} - -_shutdown() { - trap '' TERM INT - _stop_group "$app_pid" - _stop_group "$daemon_pid" -} - -_wait_for_docker() { - local deadline=$((SECONDS + DIND_STARTUP_TIMEOUT)) - while ((SECONDS < deadline)); do - if ! kill -0 "$daemon_pid" 2>/dev/null; then - echo >&2 'docker-in-docker daemon exited during startup.' - return 1 - fi - if timeout 1 docker --host "$DOCKER_HOST" info >/dev/null 2>&1; then - return 0 - fi - sleep 0.2 - done - echo >&2 'docker-in-docker timed out waiting for the daemon.' - return 1 -} - -_main() { - if [[ ! ${DIND_STARTUP_TIMEOUT:-60} =~ ^[1-9][0-9]{0,3}$ ]]; then - echo >&2 'DIND_STARTUP_TIMEOUT must be an integer from 1 to 9999.' - return 1 - fi - export DIND_STARTUP_TIMEOUT=${DIND_STARTUP_TIMEOUT:-60} - export DOCKER_HOST=unix:///var/run/docker.sock - export container=docker - unset DOCKER_CONTEXT DOCKER_TLS_VERIFY DOCKER_CERT_PATH - _prepare_host - daemon_pid='' - app_pid='' - trap _shutdown EXIT - trap 'exit 143' TERM - trap 'exit 130' INT - # Keep daemon logs on stderr and expose only the local Unix socket. - setsid dockerd --host "$DOCKER_HOST" --group docker >&2 & - daemon_pid=$! - _wait_for_docker - setsid setpriv --reuid py --regid py --init-groups \ - env HOME="$(getent passwd py | cut -d: -f6)" USER=py LOGNAME=py \ - /pkg/application-entrypoint.sh "$@" & - app_pid=$! - local finished status=0 - wait -n -p finished "$daemon_pid" "$app_pid" || status=$? - if [[ "$finished" == "$daemon_pid" ]]; then - echo >&2 'docker-in-docker daemon exited while the app was running.' - return 1 - fi - return "$status" -} - -_main "$@" -DIND_ENTRYPOINT +COPY --from=cpt-extension-docker-in-docker --chmod=0755 supervisor.sh \ + /usr/local/bin/cpt-dind-supervisor RUN mv /pkg/entrypoint.sh /pkg/application-entrypoint.sh \ && printf '%s\n' '#!/bin/sh' \ diff --git a/src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/install.sh b/src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/install.sh new file mode 100644 index 0000000..d22164a --- /dev/null +++ b/src/common_python_tasks/data/dockerfile_extensions/docker-in-docker/install.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu + +# shellcheck source=/dev/null +. /etc/os-release +if [ "$ID" != debian ] || [ "${VERSION_CODENAME:-}" != bookworm ]; then + echo >&2 'docker-in-docker requires Debian Bookworm; use slim-bookworm.' + exit 1 +fi +case "$(dpkg --print-architecture)" in + amd64 | arm64) ;; + *) + echo >&2 'docker-in-docker supports amd64 and arm64 only.' + exit 1 + ;; +esac + +apt-get update +apt-get install -y --no-install-recommends \ + bash ca-certificates curl iptables tini util-linux +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/debian/gpg \ + -o /etc/apt/keyrings/docker.asc +chmod 0644 /etc/apt/keyrings/docker.asc +cat >/etc/apt/sources.list.d/docker.sources <&2 'docker-in-docker must start as root with --privileged.' + return 1 + fi + if [[ ! -w /sys/fs/cgroup ]]; then + echo >&2 'docker-in-docker needs writable cgroups; use --privileged.' + return 1 + fi + if [[ -d /sys/kernel/security ]] && + ! mountpoint -q /sys/kernel/security; then + mount -t securityfs none /sys/kernel/security + fi + _prepare_cgroups + if ! iptables -nL >/dev/null 2>&1 && + iptables-legacy -nL >/dev/null 2>&1; then + update-alternatives --set iptables /usr/sbin/iptables-legacy + update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy + fi + # Only remove our daemon's PID file, never unrelated processes' files. + rm -f /var/run/docker.pid +} + +_prepare_cgroups() { + [[ -f /sys/fs/cgroup/cgroup.controllers ]] || return 0 + mkdir -p /sys/fs/cgroup/cpt-init + local attempt pid controllers + for ((attempt = 0; attempt < 5; attempt++)); do + while read -r pid; do + # Processes can disappear or become immovable while we move them. + printf '%s\n' "$pid" | tee \ + /sys/fs/cgroup/cpt-init/cgroup.procs >/dev/null 2>&1 || true + done /dev/null 2>&1; then + return 0 + fi + sleep 0.1 + done + echo >&2 'docker-in-docker could not enable nested cgroups.' + return 1 +} + +_stop_group() { + local pid=$1 + [[ -n "$pid" ]] || return 0 + kill -TERM -- "-$pid" 2>/dev/null || true + local attempt + for ((attempt = 0; attempt < 50; attempt++)); do + kill -0 -- "-$pid" 2>/dev/null || break + sleep 0.1 + done + kill -KILL -- "-$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true +} + +_shutdown() { + trap '' TERM INT + _stop_group "$app_pid" + _stop_group "$daemon_pid" +} + +_wait_for_docker() { + local deadline=$((SECONDS + DIND_STARTUP_TIMEOUT)) + while ((SECONDS < deadline)); do + if ! kill -0 "$daemon_pid" 2>/dev/null; then + echo >&2 'docker-in-docker daemon exited during startup.' + return 1 + fi + if timeout 1 docker --host "$DOCKER_HOST" info >/dev/null 2>&1; then + return 0 + fi + sleep 0.2 + done + echo >&2 'docker-in-docker timed out waiting for the daemon.' + return 1 +} + +_main() { + if [[ ! ${DIND_STARTUP_TIMEOUT:-60} =~ ^[1-9][0-9]{0,3}$ ]]; then + echo >&2 'DIND_STARTUP_TIMEOUT must be an integer from 1 to 9999.' + return 1 + fi + export DIND_STARTUP_TIMEOUT=${DIND_STARTUP_TIMEOUT:-60} + export DOCKER_HOST=unix:///var/run/docker.sock + export container=docker + unset DOCKER_CONTEXT DOCKER_TLS_VERIFY DOCKER_CERT_PATH + _prepare_host + daemon_pid='' + app_pid='' + trap _shutdown EXIT + trap 'exit 143' TERM + trap 'exit 130' INT + # Keep daemon logs on stderr and expose only the local Unix socket. + setsid dockerd --host "$DOCKER_HOST" --group docker >&2 & + daemon_pid=$! + _wait_for_docker + setsid setpriv --reuid py --regid py --init-groups \ + env HOME="$(getent passwd py | cut -d: -f6)" USER=py LOGNAME=py \ + /pkg/application-entrypoint.sh "$@" & + app_pid=$! + local finished status=0 + wait -n -p finished "$daemon_pid" "$app_pid" || status=$? + if [[ "$finished" == "$daemon_pid" ]]; then + echo >&2 'docker-in-docker daemon exited while the app was running.' + return 1 + fi + return "$status" +} + +_main "$@" diff --git a/src/common_python_tasks/env.py b/src/common_python_tasks/env.py index db4c20d..9f21aa3 100644 --- a/src/common_python_tasks/env.py +++ b/src/common_python_tasks/env.py @@ -227,6 +227,40 @@ def resolve_extension_content(descriptor: dict[str, str | None]) -> str: utils.fatal(f"Unknown extension descriptor source: {descriptor['source']}") +def resolve_extension_build_context( + descriptor: dict[str, str | None], +) -> tuple[str, Path] | None: + """Return the named build context for a bundled extension's asset directory. + + Args: + descriptor: Extension descriptor dictionary containing `source` and + `bundle_name` values. + + Returns: + A Docker build-context name and its directory, or `None` when the + extension has no packaged assets. + """ + if descriptor["source"] != "bundle": + return None + bundle_name = descriptor["bundle_name"] + out = utils.load_data_file( + f"{bundle_name}/Dockerfile", + type_identifier="dockerfile_extensions", + fatal_on_missing=False, + ) + if out is None: + utils.fatal(f"Extension bundle not found: {bundle_name}") + bundle_directory = out[0].parent + if not bundle_directory.is_dir() or not any( + path.name != "Dockerfile" for path in bundle_directory.iterdir() + ): + return None + return ( + f"cpt-extension-{re.sub(r'[^a-z0-9]+', '-', bundle_name.lower()).strip('-')}", + bundle_directory, + ) + + def get_cache_id_suffix(no_cache: bool) -> str: """Return a cache-break suffix for Docker cache mount IDs. Args: diff --git a/src/common_python_tasks/tasks.py b/src/common_python_tasks/tasks.py index d3a5ea1..308ce38 100644 --- a/src/common_python_tasks/tasks.py +++ b/src/common_python_tasks/tasks.py @@ -541,6 +541,7 @@ def build_image( resolve_container_docker_build_args, resolve_container_dockerfile_hook_path, resolve_container_dockerfile_path, + resolve_extension_build_context, resolve_extension_content, uv_index_secret_build_args, uv_index_secret_mounts, @@ -571,6 +572,11 @@ def build_image( # Resolve all extension fragments up-front so we fail fast on missing # bundles or files and avoid calling resolution logic multiple times. resolved_fragments = [resolve_extension_content(desc) for desc in extensions] + extension_build_contexts = [ + context + for desc in extensions + if (context := resolve_extension_build_context(desc)) is not None + ] resolved_docker_build_args = resolve_container_docker_build_args( docker_build_args, @@ -740,7 +746,14 @@ def build_image( plain=plain, single_arch=single_arch, extra_build_args=merged_build_args or None, - docker_build_args=resolved_docker_build_args, + docker_build_args=[ + *resolved_docker_build_args, + *( + item + for name, path in extension_build_contexts + for item in ("--build-context", f"{name}={path}") + ), + ], dockerfile_hook_path=resolved_dockerfile_hook_path, ) diff --git a/tests/test_build_image.py b/tests/test_build_image.py index bb525c0..94077d3 100644 --- a/tests/test_build_image.py +++ b/tests/test_build_image.py @@ -131,6 +131,49 @@ def tracking(command, *args, **kwargs): ].index("RUN echo ext2") +def test_bundled_extension_assets_are_passed_as_named_context( + temp_project_dir, + docker_build_harness, + mock_load_data_file, + mock_get_image_tag, + mock_get_authors, + mock_get_package_name, + monkeypatch, +): + """Bundled extension assets should be available to the application build.""" + from common_python_tasks.tasks import build_image + + bundle_directory = ( + Path(__file__).parents[1] + / "src/common_python_tasks/data/dockerfile_extensions/docker-in-docker" + ) + original_load_data_file = mock_load_data_file.side_effect + + def load_data_file_with_dind( + filename, type_identifier="generic", fatal_on_missing=True + ): + if ( + filename == "docker-in-docker/Dockerfile" + and type_identifier == "dockerfile_extensions" + ): + return ( + bundle_directory / "Dockerfile", + (bundle_directory / "Dockerfile").read_text(encoding="utf-8"), + ) + return original_load_data_file(filename, type_identifier, fatal_on_missing) + + mock_load_data_file.side_effect = load_data_file_with_dind + monkeypatch.setenv("CONTAINER_EXTENSIONS", "docker-in-docker") + + build_image() + + assert "--build-context" in docker_build_harness.commands[-1] + assert ( + f"cpt-extension-docker-in-docker={bundle_directory}" + in docker_build_harness.commands[-1] + ) + + def test_prune_removes_base_images_when_enabled( temp_project_dir, mock_run_command, diff --git a/tests/test_docker_in_docker.py b/tests/test_docker_in_docker.py index 5611acb..c0dbf05 100644 --- a/tests/test_docker_in_docker.py +++ b/tests/test_docker_in_docker.py @@ -7,6 +7,7 @@ from common_python_tasks.env import ( parse_container_extensions, + resolve_extension_build_context, resolve_extension_content, ) from common_python_tasks.utils import load_data_file, render_template_text @@ -18,6 +19,12 @@ def _extension(): )[1] +def _extension_context(): + return load_data_file( + "docker-in-docker/Dockerfile", type_identifier="dockerfile_extensions" + )[0].parent + + def test_dind_bundle_resolves_without_project_files(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) monkeypatch.delenv("CONTAINER_EXTENSION_FILES", raising=False) @@ -26,6 +33,25 @@ def test_dind_bundle_resolves_without_project_files(monkeypatch, tmp_path): ) assert resolve_extension_content(parse_container_extensions()[0]) == _extension() assert parse_container_extensions()[0]["args"] == "5:29.8.1-1~debian.12~bookworm" + assert resolve_extension_build_context(parse_container_extensions()[0]) == ( + "cpt-extension-docker-in-docker", + _extension_context(), + ) + + +def test_dind_scripts_are_packaged_as_separate_assets(): + extension = _extension() + assert "<<'INSTALL_DIND'" not in extension + assert "<<'DIND_ENTRYPOINT'" not in extension + assert "COPY --from=cpt-extension-docker-in-docker" in extension + assert (_extension_context() / "install.sh").is_file() + assert (_extension_context() / "supervisor.sh").is_file() + + +def test_cgroup_migration_suppresses_expected_write_races(): + supervisor = (_extension_context() / "supervisor.sh").read_text(encoding="utf-8") + assert "/sys/fs/cgroup/cpt-init/cgroup.procs >/dev/null 2>&1 || true" in supervisor + assert "/sys/fs/cgroup/cgroup.subtree_control >/dev/null 2>&1" in supervisor @pytest.mark.parametrize("debug", [False, True]) @@ -102,6 +128,8 @@ def dind_image(tmp_path_factory): "PYTHON_VARIANT=slim-bookworm", "--build-arg", "PACKAGE_NAME=dind_smoke", + "--build-context", + f"cpt-extension-docker-in-docker={_extension_context()}", "--tag", image, str(context), @@ -148,6 +176,7 @@ def test_dind_runs_nested_workload_as_py(dind_image, dind_container): ) assert result.returncode == 0, result.stdout + result.stderr assert "nested-ok" in result.stdout + assert "write error: Device or resource busy" not in result.stderr def test_dind_preserves_application_exit_code(dind_image, dind_container): @@ -204,7 +233,13 @@ def test_dind_startup_timeout_prevents_application_start( def test_dind_rejects_unsupported_distribution(dind_image, tmp_path): (tmp_path / "Dockerfile").write_text("FROM alpine:3.22\n" + _extension()) - result = _docker("build", str(tmp_path), check=False) + result = _docker( + "build", + "--build-context", + f"cpt-extension-docker-in-docker={_extension_context()}", + str(tmp_path), + check=False, + ) assert result.returncode != 0 assert "requires Debian Bookworm" in result.stderr diff --git a/tests/test_env.py b/tests/test_env.py index 3e44ca9..ae5ef9b 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -382,6 +382,41 @@ def test_file_extension_without_placeholder_and_no_args(self, tmp_path): assert content == "RUN echo hello\n" +class TestResolveExtensionBuildContext: + """Tests for packaged extension asset build contexts.""" + + def test_bundle_assets_return_named_context(self): + from common_python_tasks.env import resolve_extension_build_context + + descriptor = { + "id": "docker-in-docker", + "source": "bundle", + "path": None, + "bundle_name": "docker-in-docker", + "args": None, + } + name, path = resolve_extension_build_context(descriptor) + assert name == "cpt-extension-docker-in-docker" + assert (path / "install.sh").is_file() + assert (path / "supervisor.sh").is_file() + + def test_project_extension_has_no_managed_context(self): + from common_python_tasks.env import resolve_extension_build_context + + assert ( + resolve_extension_build_context( + { + "id": "custom", + "source": "file", + "path": "Dockerfile.custom", + "bundle_name": None, + "args": None, + } + ) + is None + ) + + class TestParseContainerDeps: """Tests for parse_container_deps env var handling."""