Skip to content

Add a smoke test for the app image init script #52

Description

@paskal

base.alpine/files/init.sh performs every piece of runtime customisation the app image offers and it is the default entrypoint (base.alpine/Dockerfile:33), so it runs for every downstream image that keeps that entrypoint rather than overriding it or using /init-root.sh. Nothing verifies any of it. On a pull request the workflow builds the three images and stops there (.github/workflows/docker.yml:56-63: docker/build-push-action with push: false, and no later step consumes the result); on a push to master it builds and publishes app:master, and a tag publishes app:latest. A change that keeps the script syntactically valid but functionally wrong therefore reaches the registry with every check green.

That is not hypothetical. On current master (42d85b8) an invalid TIME_ZONE is announced as applied:

$ docker build -t baseimage-app base.alpine
$ docker run --rm -e TIME_ZONE=Nope/Nope baseimage-app id
init container
cp: can't stat '/usr/share/zoneinfo/Nope/Nope': No such file or directory
set timezone Nope/Nope (Thu Aug 20 02:10:16 CDT 2026)
custom APP_UID not defined, using default uid=1001
custom DOCKER_GID not defined, using default gid=999
execute id
uid=1001(app) gid=1001(app) groups=999(docker),1001(app)

cp fails at base.alpine/files/init.sh:9, the script has no set -e, and base.alpine/files/init.sh:11 reports the timezone as set while the container keeps the one it already had. #51 fixes that case; the gap that let it reach master unnoticed stays open, and the same gap covers APP_UID, DOCKER_GID, the two chowns and /srv/init.sh.

Impact

Every service built on the app image with the default entrypoint inherits this script. A silent regression changes the uid the container runs as, whether app ends up in a group carrying the mounted socket's GID (the docker group when the GID is free, the group already holding it otherwise, base.alpine/files/init.sh:22-48), or the ownership of /srv and /home/app. The first report then comes from a downstream project rather than from CI.

Proposed test

Save the script below as base.alpine/test.sh. It takes the image name as its argument and builds one throwaway derived image with root-owned files, so the chowns are checked by their effect rather than by the log:

#!/bin/bash
# smoke test for the app image, pass the image name as the first argument

image="${1:-umputun/baseimage:app-latest}"
derived="baseimage-smoketest-$$"
failed=0
tmpdir=$(mktemp -d)
trap 'rm -rf "${tmpdir}"; docker rmi -f "${derived}" >/dev/null 2>&1' EXIT

expect_out() { # name, expected substring, actual output
    if [[ "$3" == *"$2"* ]]; then echo "ok   - $1"; else
        echo "FAIL - $1: '$2' not found in:"
        printf '       %s\n' "$3"
        failed=1
    fi
}

expect_no_out() { # name, unexpected substring, actual output
    if [[ "$3" != *"$2"* ]]; then echo "ok   - $1"; else
        echo "FAIL - $1: '$2' unexpectedly present in:"
        printf '       %s\n' "$3"
        failed=1
    fi
}

expect_eq() { # name, expected output, actual output
    if [[ "$3" == "$2" ]]; then echo "ok   - $1"; else
        echo "FAIL - $1: expected '$2', got '$3'"
        failed=1
    fi
}

expect_code() { # name, expected code, actual code
    if [[ "$3" == "$2" ]]; then echo "ok   - $1"; else
        echo "FAIL - $1: expected exit $2, got $3"
        failed=1
    fi
}

# image with root-owned files in the directories init.sh is expected to chown
printf 'FROM %s\nUSER root\nRUN touch /srv/root-file /home/app/root-file && chown root:root /srv/root-file /home/app/root-file\n' "${image}" |
    docker build -q -t "${derived}" - >/dev/null || {
    echo "FAIL - cannot build the helper image from ${image}"
    exit 1
}

expect_out "runs as app user" "uid=1001(app) gid=1001(app)" \
    "$(docker run --rm "${image}" id 2>&1)"

expect_eq "applies TIME_ZONE" "JST" \
    "$(docker run --rm -e INIT_QUIET=1 -e TIME_ZONE=Asia/Tokyo "${image}" date +%Z 2>&1)"

expect_eq "records TIME_ZONE in /etc/timezone" "Asia/Tokyo" \
    "$(docker run --rm -e INIT_QUIET=1 -e TIME_ZONE=Asia/Tokyo "${image}" cat /etc/timezone 2>&1)"

expect_out "applies APP_UID" "uid=2000(app) gid=2000(app)" \
    "$(docker run --rm -e APP_UID=2000 "${image}" id 2>&1)"

expect_out "applies DOCKER_GID" "1500(docker)" \
    "$(docker run --rm -e DOCKER_GID=1500 "${image}" id 2>&1)"

expect_out "joins the group already holding DOCKER_GID" "998(ping)" \
    "$(docker run --rm -e DOCKER_GID=998 "${image}" id 2>&1)"

expect_eq "chowns /srv to app" "app:app" \
    "$(docker run --rm -e INIT_QUIET=1 "${derived}" stat -c '%U:%G' /srv/root-file 2>&1)"

expect_eq "chowns /home/app to app" "app:app" \
    "$(docker run --rm -e INIT_QUIET=1 "${derived}" stat -c '%U:%G' /home/app/root-file 2>&1)"

expect_eq "SKIP_HOME_CHOWN leaves /home/app alone" "root:root" \
    "$(docker run --rm -e INIT_QUIET=1 -e SKIP_HOME_CHOWN=1 "${derived}" stat -c '%U:%G' /home/app/root-file 2>&1)"

expect_eq "SKIP_HOME_CHOWN still chowns /srv" "app:app" \
    "$(docker run --rm -e INIT_QUIET=1 -e SKIP_HOME_CHOWN=1 "${derived}" stat -c '%U:%G' /srv/root-file 2>&1)"

printf '#!/bin/sh\necho "srv init ran"\n' >"${tmpdir}/init-ok.sh"
expect_out "runs /srv/init.sh" "srv init ran" \
    "$(docker run --rm -v "${tmpdir}/init-ok.sh:/srv/init.sh" "${image}" id 2>&1)"

printf '#!/bin/sh\nexit 3\n' >"${tmpdir}/init-fail.sh"
docker run --rm -v "${tmpdir}/init-fail.sh:/srv/init.sh" "${image}" id >/dev/null 2>&1
expect_code "stops when /srv/init.sh fails" "1" "$?"

quiet_out=$(docker run --rm -e INIT_QUIET=1 "${image}" id 2>&1)
expect_no_out "INIT_QUIET suppresses informational output" "init container" "${quiet_out}"
expect_out "INIT_QUIET still runs the command" "uid=1001(app)" "${quiet_out}"

bad_tz=$(docker run --rm -e TIME_ZONE=Nope/Nope "${image}" id 2>&1)
expect_out "reports an invalid TIME_ZONE" "error:" "${bad_tz}"
expect_out "starts anyway with an invalid TIME_ZONE" "uid=1001(app)" "${bad_tz}"

echo
if [[ ${failed} -eq 0 ]]; then echo "all checks passed"; else echo "some checks failed"; fi
exit ${failed}

Run it against a locally built image:

docker build -t baseimage-app base.alpine
bash base.alpine/test.sh baseimage-app

Results on my machine (Apple Silicon, Docker 29.7.2):

  • current master: 15 of the 16 checks pass, reports an invalid TIME_ZONE fails, which is the defect quoted above
  • Report init.sh setup failures and fix golangci-lint flags in readme #51: all 16 pass
  • master with the chown -R app:app /srv line and the SKIP_HOME_CHOWN block removed from init.sh: chowns /srv to app, chowns /home/app to app and SKIP_HOME_CHOWN still chowns /srv fail on top of the timezone one, so the suite does catch a silently dropped step

A full run takes about 40 seconds, most of it container startup and the one helper image build. An image built with docker buildx build --platform linux/arm64 --load behaves the same as one built by plain docker build, so the wiring below has the image available to the runner.

Wiring it into CI

Option 1, run it in the existing build job. Add load: true and a tag to the PR validation step, then run the script for the app image:

      - name: build only (PR validation)
        if: github.event_name == 'pull_request'
        uses: docker/build-push-action@v7
        with:
          context: ${{ matrix.image.context }}
          file: ${{ matrix.image.dockerfile }}
          platforms: ${{ matrix.platform.os }}
          push: false
          load: true
          tags: baseimage-${{ matrix.image.name }}:test

      - name: smoke test the app image
        if: github.event_name == 'pull_request' && matrix.image.name == 'app'
        run: bash base.alpine/test.sh baseimage-app:test

Spliced into the current workflow it adds no new actionlint findings. No new job, and it covers both amd64 and arm64 because each matrix leg builds for its own runner. The trade-off is that it only guards pull requests: a push to master builds with push: true and never loads the image, so the script would not run there.

Option 2, a separate test job. One job on ubuntu-latest that builds the app image for its own platform and runs the script, on pushes as well as pull requests. The build matrix stays untouched, at the cost of an extra job and an extra image build per run, and amd64-only coverage unless that job is itself a matrix.

Option 3, leave it as is and keep relying on downstream projects to notice.

I would take option 1: the images are already built there, 40 seconds is negligible next to the build itself, and pull requests are where changes to the script arrive. A test_app target in the Makefile would make the same script the local check.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions