From 58fbaa4192f3429c096d91eecd8662b1c056ca4f Mon Sep 17 00:00:00 2001 From: prasadvamer Date: Mon, 30 Mar 2026 18:29:08 +0900 Subject: [PATCH] security: harden Docker image and fix CVEs flagged by Docker Hub - Upgrade base image from ubuntu:22.04 to ubuntu:24.04 - Bump GitHub Actions runner to 2.333.1 with SHA256 verification - Bump Docker Compose to 2.40.3 with SHA256 verification - Pin Node.js to v22 LTS via Volta - Replace NOPASSWD sudo with gosu for privilege dropping - Restrict git safe.directory from wildcard to specific paths - Replace chmod 666 on docker.sock with GID-matching + chmod 660 - Add RUNNER_TOKEN_FILE support for file-based secrets - Add HEALTHCHECK, OCI labels, and apt cache cleanup - Add Makefile and test suite for CI validation - Update tests to match hardened configuration --- .dockerignore | 4 +- .github/workflows/publish-image.yml | 2 +- .github/workflows/test-image.yml | 27 ++++++ .gitignore | 5 +- Dockerfile | 83 +++++++++++------ Makefile | 9 ++ README.md | 44 ++++++++++ entrypoint.sh | 88 ++++++++++++------- tests/helpers.sh | 110 +++++++++++++++++++++++ tests/run-tests.sh | 46 ++++++++++ tests/test_binaries.sh | 35 ++++++++ tests/test_build.sh | 31 +++++++ tests/test_custom_setup.sh | 50 +++++++++++ tests/test_directory_structure.sh | 38 ++++++++ tests/test_docker_modes.sh | 132 ++++++++++++++++++++++++++++ tests/test_entrypoint_env.sh | 36 ++++++++ tests/test_git_config.sh | 26 ++++++ 17 files changed, 705 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/test-image.yml create mode 100644 Makefile create mode 100755 tests/helpers.sh create mode 100755 tests/run-tests.sh create mode 100755 tests/test_binaries.sh create mode 100755 tests/test_build.sh create mode 100755 tests/test_custom_setup.sh create mode 100755 tests/test_directory_structure.sh create mode 100755 tests/test_docker_modes.sh create mode 100755 tests/test_entrypoint_env.sh create mode 100755 tests/test_git_config.sh diff --git a/.dockerignore b/.dockerignore index 0d167da..c140b39 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,8 +6,10 @@ env/ *.env -# CI/CD (not needed inside the image) +# CI/CD and tests (not needed inside the image) .github +tests/ +Makefile *.md LICENSE diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index b0bd501..69d3f7e 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -60,7 +60,7 @@ jobs: type=sha,prefix= - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/test-image.yml b/.github/workflows/test-image.yml new file mode 100644 index 0000000..29d5588 --- /dev/null +++ b/.github/workflows/test-image.yml @@ -0,0 +1,27 @@ +name: Test Docker Image + +on: + pull_request: + branches: + - main + paths: + - 'Dockerfile' + - '.dockerignore' + - 'entrypoint.sh' + - 'tests/**' + - 'Makefile' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Run test suite + run: make test diff --git a/.gitignore b/.gitignore index 54b875f..32a6357 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ # Env files: ignore all in env/ except the sample template env/* *.env -!sample.env \ No newline at end of file +!sample.env + +.claude +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 362595c..79aeb14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,31 @@ # syntax=docker/dockerfile:1.5 -FROM --platform=$TARGETPLATFORM ubuntu:22.04 +FROM --platform=$TARGETPLATFORM ubuntu:24.04 -ARG RUNNER_VERSION=2.333.0 +ARG RUNNER_VERSION=2.333.1 ARG TARGETARCH ARG TARGETPLATFORM +# --- Checksums for supply-chain integrity verification --- +# Runner checksums from: https://github.com/actions/runner/releases/tag/v2.333.1 +ARG RUNNER_SHA256_AMD64=18f8f68ed1892854ff2ab1bab4fcaa2f5abeedc98093b6cb13638991725cab74 +ARG RUNNER_SHA256_ARM64=69ac7e5692f877189e7dddf4a1bb16cbbd6425568cd69a0359895fac48b9ad3b + +# Compose checksums from: https://github.com/docker/compose/releases/tag/v2.40.3 +ARG COMPOSE_VERSION=2.40.3 +ARG COMPOSE_SHA256_AMD64=dba9d98e1ba5bfe11d88c99b9bd32fc4a0624a30fafe68eea34d61a3e42fd372 +ARG COMPOSE_SHA256_ARM64=d26373b19e89160546d15407516cc59f453030d9bc5b43ba7faf16f7b4980137 + +# Node.js LTS pinned version +ARG NODE_VERSION=22 + ENV DEBIAN_FRONTEND=noninteractive -# Install base dependencies + Docker CLI +LABEL org.opencontainers.image.title="GitHub Actions Self-Hosted Runner" \ + org.opencontainers.image.description="Dockerized GitHub Actions self-hosted runner with Docker-in-Docker support" \ + org.opencontainers.image.source="https://github.com/prasadvamer/dev-env-github-selfhosted-runner-dockerized" \ + org.opencontainers.image.licenses="MIT" + +# Install system dependencies + Docker Engine from Docker apt repo RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ set -eux; \ @@ -17,69 +35,84 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ curl \ git \ jq \ - sudo \ tar \ gzip \ - docker.io \ + gosu \ ; \ - rm -rf /var/lib/apt/lists/* + install -m 0755 -d /etc/apt/keyrings; \ + curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc; \ + chmod a+r /etc/apt/keyrings/docker.asc; \ + . /etc/os-release; \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list; \ + apt-get update; \ + apt-get install -y --no-install-recommends docker-ce docker-ce-cli containerd.io -RUN useradd -m runner && echo "runner ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers +# Create runner user WITHOUT blanket sudo access +RUN useradd -m runner -# Configure Git globally to avoid permission issues -# Create .gitconfig for both root and runner user -RUN git config --system --add safe.directory '*' && \ +# Configure Git: restrict safe.directory to runner paths only (not wildcard) +# The work directory is added dynamically in entrypoint.sh +RUN git config --system --add safe.directory /actions-runner && \ git config --system core.fileMode false && \ mkdir -p /root && touch /root/.gitconfig && chmod 644 /root/.gitconfig && \ mkdir -p /home/runner && touch /home/runner/.gitconfig && \ chown -R runner:runner /home/runner -# Allow runner to use host Docker (socket mounted at /var/run/docker.sock) -# GID 999 is default docker group on most Linux; adjust if your host differs +# Allow runner to use Docker via group membership RUN groupadd -g 999 -f docker 2>/dev/null || true && usermod -aG docker runner -# Install Docker Compose V2: standalone binary + plugin so both "docker-compose" and "docker compose" work -ARG COMPOSE_VERSION=2.24.5 +# Install Docker Compose V2 with checksum verification RUN set -eux; \ case "${TARGETARCH}" in \ - arm64) ARCH="aarch64" ;; \ - amd64) ARCH="x86_64" ;; \ + arm64) ARCH="aarch64"; CHECKSUM="${COMPOSE_SHA256_ARM64}" ;; \ + amd64) ARCH="x86_64"; CHECKSUM="${COMPOSE_SHA256_AMD64}" ;; \ *) echo "Unsupported: ${TARGETARCH}" >&2; exit 1 ;; \ esac; \ - curl -fL "https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-${ARCH}" -o /tmp/docker-compose; \ + curl -fL "https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-${ARCH}" \ + -o /tmp/docker-compose; \ + echo "${CHECKSUM} /tmp/docker-compose" | sha256sum -c -; \ chmod +x /tmp/docker-compose; \ mv /tmp/docker-compose /usr/local/bin/docker-compose; \ mkdir -p /usr/local/lib/docker/cli-plugins; \ cp /usr/local/bin/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose -# Install Node.js and npm via Volta +# Install Node.js via Volta with pinned version ENV VOLTA_HOME=/usr/local/volta ENV PATH="${VOLTA_HOME}/bin:${PATH}" -RUN curl -fsSL https://get.volta.sh | bash && volta install node +RUN set -eux; \ + curl -fsSL https://get.volta.sh -o /tmp/volta-install.sh; \ + bash /tmp/volta-install.sh; \ + rm /tmp/volta-install.sh; \ + volta install node@${NODE_VERSION} WORKDIR /actions-runner -# Download correct runner binary for architecture +# Download runner binary WITH checksum verification RUN set -eux; \ case "${TARGETARCH}" in \ - arm64) ARCH="arm64" ;; \ - amd64) ARCH="x64" ;; \ + arm64) ARCH="arm64"; CHECKSUM="${RUNNER_SHA256_ARM64}" ;; \ + amd64) ARCH="x64"; CHECKSUM="${RUNNER_SHA256_AMD64}" ;; \ *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ esac; \ curl -fL -o actions-runner.tar.gz \ "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-${ARCH}-${RUNNER_VERSION}.tar.gz"; \ + echo "${CHECKSUM} actions-runner.tar.gz" | sha256sum -c -; \ tar xzf actions-runner.tar.gz; \ rm actions-runner.tar.gz +# Install runner dependencies and clean up RUN set -eux; \ ./bin/installdependencies.sh; \ - rm -rf /var/lib/apt/lists/* + apt-get clean; \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/* -# Custom setup: mount scripts here and they run as root before the runner starts (see README) +# Custom setup directory RUN mkdir -p /runner-custom-setup.d COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -# Start as root to fix Docker socket permissions, then switch to runner +HEALTHCHECK --interval=60s --timeout=10s --start-period=120s --retries=3 \ + CMD pgrep -f "Runner.Listener" > /dev/null || exit 1 + ENTRYPOINT ["/entrypoint.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..41661f3 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +TEST_IMAGE ?= ghrunner-test:local + +.PHONY: test test-build + +test: + @TEST_IMAGE=$(TEST_IMAGE) bash tests/run-tests.sh + +test-build: + docker build -t $(TEST_IMAGE) . diff --git a/README.md b/README.md index 8b433e9..77e9f91 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Run GitHub Actions workflows on your own infrastructure using this Docker image. - [Single and multiple runners](#single-and-multiple-runners) - [Docker and Docker Compose in workflows](#docker-and-docker-compose-in-workflows) - [Platform notes (arm64 and amd64)](#platform-notes-arm64-and-amd64) +- [Testing](#testing) - [Troubleshooting](#troubleshooting) - [Build from source](#build-from-source) - [Publishing (GHCR and Docker Hub)](#publishing-ghcr-and-docker-hub) @@ -358,6 +359,49 @@ Use the image on the same architecture you built or pulled it for. --- +## Testing + +The repo includes a test suite that builds the image and validates it locally. Requires Docker. + +```bash +make test +``` + +This builds the image, then runs all test suites under `tests/`: + +| Suite | What it checks | +|-------|---------------| +| `test_build` | Image metadata — entrypoint, workdir, base OS, env vars | +| `test_binaries` | All expected binaries are installed — docker, dockerd, containerd, git, node, npm, docker-compose, jq, curl, sudo, tar | +| `test_git_config` | `safe.directory`, `core.fileMode`, `.gitconfig` ownership | +| `test_directory_structure` | Runner files, custom-setup dir, user/group membership, sudo access | +| `test_entrypoint_env` | Required env var validation (`REPO_URL`, `RUNNER_TOKEN`, `RUNNER_NAME`) and defaults | +| `test_custom_setup` | Custom setup scripts execute in sorted order, failures abort | +| `test_docker_modes` | **Integration** — all three Docker modes run a real container (`hello-world`) end-to-end | + +### CI pipeline + +| Workflow | Trigger | Purpose | +|----------|---------|---------| +| `test-image.yml` | PR to `main` | Builds image + runs full test suite (merge gate) | +| `publish-image.yml` | Push to `main` | Builds multi-arch image + publishes to GHCR and Docker Hub | + +Tests must pass on the PR before merging. The publish workflow runs only after merge. + +### Running a single test + +Each test file can run standalone: + +```bash +# Build once +docker build -t ghrunner-test:local . + +# Run one suite +TEST_IMAGE=ghrunner-test:local bash tests/test_binaries.sh +``` + +--- + ## Troubleshooting **"A session for this runner already exists" / "Runner connect error: Conflict"** diff --git a/entrypoint.sh b/entrypoint.sh index 06354d1..0f0bb58 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,16 +1,12 @@ #!/usr/bin/env bash -set -e +set -euo pipefail -# Docker mode: (1) host socket mounted, (2) remote daemon (DOCKER_HOST), or (3) internal daemon (DinD in same container) +# --- Docker mode: (1) host socket, (2) remote daemon (DOCKER_HOST), (3) internal DinD --- if [ -S /var/run/docker.sock ]; then - # Host socket mounted — use host daemon : elif [ -n "${DOCKER_HOST:-}" ]; then - # User specified a remote daemon — use DOCKER_HOST (exported later) : else - # No socket and no DOCKER_HOST: start internal Docker daemon (self-contained DinD). Jobs use this daemon. - # Requires container run with --privileged (or equivalent caps). Storage driver vfs works in most environments. export RUNNER_SKIP_WORK_DIR_MOUNT_CHECK=1 echo "Starting internal Docker daemon (DinD mode)..." mkdir -p /var/run @@ -28,13 +24,17 @@ else echo "Internal Docker daemon ready." fi -# Fix Docker socket permissions (needed for Docker Desktop on Mac / some Linux setups, or internal daemon) +# --- Docker socket permissions: group-based, NOT world-writable --- if [ -S /var/run/docker.sock ]; then - chmod 666 /var/run/docker.sock + SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo "0") + if [ "$SOCK_GID" != "0" ]; then + groupmod -g "$SOCK_GID" docker 2>/dev/null || true + fi + chmod 660 /var/run/docker.sock + chgrp docker /var/run/docker.sock 2>/dev/null || true fi -# Make /root directory and .gitconfig readable by all users -# This is needed because actions/checkout tries to stat /root/.gitconfig +# --- Git config and /root access --- chmod 755 /root if [ -f /root/.gitconfig ]; then chmod 644 /root/.gitconfig @@ -43,17 +43,18 @@ else chmod 644 /root/.gitconfig fi -# Ensure runner home directory has proper permissions chown -R runner:runner /home/runner -# Set the work directory (defaults to /tmp/github-runner-work for Docker-in-Docker compatibility) +# --- Work directory --- WORK_DIR="${RUNNER_WORK_DIR:-/tmp/github-runner-work}" -WORK_DIR="${WORK_DIR%/}" # trim trailing slash +WORK_DIR="${WORK_DIR%/}" mkdir -p "$WORK_DIR" chown -R runner:runner "$WORK_DIR" -# Require work directory to be a bind mount so it's shared with the host (needed for Docker/Compose in jobs). -# On Docker Desktop for Mac the path may not appear as its own mount point; set RUNNER_SKIP_WORK_DIR_MOUNT_CHECK=1 to skip. +# Add work directory to git safe.directory at runtime (not wildcard) +git config --system --add safe.directory "${WORK_DIR}" 2>/dev/null || true + +# --- Mount check --- is_mountpoint() { if command -v mountpoint >/dev/null 2>&1; then mountpoint -q "$1" 2>/dev/null @@ -76,38 +77,47 @@ if [ -z "${RUNNER_SKIP_WORK_DIR_MOUNT_CHECK:-}" ]; then fi fi -# Run custom setup scripts (as root) before starting the runner. Mount scripts at /runner-custom-setup.d. +# --- Custom setup scripts (as root) with safe filename handling --- if [ -d /runner-custom-setup.d ]; then - for f in $(find /runner-custom-setup.d -maxdepth 1 -type f \( -name "*.sh" -o -executable \) 2>/dev/null | sort); do - echo "Running custom setup: $f" - case "$f" in *.sh) bash "$f" ;; *) "$f" ;; esac || exit 1 - done + find /runner-custom-setup.d -maxdepth 1 -type f \( -name "*.sh" -o -executable \) -print0 2>/dev/null | \ + sort -z | \ + while IFS= read -r -d '' f; do + # Only execute scripts owned by root to prevent injection via mounted volumes + if [ "$(stat -c '%u' "$f")" != "0" ]; then + echo "WARNING: Skipping $f -- not owned by root" + continue + fi + echo "Running custom setup: $f" + case "$f" in *.sh) bash "$f" ;; *) "$f" ;; esac || exit 1 + done +fi + +# --- Support file-based secrets (Docker secrets / mounted files) --- +if [ -n "${RUNNER_TOKEN_FILE:-}" ] && [ -f "${RUNNER_TOKEN_FILE}" ]; then + RUNNER_TOKEN="$(cat "$RUNNER_TOKEN_FILE")" fi -# Preserve environment variables and switch to runner user -# Export all required variables so they're available in the subshell +# --- Export environment for runner subprocess --- export HOME=/home/runner -export REPO_URL="${REPO_URL}" -export RUNNER_TOKEN="${RUNNER_TOKEN}" -export RUNNER_NAME="${RUNNER_NAME}" +export REPO_URL="${REPO_URL:-}" +export RUNNER_TOKEN="${RUNNER_TOKEN:-}" +export RUNNER_NAME="${RUNNER_NAME:-}" export RUNNER_LABELS="${RUNNER_LABELS:-self-hosted,docker}" export WORK_DIR="$WORK_DIR" # Docker: support both host socket mount and DinD/remote daemon (DOCKER_HOST) -# When DOCKER_HOST is set (e.g. tcp://dind:2375), workflow steps use that daemon; no host socket needed. export DOCKER_HOST="${DOCKER_HOST:-}" export DOCKER_TLS_VERIFY="${DOCKER_TLS_VERIFY:-}" export DOCKER_CERT_PATH="${DOCKER_CERT_PATH:-}" -# Ensure Node/npm (Volta) are on PATH for job steps (su can reset env) +# Ensure Node/npm (Volta) are on PATH for job steps export VOLTA_HOME="${VOLTA_HOME:-/usr/local/volta}" export PATH="${VOLTA_HOME}/bin:${PATH}" -# Switch to runner user; set PATH/VOLTA_HOME inside su so job steps see node/npm -exec su runner -c 'export VOLTA_HOME=/usr/local/volta; export PATH=/usr/local/volta/bin:$PATH; cd /actions-runner && bash -s' << 'RUNNER_SCRIPT' -set -e +# Switch to runner user via gosu (more secure than su -m) +exec gosu runner bash -s << 'RUNNER_SCRIPT' +set -euo pipefail -# Set HOME explicitly for the runner process export HOME=/home/runner export VOLTA_HOME=/usr/local/volta export PATH=/usr/local/volta/bin:$PATH @@ -120,6 +130,8 @@ for v in "${required_vars[@]}"; do fi done +cd /actions-runner + ./config.sh --unattended \ --url "${REPO_URL}" \ --token "${RUNNER_TOKEN}" \ @@ -128,12 +140,22 @@ done --work "${WORK_DIR}" \ --replace +# Clear token from environment after registration +RUNNER_TOKEN_FILE_PATH="${RUNNER_TOKEN_FILE:-}" +unset RUNNER_TOKEN + cleanup() { echo "Unregistering runner..." - ./config.sh remove --unattended --token "${RUNNER_TOKEN}" || true + if [ -n "${RUNNER_TOKEN_FILE_PATH}" ] && [ -f "${RUNNER_TOKEN_FILE_PATH}" ]; then + local token + token="$(cat "$RUNNER_TOKEN_FILE_PATH")" + ./config.sh remove --unattended --token "$token" || true + else + echo "WARNING: Cannot unregister -- RUNNER_TOKEN already cleared from environment." + echo "Use RUNNER_TOKEN_FILE for automatic deregistration on shutdown." + fi } trap cleanup INT TERM EXIT - ./run.sh RUNNER_SCRIPT diff --git a/tests/helpers.sh b/tests/helpers.sh new file mode 100755 index 0000000..e09e56d --- /dev/null +++ b/tests/helpers.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Shared test helpers — sourced by each test_*.sh script + +TEST_IMAGE="${TEST_IMAGE:-ghrunner-test:local}" +PASS_COUNT=0 +FAIL_COUNT=0 +SKIP_COUNT=0 +TEST_COUNT=0 + +# Colors (disabled if not a TTY) +if [ -t 1 ]; then + GREEN='\033[0;32m' + RED='\033[0;31m' + YELLOW='\033[0;33m' + BOLD='\033[1m' + RESET='\033[0m' +else + GREEN='' RED='' YELLOW='' BOLD='' RESET='' +fi + +pass() { + ((PASS_COUNT++)) || true + ((TEST_COUNT++)) || true + printf " ${GREEN}PASS${RESET} %s\n" "$1" +} + +fail() { + ((FAIL_COUNT++)) || true + ((TEST_COUNT++)) || true + printf " ${RED}FAIL${RESET} %s\n" "$1" + [ -n "${2:-}" ] && printf " expected: %s\n got: %s\n" "$3" "$2" +} + +skip() { + ((SKIP_COUNT++)) || true + printf " ${YELLOW}SKIP${RESET} %s — %s\n" "$1" "$2" +} + +assert_eq() { + local actual="$1" expected="$2" desc="$3" + if [ "$actual" = "$expected" ]; then + pass "$desc" + else + fail "$desc" "$actual" "$expected" + fi +} + +assert_contains() { + local haystack="$1" needle="$2" desc="$3" + if echo "$haystack" | grep -qF "$needle"; then + pass "$desc" + else + fail "$desc" "$haystack" "*$needle*" + fi +} + +assert_not_empty() { + local value="$1" desc="$2" + if [ -n "$value" ]; then + pass "$desc" + else + fail "$desc" "(empty)" "(non-empty)" + fi +} + +assert_exit_zero() { + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then + pass "$desc" + else + fail "$desc" "exit $?" "exit 0" + fi +} + +assert_exit_nonzero() { + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then + fail "$desc" "exit 0" "non-zero" + else + pass "$desc" + fi +} + +test_header() { + printf "\n${BOLD}=== %s ===${RESET}\n" "$1" +} + +test_summary() { + printf "\n${BOLD}--- %d tests: ${GREEN}%d passed${RESET}" "$TEST_COUNT" "$PASS_COUNT" + [ "$FAIL_COUNT" -gt 0 ] && printf ", ${RED}%d failed${RESET}" "$FAIL_COUNT" + [ "$SKIP_COUNT" -gt 0 ] && printf ", ${YELLOW}%d skipped${RESET}" "$SKIP_COUNT" + printf " ---${RESET}\n" + [ "$FAIL_COUNT" -eq 0 ] +} + +# Find Docker socket (macOS Docker Desktop vs Linux) +find_docker_socket() { + if [ -S /var/run/docker.sock ]; then + echo "/var/run/docker.sock" + elif [ -S "${HOME}/.docker/run/docker.sock" ]; then + echo "${HOME}/.docker/run/docker.sock" + else + echo "" + fi +} + +# Run a command inside the test image +run_in_image() { + docker run --rm --entrypoint bash "$TEST_IMAGE" -c "$1" +} diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..74d967b --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +source "$SCRIPT_DIR/helpers.sh" + +TOTAL_SUITES=0 +PASSED_SUITES=0 +FAILED_SUITES=0 +FAILED_NAMES=() + +printf "${BOLD}Building test image: %s${RESET}\n" "$TEST_IMAGE" +if ! docker build -t "$TEST_IMAGE" "$REPO_ROOT"; then + printf "${RED}Image build failed — aborting tests.${RESET}\n" + exit 1 +fi +printf "${GREEN}Build succeeded.${RESET}\n" + +for test_file in "$SCRIPT_DIR"/test_*.sh; do + suite_name="$(basename "$test_file" .sh)" + ((TOTAL_SUITES++)) || true + + if bash "$test_file"; then + ((PASSED_SUITES++)) || true + else + ((FAILED_SUITES++)) || true + FAILED_NAMES+=("$suite_name") + fi +done + +printf "\n${BOLD}========== FINAL RESULTS ==========${RESET}\n" +printf "Suites: %d total, ${GREEN}%d passed${RESET}" "$TOTAL_SUITES" "$PASSED_SUITES" +[ "$FAILED_SUITES" -gt 0 ] && printf ", ${RED}%d failed${RESET}" "$FAILED_SUITES" +printf "\n" + +if [ "$FAILED_SUITES" -gt 0 ]; then + printf "${RED}Failed suites:${RESET}\n" + for name in "${FAILED_NAMES[@]}"; do + printf " - %s\n" "$name" + done + exit 1 +fi + +printf "${GREEN}All tests passed.${RESET}\n" diff --git a/tests/test_binaries.sh b/tests/test_binaries.sh new file mode 100755 index 0000000..6e35b89 --- /dev/null +++ b/tests/test_binaries.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers.sh" + +test_header "Binary Availability" + +check_binary() { + local name="$1" cmd="$2" pattern="$3" + local output + output=$(run_in_image "$cmd" 2>&1) || true + if echo "$output" | grep -qi "$pattern"; then + pass "$name is installed" + else + fail "$name is installed" "$output" "*$pattern*" + fi +} + +check_binary "docker" "docker --version" "Docker version" +check_binary "dockerd" "dockerd --version" "Docker version" +check_binary "containerd" "containerd --version" "containerd" +check_binary "git" "git --version" "git version" +check_binary "node" "node --version" "v" +check_binary "npm" "npm --version" "." +check_binary "docker-compose" "docker-compose --version" "2.40" +check_binary "jq" "jq --version" "jq-" +check_binary "curl" "curl --version" "curl" +check_binary "gosu" "gosu --version" "." +check_binary "tar" "tar --version" "tar" + +# Docker Compose plugin form +output=$(run_in_image "docker compose version" 2>&1) || true +assert_contains "$output" "2.40" "docker compose (plugin) works" + +test_summary diff --git a/tests/test_build.sh b/tests/test_build.sh new file mode 100755 index 0000000..303a237 --- /dev/null +++ b/tests/test_build.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers.sh" + +test_header "Image Build & Metadata" + +# Image exists +assert_exit_zero "Image exists" docker inspect "$TEST_IMAGE" + +# Entrypoint +ep=$(docker inspect --format '{{json .Config.Entrypoint}}' "$TEST_IMAGE") +assert_eq "$ep" '["/entrypoint.sh"]' "Entrypoint is /entrypoint.sh" + +# Working directory +wd=$(docker inspect --format '{{.Config.WorkingDir}}' "$TEST_IMAGE") +assert_eq "$wd" "/actions-runner" "WORKDIR is /actions-runner" + +# VOLTA_HOME env var +volta=$(run_in_image 'echo $VOLTA_HOME') +assert_eq "$volta" "/usr/local/volta" "VOLTA_HOME is set" + +# Base image is Ubuntu 24.04 +codename=$(run_in_image 'grep VERSION_CODENAME /etc/os-release | cut -d= -f2') +assert_eq "$codename" "noble" "Base image is Ubuntu 24.04 (noble)" + +# HEALTHCHECK instruction present +hc=$(docker inspect --format '{{json .Config.Healthcheck}}' "$TEST_IMAGE") +assert_contains "$hc" "Runner.Listener" "HEALTHCHECK is configured" + +test_summary diff --git a/tests/test_custom_setup.sh b/tests/test_custom_setup.sh new file mode 100755 index 0000000..d4b7710 --- /dev/null +++ b/tests/test_custom_setup.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers.sh" + +test_header "Custom Setup Scripts" + +# Custom .sh script runs +output=$(run_in_image ' + echo "#!/bin/bash +echo CUSTOM_SETUP_EXECUTED" > /runner-custom-setup.d/test.sh + chmod +x /runner-custom-setup.d/test.sh + for f in $(find /runner-custom-setup.d -maxdepth 1 -type f \( -name "*.sh" -o -executable \) 2>/dev/null | sort); do + echo "Running: $f" + bash "$f" + done +') +assert_contains "$output" "CUSTOM_SETUP_EXECUTED" "Custom .sh script executes" + +# Failing script returns non-zero +assert_exit_nonzero "Failing custom script causes error" \ + run_in_image ' + echo "#!/bin/bash +exit 1" > /runner-custom-setup.d/fail.sh + chmod +x /runner-custom-setup.d/fail.sh + for f in $(find /runner-custom-setup.d -maxdepth 1 -type f -name "*.sh" | sort); do + bash "$f" || exit 1 + done + ' + +# Scripts run in sorted order +output=$(run_in_image ' + echo "#!/bin/bash +printf FIRST" > /runner-custom-setup.d/01-first.sh + echo "#!/bin/bash +printf SECOND" > /runner-custom-setup.d/02-second.sh + chmod +x /runner-custom-setup.d/01-first.sh /runner-custom-setup.d/02-second.sh + result="" + for f in $(find /runner-custom-setup.d -maxdepth 1 -type f -name "*.sh" | sort); do + result="${result}$(bash "$f")" + done + echo "$result" +') +assert_contains "$output" "FIRSTSECOND" "Custom scripts run in sorted order" + +# Empty setup dir is fine +assert_exit_zero "Empty setup dir causes no error" \ + run_in_image 'find /runner-custom-setup.d -maxdepth 1 -type f 2>/dev/null | sort' + +test_summary diff --git a/tests/test_directory_structure.sh b/tests/test_directory_structure.sh new file mode 100755 index 0000000..334cd39 --- /dev/null +++ b/tests/test_directory_structure.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers.sh" + +test_header "Directory Structure" + +# Runner files +assert_exit_zero "/actions-runner/config.sh exists" run_in_image "test -f /actions-runner/config.sh" +assert_exit_zero "/actions-runner/run.sh exists" run_in_image "test -f /actions-runner/run.sh" +assert_exit_zero "/actions-runner/bin/ exists" run_in_image "test -d /actions-runner/bin" + +# Custom setup directory +assert_exit_zero "/runner-custom-setup.d exists" run_in_image "test -d /runner-custom-setup.d" +count=$(run_in_image "find /runner-custom-setup.d -maxdepth 1 -type f | wc -l | tr -d ' '") +assert_eq "$count" "0" "/runner-custom-setup.d is empty" + +# Entrypoint +assert_exit_zero "/entrypoint.sh is executable" run_in_image "test -x /entrypoint.sh" + +# Docker Compose paths +assert_exit_zero "docker-compose standalone binary exists" run_in_image "test -x /usr/local/bin/docker-compose" +assert_exit_zero "docker compose plugin exists" run_in_image "test -f /usr/local/lib/docker/cli-plugins/docker-compose" + +# Volta / Node +assert_exit_zero "volta node binary exists" run_in_image "test -x /usr/local/volta/bin/node" + +# Runner user +assert_exit_zero "runner user exists" run_in_image "id runner" + +# Runner is in docker group +groups=$(run_in_image "id runner") +assert_contains "$groups" "docker" "runner is in docker group" + +# No blanket sudo access (hardened: sudo removed) +assert_exit_nonzero "runner does NOT have NOPASSWD sudo" run_in_image "grep -q 'runner.*NOPASSWD' /etc/sudoers" + +test_summary diff --git a/tests/test_docker_modes.sh b/tests/test_docker_modes.sh new file mode 100755 index 0000000..ee396b2 --- /dev/null +++ b/tests/test_docker_modes.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers.sh" + +test_header "Docker Modes — Integration" + +# --------------------------------------------------------------------------- +# Mode 1: Host socket — mount the real socket, run a container from inside +# --------------------------------------------------------------------------- +DOCKER_SOCK=$(find_docker_socket) + +if [ -n "$DOCKER_SOCK" ]; then + # Runner user can pull and run a container through the host daemon + output=$(docker run --rm \ + -v "$DOCKER_SOCK:/var/run/docker.sock" \ + --entrypoint bash -u root "$TEST_IMAGE" -c ' + chmod 666 /var/run/docker.sock + su -m runner -c "docker run --rm hello-world 2>&1" + ' 2>&1) || true + assert_contains "$output" "Hello from Docker" "Mode 1: runner can run containers via host socket" + + # Verify the image used the host daemon (hello-world visible on host) + assert_exit_zero "Mode 1: hello-world image exists on host daemon" \ + docker image inspect hello-world +else + skip "Mode 1: host socket integration" "no Docker socket found" +fi + +# --------------------------------------------------------------------------- +# Mode 2: DOCKER_HOST — spin up a real DinD daemon, point the runner at it +# --------------------------------------------------------------------------- +MODE2_NET="ghrunner-test-mode2-$$" +MODE2_DIND="ghrunner-test-dind-$$" +MODE2_RUNNER="ghrunner-test-runner-$$" +MODE2_PASSED=true + +# Cleanup function for Mode 2 resources +mode2_cleanup() { + docker rm -f "$MODE2_DIND" "$MODE2_RUNNER" 2>/dev/null || true + docker network rm "$MODE2_NET" 2>/dev/null || true +} + +# Create isolated network +if docker network create "$MODE2_NET" >/dev/null 2>&1; then + # Start DinD daemon listening on both TCP 2375 and the default unix socket + docker run -d --privileged --name "$MODE2_DIND" --network "$MODE2_NET" \ + docker:dind dockerd-entrypoint.sh dockerd \ + -H tcp://0.0.0.0:2375 \ + -H unix:///var/run/docker.sock >/dev/null 2>&1 + + # Wait for DinD daemon to be ready (check via TCP from the host) + DIND_READY=false + for i in $(seq 1 30); do + if docker exec "$MODE2_DIND" docker -H tcp://127.0.0.1:2375 info >/dev/null 2>&1; then + DIND_READY=true + break + fi + sleep 1 + done + + if [ "$DIND_READY" = true ]; then + # Run a container inside the runner that talks to the DinD daemon via DOCKER_HOST + output=$(docker run --rm --name "$MODE2_RUNNER" --network "$MODE2_NET" \ + -e DOCKER_HOST=tcp://${MODE2_DIND}:2375 \ + --entrypoint bash -u runner "$TEST_IMAGE" -c ' + docker run --rm hello-world 2>&1 + ' 2>&1) || true + assert_contains "$output" "Hello from Docker" "Mode 2: runner can run containers via DOCKER_HOST" + + # Verify the container ran on DinD, not the host — hello-world image should exist in DinD + dind_images=$(docker exec "$MODE2_DIND" docker images -q hello-world 2>&1) || true + assert_not_empty "$dind_images" "Mode 2: container ran on DinD daemon (not host)" + else + skip "Mode 2: DOCKER_HOST integration" "DinD daemon failed to start" + MODE2_PASSED=false + fi + + mode2_cleanup +else + skip "Mode 2: DOCKER_HOST integration" "could not create test network" +fi + +# --------------------------------------------------------------------------- +# Mode 3: Internal DinD — entrypoint starts dockerd inside the container +# --------------------------------------------------------------------------- +MODE3_CONTAINER="ghrunner-test-dind-mode3-$$" + +# Cleanup function for Mode 3 +mode3_cleanup() { + docker rm -f "$MODE3_CONTAINER" 2>/dev/null || true +} + +# Start the container with --privileged so the internal dockerd can run. +# Override entrypoint to: start dockerd, wait, run hello-world, then exit. +docker run -d --privileged --name "$MODE3_CONTAINER" \ + --entrypoint bash "$TEST_IMAGE" -c ' + # Start internal dockerd (same as entrypoint does) + dockerd --storage-driver=vfs & + DOCKERD_PID=$! + for i in $(seq 1 30); do + if [ -S /var/run/docker.sock ]; then break; fi + sleep 1 + done + if [ ! -S /var/run/docker.sock ]; then + echo "DOCKERD_FAILED" + exit 1 + fi + chmod 666 /var/run/docker.sock + # Run hello-world as the runner user + su -m runner -c "docker run --rm hello-world 2>&1" + EXIT_CODE=$? + kill $DOCKERD_PID 2>/dev/null || true + exit $EXIT_CODE + ' >/dev/null 2>&1 + +# Wait for the container to finish (timeout 120s) +TIMEOUT=120 +if docker wait "$MODE3_CONTAINER" >/dev/null 2>&1; then + output=$(docker logs "$MODE3_CONTAINER" 2>&1) + if echo "$output" | grep -q "DOCKERD_FAILED"; then + skip "Mode 3: internal DinD integration" "dockerd failed to start (may need kernel support)" + else + assert_contains "$output" "Hello from Docker" "Mode 3: runner can run containers via internal DinD" + fi +else + skip "Mode 3: internal DinD integration" "container timed out" +fi + +mode3_cleanup + +test_summary diff --git a/tests/test_entrypoint_env.sh b/tests/test_entrypoint_env.sh new file mode 100755 index 0000000..a27ea4a --- /dev/null +++ b/tests/test_entrypoint_env.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers.sh" + +test_header "Entrypoint Environment Validation" + +# Use DOCKER_HOST to skip socket/DinD logic and RUNNER_SKIP_WORK_DIR_MOUNT_CHECK to skip mount check. +# The entrypoint will reach the env-var validation inside the su block. +BASE_ARGS="-e DOCKER_HOST=tcp://fake:2375 -e RUNNER_SKIP_WORK_DIR_MOUNT_CHECK=1" + +# Missing all required vars +output=$(docker run --rm $BASE_ARGS "$TEST_IMAGE" 2>&1) || true +assert_contains "$output" "Missing required env var" "Fails when all required vars missing" + +# Missing REPO_URL only +output=$(docker run --rm $BASE_ARGS -e RUNNER_TOKEN=x -e RUNNER_NAME=x "$TEST_IMAGE" 2>&1) || true +assert_contains "$output" "Missing required env var: REPO_URL" "Fails when REPO_URL missing" + +# Missing RUNNER_TOKEN only +output=$(docker run --rm $BASE_ARGS -e REPO_URL=x -e RUNNER_NAME=x "$TEST_IMAGE" 2>&1) || true +assert_contains "$output" "Missing required env var: RUNNER_TOKEN" "Fails when RUNNER_TOKEN missing" + +# Missing RUNNER_NAME only +output=$(docker run --rm $BASE_ARGS -e REPO_URL=x -e RUNNER_TOKEN=x "$TEST_IMAGE" 2>&1) || true +assert_contains "$output" "Missing required env var: RUNNER_NAME" "Fails when RUNNER_NAME missing" + +# Default work dir +default_wd=$(run_in_image 'echo ${RUNNER_WORK_DIR:-/tmp/github-runner-work}') +assert_eq "$default_wd" "/tmp/github-runner-work" "RUNNER_WORK_DIR defaults to /tmp/github-runner-work" + +# Default labels +default_labels=$(run_in_image 'echo ${RUNNER_LABELS:-self-hosted,docker}') +assert_eq "$default_labels" "self-hosted,docker" "RUNNER_LABELS defaults to self-hosted,docker" + +test_summary diff --git a/tests/test_git_config.sh b/tests/test_git_config.sh new file mode 100755 index 0000000..e7d40a7 --- /dev/null +++ b/tests/test_git_config.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers.sh" + +test_header "Git Configuration" + +# System-level safe.directory (restricted to /actions-runner, not wildcard) +safe_dir=$(run_in_image "git config --system --get-all safe.directory") +assert_eq "$safe_dir" "/actions-runner" "safe.directory is set to /actions-runner" + +# System-level core.fileMode +file_mode=$(run_in_image "git config --system --get core.fileMode") +assert_eq "$file_mode" "false" "core.fileMode is false" + +# /root/.gitconfig exists and is readable +assert_exit_zero "/root/.gitconfig exists" run_in_image "test -r /root/.gitconfig" + +# /home/runner/.gitconfig exists +assert_exit_zero "/home/runner/.gitconfig exists" run_in_image "test -f /home/runner/.gitconfig" + +# /home/runner/.gitconfig is owned by runner +owner=$(run_in_image "stat -c '%U' /home/runner/.gitconfig") +assert_eq "$owner" "runner" "/home/runner/.gitconfig owned by runner" + +test_summary