test: cover dokku container healthcheck wiring

Adds a bats lint test that guards the static wiring (nginx conf, dokku-restore finish-script ordering, my_init sentinel reset, and the Dockerfile HEALTHCHECK line) plus a docker smoke test that boots the built image, waits for the health flip, exercises the loopback endpoint, asserts the port is not published to the host, and verifies the negative path. The smoke test is invoked via a new `make test-image-healthcheck` target and runs automatically in the build-image action after `docker buildx --load`.
This commit is contained in:
Jose Diaz-Gonzalez
2026-05-10 21:39:43 -04:00
parent cc0843391f
commit 2a4393a63a
4 changed files with 196 additions and 0 deletions

View File

@@ -26,6 +26,12 @@ runs:
shell: bash
run: |
docker buildx build \
--load \
--progress plain \
--tag "dokku/dokku:latest" \
.
- name: smoke test image
shell: bash
run: |
make test-image-healthcheck IMAGE_TAG=dokku/dokku:latest

View File

@@ -226,3 +226,8 @@ vagrant-acl-add:
vagrant-dokku:
vagrant ssh -- "sudo -H -u root bash -c 'dokku $(RUN_ARGS)'"
IMAGE_TAG ?= dokku/dokku:latest
test-image-healthcheck:
IMAGE_TAG=$(IMAGE_TAG) ./tests/image/healthcheck.sh

128
tests/image/healthcheck.sh Executable file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_TAG="${IMAGE_TAG:-dokku/dokku:latest}"
CONTAINER_NAME="${CONTAINER_NAME:-dokku-healthcheck-smoke}"
START_TIMEOUT_SECONDS="${START_TIMEOUT_SECONDS:-360}"
UNHEALTHY_TIMEOUT_SECONDS="${UNHEALTHY_TIMEOUT_SECONDS:-180}"
log-info() {
echo "-----> $*"
}
log-fail() {
echo " ! $*" >&2
exit 1
}
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
require-image() {
if ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then
log-fail "Image '$IMAGE_TAG' not found in local docker daemon. Build it first (eg. 'docker build -t $IMAGE_TAG .')."
fi
}
start-container() {
log-info "Starting container '$CONTAINER_NAME' from image '$IMAGE_TAG'"
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker run -d \
--name "$CONTAINER_NAME" \
--env DOKKU_HOSTNAME=dokku.me \
-v /var/run/docker.sock:/var/run/docker.sock \
"$IMAGE_TAG" >/dev/null
}
wait-for-catchall-vhost() {
local deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
if docker exec "$CONTAINER_NAME" test -f /etc/nginx/conf.d/00-default-vhost.conf; then
return 0
fi
sleep 1
done
log-fail "Catch-all default vhost /etc/nginx/conf.d/00-default-vhost.conf was never created. \
Likely cause: a stale build/package/dokku-*.deb that predates the catch-all template shipped in \
plugins/nginx-vhosts/templates/default-site.conf. Rebuild the deb before running this test."
}
wait-for-healthy() {
local deadline=$((SECONDS + START_TIMEOUT_SECONDS))
local status
while (( SECONDS < deadline )); do
status=$(docker inspect --format='{{.State.Health.Status}}' "$CONTAINER_NAME" 2>/dev/null || echo "missing")
if [[ "$status" == "healthy" ]]; then
log-info "Container reached healthy status"
return 0
fi
sleep 5
done
log-info "Container failed to become healthy within ${START_TIMEOUT_SECONDS}s. Last logs:"
docker logs --tail 100 "$CONTAINER_NAME" || true
log-fail "Container '$CONTAINER_NAME' did not reach healthy status (last status: ${status:-unknown})"
}
assert-endpoint-ok() {
log-info "Verifying /_dokku/health returns ok from inside the container"
local body
body=$(docker exec "$CONTAINER_NAME" curl -fsS http://127.0.0.1:18080/_dokku/health)
if [[ "$body" != "ok" ]]; then
log-fail "Expected health endpoint body 'ok', got: ${body}"
fi
}
assert-port-not-published() {
log-info "Verifying port 18080 is not published to the host"
local ports
ports=$(docker inspect --format='{{json .NetworkSettings.Ports}}' "$CONTAINER_NAME")
if echo "$ports" | grep -q "18080/tcp"; then
log-fail "Port 18080/tcp should not be published to the host. Inspect output: $ports"
fi
if curl --max-time 2 -fsS http://127.0.0.1:18080/_dokku/health >/dev/null 2>&1; then
log-fail "Health endpoint is reachable from the host on 127.0.0.1:18080 - it must be loopback-only inside the container."
fi
}
assert-unhealthy-after-sentinel-removed() {
log-info "Removing /var/run/dokku/ready and confirming endpoint returns failure"
docker exec "$CONTAINER_NAME" rm -f /var/run/dokku/ready
local exit_code=0
docker exec "$CONTAINER_NAME" curl -fsS http://127.0.0.1:18080/_dokku/health >/dev/null 2>&1 || exit_code=$?
if (( exit_code == 0 )); then
log-fail "Health endpoint still returned success after the readiness sentinel was removed"
fi
log-info "Waiting for docker to flip the health status to unhealthy"
local deadline=$((SECONDS + UNHEALTHY_TIMEOUT_SECONDS))
local status
while (( SECONDS < deadline )); do
status=$(docker inspect --format='{{.State.Health.Status}}' "$CONTAINER_NAME" 2>/dev/null || echo "missing")
if [[ "$status" == "unhealthy" ]]; then
log-info "Container flipped to unhealthy"
return 0
fi
sleep 5
done
log-fail "Container did not flip to unhealthy within ${UNHEALTHY_TIMEOUT_SECONDS}s (last status: ${status:-unknown})"
}
main() {
require-image
start-container
wait-for-catchall-vhost
wait-for-healthy
assert-endpoint-ok
assert-port-not-published
assert-unhealthy-after-sentinel-removed
log-info "All healthcheck smoke tests passed."
}
main "$@"

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bats
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
NGINX_CONF="${REPO_ROOT}/docker/etc/nginx/conf.d/zz-dokku-health.conf"
FINISH_SCRIPT="${REPO_ROOT}/docker/etc/runit/runsvdir/default/dokku-restore/finish"
MY_INIT_SCRIPT="${REPO_ROOT}/docker/etc/my_init.d/10_dokku_init"
DOCKERFILE="${REPO_ROOT}/Dockerfile"
@test "(docker-image) [healthcheck] nginx health conf is shipped via the docker overlay" {
[[ -f "$NGINX_CONF" ]]
grep -qE "listen[[:space:]]+127\.0\.0\.1:18080" "$NGINX_CONF"
grep -qE "location = /_dokku/health" "$NGINX_CONF"
grep -qE "/var/run/dokku/ready" "$NGINX_CONF"
grep -qE "default_type[[:space:]]+text/plain" "$NGINX_CONF"
}
@test "(docker-image) [healthcheck] finish script touches sentinel after waiting for sshd and nginx" {
[[ -f "$FINISH_SCRIPT" ]]
local sshd_line nginx_line touch_line
sshd_line=$(grep -nE "/dev/tcp/127\.0\.0\.1/22" "$FINISH_SCRIPT" | head -n1 | cut -d: -f1)
nginx_line=$(grep -nE "/dev/tcp/127\.0\.0\.1/80" "$FINISH_SCRIPT" | head -n1 | cut -d: -f1)
touch_line=$(grep -nE "touch /var/run/dokku/ready" "$FINISH_SCRIPT" | head -n1 | cut -d: -f1)
[[ -n "$sshd_line" ]]
[[ -n "$nginx_line" ]]
[[ -n "$touch_line" ]]
(( touch_line > sshd_line ))
(( touch_line > nginx_line ))
}
@test "(docker-image) [healthcheck] my_init clears stale sentinel on container boot" {
[[ -f "$MY_INIT_SCRIPT" ]]
local rm_line mkdir_line plugin_line hostname_line
rm_line=$(grep -nE "rm -f /var/run/dokku/ready" "$MY_INIT_SCRIPT" | head -n1 | cut -d: -f1)
mkdir_line=$(grep -nE "mkdir -p /var/run/dokku( |$)" "$MY_INIT_SCRIPT" | head -n1 | cut -d: -f1)
plugin_line=$(grep -nE "plugin:install" "$MY_INIT_SCRIPT" | head -n1 | cut -d: -f1)
hostname_line=$(grep -nE "domains:set-global" "$MY_INIT_SCRIPT" | head -n1 | cut -d: -f1)
[[ -n "$rm_line" ]]
[[ -n "$mkdir_line" ]]
[[ -n "$plugin_line" ]]
[[ -n "$hostname_line" ]]
(( rm_line < plugin_line ))
(( rm_line < hostname_line ))
(( mkdir_line < plugin_line ))
(( mkdir_line < hostname_line ))
}
@test "(docker-image) [healthcheck] Dockerfile declares HEALTHCHECK with the expected endpoint" {
[[ -f "$DOCKERFILE" ]]
grep -qE "^HEALTHCHECK" "$DOCKERFILE"
grep -qE "http://127\.0\.0\.1:18080/_dokku/health" "$DOCKERFILE"
grep -qE "\-\-start-period" "$DOCKERFILE"
}