Manual smoke test: freshdock recreate
This walks through the recreate cycle on a real Docker daemon. As of Phase 3
the cycle is health-gated: the new container must reach healthy (or stay
running for a grace period if it has no healthcheck) before the run is declared
a success and the archived -old- container is removed; a new image that fails
health is rolled back to the previous container.
Canonical automated gate. The authoritative “is the tool safe to ship” check is the live round-trip test tests/recreate_roundtrip_live.rs (PLAN §6.3, P3-3). It creates a kitchen-sink container, recreates it, and asserts the inspected config round-trips byte-identical. Run it with a daemon available:
cargo test --test recreate_roundtrip_live -- --ignoredA failure of that test is a release blocker. This manual procedure remains for quick human verification on first install.
Prerequisites
- A working Docker daemon reachable on the standard socket.
freshdockbuilt locally:just build.- A checkout where
freshdock recreateis wired in (i.e. anything frommainpost-Phase-2 onward).
Steps
freshdock recreate is a manual admin tool, not the automatic update
loop. It refuses two opt-out signals — freshdock.enable not true, or
freshdock.mode=off — and otherwise honours the operator’s explicit
invocation regardless of the scheduler mode (live, nightly,
weekly, monthly, watch). This is why the test container below
uses mode=watch: a watch-mode container is never touched by the
automatic loop, but is a fine target for a manual recreate.
# 1. Launch the test container with freshdock labels so the recreate
# command is willing to act on it.
docker run -d --name fd-smoke \
--label freshdock.enable=true \
--label freshdock.mode=watch \
-p 8081:80 nginx:alpine
# 2. Confirm it's serving.
curl -fsS http://localhost:8081/ > /dev/null && echo "nginx OK"
# 3. Capture Config.Image *before* the recreate so we can assert the
# round-trip below. This must read `nginx:alpine` — not the resolved
# `Image` digest, which is a separate field.
docker inspect fd-smoke --format '{{.Config.Image}}' # → nginx:alpine
# 4. Run the recreate.
./target/release/freshdock recreate fd-smoke
Expected observations
- The CLI prints
recreated fd-smoke: healthy — removed old container fd-smoke-old-<unix-ts>, new id <id>. docker ps -ashows a singlefd-smokecontainer, running with a fresh id — the archivedfd-smoke-old-<unix-ts>was removed once the new instance passed health gating. (nginx:alpine has no healthcheck, so success is decided by the grace period; add a healthcheck to exercise thehealthypath.)- The new container has the same port mapping (
0.0.0.0:8081->80/tcp), the samefreshdock.enable=true/freshdock.mode=watchlabels, and the same nginx image. Config.Imageround-trip (regression #25). After the recreate,docker inspect fd-smoke --format '{{.Config.Image}}'must read byte-identical to the pre-recreate value (nginx:alpine). Drift tolibrary/nginx:alpineis the original bug — it must not return.curl -fsS http://localhost:8081/still returns the default nginx page from the new container.
To exercise rollback: recreate a container whose tag has been re-pointed to
an image with a failing healthcheck; the CLI prints
recreate failed for <name>: ... rolled back to the previous container and the
original container is restored under its original name.
Cleanup
docker rm -f fd-smoke fd-smoke-old-*
Weird-config round-trip smoke
Mirrors the dimensions covered by [tests/fixtures/container_inspect_weird.json]
so the manual procedure stays in lockstep with the automated round-trip suite
in [tests/spec_roundtrip.rs] (the weird_spec_* tests). Run this whenever the
recreate orchestrator or ContainerSpec projection changes.
This uses alpine running sleep rather than nginx:alpine, so the
container doesn’t fight the config (nginx fails when run as uid 1000 with
its cache dirs replaced by tmpfs — orthogonal to recreate). The point of
this smoke is to verify spec preservation, not application behaviour.
# Two networks the container will attach to.
docker network create fd-front >/dev/null
docker network create fd-back >/dev/null
# Bind-mount sources. /tmp keeps these cleanup-friendly and avoids needing
# root for /host/* paths; the container-side destinations match the
# fixture so `weird_spec_preserves_binds_and_tmpfs` exercises the same
# `Binds` shape that this manual procedure does.
mkdir -p /tmp/fd-state /tmp/fd-secrets
# Launch with the kitchen-sink dimensions: non-default user, multi-port
# binding (one with explicit HostIp), bind mounts + tmpfs (HostConfig.Tmpfs
# dict), multiple cap_add / cap_drop, sysctls, restart policy with retry
# count, memory + nano-cpus + pids limit, custom stop signal + timeout,
# multiple ulimits, extra_hosts, and freshdock.* labels alongside user
# labels.
docker run -d --name fd-smoke-weird \
--label freshdock.enable=true \
--label freshdock.mode=watch \
--label freshdock.notify=true \
--label app=weird --label team=platform --label owner=owner@example.invalid \
--user 1000:1000 \
--network fd-front --network-alias weird-front \
-p 127.0.0.1:18443:8443 -p 19090:9090 \
-v /tmp/fd-state:/var/lib/state:rw -v /tmp/fd-secrets:/run/secrets:ro \
--tmpfs /run:rw,size=32m --tmpfs /var/cache:rw,size=64m \
--cap-add NET_BIND_SERVICE --cap-add SYS_TIME \
--cap-drop MKNOD --cap-drop AUDIT_WRITE \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
--sysctl net.core.somaxconn=4096 \
--restart on-failure:5 \
--memory 128m --memory-reservation 64m --cpus 0.5 --pids-limit 256 \
--stop-signal SIGUSR1 --stop-timeout 45 \
--ulimit nofile=8192:16384 --ulimit nproc=512:1024 \
--add-host db.internal:10.0.0.5 \
-e APP_MODE=production -e 'APP_TOKEN=base64=padded==' -e EMPTY_VAR= \
alpine sleep 600
# Attach the second network with its own alias — mirrors the multi-network
# attachment that `weird_spec_preserves_multi_network_endpoints_with_aliases`
# pins on the fixture side.
docker network connect --alias weird-back fd-back fd-smoke-weird
# Capture every dimension we care about *before* the recreate.
before=$(docker inspect fd-smoke-weird)
# Run the recreate.
./target/release/freshdock recreate fd-smoke-weird
# Spot-check the headline #25 assertion: Config.Image must round-trip
# byte-identical (alpine, NOT library/alpine).
docker inspect fd-smoke-weird --format '{{.Config.Image}}' # → alpine
# Diff the recreate-relevant slices of the inspect. Container ID and
# Image (resolved digest) are expected to differ; everything else under
# Config.* and HostConfig.* should match `before`.
after=$(docker inspect fd-smoke-weird)
diff \
<(echo "$before" | jq '.[0].Config' ) \
<(echo "$after" | jq '.[0].Config' ) # only Config.MacAddress may
# appear in AFTER (daemon
# quirk: API-created containers
# surface the auto-assigned MAC
# on Config.MacAddress, while
# `docker run` originals expose
# it only on NetworkSettings).
# Not a recreate bug.
diff \
<(echo "$before" | jq '.[0].HostConfig') \
<(echo "$after" | jq '.[0].HostConfig') # expected: empty diff
# Bind mounts must round-trip: same source/destination/RW. .Mounts entries
# for binds carry only durable fields (the daemon-assigned propagation is
# also stable across recreate).
diff \
<(echo "$before" | jq '[.[0].Mounts[] | select(.Type == "bind") | {Source, Destination, Mode, RW}] | sort_by(.Destination)') \
<(echo "$after" | jq '[.[0].Mounts[] | select(.Type == "bind") | {Source, Destination, Mode, RW}] | sort_by(.Destination)')
# expected: empty diff
# NetworkSettings.Networks: a strict diff would fail because the new
# container gets a fresh IP / MAC / EndpointID — those are network-driver
# assignments, not part of the spec. Docker also auto-adds the new
# container's own short id as an alias on every attached network, which
# differs per instance. Project to the durable invariants — set of
# attached networks + the *user-assigned* aliases (filtering out the
# auto-added short id) — and diff those.
sid_before=$(echo "$before" | jq -r '.[0].Id[0:12]')
sid_after=$(echo "$after" | jq -r '.[0].Id[0:12]')
diff \
<(echo "$before" | jq --arg sid "$sid_before" '.[0].NetworkSettings.Networks | to_entries | map({net: .key, aliases: ((.value.Aliases // []) | map(select(. != $sid)) | sort)}) | sort_by(.net)') \
<(echo "$after" | jq --arg sid "$sid_after" '.[0].NetworkSettings.Networks | to_entries | map({net: .key, aliases: ((.value.Aliases // []) | map(select(. != $sid)) | sort)}) | sort_by(.net)')
# expected: empty diff
Cleanup:
docker rm -f fd-smoke-weird $(docker ps -a --filter name=fd-smoke-weird-old- -q)
docker network rm fd-front fd-back
rm -rf /tmp/fd-state /tmp/fd-secrets
Current limitations (do not file as bugs)
- Registry auth is out of scope. Pull works for Docker Hub anonymously and for any image already present in the local cache. GHCR / Quay / lscr.io land in Phase 5.
- Health timing is not yet configurable from the CLI/config. Phase 3 uses
built-in defaults (
HealthConfig::default()); sourcing it fromfreshdock.toml/labels is Phase 4/5.