diff --git a/.github/actions/build-image/action.yml b/.github/actions/build-image/action.yml index b64856f56..e25c4ec59 100644 --- a/.github/actions/build-image/action.yml +++ b/.github/actions/build-image/action.yml @@ -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 diff --git a/Makefile b/Makefile index 10408d299..6fafe4b3c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/tests/image/healthcheck.sh b/tests/image/healthcheck.sh new file mode 100755 index 000000000..49f114e1a --- /dev/null +++ b/tests/image/healthcheck.sh @@ -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 "$@" diff --git a/tests/unit/docker-image-healthcheck.bats b/tests/unit/docker-image-healthcheck.bats new file mode 100644 index 000000000..e91a65829 --- /dev/null +++ b/tests/unit/docker-image-healthcheck.bats @@ -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" +}