diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 98286d5..ef2c441 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -17,11 +17,11 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: # list of Docker images to use as base name for tags images: | @@ -32,26 +32,26 @@ jobs: org.opencontainers.image.description=dnsmasq DNS proxy, configured for use with the pygmy stack - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -61,15 +61,15 @@ jobs: test: needs: docker - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Establish some SSH keys. - name: Setup SSH run: | - eval $(ssh-agent); + eval "$(ssh-agent)"; ssh-keygen -t rsa -q -f "$HOME/.ssh/id_rsa" -N ""; ssh-keygen -t rsa -q -f "$HOME/.ssh/id_pwd" -N "passphrase"; ssh-add; @@ -77,33 +77,50 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: # list of Docker images to use as base name for tags images: | ghcr.io/${{ github.repository_owner }}/haproxy flavor: | latest=false + - + name: Set single image tag + id: single_tag + run: | + echo "tag=$(echo '${{ steps.meta.outputs.tags }}' | head -n1)" >> "$GITHUB_OUTPUT" - name: Find and Replace - uses: jacobtomlinson/gha-find-replace@v3 - with: - find: "ghcr.io/pygmystack/haproxy:main" - replace: ${{ steps.meta.outputs.tags }} - include: "examples/**" - - - name: Show changes + env: + IMAGE_TAG: ${{ steps.single_tag.outputs.tag }} run: | + find examples/ -type f -exec sed -i.bak "s|ghcr.io/pygmystack/haproxy:main|${IMAGE_TAG}|g" {} \; + find examples/ -name "*.bak" -delete grep -n ghcr examples/* - - name: Install pygmy and dockerize via brew + name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@fb0fe15d936a80ac0ba4c6cee691bc3da3f00f4e # main + - + name: Install homebrew packages + env: + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK: 1 + HOMEBREW_NO_ENV_HINTS: 1 run: | - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"; - brew tap pygmystack/pygmy; - brew install pygmy; + brew install bats-core; brew install dockerize; - echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH; + brew install pygmystack/pygmy/pygmy; pygmy version; + - + name: Pull image for tests + run: docker pull ${{ steps.single_tag.outputs.tag }} + - + name: Run BATS tests + env: + IMAGE_NAME: ${{ steps.single_tag.outputs.tag }} + run: | + bats --tap tests/image_structure.bats + bats --tap tests/runtime.bats - name: Switch pygmy configs from vanilla to basic run: | @@ -123,7 +140,7 @@ jobs: pygmy --config examples/pygmy.basic.yml export -o ./exported-config.yml cat ./exported-config.yml echo "Checking image references in started containers..."; - docker container inspect amazeeio-haproxy | jq '.[].Config.Image' | grep '${{ steps.meta.outputs.tags }}'; + docker container inspect amazeeio-haproxy | jq '.[].Config.Image' | grep -F '${{ steps.single_tag.outputs.tag }}'; - name: Resolv file test run: | @@ -185,7 +202,7 @@ jobs: pygmy --config examples/pygmy.yml export -o ./exported-config-2.yml cat ./exported-config-2.yml echo "Checking image references in started containers..."; - docker container inspect amazeeio-haproxy | jq '.[].Config.Image' | grep '${{ steps.meta.outputs.tags }}'; + docker container inspect amazeeio-haproxy | jq '.[].Config.Image' | grep -F '${{ steps.single_tag.outputs.tag }}'; - name: SSH Key test run: | @@ -201,14 +218,14 @@ jobs: name: "[Example] Drupal Base" run: | cd lagoon-examples/drupal-base; - docker-compose -p drupal-base up -d; - docker-compose -p drupal-base exec -T cli composer install; + docker compose -p drupal-base up -d; + docker compose -p drupal-base exec -T cli composer install; dockerize -wait http://drupal-base.docker.amazee.io:80 -timeout 10s; - curl --HEAD http://drupal-base.docker.amazee.io; - curl --HEAD http://drupal-base.docker.amazee.io | grep -i "x-lagoon"; + curl --head http://drupal-base.docker.amazee.io; + curl --head http://drupal-base.docker.amazee.io | grep -i "x-lagoon"; pygmy --config examples/pygmy.yml status | grep '\- http://drupal-base.docker.amazee.io'; - docker-compose -p drupal-base down; - docker-compose -p drupal-base rm; + docker compose -p drupal-base down; + docker compose -p drupal-base rm -f; cd ../../; - name: Test the stop command diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..700d474 --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +IMAGE_NAME ?= pygmystack/haproxy +IMAGE_TAG ?= test +FULL_IMAGE := $(IMAGE_NAME):$(IMAGE_TAG) + +.DEFAULT_GOAL := help + +.PHONY: help build test test-bats test-structure test-runtime validate-config up down shell clean + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}' + +build: ## Build the Docker image + docker build --tag $(FULL_IMAGE) . + +test: build ## Build image and run all tests (requires: brew install bats-core) + @command -v bats >/dev/null 2>&1 || { \ + echo "Error: bats is not installed. Install with: brew install bats-core"; \ + exit 1; \ + } + IMAGE_NAME=$(FULL_IMAGE) bats --tap tests/ + +test-bats: ## Run all BATS tests without rebuilding the image + @command -v bats >/dev/null 2>&1 || { \ + echo "Error: bats is not installed. Install with: brew install bats-core"; \ + exit 1; \ + } + IMAGE_NAME=$(FULL_IMAGE) bats --tap tests/ + +test-structure: ## Run image structure tests only (no Docker socket required) + @command -v bats >/dev/null 2>&1 || { \ + echo "Error: bats is not installed. Install with: brew install bats-core"; \ + exit 1; \ + } + IMAGE_NAME=$(FULL_IMAGE) bats --tap tests/image_structure.bats + +test-runtime: ## Run container runtime and integration tests (Docker socket required) + @command -v bats >/dev/null 2>&1 || { \ + echo "Error: bats is not installed. Install with: brew install bats-core"; \ + exit 1; \ + } + IMAGE_NAME=$(FULL_IMAGE) bats --tap tests/runtime.bats + +validate-config: ## Validate the default haproxy.cfg syntax + docker run --rm $(FULL_IMAGE) haproxy -c -f /app/haproxy.cfg + +up: ## Start the stack with docker compose + docker compose up -d + +down: ## Stop the stack with docker compose + docker compose down + +shell: ## Open an interactive shell inside the container + docker run --rm -it --entrypoint bash $(FULL_IMAGE) + +clean: ## Remove the local test Docker image + docker rmi $(FULL_IMAGE) 2>/dev/null || true diff --git a/tests/image_structure.bats b/tests/image_structure.bats new file mode 100644 index 0000000..ca8ae30 --- /dev/null +++ b/tests/image_structure.bats @@ -0,0 +1,124 @@ +#!/usr/bin/env bats +# Image structure tests — verify binaries, files, and configuration baked into +# the image. These tests run ephemeral containers and do not require access to +# the Docker socket. + +IMAGE="${IMAGE_NAME:-pygmystack/haproxy:test}" + +# --------------------------------------------------------------------------- +# Binaries +# --------------------------------------------------------------------------- + +@test "haproxy binary is available in PATH" { + run docker run --rm --entrypoint which "${IMAGE}" haproxy + [ "$status" -eq 0 ] + [ -n "$output" ] +} + +@test "haproxy version is 2.9.x" { + run docker run --rm "${IMAGE}" sh -c 'haproxy -v 2>&1' + [ "$status" -eq 0 ] + [[ "$output" =~ "2.9" ]] +} + +@test "docker-gen binary is available in PATH" { + run docker run --rm --entrypoint which "${IMAGE}" docker-gen + [ "$status" -eq 0 ] + [ -n "$output" ] +} + +@test "bash is installed" { + run docker run --rm "${IMAGE}" sh -c 'bash --version 2>&1' + [ "$status" -eq 0 ] + [[ "$output" =~ "GNU bash" ]] +} + +# --------------------------------------------------------------------------- +# Required files +# --------------------------------------------------------------------------- + +@test "/app/haproxy.cfg exists" { + run docker run --rm "${IMAGE}" test -f /app/haproxy.cfg + [ "$status" -eq 0 ] +} + +@test "/app/haproxy.tmpl exists" { + run docker run --rm "${IMAGE}" test -f /app/haproxy.tmpl + [ "$status" -eq 0 ] +} + +@test "/app/docker-entrypoint.sh is executable" { + run docker run --rm "${IMAGE}" test -x /app/docker-entrypoint.sh + [ "$status" -eq 0 ] +} + +@test "/app/haproxy_start.sh is executable" { + run docker run --rm "${IMAGE}" test -x /app/haproxy_start.sh + [ "$status" -eq 0 ] +} + +@test "/app/haproxy_reload.sh is executable" { + run docker run --rm "${IMAGE}" test -x /app/haproxy_reload.sh + [ "$status" -eq 0 ] +} + +# --------------------------------------------------------------------------- +# Container environment +# --------------------------------------------------------------------------- + +@test "working directory is /app" { + run docker run --rm --entrypoint sh "${IMAGE}" -c 'pwd' + [ "$status" -eq 0 ] + [ "$output" = "/app" ] +} + +@test "DOCKER_HOST is set to the expected unix socket path" { + run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "${IMAGE}" + [ "$status" -eq 0 ] + [[ "$output" =~ "DOCKER_HOST=unix:///tmp/docker.sock" ]] +} + +# --------------------------------------------------------------------------- +# HAProxy config validation +# --------------------------------------------------------------------------- + +@test "haproxy config checker accepts a valid config" { + # The baked-in haproxy.cfg is an intentionally minimal bootstrap config + # (no backend) that docker-gen immediately replaces at runtime. Instead, + # verify that the haproxy binary itself correctly validates a well-formed + # config, proving the binary is functional. + run docker run --rm --entrypoint sh "${IMAGE}" -c ' +cat > /tmp/valid.cfg < "${BATS_SUITE_TMPDIR}/.suffix" + HAPROXY_CONTAINER="haproxy-bats-test-${suffix}" + BACKEND_AMAZEEIO="haproxy-bats-backend-amazeeio-${suffix}" + BACKEND_LAGOON="haproxy-bats-backend-lagoon-${suffix}" + + if [ ! -S "${DOCKER_SOCKET}" ]; then + echo "# Docker socket not found at ${DOCKER_SOCKET} – skipping runtime tests" >&3 + return 0 + fi + + # Remove any leftover container from a previous (failed) run. + docker rm -f "${HAPROXY_CONTAINER}" 2>/dev/null || true + + docker run -d \ + --name "${HAPROXY_CONTAINER}" \ + --volume "${DOCKER_SOCKET}:/tmp/docker.sock" \ + -p 0:80 \ + "${IMAGE}" + + # Discover the ephemeral host port assigned by Docker. + local port + port="$(docker port "${HAPROXY_CONTAINER}" 80 | head -n1 | awk -F: '{print $NF}')" + echo "${port}" > "${BATS_SUITE_TMPDIR}/.port" + + # Wait for docker-gen to run once and reload haproxy with the stats frontend. + # The template includes "stats uri /stats", so /stats becomes available only + # after the first docker-gen pass — give it up to 30 seconds. + local max_wait=30 + local waited=0 + until curl -sf "http://localhost:${port}/stats" >/dev/null 2>&1; do + sleep 1 + waited=$((waited + 1)) + if [ "$waited" -ge "$max_wait" ]; then + echo "# Timed out waiting for haproxy /stats endpoint (port ${port})" >&3 + docker logs "${HAPROXY_CONTAINER}" >&3 2>&3 + return 1 + fi + done +} + +teardown_file() { + local suffix + suffix="$(cat "${BATS_SUITE_TMPDIR}/.suffix" 2>/dev/null || true)" + docker rm -f "haproxy-bats-test-${suffix}" 2>/dev/null || true + docker rm -f "haproxy-bats-backend-amazeeio-${suffix}" 2>/dev/null || true + docker rm -f "haproxy-bats-backend-lagoon-${suffix}" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# Per-test setup — restore container name variables from the stable suffix +# written by setup_file(), because BATS re-sources the file for every test. +# --------------------------------------------------------------------------- + +setup() { + local suffix + suffix="$(cat "${BATS_SUITE_TMPDIR}/.suffix" 2>/dev/null || true)" + HAPROXY_CONTAINER="haproxy-bats-test-${suffix}" + BACKEND_AMAZEEIO="haproxy-bats-backend-amazeeio-${suffix}" + BACKEND_LAGOON="haproxy-bats-backend-lagoon-${suffix}" + TEST_PORT="$(cat "${BATS_SUITE_TMPDIR}/.port" 2>/dev/null || true)" +} + +# --------------------------------------------------------------------------- +# Helper — skip the current test when the Docker socket is absent. +# --------------------------------------------------------------------------- + +_require_docker_socket() { + if [ ! -S "${DOCKER_SOCKET}" ]; then + skip "Docker socket not available at ${DOCKER_SOCKET}" + fi +} + +# --------------------------------------------------------------------------- +# Container lifecycle +# --------------------------------------------------------------------------- + +@test "container is running" { + _require_docker_socket + run docker inspect --format='{{.State.Status}}' "${HAPROXY_CONTAINER}" + [ "$status" -eq 0 ] + [ "$output" = "running" ] +} + +@test "haproxy process is alive (pidfile check)" { + _require_docker_socket + run docker exec "${HAPROXY_CONTAINER}" sh -c \ + 'kill -0 "$(cat /var/run/haproxy.pid)" 2>/dev/null' + [ "$status" -eq 0 ] +} + +@test "docker-gen process is running inside the container" { + _require_docker_socket + run docker exec "${HAPROXY_CONTAINER}" sh -c \ + 'pidof docker-gen >/dev/null 2>&1 && echo 1 || echo 0' + [ "$status" -eq 0 ] + [ "$output" -ge 1 ] +} + +# --------------------------------------------------------------------------- +# HAProxy stats page — mirrors the GitHub Actions "haproxy test" step. +# --------------------------------------------------------------------------- + +@test "stats page is accessible via published HTTP port" { + _require_docker_socket + run curl -sf "http://localhost:${TEST_PORT}/stats" + [ "$status" -eq 0 ] +} + +@test "stats page contains HAProxy version information" { + _require_docker_socket + run curl -s "http://localhost:${TEST_PORT}/stats" + [ "$status" -eq 0 ] + [[ "$output" =~ "HAProxy version" ]] +} + +@test "stats page contains the statistics table" { + _require_docker_socket + run curl -s "http://localhost:${TEST_PORT}/stats" + [ "$status" -eq 0 ] + [[ "$output" =~ "class=px" ]] +} + +# --------------------------------------------------------------------------- +# Integration — backend containers appear in the generated haproxy config. +# These tests mirror the "mailhog test" / backend discovery steps in CI. +# --------------------------------------------------------------------------- + +@test "backend with AMAZEEIO env is added to haproxy config" { + _require_docker_socket + + local backend_host="test-amazeeio.docker.amazee.io" + + docker rm -f "${BACKEND_AMAZEEIO}" 2>/dev/null || true + docker run -d \ + --name "${BACKEND_AMAZEEIO}" \ + -e AMAZEEIO=AMAZEEIO \ + -e "AMAZEEIO_URL=${backend_host}" \ + -e AMAZEEIO_HTTP_PORT=80 \ + --expose 80 \ + nginx:alpine + + # Wait for docker-gen to detect the new container and reload haproxy. + local max_wait=20 + local waited=0 + until curl -s "http://localhost:${TEST_PORT}/stats" | grep -Fq -- "${backend_host}"; do + sleep 1 + waited=$((waited + 1)) + [ "$waited" -lt "$max_wait" ] || break + done + + run curl -s "http://localhost:${TEST_PORT}/stats" + [ "$status" -eq 0 ] + [[ "$output" == *"${backend_host}"* ]] + + docker rm -f "${BACKEND_AMAZEEIO}" 2>/dev/null || true +} + +@test "backend with LAGOON_LOCALDEV_HTTP_PORT env is added to haproxy config" { + _require_docker_socket + + local backend_host="lagoon-test.docker.amazee.io" + + docker rm -f "${BACKEND_LAGOON}" 2>/dev/null || true + docker run -d \ + --name "${BACKEND_LAGOON}" \ + -e LAGOON_LOCALDEV_HTTP_PORT=8080 \ + -e "LAGOON_ROUTE=http://${backend_host}" \ + --expose 8080 \ + nginx:alpine + + # Wait for docker-gen to detect the new container and reload haproxy. + local max_wait=20 + local waited=0 + until curl -s "http://localhost:${TEST_PORT}/stats" | grep -Fq -- "${backend_host}"; do + sleep 1 + waited=$((waited + 1)) + [ "$waited" -lt "$max_wait" ] || break + done + + run curl -s "http://localhost:${TEST_PORT}/stats" + [ "$status" -eq 0 ] + [[ "$output" == *"${backend_host}"* ]] + + docker rm -f "${BACKEND_LAGOON}" 2>/dev/null || true +}