diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index cec6ab5..fa46842 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -16,7 +16,39 @@ env: components_branch: ${{ github.event_name == 'release' && 'master' || 'development' }} jobs: + test: + if: github.event_name == 'pull_request' + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/386 + runner: ubuntu-latest + - platform: linux/arm/v6 + runner: ubuntu-24.04-arm + - platform: linux/arm/v7 + runner: ubuntu-24.04-arm + - platform: linux/arm64 + runner: ubuntu-24.04-arm + - platform: linux/riscv64 + runner: ubuntu-24.04-arm + env: + CI_ARCH: ${{ matrix.platform }} + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0 + + - name: Test + run: CIPLATFORM=${{ env.CI_ARCH }} bash test/run.sh + build-prepare: + if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 outputs: components_branch: ${{ env.components_branch }} @@ -25,6 +57,7 @@ jobs: - run: echo "Exposing env vars for reusable workflow" build: + if: github.event_name != 'pull_request' uses: docker/github-builder/.github/workflows/build.yml@v1 needs: - build-prepare @@ -45,7 +78,7 @@ jobs: WEB_BRANCH=${{ needs.build-prepare.outputs.components_branch }} PADD_BRANCH=${{ needs.build-prepare.outputs.components_branch }} platforms: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64,linux/riscv64 - push: ${{ github.event_name != 'pull_request' }} + push: true set-meta-labels: true meta-images: | pihole/pihole diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml deleted file mode 100644 index e677aba..0000000 --- a/.github/workflows/build-and-test.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Build Image and Test -on: - pull_request: - -permissions: - contents: read - -jobs: - build-and-test: - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - - platform: linux/386 - runner: ubuntu-latest - - platform: linux/arm/v6 - runner: ubuntu-24.04-arm - - platform: linux/arm/v7 - runner: ubuntu-24.04-arm - - platform: linux/arm64 - runner: ubuntu-24.04-arm - - platform: linux/riscv64 - runner: ubuntu-24.04-arm - env: - CI_ARCH: ${{ matrix.platform }} - steps: - - name: Checkout Repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0 - - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 #v6.2.0 - with: - python-version: "3.13" - - - name: Run black formatter - run: | - pip install black - black --check --diff test/tests/ - - - name: Install wheel - run: pip install wheel - - - name: Install dependencies - run: pip install -r test/requirements.txt - - - name: Test with tox - run: | - CIPLATFORM=${{ env.CI_ARCH }} tox -c test/tox.ini diff --git a/.gitignore b/.gitignore index 3a9365f..5010ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ etc-pihole/ var-log/ .vscode/ .pytest_cache/ +test/libs/ # WIP/test stuff doco.yml diff --git a/test/TESTING.md b/test/TESTING.md index c16a4bc..9b9f36d 100644 --- a/test/TESTING.md +++ b/test/TESTING.md @@ -1,12 +1,22 @@ # Prerequisites -Make sure you have `docker`, `python` and `tox` installed. +Make sure you have `docker` and `git` installed. # Running tests locally -`tox -c test/tox.ini` +```sh +bash test/run.sh +``` -Should result in: +This will: -- An image named `pihole:CI_container` being built -- Tests being ran to confirm the image doesn't have any regressions +- Build an image named `pihole:test` +- Start a set of containers (one per configuration under test) +- Run the BATS test suite against those containers +- Remove all test containers on exit + +To test a specific platform via emulation, set `CIPLATFORM`: + +```sh +CIPLATFORM=linux/arm64 bash test/run.sh +``` diff --git a/test/requirements.txt b/test/requirements.txt deleted file mode 100644 index 6a4cdde..0000000 --- a/test/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -pytest == 9.0.2 -pytest-testinfra == 10.2.2 -pytest-clarity == 1.0.1 -tox == 4.51.0 -# Not adding pytest-xdist as using pytest with n > 1 cores -# causes random issues with the emulated architectures diff --git a/test/run.sh b/test/run.sh new file mode 100644 index 0000000..4fa1665 --- /dev/null +++ b/test/run.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run from the test/ directory regardless of where the script is called from +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# ---- Build the image -------------------------------------------------------- + +PLATFORM_ARGS=() +[ -n "${CIPLATFORM:-}" ] && PLATFORM_ARGS=(--platform "${CIPLATFORM}") + +docker buildx build \ + --load \ + "${PLATFORM_ARGS[@]}" \ + --progress plain \ + -f ../src/Dockerfile \ + -t pihole:test \ + ../src/ + +# ---- Install BATS ----------------------------------------------------------- + +if [ -z "${BATS:-}" ]; then + mkdir -p libs + if [ ! -d libs/bats ]; then + git clone --depth=1 --quiet https://github.com/bats-core/bats-core libs/bats + fi + BATS=libs/bats/bin/bats +fi + +# ---- Start containers ------------------------------------------------------- + +# Cleanup all test containers on exit (success or failure) +CONTAINERS=() +cleanup() { + if [ ${#CONTAINERS[@]} -gt 0 ]; then + docker rm -f "${CONTAINERS[@]}" > /dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +start_container() { + local id + id=$(docker run -d -t "${PLATFORM_ARGS[@]}" -e TZ="Europe/London" "$@" pihole:test) + CONTAINERS+=("$id") + echo "$id" +} + +CONTAINER_DEFAULT=$(start_container) +CONTAINER_CUSTOM=$(start_container \ + -e PIHOLE_UID=456 \ + -e PIHOLE_GID=456 \ + -e FTLCONF_webserver_api_password=1234567890) + +export CONTAINER_DEFAULT CONTAINER_CUSTOM CIPLATFORM + +# ---- Wait for containers to be ready ---------------------------------------- + +wait_for_ftl() { + local container="$1" + local timeout=60 + local elapsed=0 + printf "Waiting for FTL in %.12s... " "${container}" + until docker logs "${container}" 2>&1 | grep -q "########## FTL started"; do + sleep 1 + elapsed=$(( elapsed + 1 )) + if (( elapsed >= timeout )); then + echo "TIMEOUT" + echo "--- Container logs ---" + docker logs "${container}" + return 1 + fi + done + echo "ready (${elapsed}s)" +} + +for container in "$CONTAINER_DEFAULT" "$CONTAINER_CUSTOM"; do + wait_for_ftl "$container" +done + +# ---- Run BATS --------------------------------------------------------------- + +"$BATS" -p test_suite.bats diff --git a/test/test_suite.bats b/test/test_suite.bats new file mode 100644 index 0000000..6edbe8c --- /dev/null +++ b/test/test_suite.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats + +# Containers are started by run.sh and their IDs exported as environment +# variables. All tests (except the shutdown test) share these containers, +# so each configuration is only booted once per test run. +# +# CONTAINER_DEFAULT - no extra env vars +# CONTAINER_CUSTOM - PIHOLE_UID=456, PIHOLE_GID=456, FTLCONF_webserver_api_password=1234567890 + +# ---- FTL binary ------------------------------------------------------------- + +@test "FTL reports version" { + run docker exec "$CONTAINER_DEFAULT" pihole-FTL -vv + [ "$status" -eq 0 ] + [[ "$output" == *"Version:"* ]] +} + +@test "FTL reports correct architecture" { + [ -n "${CIPLATFORM:-}" ] || skip "CIPLATFORM not set, running locally" + run docker exec "$CONTAINER_DEFAULT" pihole-FTL -vv + [ "$status" -eq 0 ] + [[ "$output" == *"Architecture:"* ]] + [[ "$output" == *"$CIPLATFORM"* ]] +} + +@test "FTL starts up and shuts down cleanly" { + # This test needs its own container because it stops it + local platform_args=() + [ -n "${CIPLATFORM:-}" ] && platform_args=(--platform "$CIPLATFORM") + + local container + container=$(docker run -d -t "${platform_args[@]}" -e TZ="Europe/London" pihole:test) + + # Wait for FTL to start + local timeout=60 + local elapsed=0 + until docker logs "$container" 2>&1 | grep -q "########## FTL started"; do + sleep 1 + elapsed=$(( elapsed + 1 )) + if (( elapsed >= timeout )); then + docker rm -f "$container" + echo "FTL did not start within ${timeout}s" + return 1 + fi + done + + # Stop gracefully (SIGTERM), then capture logs before removing + docker stop "$container" + local logs + logs=$(docker logs "$container" 2>&1) + docker rm "$container" + + [[ "$logs" == *"INFO: ########## FTL terminated after"* ]] + [[ "$logs" == *"(code 0)"* ]] +} + +# ---- Container configuration ------------------------------------------------ + +@test "Cron file is valid" { + run docker exec "$CONTAINER_DEFAULT" bash -c \ + "/usr/bin/crontab /crontab.txt 2>&1; crond -d 8 -L /cron.log 2>&1; cat /cron.log" + [[ "$output" != *"parse error"* ]] +} + +@test "Custom PIHOLE_UID is applied to pihole user" { + run docker exec "$CONTAINER_CUSTOM" id -u pihole + [ "$status" -eq 0 ] + [ "$output" = "456" ] +} + +@test "Custom PIHOLE_GID is applied to pihole group" { + run docker exec "$CONTAINER_CUSTOM" id -g pihole + [ "$status" -eq 0 ] + [ "$output" = "456" ] +} + +# ---- Web password setup ----------------------------------------------------- + +@test "Random password is assigned on fresh start" { + run docker logs "$CONTAINER_DEFAULT" + [ "$status" -eq 0 ] + [[ "$output" == *"assigning random password:"* ]] +} + +@test "Password defined by environment variable is used" { + run docker exec "$CONTAINER_CUSTOM" bash -c ". bash_functions.sh; setup_web_password" + [ "$status" -eq 0 ] + [[ "$output" == *"Assigning password defined by Environment Variable"* ]] +} diff --git a/test/tests/__init__.py b/test/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/test/tests/conftest.py b/test/tests/conftest.py deleted file mode 100644 index 29ce56c..0000000 --- a/test/tests/conftest.py +++ /dev/null @@ -1,60 +0,0 @@ -import pytest -import subprocess -import testinfra -import testinfra.backend.docker -import os - - -# Monkeypatch sh to bash, if they ever support non hard code /bin/sh this can go away -# https://github.com/pytest-dev/pytest-testinfra/blob/master/testinfra/backend/docker.py -def run_bash(self, command, *args, **kwargs): - cmd = self.get_command(command, *args) - if self.user is not None: - out = self.run_local( - "docker exec -u %s %s /bin/bash -c %s", self.user, self.name, cmd - ) - else: - out = self.run_local("docker exec %s /bin/bash -c %s", self.name, cmd) - out.command = self.encode(cmd) - return out - - -testinfra.backend.docker.DockerBackend.run = run_bash - - -# scope='session' uses the same container for all the tests; -# scope='function' uses a new container per test function. -@pytest.fixture(scope="function") -def docker(request): - # Get platform from environment variable, default to None if not set - platform = os.environ.get("CIPLATFORM") - - # build the docker run command with args - cmd = ["docker", "run", "-d", "-t"] - - # Only add platform flag if CIPLATFORM is set - if platform: - cmd.extend(["--platform", platform]) - - # Get env vars from parameterization - env_vars = getattr(request, "param", []) - if isinstance(env_vars, str): - env_vars = [env_vars] - - # add parameterized environment variables - for env_var in env_vars: - cmd.extend(["-e", env_var]) - - # add default TZ if not already set - if not any("TZ=" in arg for arg in cmd): - cmd.extend(["-e", 'TZ="Europe/London"']) - - # add the image name - cmd.append("pihole:CI_container") - - # run a container - docker_id = subprocess.check_output(cmd).decode().strip() - # return a testinfra connection to the container - yield testinfra.get_host("docker://" + docker_id) - # at the end of the test suite, destroy the container - subprocess.check_call(["docker", "rm", "-f", docker_id]) diff --git a/test/tests/test_bash_functions.py b/test/tests/test_bash_functions.py deleted file mode 100644 index 9c86b7f..0000000 --- a/test/tests/test_bash_functions.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest - - -# Adding 5 seconds sleep to give the emulated architecture time to run -@pytest.mark.parametrize("docker", ["FTLCONF_webserver_port=999"], indirect=True) -def test_ftlconf_webserver_port(docker): - func = docker.run("echo ${FTLCONF_webserver_port}") - assert "999" in func.stdout - func = docker.run(""" - sleep 5 - pihole-FTL --config webserver.port - """) - assert "999" in func.stdout - - -# Adding 5 seconds sleep to give the emulated architecture time to run -@pytest.mark.parametrize( - "docker", ["FTLCONF_dns_upstreams=1.2.3.4;5.6.7.8#1234"], indirect=True -) -def test_ftlconf_dns_upstreams(docker): - func = docker.run("echo ${FTLCONF_dns_upstreams}") - assert "1.2.3.4;5.6.7.8#1234" in func.stdout - func = docker.run(""" - sleep 5 - pihole-FTL --config dns.upstreams - """) - assert "[ 1.2.3.4, 5.6.7.8#1234 ]" in func.stdout - - -CMD_SETUP_WEB_PASSWORD = ". bash_functions.sh ; setup_web_password" - - -def test_random_password_assigned_fresh_start(docker): - func = docker.run(CMD_SETUP_WEB_PASSWORD) - assert "assigning random password:" in func.stdout - - -@pytest.mark.parametrize( - "docker", ["FTLCONF_webserver_api_password=1234567890"], indirect=True -) -def test_password_set_by_envvar(docker): - func = docker.run(CMD_SETUP_WEB_PASSWORD) - assert "Assigning password defined by Environment Variable" in func.stdout diff --git a/test/tests/test_general.py b/test/tests/test_general.py deleted file mode 100644 index e29bf45..0000000 --- a/test/tests/test_general.py +++ /dev/null @@ -1,94 +0,0 @@ -import pytest -import os - - -# Adding 5 seconds sleep to give the emulated architecture time to run -@pytest.mark.parametrize("docker", ["PIHOLE_UID=456"], indirect=True) -def test_pihole_uid_env_var(docker): - func = docker.run("echo ${PIHOLE_UID}") - assert "456" in func.stdout - func = docker.run(""" - sleep 5 - id -u pihole - """) - assert "456" in func.stdout - - -# Adding 5 seconds sleep to give the emulated architecture time to run -@pytest.mark.parametrize("docker", ["PIHOLE_GID=456"], indirect=True) -def test_pihole_gid_env_var(docker): - func = docker.run("echo ${PIHOLE_GID}") - assert "456" in func.stdout - func = docker.run(""" - sleep 5 - id -g pihole - """) - assert "456" in func.stdout - - -def test_pihole_ftl_version(docker): - func = docker.run("pihole-FTL -vv") - assert func.rc == 0 - assert "Version:" in func.stdout - - -@pytest.mark.skipif( - not os.environ.get("CIPLATFORM"), - reason="CIPLATFORM environment variable not set, running locally", -) -def test_pihole_ftl_architecture(docker): - func = docker.run("pihole-FTL -vv") - assert func.rc == 0 - assert "Architecture:" in func.stdout - # Get the expected architecture from CIPLATFORM environment variable - platform = os.environ.get("CIPLATFORM") - assert platform in func.stdout - - -# Wait for FTL to start up, then stop the container gracefully -# Finally, check the container logs to see if FTL was shut down cleanly -def test_pihole_ftl_starts_and_shuts_down_cleanly(docker): - import subprocess - import time - - # Get the container ID from the docker fixture - container_id = docker.backend.name - - # Wait for FTL to fully start up by checking logs - max_wait_time = 60 # Maximum wait time in seconds - start_time = time.time() - ftl_started = False - - while time.time() - start_time < max_wait_time: - result = subprocess.run( - ["docker", "logs", container_id], capture_output=True, text=True - ) - - if "########## FTL started" in result.stdout: - ftl_started = True - break - - time.sleep(1) # Check every second - - assert ftl_started, f"FTL did not start within {max_wait_time} seconds" - - # Stop the container gracefully (sends SIGTERM) - subprocess.run(["docker", "stop", container_id], check=True) - - # Get the container logs - result = subprocess.run( - ["docker", "logs", container_id], capture_output=True, text=True - ) - - # Check for clean shutdown messages in the logs - assert "INFO: ########## FTL terminated after" in result.stdout - assert "(code 0)" in result.stdout - - -def test_cronfile_valid(docker): - func = docker.run(""" - /usr/bin/crontab /crontab.txt - crond -d 8 -L /cron.log - grep 'parse error' /cron.log - """) - assert "parse error" not in func.stdout diff --git a/test/tox.ini b/test/tox.ini deleted file mode 100644 index 5aa913d..0000000 --- a/test/tox.ini +++ /dev/null @@ -1,16 +0,0 @@ -[tox] -envlist = py3 - -[testenv:py3] -allowlist_externals = docker -deps = -rrequirements.txt -passenv = CIPLATFORM -setenv = - COLUMNS=120 - PY_COLORS=1 -commands = # Build the Docker image for testing depending on the architecture, fall back to 'local' if not set - # This allows us to run the tests on the host architecture if not on CI - docker buildx build --load --platform={env:CIPLATFORM:local} --progress plain -f ../src/Dockerfile -t pihole:CI_container ../src/ - # run the tests - # # Not using > 1 cores as it causes random issues with the emulated architectures - pytest {posargs:-vv} ./tests/