From 5785a0d1ed143150b5845dbf4f4f7e845fa0deb7 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Jun 2026 21:28:01 +0000 Subject: [PATCH 1/6] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/box/issues/94 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..65e712e --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-06-09T21:28:01.405Z for PR creation at branch issue-94-c1c4a75d3b0d for issue https://github.com/link-foundation/box/issues/94 \ No newline at end of file From 09685c232a9f8f1805487a68109b9a64dfe63be1 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Jun 2026 21:39:57 +0000 Subject: [PATCH 2/6] feat(dind): preload host images into the nested daemon (issue #94) The nested dockerd starts with an empty image store, so a fresh box-dind container re-downloads images the host already has on the first inner 'docker run'. Add a documented startup preload hook to dind-entrypoint.sh: - DIND_PRELOAD_TARBALL: docker load 'docker save' tarballs and/or directories of *.tar into the inner daemon once it is ready. - DIND_PRELOAD_IMAGES: docker pull registry/mirror references, skipping any image already present. Preload is non-fatal, daemon-gated, and skipped when DIND_SKIP_DAEMON=1. Documented in docs/dind/USAGE.md and README, covered by tests/dind/example-preload-images.sh (wired into pr-test-dind CI) and experiments/preload-unit-test.sh. Fixes #94 --- .changeset/dind-preload-images.md | 10 +++ .github/workflows/release.yml | 1 + README.md | 1 + docs/case-studies/issue-94/CASE-STUDY.md | 86 ++++++++++++++++++ docs/case-studies/issue-94/issue.md | 46 ++++++++++ docs/dind/USAGE.md | 85 ++++++++++++++++++ experiments/preload-unit-test.sh | 106 +++++++++++++++++++++++ tests/dind/example-preload-images.sh | 76 ++++++++++++++++ ubuntu/24.04/dind/dind-entrypoint.sh | 78 +++++++++++++++++ 9 files changed, 489 insertions(+) create mode 100644 .changeset/dind-preload-images.md create mode 100644 docs/case-studies/issue-94/CASE-STUDY.md create mode 100644 docs/case-studies/issue-94/issue.md create mode 100755 experiments/preload-unit-test.sh create mode 100755 tests/dind/example-preload-images.sh diff --git a/.changeset/dind-preload-images.md b/.changeset/dind-preload-images.md new file mode 100644 index 0000000..d240833 --- /dev/null +++ b/.changeset/dind-preload-images.md @@ -0,0 +1,10 @@ +--- +bump: minor +--- + +dind-box: add a documented startup preload hook so the nested daemon no longer +re-downloads images the host already has (issue #94). `DIND_PRELOAD_TARBALL` +loads `docker save` tarballs (or directories of `*.tar`) into the inner daemon +once it is ready, and `DIND_PRELOAD_IMAGES` pulls registry/mirror references, +skipping any image that is already present. Covered by +`tests/dind/example-preload-images.sh` and documented in `docs/dind/USAGE.md`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2e5d47..232d057 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -892,6 +892,7 @@ jobs: tests/dind/example-commit-cycle.sh tests/dind/example-sudoers-extension.sh tests/dind/example-storage-driver-vfs.sh + tests/dind/example-preload-images.sh echo "=== Documented dind examples passed ===" # --- Aggregator: single status check for branch protection --- diff --git a/README.md b/README.md index 593a833..3d6fa09 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ Each row below has the same toolchain as its non-dind sibling **plus** a working > - **Recommended secure invocation:** [`docker run --runtime=sysbox-runc konard/box-dind`](https://github.com/nestybox/sysbox) — Sysbox is a drop-in OCI runtime that runs system containers without `--privileged` and without exposing host devices. > - **Do NOT bind-mount `/var/run/docker.sock`.** That gives the container root on the host ([Quarkslab](https://blog.quarkslab.com/why-is-exposing-the-docker-socket-a-really-bad-idea.html), [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html)) and breaks the per-box `docker ps` scoping property. > - **Storage:** the inner daemon writes to `/var/lib/docker` inside the container by default. For persistence, mount a volume: `-v box-dind-data:/var/lib/docker`. +> - **Reusing host images:** the nested daemon starts with an empty image store, so a fresh container re-downloads images the host already has. Seed it at startup with `DIND_PRELOAD_TARBALL` (mount `docker save` tarballs) or `DIND_PRELOAD_IMAGES` (pull from a registry/mirror). See [Reusing Host Images](docs/dind/USAGE.md#reusing-host-images-preload). > - **Usage examples:** see [`docs/dind/USAGE.md`](docs/dind/USAGE.md). Its examples are backed by executable tests under `tests/dind/`. See [docs/case-studies/issue-80/CASE-STUDY.md](docs/case-studies/issue-80/CASE-STUDY.md) for the full design and threat model. diff --git a/docs/case-studies/issue-94/CASE-STUDY.md b/docs/case-studies/issue-94/CASE-STUDY.md new file mode 100644 index 0000000..9d94adc --- /dev/null +++ b/docs/case-studies/issue-94/CASE-STUDY.md @@ -0,0 +1,86 @@ +# Case Study: Issue #94 — dind-box nested daemon starts with an empty image store + +## Executive Summary + +Issue [#94](https://github.com/link-foundation/box/issues/94) reports the classic +Docker-in-Docker image-cache pitfall in the `konard/box-dind` family: the nested +`dockerd` started by [`dind-entrypoint.sh`](../../../ubuntu/24.04/dind/dind-entrypoint.sh) +boots with an **empty image store**. The first `docker run ` *inside* a +fresh container therefore reports `Unable to find image '' locally` and +pulls a full copy from the registry — even when the **host** daemon already has +that exact image. For multi-GB images this re-download happens on every fresh +container and is pure waste. + +The original `issue.md` is preserved [here](./issue.md). Downstream report: +[link-assistant/hive-mind#1879](https://github.com/link-assistant/hive-mind/issues/1879). + +## 1. Why the inner store is empty (and why that is correct) + +Each dind-box owns its **own** `dockerd` with its own `--data-root` +(`/var/lib/docker` inside the container). This is the deliberate isolation +property from issue #80: `docker ps -a` inside a box lists only that box's +children, and the inner daemon never touches the host image store or socket. + +Isolation and cache-sharing are in tension. The inner daemon cannot see the host +images precisely because it is isolated. So the fix must be **opt-in seeding**, +not automatic socket/store sharing (which would re-introduce the +Docker-outside-of-Docker security problems the project already rejects — see the +issue #80 case study and the "Host Prerequisites" notes in `docs/dind/USAGE.md`). + +This matches jpetazzo's well-known +[*"Using Docker-in-Docker for your CI… is it a good idea?"*](https://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/), +which calls out the duplicated image cache as the canonical DinD gotcha. + +## 2. Prior workaround (what downstream did) + +Downstream seeded the nested daemon by streaming a host `docker save` into the +container's `docker load`, via a bespoke helper +([`preload-dind-isolation-image.mjs`](https://github.com/link-assistant/hive-mind/blob/main/scripts/preload-dind-isolation-image.mjs)). +That works but every consumer has to reinvent it; the issue asks to make image +reuse a first-class, documented capability of `box-dind`. + +## 3. Solution — a documented startup preload hook (issue option 1) + +The entrypoint now seeds the nested daemon **after dockerd is ready** and before +it hands off to the normal box entrypoint, driven by two environment variables: + +| Variable | Behavior | +| --- | --- | +| `DIND_PRELOAD_TARBALL` | Space-separated list of `docker save` tarball files and/or directories. Each file is `docker load`-ed; each directory loads every `*.tar` inside. This is the zero-network path for reusing host images. | +| `DIND_PRELOAD_IMAGES` | Space-separated image references. Each is `docker pull`-ed, but only when `docker image inspect` shows it is not already present — so it is idempotent and free when a volume or tarball already provided the image. | + +Design choices, all consistent with the existing entrypoint: + +- **Non-fatal.** A bad path, an unreadable tarball, or a failed pull logs a + `WARN` and continues; the user shell still starts. The entrypoint already + treats dockerd startup failures the same way. +- **Daemon-gated.** Preload is attempted only when `docker info` succeeds, and is + skipped entirely (with a warning) when `DIND_SKIP_DAEMON=1`, since there is no + inner daemon to load into. +- **Order.** Tarballs load before registry pulls, so a tarball-provided image + short-circuits the matching `DIND_PRELOAD_IMAGES` pull. +- **Bake or mount.** Operators can mount a tarball/directory at runtime, or + `COPY` a tarball into a derived image and set `ENV DIND_PRELOAD_TARBALL=…` so + every container starts warm. + +## 4. Verification + +- **Integration example:** [`tests/dind/example-preload-images.sh`](../../../tests/dind/example-preload-images.sh) + builds an offline fixture image with `docker import` (no registry pull), saves + it to a tarball, and asserts it is present in the **inner** daemon as soon as + the container is ready — for both the single-file and directory forms — and + that `DIND_PRELOAD_IMAGES` skips the redundant pull. Wired into the + `pr-test-dind` CI job alongside the other documented dind examples. +- **Isolated unit test:** [`experiments/preload-unit-test.sh`](../../../experiments/preload-unit-test.sh) + extracts the preload functions from the real entrypoint and drives them with a + mock `docker`, covering load/pull/skip/daemon-down/no-op/missing-path branches. + This runs anywhere (the CI sandbox only has the `vfs` storage driver, which + cannot build the full overlay-backed dind image). + +## 5. Files changed + +- `ubuntu/24.04/dind/dind-entrypoint.sh` — preload hook + env documentation. +- `docs/dind/USAGE.md` — "Reusing Host Images (Preload)" section + env table rows. +- `README.md` — security-model note pointing at the preload section. +- `tests/dind/example-preload-images.sh` — executable example, run in CI. +- `experiments/preload-unit-test.sh` — isolated branch coverage. diff --git a/docs/case-studies/issue-94/issue.md b/docs/case-studies/issue-94/issue.md new file mode 100644 index 0000000..db03a4e --- /dev/null +++ b/docs/case-studies/issue-94/issue.md @@ -0,0 +1,46 @@ +## Summary + +When `konard/box-dind` runs its nested Docker daemon, the nested daemon starts with an **empty image store**. Any `docker run ` issued *inside* the container therefore reports `Unable to find image '' locally` and pulls a fresh, full copy from the registry — even when the **host** daemon already has that exact image. For large images (e.g. our `konard/hive-mind-dind`, multiple GB) this re-download happens on the first nested `docker run` of every fresh container. + +This is the well-known Docker-in-Docker pitfall described in jpetazzo's "Using Docker-in-Docker for your CI… is it a good idea?" — the inner Docker has its own image cache and will re-download images. + +Downstream report: https://github.com/link-assistant/hive-mind/issues/1879 + +## Reproduction + +```sh +# Host already has the image: +docker pull alpine:3.20 + +# Start a box-dind container and wait for the nested dockerd to be ready: +docker run -d --privileged --name dind-test konard/box-dind:latest +sleep 20 # wait for dind-entrypoint.sh to bring dockerd up + +# The nested daemon does NOT see the host image — it pulls a fresh copy: +docker exec dind-test docker run --rm alpine:3.20 echo hi +# => Unable to find image 'alpine:3.20' locally +# 3.20: Pulling from library/alpine ... +``` + +## Workaround (what downstream does today) + +Seed the nested daemon from the host with `docker save | docker load`: + +```sh +docker save alpine:3.20 | docker exec -i dind-test docker load +docker exec dind-test docker run --rm alpine:3.20 echo hi # now reused, no pull +``` + +We added a helper script that does exactly this for our deployment: +https://github.com/link-assistant/hive-mind/blob/main/scripts/preload-dind-isolation-image.mjs + +## Suggested fix / enhancement + +Make image reuse a first-class, documented capability of `box-dind` so consumers don't each reinvent it. Options, in rough order of preference: + +1. **Documented startup pre-load hook.** Support an env var (e.g. `DIND_PRELOAD_IMAGES` and/or `DIND_PRELOAD_TARBALL=/path/to/images.tar`) that `dind-entrypoint.sh` loads into the nested daemon (via `docker load`) after dockerd is ready. This lets an operator bake or mount a tarball and have it auto-loaded. +2. **Optional host-image passthrough.** Document a supported pattern for sharing the host image store / socket when isolation between inner and outer daemon is not required (with the security caveats spelled out), so reuse is free. +3. **Docs.** At minimum, add a "the nested daemon starts empty; here is how to reuse host images (`docker save | docker load`, or a local registry mirror)" section to the README, since this surprises every new consumer. + +Happy to send a PR for option 1 (entrypoint pre-load hook) if that direction is acceptable. + diff --git a/docs/dind/USAGE.md b/docs/dind/USAGE.md index 2a43b7b..eadd13b 100644 --- a/docs/dind/USAGE.md +++ b/docs/dind/USAGE.md @@ -69,6 +69,8 @@ The entrypoint supports these environment variables: | `DIND_LOG_FILE` | `/var/log/dockerd.log` | Write dockerd logs to this path. | | `DIND_WAIT_SECONDS` | `30` | Wait this many seconds for dockerd readiness. | | `DIND_SKIP_DAEMON` | `0` | Set to `1` to skip dockerd startup. | +| `DIND_PRELOAD_TARBALL` | _(empty)_ | Space-separated `docker save` tarballs and/or directories of `*.tar` to `docker load` into the nested daemon once it is ready. | +| `DIND_PRELOAD_IMAGES` | _(empty)_ | Space-separated image references to `docker pull` into the nested daemon once it is ready, skipping any that are already present. | Use a named volume when the inner Docker state should survive container removal: @@ -80,6 +82,88 @@ docker run -d --privileged \ konard/box-dind sleep infinity ``` +## Reusing Host Images (Preload) + +The nested daemon starts with an **empty image store**. By default a +`docker run ` *inside* the container reports +`Unable to find image '' locally` and pulls a fresh copy from the +registry — even when the host daemon already has that exact image (issue #94). +This is the well-known [Docker-in-Docker image-cache pitfall][jpetazzo]. + +The entrypoint can seed the nested daemon at startup so no re-download happens. + +### `DIND_PRELOAD_TARBALL` — load `docker save` tarballs (reuse host images) + +On the host, save the image you already have to a tarball, mount it into the +container, and point `DIND_PRELOAD_TARBALL` at it. The entrypoint loads it with +`docker load` as soon as the inner daemon is ready, before your workload runs: + +```bash +# Host already has the image; export it without a registry round-trip: +docker pull alpine:3.20 +docker save alpine:3.20 -o /tmp/preload/alpine.tar + +docker run -d --privileged \ + -v /tmp/preload:/preload:ro \ + -e DIND_PRELOAD_TARBALL=/preload/alpine.tar \ + --name box-dind \ + konard/box-dind sleep infinity + +until docker exec box-dind docker info >/dev/null 2>&1; do sleep 1; done + +# No "Unable to find image locally" — it was preloaded, not pulled: +docker exec box-dind docker run --rm alpine:3.20 echo hi +``` + +`DIND_PRELOAD_TARBALL` accepts a space-separated list. Any entry that is a +directory loads every `*.tar` file inside it, so you can mount a whole folder of +saved images: + +```bash +docker run -d --privileged \ + -v /tmp/preload:/preload:ro \ + -e DIND_PRELOAD_TARBALL=/preload \ + --name box-dind \ + konard/box-dind sleep infinity +``` + +You can also bake a tarball into a derived image so every container starts warm: + +```dockerfile +FROM konard/box-dind +USER root +COPY images.tar /opt/preload/images.tar +ENV DIND_PRELOAD_TARBALL=/opt/preload/images.tar +USER box +ENV HOME=/home/box +``` + +### `DIND_PRELOAD_IMAGES` — warm the cache from a registry + +When the source is a registry or pull-through mirror rather than a tarball, list +the references in `DIND_PRELOAD_IMAGES`. The entrypoint pulls each one after the +daemon is ready, but skips any image that is already present (for example one a +mounted `/var/lib/docker` volume or a `DIND_PRELOAD_TARBALL` already provided): + +```bash +docker run -d --privileged \ + -e DIND_PRELOAD_IMAGES="alpine:3.20 busybox:1.36" \ + --name box-dind \ + konard/box-dind sleep infinity +``` + +Preload failures are non-fatal: the entrypoint logs a warning and continues so +the container shell still starts. Preload is skipped entirely when +`DIND_SKIP_DAEMON=1`, since there is no inner daemon to load into. + +CI covers this behavior here: + +```bash +DIND_IMAGE=box-dind-js tests/dind/example-preload-images.sh +``` + +[jpetazzo]: https://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/ + ## Commit Cycles `DIND_SKIP_DAEMON=1` is useful for setup containers where you want to install or @@ -195,6 +279,7 @@ DIND_IMAGE=box-dind-js tests/dind/example-basic-docker-ps.sh DIND_IMAGE=box-dind-js tests/dind/example-commit-cycle.sh DIND_IMAGE=box-dind-js tests/dind/example-sudoers-extension.sh DIND_IMAGE=box-dind-js tests/dind/example-storage-driver-vfs.sh +DIND_IMAGE=box-dind-js tests/dind/example-preload-images.sh ``` Set `DIND_KEEP_CONTAINERS=1` while debugging to keep the temporary containers diff --git a/experiments/preload-unit-test.sh b/experiments/preload-unit-test.sh new file mode 100755 index 0000000..9aff267 --- /dev/null +++ b/experiments/preload-unit-test.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Isolated unit test for the issue #94 preload hook in dind-entrypoint.sh. +# +# Building the full box-dind image requires overlay-backed nested Docker; this +# sandbox only has the vfs storage driver, which exhausts disk. So instead we +# extract the preload functions from the real entrypoint and drive them with a +# mock `docker` that records every call, asserting the load/pull/skip behavior. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENTRYPOINT="$SCRIPT_DIR/../ubuntu/24.04/dind/dind-entrypoint.sh" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# --- Extract just the preload functions (lines 198..259) from the entrypoint --- +FUNCS="$WORK/funcs.sh" +sed -n '198,259p' "$ENTRYPOINT" > "$FUNCS" + +# --- Mock docker on PATH; records calls and simulates state --- +mkdir -p "$WORK/bin" +cat > "$WORK/bin/docker" <<'MOCK' +#!/usr/bin/env bash +echo "$*" >> "$DOCKER_CALLS" +case "$1" in + info) [ "${DOCKER_INFO_OK:-1}" = "1" ] && exit 0 || exit 1 ;; + image) + # image inspect + ref="$3" + grep -qxF "$ref" "$DOCKER_PRESENT" 2>/dev/null && exit 0 || exit 1 ;; + load) + # docker load -i -> mark a sentinel image present + echo "loaded:$3" >> "$DOCKER_LOADED"; exit 0 ;; + pull) + echo "$2" >> "$DOCKER_PULLED"; echo "$2" >> "$DOCKER_PRESENT"; exit 0 ;; + *) exit 0 ;; +esac +MOCK +chmod +x "$WORK/bin/docker" +export PATH="$WORK/bin:$PATH" + +export DOCKER_CALLS="$WORK/calls.log" +export DOCKER_LOADED="$WORK/loaded.log" +export DOCKER_PULLED="$WORK/pulled.log" +export DOCKER_PRESENT="$WORK/present.log" + +log() { echo "[test] $*"; } +warn() { echo "[test] WARN: $*" >&2; } + +# shellcheck disable=SC1090 +source "$FUNCS" + +pass=0; fail=0 +reset_state() { : > "$DOCKER_CALLS"; : > "$DOCKER_LOADED"; : > "$DOCKER_PULLED"; : > "$DOCKER_PRESENT"; } +check() { # check + desc="$1"; shift + if "$@"; then echo " PASS: $desc"; pass=$((pass+1)); else echo " FAIL: $desc"; fail=$((fail+1)); fi +} + +echo "== Case 1: single tarball file is loaded ==" +reset_state +touch "$WORK/image.tar" +DIND_PRELOAD_TARBALL="$WORK/image.tar" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 preload_into_daemon +check "docker load called for the tarball" grep -q "load -i $WORK/image.tar" "$DOCKER_CALLS" + +echo "== Case 2: directory loads every *.tar inside ==" +reset_state +mkdir -p "$WORK/dir" +touch "$WORK/dir/a.tar" "$WORK/dir/b.tar" "$WORK/dir/ignore.txt" +DIND_PRELOAD_TARBALL="$WORK/dir" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 preload_into_daemon +check "a.tar loaded" grep -q "load -i $WORK/dir/a.tar" "$DOCKER_CALLS" +check "b.tar loaded" grep -q "load -i $WORK/dir/b.tar" "$DOCKER_CALLS" +check "ignore.txt not loaded" bash -c '! grep -q "ignore.txt" "$DOCKER_CALLS"' + +echo "== Case 3: DIND_PRELOAD_IMAGES pulls a missing image ==" +reset_state +DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="alpine:3.20" DOCKER_INFO_OK=1 preload_into_daemon +check "missing image was pulled" grep -qx "alpine:3.20" "$DOCKER_PULLED" + +echo "== Case 4: DIND_PRELOAD_IMAGES skips an already-present image ==" +reset_state +echo "alpine:3.20" > "$DOCKER_PRESENT" +DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="alpine:3.20" DOCKER_INFO_OK=1 preload_into_daemon +check "present image was NOT pulled" bash -c '! test -s "$DOCKER_PULLED"' + +echo "== Case 5: nothing happens when daemon is not ready ==" +reset_state +touch "$WORK/image.tar" +DIND_PRELOAD_TARBALL="$WORK/image.tar" DIND_PRELOAD_IMAGES="alpine:3.20" DOCKER_INFO_OK=0 preload_into_daemon +check "no load attempted when daemon down" bash -c '! grep -q "load -i" "$DOCKER_CALLS"' +check "no pull attempted when daemon down" bash -c '! test -s "$DOCKER_PULLED"' + +echo "== Case 6: no-op when neither var is set (no docker info probe) ==" +reset_state +DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 preload_into_daemon +check "no docker calls at all" bash -c '! test -s "$DOCKER_CALLS"' + +echo "== Case 7: missing tarball path warns, no load ==" +reset_state +DIND_PRELOAD_TARBALL="$WORK/does-not-exist.tar" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 preload_into_daemon 2>"$WORK/err.log" +check "no load for missing path" bash -c '! grep -q "load -i" "$DOCKER_CALLS"' +check "warning emitted for missing path" grep -q "does not exist" "$WORK/err.log" + +echo +echo "RESULT: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/tests/dind/example-preload-images.sh b/tests/dind/example-preload-images.sh new file mode 100755 index 0000000..c4210a4 --- /dev/null +++ b/tests/dind/example-preload-images.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Issue #94: the nested daemon starts with an empty image store, so the first +# `docker run ` inside the container re-downloads an image the host +# already has. This example proves the DIND_PRELOAD_TARBALL / DIND_PRELOAD_IMAGES +# entrypoint hook seeds the nested daemon at startup so no re-download is needed. +# +# The fixture image is built fully offline with `docker import` (no registry +# pull), saved to a tarball on the host, mounted into the dind container, and +# expected to be present in the *inner* daemon as soon as it is ready. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=tests/dind/lib.sh +source "$SCRIPT_DIR/lib.sh" + +require_docker + +fixture_image="preload-fixture-${DIND_EXAMPLE_ID}:issue94" +file_container="${DIND_EXAMPLE_ID}-preload-file" +dir_container="${DIND_EXAMPLE_ID}-preload-dir" + +tarball_dir="$(mktemp -d)" +register_temp_dir "$tarball_dir" +register_image "$fixture_image" + +log "building an offline fixture image with docker import (no registry pull)" +rootfs_dir="$(mktemp -d)" +register_temp_dir "$rootfs_dir" +echo "issue-94 preload fixture" > "$rootfs_dir/marker.txt" +tar -C "$rootfs_dir" -cf "$tarball_dir/rootfs.tar" marker.txt +docker import "$tarball_dir/rootfs.tar" "$fixture_image" >/dev/null + +log "saving the fixture image to a tarball the way a host would seed it" +docker save "$fixture_image" -o "$tarball_dir/image.tar" + +# The inner daemon loads the tarball as the box user, whose uid differs from the +# host creator of this temp dir. Make the mounted bind readable for everyone so +# the load is not blocked by host-side permissions. +chmod -R a+rX "$tarball_dir" + +assert_inner_has_image() { + local container="$1" + if ! docker exec "$container" docker image inspect "$fixture_image" >/dev/null 2>&1; then + docker exec "$container" docker images >&2 || true + fail "expected ${fixture_image} to be preloaded in the inner daemon of ${container}" + fi +} + +# --- DIND_PRELOAD_TARBALL pointing at a single tarball file --- +log "starting ${DIND_IMAGE} with DIND_PRELOAD_TARBALL=/preload/image.tar" +run_dind_container "$file_container" \ + -e DIND_PRELOAD_TARBALL=/preload/image.tar \ + -v "$tarball_dir:/preload:ro" +wait_for_inner_docker "$file_container" +assert_inner_has_image "$file_container" +log "single-tarball preload made ${fixture_image} available without a pull" + +# --- DIND_PRELOAD_TARBALL pointing at a directory of *.tar files, plus the +# DIND_PRELOAD_IMAGES skip-when-present branch (no network pull happens +# because the tarball already seeded the image). --- +log "starting ${DIND_IMAGE} with DIND_PRELOAD_TARBALL=/preload (directory form)" +run_dind_container "$dir_container" \ + -e DIND_PRELOAD_TARBALL=/preload \ + -e "DIND_PRELOAD_IMAGES=$fixture_image" \ + -v "$tarball_dir:/preload:ro" +wait_for_inner_docker "$dir_container" +assert_inner_has_image "$dir_container" + +if ! docker logs "$dir_container" 2>&1 | grep -q "preload image already present, skipping pull"; then + docker logs "$dir_container" >&2 || true + fail "expected DIND_PRELOAD_IMAGES to skip the pull for an already-loaded image" +fi +log "directory preload loaded the tarball and DIND_PRELOAD_IMAGES skipped the redundant pull" + +log "preload example passed" diff --git a/ubuntu/24.04/dind/dind-entrypoint.sh b/ubuntu/24.04/dind/dind-entrypoint.sh index 61127ac..58920a5 100644 --- a/ubuntu/24.04/dind/dind-entrypoint.sh +++ b/ubuntu/24.04/dind/dind-entrypoint.sh @@ -21,6 +21,16 @@ # DIND_LOG_FILE Where to write dockerd logs (default: /var/log/dockerd.log) # DIND_WAIT_SECONDS How long to wait for dockerd to come up (default: 30) # DIND_SKIP_DAEMON If set to "1", do not start dockerd (use for DooD/Sysbox-only mode) +# DIND_PRELOAD_TARBALL Space-separated list of image tarball files and/or +# directories to `docker load` into the nested daemon +# once it is ready. Directories load every *.tar inside. +# This is how you reuse host images without re-downloading +# them: `docker save img | ... ` on the host, mount the +# tarball, and point this at it. (issue #94) +# DIND_PRELOAD_IMAGES Space-separated list of image references to `docker pull` +# into the nested daemon once it is ready, but only when +# the image is not already present. Useful to warm the +# cache from a registry or pull-through mirror. (issue #94) set -eu @@ -29,6 +39,8 @@ DIND_DATA_ROOT="${DIND_DATA_ROOT:-/var/lib/docker}" DIND_LOG_FILE="${DIND_LOG_FILE:-/var/log/dockerd.log}" DIND_WAIT_SECONDS="${DIND_WAIT_SECONDS:-30}" DIND_SKIP_DAEMON="${DIND_SKIP_DAEMON:-0}" +DIND_PRELOAD_TARBALL="${DIND_PRELOAD_TARBALL:-}" +DIND_PRELOAD_IMAGES="${DIND_PRELOAD_IMAGES:-}" log() { echo "[dind-entrypoint] $*"; } warn() { echo "[dind-entrypoint] WARN: $*" >&2; } @@ -183,10 +195,76 @@ start_dockerd() { return 0 } +load_one_tarball() { + tarball="$1" + if [ ! -r "$tarball" ]; then + warn "preload tarball is not readable: ${tarball}" + return 1 + fi + log "Loading images from tarball ${tarball}" + if docker load -i "$tarball"; then + return 0 + fi + warn "docker load failed for tarball ${tarball}" + return 1 +} + +preload_tarballs() { + [ -n "$DIND_PRELOAD_TARBALL" ] || return 0 + + for entry in $DIND_PRELOAD_TARBALL; do + if [ -d "$entry" ]; then + loaded_any=0 + for tarball in "$entry"/*.tar; do + [ -e "$tarball" ] || continue + loaded_any=1 + load_one_tarball "$tarball" || true + done + if [ "$loaded_any" -eq 0 ]; then + warn "preload directory has no *.tar files: ${entry}" + fi + elif [ -e "$entry" ]; then + load_one_tarball "$entry" || true + else + warn "preload tarball path does not exist: ${entry}" + fi + done +} + +preload_images() { + [ -n "$DIND_PRELOAD_IMAGES" ] || return 0 + + for image in $DIND_PRELOAD_IMAGES; do + if docker image inspect "$image" >/dev/null 2>&1; then + log "preload image already present, skipping pull: ${image}" + continue + fi + log "Pulling preload image ${image}" + if ! docker pull "$image"; then + warn "docker pull failed for preload image ${image}" + fi + done +} + +preload_into_daemon() { + [ -n "$DIND_PRELOAD_TARBALL" ] || [ -n "$DIND_PRELOAD_IMAGES" ] || return 0 + + if ! docker info >/dev/null 2>&1; then + warn "Skipping image preload because the nested dockerd is not ready" + return 0 + fi + + preload_tarballs + preload_images +} + if [ "$DIND_SKIP_DAEMON" != "1" ]; then if ! start_dockerd; then warn "dockerd startup failed. Use --user root, check /etc/sudoers.d/box-dind, or set DIND_SKIP_DAEMON=1 to silence." fi + preload_into_daemon +elif [ -n "$DIND_PRELOAD_TARBALL" ] || [ -n "$DIND_PRELOAD_IMAGES" ]; then + warn "DIND_PRELOAD_* is set but DIND_SKIP_DAEMON=1; nothing will be preloaded" fi # Ensure the docker socket is group-readable for the box user. From a79114867f4d034acea9962019d5f2554166af5d Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Jun 2026 21:41:59 +0000 Subject: [PATCH 3/6] test(dind): keep raw rootfs tar out of the directory-form preload scan --- tests/dind/example-preload-images.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/dind/example-preload-images.sh b/tests/dind/example-preload-images.sh index c4210a4..9dcd820 100755 --- a/tests/dind/example-preload-images.sh +++ b/tests/dind/example-preload-images.sh @@ -27,9 +27,11 @@ register_image "$fixture_image" log "building an offline fixture image with docker import (no registry pull)" rootfs_dir="$(mktemp -d)" register_temp_dir "$rootfs_dir" +# Keep the rootfs tar out of $tarball_dir so the directory-form preload below +# only ever sees a real image tarball (image.tar), not this raw filesystem tar. echo "issue-94 preload fixture" > "$rootfs_dir/marker.txt" -tar -C "$rootfs_dir" -cf "$tarball_dir/rootfs.tar" marker.txt -docker import "$tarball_dir/rootfs.tar" "$fixture_image" >/dev/null +tar -C "$rootfs_dir" -cf "$rootfs_dir/rootfs.tar" marker.txt +docker import "$rootfs_dir/rootfs.tar" "$fixture_image" >/dev/null log "saving the fixture image to a tarball the way a host would seed it" docker save "$fixture_image" -o "$tarball_dir/image.tar" From 8debb729b3cb8485b0e58e922ef5d688172168be Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Jun 2026 22:17:32 +0000 Subject: [PATCH 4/6] feat(dind): default-on host-image passthrough with public-only security filter Implements the issue #94 follow-up: copy images the host daemon already has into the nested daemon at startup so they are not re-pulled, on by default but safe and opt-out-able. - DIND_HOST_PASSTHROUGH (default 'public'): 'public' copies only host images carrying a RepoDigest from an allowlisted public registry (freely re-pullable, so no local build secrets or private credentials leak); 'all' copies every tagged image; 'off' disables it. - DIND_HOST_DOCKER_SOCK (default /var/run/host-docker.sock): a NON-default path, mounted read-only, used only to seed images at startup so the inner daemon keeps its own isolated socket (preserves issue #80 isolation). - DIND_HOST_PASSTHROUGH_REGISTRIES: the public-registry allowlist. - Quiet no-op when no host socket is mounted, so the normal --privileged run is unchanged. Tests: unit test sources the entrypoint and drives the passthrough branches with a mock docker + real AF_UNIX socket (public filter, all mode, skip-present, off, registry detection); the integration example stands up a throwaway host daemon and asserts 'all' copies a local fixture while 'public' refuses it. Docs updated in USAGE.md, README.md, the issue-94 case study, and the changeset. --- .changeset/dind-preload-images.md | 23 +++- README.md | 2 +- docs/case-studies/issue-94/CASE-STUDY.md | 90 ++++++++++--- docs/dind/USAGE.md | 62 +++++++++ experiments/preload-unit-test.sh | 162 +++++++++++++++++++---- tests/dind/example-preload-images.sh | 76 +++++++++++ ubuntu/24.04/dind/dind-entrypoint.sh | 155 +++++++++++++++++++++- 7 files changed, 513 insertions(+), 57 deletions(-) diff --git a/.changeset/dind-preload-images.md b/.changeset/dind-preload-images.md index d240833..de90149 100644 --- a/.changeset/dind-preload-images.md +++ b/.changeset/dind-preload-images.md @@ -2,9 +2,20 @@ bump: minor --- -dind-box: add a documented startup preload hook so the nested daemon no longer -re-downloads images the host already has (issue #94). `DIND_PRELOAD_TARBALL` -loads `docker save` tarballs (or directories of `*.tar`) into the inner daemon -once it is ready, and `DIND_PRELOAD_IMAGES` pulls registry/mirror references, -skipping any image that is already present. Covered by -`tests/dind/example-preload-images.sh` and documented in `docs/dind/USAGE.md`. +dind-box: stop the nested daemon from re-downloading images the host already has +(issue #94). Two complementary paths, both seeding the inner daemon once it is +ready and skipping any image already present: + +- **Explicit preload:** `DIND_PRELOAD_TARBALL` loads `docker save` tarballs (or + directories of `*.tar`) into the inner daemon, and `DIND_PRELOAD_IMAGES` pulls + registry/mirror references. +- **Host-image passthrough (on by default):** when the host Docker socket is + mounted at `DIND_HOST_DOCKER_SOCK` (default `/var/run/host-docker.sock`, a + non-default path so the inner daemon stays isolated), host images are copied + into the nested daemon at startup. `DIND_HOST_PASSTHROUGH=public` (default) + passes only images re-pullable from an allowlisted public registry — safe from + local secrets and private credentials — while `all` passes everything and + `off` disables it. A quiet no-op when no host socket is mounted. + +Covered by `tests/dind/example-preload-images.sh` and +`experiments/preload-unit-test.sh`, documented in `docs/dind/USAGE.md`. diff --git a/README.md b/README.md index 3d6fa09..dd7954d 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ Each row below has the same toolchain as its non-dind sibling **plus** a working > - **Recommended secure invocation:** [`docker run --runtime=sysbox-runc konard/box-dind`](https://github.com/nestybox/sysbox) — Sysbox is a drop-in OCI runtime that runs system containers without `--privileged` and without exposing host devices. > - **Do NOT bind-mount `/var/run/docker.sock`.** That gives the container root on the host ([Quarkslab](https://blog.quarkslab.com/why-is-exposing-the-docker-socket-a-really-bad-idea.html), [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html)) and breaks the per-box `docker ps` scoping property. > - **Storage:** the inner daemon writes to `/var/lib/docker` inside the container by default. For persistence, mount a volume: `-v box-dind-data:/var/lib/docker`. -> - **Reusing host images:** the nested daemon starts with an empty image store, so a fresh container re-downloads images the host already has. Seed it at startup with `DIND_PRELOAD_TARBALL` (mount `docker save` tarballs) or `DIND_PRELOAD_IMAGES` (pull from a registry/mirror). See [Reusing Host Images](docs/dind/USAGE.md#reusing-host-images-preload). +> - **Reusing host images:** the nested daemon starts with an empty image store, so a fresh container re-downloads images the host already has. Seed it explicitly at startup with `DIND_PRELOAD_TARBALL` (mount `docker save` tarballs) or `DIND_PRELOAD_IMAGES` (pull from a registry/mirror); see [Reusing Host Images](docs/dind/USAGE.md#reusing-host-images-preload). For automatic seeding, mount the host socket at `-v /var/run/docker.sock:/var/run/host-docker.sock:ro` — host-image passthrough is on by default and copies the host's **public** images (those re-pullable from a public registry, so no local secrets or private credentials leak) into the inner daemon; `DIND_HOST_PASSTHROUGH=all` passes everything and `=off` disables it. The host socket is mounted at a non-default path and read only at startup to seed images, so the inner daemon keeps its own isolated socket. See [Host-Image Passthrough](docs/dind/USAGE.md#host-image-passthrough-dind_host_passthrough). > - **Usage examples:** see [`docs/dind/USAGE.md`](docs/dind/USAGE.md). Its examples are backed by executable tests under `tests/dind/`. See [docs/case-studies/issue-80/CASE-STUDY.md](docs/case-studies/issue-80/CASE-STUDY.md) for the full design and threat model. diff --git a/docs/case-studies/issue-94/CASE-STUDY.md b/docs/case-studies/issue-94/CASE-STUDY.md index 9d94adc..9cf1d90 100644 --- a/docs/case-studies/issue-94/CASE-STUDY.md +++ b/docs/case-studies/issue-94/CASE-STUDY.md @@ -39,29 +39,74 @@ container's `docker load`, via a bespoke helper That works but every consumer has to reinvent it; the issue asks to make image reuse a first-class, documented capability of `box-dind`. -## 3. Solution — a documented startup preload hook (issue option 1) +## 3. Solution — explicit preload plus default-on host passthrough The entrypoint now seeds the nested daemon **after dockerd is ready** and before -it hands off to the normal box entrypoint, driven by two environment variables: +it hands off to the normal box entrypoint. There are two complementary paths. + +### 3.1 Explicit preload (issue option 1) + +Driven by two environment variables for operators who want to name exactly what +to seed: | Variable | Behavior | | --- | --- | | `DIND_PRELOAD_TARBALL` | Space-separated list of `docker save` tarball files and/or directories. Each file is `docker load`-ed; each directory loads every `*.tar` inside. This is the zero-network path for reusing host images. | | `DIND_PRELOAD_IMAGES` | Space-separated image references. Each is `docker pull`-ed, but only when `docker image inspect` shows it is not already present — so it is idempotent and free when a volume or tarball already provided the image. | -Design choices, all consistent with the existing entrypoint: - -- **Non-fatal.** A bad path, an unreadable tarball, or a failed pull logs a - `WARN` and continues; the user shell still starts. The entrypoint already - treats dockerd startup failures the same way. -- **Daemon-gated.** Preload is attempted only when `docker info` succeeds, and is +### 3.2 Host-image passthrough (issue follow-up: on by default, opt-out-able) + +The issue follow-up asked to **"by default add host-image passthrough"**, make +it **"possible to turn it off"**, default to passing only images that are +**"available in docker hub and so on, so these are safe from tokens and baked in +configuration"**, and **"also have an option to pass through them all"**. That +maps directly onto three environment variables: + +| Variable | Default | Behavior | +| --- | --- | --- | +| `DIND_HOST_PASSTHROUGH` | `public` | `public`: copy only host images carrying a `RepoDigest` from an allowlisted public registry. `all`: copy every tagged host image. `off`/`0`/`false`/`no`: disable. | +| `DIND_HOST_DOCKER_SOCK` | `/var/run/host-docker.sock` | Path inside the container to the mounted *host* Docker socket used to read host images. | +| `DIND_HOST_PASSTHROUGH_REGISTRIES` | `docker.io ghcr.io quay.io gcr.io registry.k8s.io public.ecr.aws mcr.microsoft.com` | Registries treated as "public" in `public` mode. | + +The key isolation-preserving decision: passthrough reads the host socket from a +**non-default path** (`/var/run/host-docker.sock`), mounted read-only, used only +to `docker save | docker load` images at startup. The inner daemon keeps its own +`/var/run/docker.sock` and stays the container's isolated runtime — so the +per-container `docker ps` property from issue #80 is preserved and the host +socket is never mounted at the default path (which would be Docker-outside-of- +Docker; see §1 and `docs/dind/USAGE.md` "Host Prerequisites"). + +Why `public` is the safe default: a `RepoDigest` only exists once an image has +been pulled from (or pushed to) a registry, and we additionally require that +registry to be on the public allowlist. Such an image is freely re-pullable by +anyone, so copying it into the inner daemon leaks **no** local build secrets and +needs **no** registry credential. Locally-built images (which have no +`RepoDigest`) and private-registry images are excluded unless the operator +explicitly opts into `all`. This is exactly the "safe from tokens and baked in +configuration" property the issue asked for. + +Because the default is on but a no-op without a mounted host socket, the normal +`docker run --privileged konard/box-dind` is unchanged: passthrough activates +only when the operator opts in by mounting the host socket. + +### 3.3 Shared design choices + +All consistent with the existing entrypoint: + +- **Non-fatal.** A bad path, an unreadable tarball, a failed pull, or a single + un-copyable host image logs a `WARN` and continues; the user shell still + starts. The entrypoint already treats dockerd startup failures the same way. +- **Daemon-gated.** Seeding is attempted only when `docker info` succeeds, and is skipped entirely (with a warning) when `DIND_SKIP_DAEMON=1`, since there is no inner daemon to load into. -- **Order.** Tarballs load before registry pulls, so a tarball-provided image - short-circuits the matching `DIND_PRELOAD_IMAGES` pull. -- **Bake or mount.** Operators can mount a tarball/directory at runtime, or - `COPY` a tarball into a derived image and set `ENV DIND_PRELOAD_TARBALL=…` so - every container starts warm. +- **Idempotent.** Every path skips an image that is already present in the inner + daemon, so volumes, tarballs, pulls, and passthrough compose without + duplicating work. +- **Order.** Tarballs load first, then host passthrough, then registry pulls, so + an already-seeded image short-circuits the later, more expensive steps. +- **Bake or mount.** Operators can mount tarballs/sockets at runtime, or `COPY` a + tarball into a derived image and set `ENV DIND_PRELOAD_TARBALL=…` so every + container starts warm. ## 4. Verification @@ -72,15 +117,18 @@ Design choices, all consistent with the existing entrypoint: that `DIND_PRELOAD_IMAGES` skips the redundant pull. Wired into the `pr-test-dind` CI job alongside the other documented dind examples. - **Isolated unit test:** [`experiments/preload-unit-test.sh`](../../../experiments/preload-unit-test.sh) - extracts the preload functions from the real entrypoint and drives them with a - mock `docker`, covering load/pull/skip/daemon-down/no-op/missing-path branches. - This runs anywhere (the CI sandbox only has the `vfs` storage driver, which - cannot build the full overlay-backed dind image). + sources the real entrypoint (via `DIND_ENTRYPOINT_SOURCE_ONLY=1`) and drives + its functions with a mock `docker` and a real AF_UNIX socket, covering + load/pull/skip/daemon-down/no-op/missing-path **and** the passthrough branches: + no-socket no-op, `public` mode passing a Docker Hub image while skipping a + local one, `all` mode, already-present skip, `off`, and the registry-detection + helpers. This runs anywhere (the CI sandbox only has the `vfs` storage driver, + which cannot build the full overlay-backed dind image). ## 5. Files changed -- `ubuntu/24.04/dind/dind-entrypoint.sh` — preload hook + env documentation. -- `docs/dind/USAGE.md` — "Reusing Host Images (Preload)" section + env table rows. -- `README.md` — security-model note pointing at the preload section. +- `ubuntu/24.04/dind/dind-entrypoint.sh` — preload hook, host passthrough, env documentation. +- `docs/dind/USAGE.md` — "Reusing Host Images (Preload)" and "Host-Image Passthrough" sections + env table rows. +- `README.md` — security-model note pointing at the preload and passthrough sections. - `tests/dind/example-preload-images.sh` — executable example, run in CI. -- `experiments/preload-unit-test.sh` — isolated branch coverage. +- `experiments/preload-unit-test.sh` — isolated branch coverage (preload + passthrough). diff --git a/docs/dind/USAGE.md b/docs/dind/USAGE.md index eadd13b..43f90a9 100644 --- a/docs/dind/USAGE.md +++ b/docs/dind/USAGE.md @@ -71,6 +71,9 @@ The entrypoint supports these environment variables: | `DIND_SKIP_DAEMON` | `0` | Set to `1` to skip dockerd startup. | | `DIND_PRELOAD_TARBALL` | _(empty)_ | Space-separated `docker save` tarballs and/or directories of `*.tar` to `docker load` into the nested daemon once it is ready. | | `DIND_PRELOAD_IMAGES` | _(empty)_ | Space-separated image references to `docker pull` into the nested daemon once it is ready, skipping any that are already present. | +| `DIND_HOST_PASSTHROUGH` | `public` | Copy images already present on the host into the nested daemon at startup when a host socket is mounted (see below). `public` only passes images with a RepoDigest from an allowlisted public registry; `all` passes every tagged image; `off` disables it. A quiet no-op when no host socket is mounted. | +| `DIND_HOST_DOCKER_SOCK` | `/var/run/host-docker.sock` | Path inside the container to the mounted *host* Docker socket used for passthrough. Deliberately **not** `/var/run/docker.sock`, so the inner daemon keeps its own isolated socket. | +| `DIND_HOST_PASSTHROUGH_REGISTRIES` | common public registries | Space-separated allowlist of registries treated as "public" in `DIND_HOST_PASSTHROUGH=public` mode (default: `docker.io ghcr.io quay.io gcr.io registry.k8s.io public.ecr.aws mcr.microsoft.com`). | Use a named volume when the inner Docker state should survive container removal: @@ -164,6 +167,65 @@ DIND_IMAGE=box-dind-js tests/dind/example-preload-images.sh [jpetazzo]: https://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/ +## Host-Image Passthrough (`DIND_HOST_PASSTHROUGH`) + +`DIND_PRELOAD_*` above are explicit: you name the tarballs or references to seed. +Passthrough is the **automatic** counterpart — when you mount the host Docker +socket into the container, the entrypoint copies images the host *already has* +into the nested daemon at startup, so the inner `docker run` does not re-pull +them. It is on by default (`public` mode) but a quiet no-op until a host socket +is mounted, so the standard `--privileged` run is unchanged. + +Mount the host socket at `DIND_HOST_DOCKER_SOCK` (default +`/var/run/host-docker.sock`), read-only: + +```bash +docker run -d --privileged \ + -v /var/run/docker.sock:/var/run/host-docker.sock:ro \ + --name box-dind \ + konard/box-dind sleep infinity + +until docker exec box-dind docker info >/dev/null 2>&1; do sleep 1; done + +# Public host images were copied into the inner daemon — no re-pull: +docker exec box-dind docker images +``` + +The mount path is deliberately **not** `/var/run/docker.sock`. The host socket is +read only at startup to *seed* images; the inner daemon keeps its own isolated +socket and remains the container's runtime. This preserves the per-container +Docker view from issue #80 — mounting the host socket at the default path would +switch the model to Docker-outside-of-Docker and expose host Docker control +(see [Host Prerequisites](#host-prerequisites)). + +### Modes — and why `public` is the default + +| Mode | What it copies | +| --- | --- | +| `public` _(default)_ | Only host images carrying a `RepoDigest` from an allowlisted public registry (`DIND_HOST_PASSTHROUGH_REGISTRIES`). A RepoDigest proves the image was pulled from that registry and is freely re-pullable, so copying it leaks **no** local build secrets and needs **no** registry credential. Locally-built images (no RepoDigest) and private-registry images are skipped. | +| `all` | Every tagged host image, including locally-built and private-registry images. Use only when you trust the inner workload with those images. | +| `off` (also `0`/`false`/`no`) | Disable passthrough entirely. | + +```bash +# Pass through everything the host has, including local builds: +docker run -d --privileged \ + -v /var/run/docker.sock:/var/run/host-docker.sock:ro \ + -e DIND_HOST_PASSTHROUGH=all \ + --name box-dind \ + konard/box-dind sleep infinity + +# Opt out completely: +docker run -d --privileged \ + -e DIND_HOST_PASSTHROUGH=off \ + --name box-dind \ + konard/box-dind sleep infinity +``` + +Passthrough is idempotent and additive: an image already present in the inner +daemon (from a volume, tarball, or earlier run) is skipped, and any single +image that fails to copy logs a warning and continues. Like preload, it is +skipped entirely when `DIND_SKIP_DAEMON=1`. + ## Commit Cycles `DIND_SKIP_DAEMON=1` is useful for setup containers where you want to install or diff --git a/experiments/preload-unit-test.sh b/experiments/preload-unit-test.sh index 9aff267..f77cbbc 100755 --- a/experiments/preload-unit-test.sh +++ b/experiments/preload-unit-test.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash -# Isolated unit test for the issue #94 preload hook in dind-entrypoint.sh. +# Isolated unit test for the issue #94 preload + host-passthrough hooks in +# dind-entrypoint.sh. # # Building the full box-dind image requires overlay-backed nested Docker; this # sandbox only has the vfs storage driver, which exhausts disk. So instead we -# extract the preload functions from the real entrypoint and drive them with a -# mock `docker` that records every call, asserting the load/pull/skip behavior. +# source the real entrypoint (via DIND_ENTRYPOINT_SOURCE_ONLY=1, which returns +# before the startup/handoff flow) to get its functions verbatim, and drive +# them with a mock `docker` that records every call and simulates both the +# inner daemon and a mounted host daemon (`docker -H unix:// ...`). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -13,24 +16,36 @@ ENTRYPOINT="$SCRIPT_DIR/../ubuntu/24.04/dind/dind-entrypoint.sh" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT -# --- Extract just the preload functions (lines 198..259) from the entrypoint --- -FUNCS="$WORK/funcs.sh" -sed -n '198,259p' "$ENTRYPOINT" > "$FUNCS" - -# --- Mock docker on PATH; records calls and simulates state --- +# --- Mock docker on PATH; records calls and simulates inner + host state --- mkdir -p "$WORK/bin" cat > "$WORK/bin/docker" <<'MOCK' #!/usr/bin/env bash -echo "$*" >> "$DOCKER_CALLS" +# Host-daemon calls look like: docker -H unix:// ... +host=0 +if [ "${1:-}" = "-H" ]; then host=1; shift 2; fi +echo "${host}|$*" >> "$DOCKER_CALLS" case "$1" in + version) [ "$host" = "1" ] && { [ "${HOST_DOCKER_OK:-1}" = "1" ] && exit 0 || exit 1; }; exit 0 ;; info) [ "${DOCKER_INFO_OK:-1}" = "1" ] && exit 0 || exit 1 ;; image) - # image inspect + # image inspect [--format ...] ref="$3" + if [ "$host" = "1" ]; then + # Emit the RepoDigests recorded for this host image, if any. + grep "^${ref}|" "$HOST_DIGESTS" 2>/dev/null | head -n1 | cut -d'|' -f2- + grep "^${ref}|" "$HOST_DIGESTS" >/dev/null 2>&1 && exit 0 || exit 0 + fi grep -qxF "$ref" "$DOCKER_PRESENT" 2>/dev/null && exit 0 || exit 1 ;; + images) + # host: list refs from the fixture file + [ "$host" = "1" ] && cat "$HOST_IMAGES" 2>/dev/null + exit 0 ;; load) - # docker load -i -> mark a sentinel image present - echo "loaded:$3" >> "$DOCKER_LOADED"; exit 0 ;; + cat >/dev/null 2>&1 || true # drain the piped tar stream like real `docker load` + echo "loaded" >> "$DOCKER_LOADED"; exit 0 ;; + save) + # `docker -H .. save ` streams a tarball; mark it saved. + echo "$2" >> "$DOCKER_SAVED"; echo "fake-tar-stream"; exit 0 ;; pull) echo "$2" >> "$DOCKER_PULLED"; echo "$2" >> "$DOCKER_PRESENT"; exit 0 ;; *) exit 0 ;; @@ -43,64 +58,161 @@ export DOCKER_CALLS="$WORK/calls.log" export DOCKER_LOADED="$WORK/loaded.log" export DOCKER_PULLED="$WORK/pulled.log" export DOCKER_PRESENT="$WORK/present.log" +export DOCKER_SAVED="$WORK/saved.log" +export HOST_IMAGES="$WORK/host-images.log" +export HOST_DIGESTS="$WORK/host-digests.log" -log() { echo "[test] $*"; } -warn() { echo "[test] WARN: $*" >&2; } - +# --- Source the real entrypoint for its functions only --- # shellcheck disable=SC1090 -source "$FUNCS" +DIND_ENTRYPOINT_SOURCE_ONLY=1 . "$ENTRYPOINT" pass=0; fail=0 -reset_state() { : > "$DOCKER_CALLS"; : > "$DOCKER_LOADED"; : > "$DOCKER_PULLED"; : > "$DOCKER_PRESENT"; } +reset_state() { + : > "$DOCKER_CALLS"; : > "$DOCKER_LOADED"; : > "$DOCKER_PULLED" + : > "$DOCKER_PRESENT"; : > "$DOCKER_SAVED"; : > "$HOST_IMAGES"; : > "$HOST_DIGESTS" +} check() { # check desc="$1"; shift if "$@"; then echo " PASS: $desc"; pass=$((pass+1)); else echo " FAIL: $desc"; fail=$((fail+1)); fi } +# Defaults the entrypoint expects (it ran `VAR="${VAR:-default}"` at source time, +# but we re-assert here so each case is explicit/hermetic). +HOST_SOCK="$WORK/host-docker.sock" + +# Create a real AF_UNIX socket file so the entrypoint's `[ -S ... ]` guard +# passes. The mock `docker version` never actually connects, so binding and +# leaving the inode behind is enough. +make_sock() { + rm -f "$1" + python3 - "$1" <<'PY' +import socket, sys +socket.socket(socket.AF_UNIX).bind(sys.argv[1]) +PY +} + echo "== Case 1: single tarball file is loaded ==" reset_state touch "$WORK/image.tar" -DIND_PRELOAD_TARBALL="$WORK/image.tar" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 preload_into_daemon +DIND_HOST_PASSTHROUGH=off DIND_PRELOAD_TARBALL="$WORK/image.tar" DIND_PRELOAD_IMAGES="" \ + DOCKER_INFO_OK=1 preload_into_daemon check "docker load called for the tarball" grep -q "load -i $WORK/image.tar" "$DOCKER_CALLS" echo "== Case 2: directory loads every *.tar inside ==" reset_state mkdir -p "$WORK/dir" touch "$WORK/dir/a.tar" "$WORK/dir/b.tar" "$WORK/dir/ignore.txt" -DIND_PRELOAD_TARBALL="$WORK/dir" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 preload_into_daemon +DIND_HOST_PASSTHROUGH=off DIND_PRELOAD_TARBALL="$WORK/dir" DIND_PRELOAD_IMAGES="" \ + DOCKER_INFO_OK=1 preload_into_daemon check "a.tar loaded" grep -q "load -i $WORK/dir/a.tar" "$DOCKER_CALLS" check "b.tar loaded" grep -q "load -i $WORK/dir/b.tar" "$DOCKER_CALLS" check "ignore.txt not loaded" bash -c '! grep -q "ignore.txt" "$DOCKER_CALLS"' echo "== Case 3: DIND_PRELOAD_IMAGES pulls a missing image ==" reset_state -DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="alpine:3.20" DOCKER_INFO_OK=1 preload_into_daemon +DIND_HOST_PASSTHROUGH=off DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="alpine:3.20" \ + DOCKER_INFO_OK=1 preload_into_daemon check "missing image was pulled" grep -qx "alpine:3.20" "$DOCKER_PULLED" echo "== Case 4: DIND_PRELOAD_IMAGES skips an already-present image ==" reset_state echo "alpine:3.20" > "$DOCKER_PRESENT" -DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="alpine:3.20" DOCKER_INFO_OK=1 preload_into_daemon +DIND_HOST_PASSTHROUGH=off DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="alpine:3.20" \ + DOCKER_INFO_OK=1 preload_into_daemon check "present image was NOT pulled" bash -c '! test -s "$DOCKER_PULLED"' echo "== Case 5: nothing happens when daemon is not ready ==" reset_state touch "$WORK/image.tar" -DIND_PRELOAD_TARBALL="$WORK/image.tar" DIND_PRELOAD_IMAGES="alpine:3.20" DOCKER_INFO_OK=0 preload_into_daemon +DIND_HOST_PASSTHROUGH=off DIND_PRELOAD_TARBALL="$WORK/image.tar" DIND_PRELOAD_IMAGES="alpine:3.20" \ + DOCKER_INFO_OK=0 preload_into_daemon check "no load attempted when daemon down" bash -c '! grep -q "load -i" "$DOCKER_CALLS"' check "no pull attempted when daemon down" bash -c '! test -s "$DOCKER_PULLED"' -echo "== Case 6: no-op when neither var is set (no docker info probe) ==" +echo "== Case 6: no-op when nothing is configured and passthrough is off ==" reset_state -DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 preload_into_daemon +DIND_HOST_PASSTHROUGH=off DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" \ + DOCKER_INFO_OK=1 preload_into_daemon check "no docker calls at all" bash -c '! test -s "$DOCKER_CALLS"' echo "== Case 7: missing tarball path warns, no load ==" reset_state -DIND_PRELOAD_TARBALL="$WORK/does-not-exist.tar" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 preload_into_daemon 2>"$WORK/err.log" +DIND_HOST_PASSTHROUGH=off DIND_PRELOAD_TARBALL="$WORK/does-not-exist.tar" DIND_PRELOAD_IMAGES="" \ + DOCKER_INFO_OK=1 preload_into_daemon 2>"$WORK/err.log" check "no load for missing path" bash -c '! grep -q "load -i" "$DOCKER_CALLS"' check "warning emitted for missing path" grep -q "does not exist" "$WORK/err.log" +echo "== Case 8: passthrough is a quiet no-op when no host socket is mounted ==" +reset_state +DIND_HOST_PASSTHROUGH=public DIND_HOST_DOCKER_SOCK="$WORK/absent.sock" \ + DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 \ + preload_into_daemon 2>"$WORK/err.log" +check "no host save attempted without a socket" bash -c '! test -s "$DOCKER_SAVED"' +check "no warning emitted when socket simply absent" bash -c '! test -s "$WORK/err.log"' + +echo "== Case 9: public mode copies a Docker Hub image, skips a local one ==" +reset_state +# A hub image (has a docker.io RepoDigest) and a locally-built one (no digest): +printf '%s\n%s\n' "alpine:3.20" "myapp:latest" > "$HOST_IMAGES" +echo "alpine:3.20|alpine@sha256:deadbeef " > "$HOST_DIGESTS" # myapp has no digest line +# host_docker_available requires [ -S sock ]; emulate by pointing at a real sock. +make_sock "$HOST_SOCK" +DIND_HOST_PASSTHROUGH=public DIND_HOST_DOCKER_SOCK="$HOST_SOCK" \ + DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 HOST_DOCKER_OK=1 \ + preload_into_daemon +check "public mode saved the hub image" grep -qx "alpine:3.20" "$DOCKER_SAVED" +check "public mode loaded the hub image" grep -q "load" "$DOCKER_CALLS" +check "public mode did NOT save the local image" bash -c '! grep -qx "myapp:latest" "$DOCKER_SAVED"' +rm -f "$HOST_SOCK" + +echo "== Case 10: all mode copies every host image including local ==" +reset_state +printf '%s\n%s\n' "alpine:3.20" "myapp:latest" > "$HOST_IMAGES" +echo "alpine:3.20|alpine@sha256:deadbeef " > "$HOST_DIGESTS" +make_sock "$HOST_SOCK" +DIND_HOST_PASSTHROUGH=all DIND_HOST_DOCKER_SOCK="$HOST_SOCK" \ + DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 HOST_DOCKER_OK=1 \ + preload_into_daemon +check "all mode saved the hub image" grep -qx "alpine:3.20" "$DOCKER_SAVED" +check "all mode saved the local image too" grep -qx "myapp:latest" "$DOCKER_SAVED" +rm -f "$HOST_SOCK" + +echo "== Case 11: passthrough skips images already present in the inner daemon ==" +reset_state +printf '%s\n' "alpine:3.20" > "$HOST_IMAGES" +echo "alpine:3.20|alpine@sha256:deadbeef " > "$HOST_DIGESTS" +echo "alpine:3.20" > "$DOCKER_PRESENT" # already in the inner daemon +make_sock "$HOST_SOCK" +DIND_HOST_PASSTHROUGH=public DIND_HOST_DOCKER_SOCK="$HOST_SOCK" \ + DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 HOST_DOCKER_OK=1 \ + preload_into_daemon +check "present image was NOT re-saved from host" bash -c '! test -s "$DOCKER_SAVED"' +rm -f "$HOST_SOCK" + +echo "== Case 12: off mode never touches the host even with a socket ==" +reset_state +printf '%s\n' "alpine:3.20" > "$HOST_IMAGES" +echo "alpine:3.20|alpine@sha256:deadbeef " > "$HOST_DIGESTS" +make_sock "$HOST_SOCK" +DIND_HOST_PASSTHROUGH=off DIND_HOST_DOCKER_SOCK="$HOST_SOCK" \ + DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 HOST_DOCKER_OK=1 \ + preload_into_daemon +check "off mode made no docker calls" bash -c '! test -s "$DOCKER_CALLS"' +rm -f "$HOST_SOCK" + +echo "== Case 13: registry classification helpers ==" +reset_state +# These call the sourced functions in the current shell (command substitution +# and `eval` keep them in scope, unlike a `bash -c` subshell). +# shellcheck disable=SC2034 # consumed by registry_is_public, sourced above +DIND_HOST_PASSTHROUGH_REGISTRIES="docker.io ghcr.io" +check "bare name -> docker.io" test "$(image_registry alpine)" = "docker.io" +check "user/repo -> docker.io" test "$(image_registry library/alpine)" = "docker.io" +check "ghcr.io host detected" test "$(image_registry ghcr.io/o/i)" = "ghcr.io" +check "private registry host kept" test "$(image_registry registry.example.com:5000/i)" = "registry.example.com:5000" +check "docker.io is public" eval 'registry_is_public docker.io' +check "private host not public" eval '! registry_is_public registry.example.com:5000' + echo echo "RESULT: $pass passed, $fail failed" [ "$fail" -eq 0 ] diff --git a/tests/dind/example-preload-images.sh b/tests/dind/example-preload-images.sh index 9dcd820..7aa783b 100755 --- a/tests/dind/example-preload-images.sh +++ b/tests/dind/example-preload-images.sh @@ -75,4 +75,80 @@ if ! docker logs "$dir_container" 2>&1 | grep -q "preload image already present, fi log "directory preload loaded the tarball and DIND_PRELOAD_IMAGES skipped the redundant pull" +# --- Host-image passthrough (issue #94 follow-up) --------------------------- +# Passthrough copies images already present on a *host* daemon into the nested +# daemon at startup, reading the host socket from a non-default path so the inner +# daemon stays isolated. To keep this test fully self-contained and bounded (no +# copying of whatever images the CI runner happens to have), we stand up a +# throwaway "host" daemon inside a second dind-box and expose its socket on a +# runner bind mount, then point consumers at it. +host_daemon_container="${DIND_EXAMPLE_ID}-passthrough-host" +all_container="${DIND_EXAMPLE_ID}-passthrough-all" +public_container="${DIND_EXAMPLE_ID}-passthrough-public" + +host_sock_dir="$(mktemp -d)" +register_temp_dir "$host_sock_dir" +# Both the host daemon (root) and the consumer's box user reach this socket +# across the bind mount; make the directory traversable for everyone. +chmod 0777 "$host_sock_dir" + +log "starting a throwaway host daemon (second dind-box) to passthrough from" +run_dind_container "$host_daemon_container" \ + -e DIND_SKIP_DAEMON=1 \ + -v "$host_sock_dir:/sockets" +# Bring up a dedicated dockerd inside the host box, listening on the shared +# socket path. vfs keeps it independent of the outer storage driver. +docker exec -d "$host_daemon_container" \ + sudo -n /usr/bin/dockerd \ + --host=unix:///sockets/docker.sock \ + --data-root=/var/lib/docker \ + --storage-driver=vfs + +host_docker="docker exec $host_daemon_container docker -H unix:///sockets/docker.sock" +i=0 +until $host_docker info >/dev/null 2>&1; do + i=$((i + 1)) + if [ "$i" -ge "${DIND_WAIT_SECONDS:-60}" ]; then + docker exec "$host_daemon_container" tail -n 80 /var/log/dockerd.log >&2 2>/dev/null || true + fail "throwaway host daemon did not become ready within ${DIND_WAIT_SECONDS:-60}s" + fi + sleep 1 +done +log "throwaway host daemon is ready" + +# Seed the host daemon with the offline fixture image. Built via docker import, +# it has no RepoDigest, which is exactly the "locally built" case the default +# public mode must refuse to pass through. +docker exec -i "$host_daemon_container" \ + docker -H unix:///sockets/docker.sock load < "$tarball_dir/image.tar" + +# all mode: every tagged host image is copied, including this local fixture. +log "starting consumer with DIND_HOST_PASSTHROUGH=all" +run_dind_container "$all_container" \ + -e DIND_HOST_PASSTHROUGH=all \ + -e DIND_HOST_DOCKER_SOCK=/host-sock/docker.sock \ + -v "$host_sock_dir:/host-sock:ro" +wait_for_inner_docker "$all_container" +assert_inner_has_image "$all_container" +log "all-mode passthrough copied the host fixture into the inner daemon (no pull)" + +# public mode (the default): the fixture has no RepoDigest, so it must be +# skipped — passthrough never copies locally built / private images by default. +log "starting consumer with DIND_HOST_PASSTHROUGH=public (default security mode)" +run_dind_container "$public_container" \ + -e DIND_HOST_PASSTHROUGH=public \ + -e DIND_HOST_DOCKER_SOCK=/host-sock/docker.sock \ + -v "$host_sock_dir:/host-sock:ro" +wait_for_inner_docker "$public_container" + +if docker exec "$public_container" docker image inspect "$fixture_image" >/dev/null 2>&1; then + docker logs "$public_container" >&2 || true + fail "public mode must NOT pass through the local fixture image (no RepoDigest)" +fi +if ! docker logs "$public_container" 2>&1 | grep -q "host-image passthrough (mode=public)"; then + docker logs "$public_container" >&2 || true + fail "expected the consumer to run host-image passthrough in public mode" +fi +log "public-mode passthrough correctly skipped the local fixture (security filter held)" + log "preload example passed" diff --git a/ubuntu/24.04/dind/dind-entrypoint.sh b/ubuntu/24.04/dind/dind-entrypoint.sh index 58920a5..6d1a045 100644 --- a/ubuntu/24.04/dind/dind-entrypoint.sh +++ b/ubuntu/24.04/dind/dind-entrypoint.sh @@ -31,6 +31,38 @@ # into the nested daemon once it is ready, but only when # the image is not already present. Useful to warm the # cache from a registry or pull-through mirror. (issue #94) +# DIND_HOST_PASSTHROUGH +# Host-image passthrough mode (default: "public"). When a +# host Docker socket is mounted into the container at +# DIND_HOST_DOCKER_SOCK, images already present on the +# host are copied into the nested daemon at startup +# (docker save | docker load) so they are not re-pulled. +# Modes: +# public - (default) only pass host images that carry a +# RepoDigest from an allowlisted public +# registry (DIND_HOST_PASSTHROUGH_REGISTRIES). +# These are freely re-pullable, so passing them +# leaks no local build secrets or private +# registry credentials. +# all - pass every tagged host image, including +# locally-built and private-registry images. +# off - disable passthrough entirely. +# If no host socket is mounted this is a quiet no-op, so +# the default is safe for the normal --privileged run. +# (issue #94) +# DIND_HOST_DOCKER_SOCK +# Path inside the container to the mounted *host* Docker +# socket used for passthrough (default: +# /var/run/host-docker.sock). Mount it read-only with +# `-v /var/run/docker.sock:/var/run/host-docker.sock:ro`. +# Note: deliberately NOT /var/run/docker.sock, so the +# inner daemon keeps its own isolated socket. (issue #94) +# DIND_HOST_PASSTHROUGH_REGISTRIES +# Space-separated allowlist of registries treated as +# "public" in DIND_HOST_PASSTHROUGH=public mode (default: +# the common public registries: docker.io ghcr.io quay.io +# gcr.io registry.k8s.io public.ecr.aws mcr.microsoft.com). +# (issue #94) set -eu @@ -41,6 +73,9 @@ DIND_WAIT_SECONDS="${DIND_WAIT_SECONDS:-30}" DIND_SKIP_DAEMON="${DIND_SKIP_DAEMON:-0}" DIND_PRELOAD_TARBALL="${DIND_PRELOAD_TARBALL:-}" DIND_PRELOAD_IMAGES="${DIND_PRELOAD_IMAGES:-}" +DIND_HOST_PASSTHROUGH="${DIND_HOST_PASSTHROUGH:-public}" +DIND_HOST_DOCKER_SOCK="${DIND_HOST_DOCKER_SOCK:-/var/run/host-docker.sock}" +DIND_HOST_PASSTHROUGH_REGISTRIES="${DIND_HOST_PASSTHROUGH_REGISTRIES:-docker.io ghcr.io quay.io gcr.io registry.k8s.io public.ecr.aws mcr.microsoft.com}" log() { echo "[dind-entrypoint] $*"; } warn() { echo "[dind-entrypoint] WARN: $*" >&2; } @@ -246,25 +281,137 @@ preload_images() { done } +host_passthrough_enabled() { + case "$DIND_HOST_PASSTHROUGH" in + off|0|false|no|"") return 1 ;; + *) return 0 ;; + esac +} + +# The host docker CLI invocation, if a usable host socket is mounted. Returns +# non-zero (so passthrough is a quiet no-op) when no host socket is present or +# the socket cannot be reached. +host_docker_available() { + [ -n "$DIND_HOST_DOCKER_SOCK" ] || return 1 + [ -S "$DIND_HOST_DOCKER_SOCK" ] || return 1 + docker -H "unix://$DIND_HOST_DOCKER_SOCK" version >/dev/null 2>&1 || return 1 + return 0 +} + +# Extract the registry host from an image reference or repo-digest. Docker Hub +# refs ("alpine", "library/alpine", "user/repo") have no host component and map +# to docker.io. A first path segment containing '.' or ':' (or "localhost") is +# treated as an explicit registry host. +image_registry() { + first="${1%%/*}" + case "$first" in + localhost|*.*|*:*) printf '%s\n' "$first" ;; + *) printf '%s\n' "docker.io" ;; + esac +} + +registry_is_public() { + for allowed in $DIND_HOST_PASSTHROUGH_REGISTRIES; do + [ "$1" = "$allowed" ] && return 0 + done + return 1 +} + +# Decide whether a host image should be passed through under the current mode. +# "all" -> every tagged image qualifies. +# "public" -> the image must carry a RepoDigest from an allowlisted public +# registry, proving it was pulled from a public registry and is +# freely re-pullable. Locally-built images (no RepoDigest) and +# private-registry images are excluded, so passthrough never copies +# local build secrets or images that required a credential. +host_image_passes_filter() { + ref="$1"; repo_digests="$2" + case "$DIND_HOST_PASSTHROUGH" in + all) return 0 ;; + public) + [ -n "$repo_digests" ] || return 1 + for rd in $repo_digests; do + if registry_is_public "$(image_registry "${rd%@*}")"; then + return 0 + fi + done + return 1 ;; + *) return 1 ;; + esac +} + +passthrough_host_images() { + host_passthrough_enabled || return 0 + + if ! host_docker_available; then + # A socket file exists but is unreachable: surface it. Otherwise the common + # "no host socket mounted" case stays silent so the default mode is free. + if [ -n "$DIND_HOST_DOCKER_SOCK" ] && [ -e "$DIND_HOST_DOCKER_SOCK" ]; then + warn "host docker socket at ${DIND_HOST_DOCKER_SOCK} is not accessible; skipping passthrough" + fi + return 0 + fi + + hostdocker="docker -H unix://$DIND_HOST_DOCKER_SOCK" + log "host-image passthrough (mode=${DIND_HOST_PASSTHROUGH}) from ${DIND_HOST_DOCKER_SOCK}" + + $hostdocker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | sort -u \ + | while IFS= read -r ref; do + [ -n "$ref" ] || continue + case "$ref" in *''*) continue ;; esac + + repo_digests="$($hostdocker image inspect "$ref" \ + --format '{{range .RepoDigests}}{{.}} {{end}}' 2>/dev/null || true)" + + if ! host_image_passes_filter "$ref" "$repo_digests"; then + log "passthrough skip (filtered by mode=${DIND_HOST_PASSTHROUGH}): ${ref}" + continue + fi + + if docker image inspect "$ref" >/dev/null 2>&1; then + log "passthrough skip (already present): ${ref}" + continue + fi + + log "passthrough loading host image: ${ref}" + if ! $hostdocker save "$ref" | docker load; then + warn "passthrough failed for ${ref}" + fi + done +} + preload_into_daemon() { - [ -n "$DIND_PRELOAD_TARBALL" ] || [ -n "$DIND_PRELOAD_IMAGES" ] || return 0 + # Tarball/registry preload only run when their vars are set; host passthrough + # is on by default, so we still proceed to give it a chance to find a socket. + if [ -z "$DIND_PRELOAD_TARBALL" ] && [ -z "$DIND_PRELOAD_IMAGES" ] \ + && ! host_passthrough_enabled; then + return 0 + fi if ! docker info >/dev/null 2>&1; then - warn "Skipping image preload because the nested dockerd is not ready" + warn "Skipping image preload/passthrough because the nested dockerd is not ready" return 0 fi preload_tarballs + passthrough_host_images preload_images } +# Allow the unit tests to source this file for the function definitions without +# running the startup/handoff flow below. +if [ "${DIND_ENTRYPOINT_SOURCE_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + if [ "$DIND_SKIP_DAEMON" != "1" ]; then if ! start_dockerd; then warn "dockerd startup failed. Use --user root, check /etc/sudoers.d/box-dind, or set DIND_SKIP_DAEMON=1 to silence." fi preload_into_daemon -elif [ -n "$DIND_PRELOAD_TARBALL" ] || [ -n "$DIND_PRELOAD_IMAGES" ]; then - warn "DIND_PRELOAD_* is set but DIND_SKIP_DAEMON=1; nothing will be preloaded" +elif [ -n "$DIND_PRELOAD_TARBALL" ] || [ -n "$DIND_PRELOAD_IMAGES" ] \ + || { host_passthrough_enabled && [ -n "$DIND_HOST_DOCKER_SOCK" ] && [ -e "$DIND_HOST_DOCKER_SOCK" ]; }; then + warn "DIND_PRELOAD_*/host passthrough requested but DIND_SKIP_DAEMON=1; nothing will be preloaded" fi # Ensure the docker socket is group-readable for the box user. From 63e994e1049a699c99ec83a071e9267c6bbb89fc Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Jun 2026 22:20:42 +0000 Subject: [PATCH 5/6] chore: remove stray root .gitkeep PR-creation artifact --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 65e712e..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-06-09T21:28:01.405Z for PR creation at branch issue-94-c1c4a75d3b0d for issue https://github.com/link-foundation/box/issues/94 \ No newline at end of file From 0b7ed19c3d82bfb3a581f9a703c02c1b18e181ed Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 9 Jun 2026 23:35:24 +0000 Subject: [PATCH 6/6] fix(dind): sync preload test on completion marker, not dockerd readiness The example-preload-images.sh test waited only for the inner dockerd to answer 'docker info' (wait_for_inner_docker), then immediately asserted the seeded images were present. But the entrypoint loads images *after* dockerd reports ready, so the assertion raced the asynchronous 'docker load' and intermittently failed with the image showing up in the diagnostic dump a fraction of a second later. Emit an 'image preload/passthrough complete' marker once every preload path finishes, and have the test wait for that marker before asserting (positive and negative cases alike). Deterministic sync instead of a readiness race. --- tests/dind/example-preload-images.sh | 25 +++++++++++++++++++++++++ ubuntu/24.04/dind/dind-entrypoint.sh | 4 ++++ 2 files changed, 29 insertions(+) diff --git a/tests/dind/example-preload-images.sh b/tests/dind/example-preload-images.sh index 7aa783b..be16206 100755 --- a/tests/dind/example-preload-images.sh +++ b/tests/dind/example-preload-images.sh @@ -41,6 +41,27 @@ docker save "$fixture_image" -o "$tarball_dir/image.tar" # the load is not blocked by host-side permissions. chmod -R a+rX "$tarball_dir" +# The entrypoint loads images *after* dockerd reports ready, so waiting only for +# the inner daemon (wait_for_inner_docker) can race the asynchronous load. Wait +# for the entrypoint's completion marker before asserting on the seeded images. +wait_for_preload_complete() { + local container="$1" + local limit="${2:-$DIND_WAIT_SECONDS}" + local i=0 + + while [ "$i" -lt "$limit" ]; do + if docker logs "$container" 2>&1 | grep -q "image preload/passthrough complete"; then + log "image preload/passthrough completed in ${container} after ${i}s" + return 0 + fi + i=$((i + 1)) + sleep 1 + done + + docker logs "$container" >&2 || true + fail "image preload/passthrough did not complete in ${container} within ${limit}s" +} + assert_inner_has_image() { local container="$1" if ! docker exec "$container" docker image inspect "$fixture_image" >/dev/null 2>&1; then @@ -55,6 +76,7 @@ run_dind_container "$file_container" \ -e DIND_PRELOAD_TARBALL=/preload/image.tar \ -v "$tarball_dir:/preload:ro" wait_for_inner_docker "$file_container" +wait_for_preload_complete "$file_container" assert_inner_has_image "$file_container" log "single-tarball preload made ${fixture_image} available without a pull" @@ -67,6 +89,7 @@ run_dind_container "$dir_container" \ -e "DIND_PRELOAD_IMAGES=$fixture_image" \ -v "$tarball_dir:/preload:ro" wait_for_inner_docker "$dir_container" +wait_for_preload_complete "$dir_container" assert_inner_has_image "$dir_container" if ! docker logs "$dir_container" 2>&1 | grep -q "preload image already present, skipping pull"; then @@ -129,6 +152,7 @@ run_dind_container "$all_container" \ -e DIND_HOST_DOCKER_SOCK=/host-sock/docker.sock \ -v "$host_sock_dir:/host-sock:ro" wait_for_inner_docker "$all_container" +wait_for_preload_complete "$all_container" assert_inner_has_image "$all_container" log "all-mode passthrough copied the host fixture into the inner daemon (no pull)" @@ -140,6 +164,7 @@ run_dind_container "$public_container" \ -e DIND_HOST_DOCKER_SOCK=/host-sock/docker.sock \ -v "$host_sock_dir:/host-sock:ro" wait_for_inner_docker "$public_container" +wait_for_preload_complete "$public_container" if docker exec "$public_container" docker image inspect "$fixture_image" >/dev/null 2>&1; then docker logs "$public_container" >&2 || true diff --git a/ubuntu/24.04/dind/dind-entrypoint.sh b/ubuntu/24.04/dind/dind-entrypoint.sh index 6d1a045..a0024f3 100644 --- a/ubuntu/24.04/dind/dind-entrypoint.sh +++ b/ubuntu/24.04/dind/dind-entrypoint.sh @@ -396,6 +396,10 @@ preload_into_daemon() { preload_tarballs passthrough_host_images preload_images + # Emit a completion marker once every preload path has finished so consumers + # (and tests) can synchronize on "images are seeded" rather than racing the + # asynchronous load against mere dockerd readiness. (issue #94) + log "image preload/passthrough complete" } # Allow the unit tests to source this file for the function definitions without