test(e2e): validate root docker-compose stack bootstrap flow
Some checks failed
ci / tests (push) Has been cancelled
Some checks failed
ci / tests (push) Has been cancelled
Adds an end-to-end test that brings up the root docker-compose.yml stack, runs the one-shot bootstrap container, verifies token-only stdout, and checks the token via Matomo API, with full cleanup via down -v. https://chatgpt.com/share/694af650-a484-800f-ace7-0a634d57b0a0
This commit is contained in:
@@ -1,106 +0,0 @@
|
|||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
|
|
||||||
COMPOSE_FILE = "tests/e2e/docker-compose.yml"
|
|
||||||
|
|
||||||
|
|
||||||
def run(cmd: list[str], check: bool = False) -> subprocess.CompletedProcess:
|
|
||||||
return subprocess.run(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
check=check,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def wait_http_any(url: str, timeout_s: int = 180) -> None:
|
|
||||||
# "any HTTP code" reachability via curl:
|
|
||||||
# curl exits 0 on 2xx, non-zero on 4xx/5xx; so we just wait for any response code != 000
|
|
||||||
deadline = time.time() + timeout_s
|
|
||||||
while time.time() < deadline:
|
|
||||||
p = run(
|
|
||||||
[
|
|
||||||
"curl",
|
|
||||||
"-sS",
|
|
||||||
"-o",
|
|
||||||
"/dev/null",
|
|
||||||
"-w",
|
|
||||||
"%{http_code}",
|
|
||||||
"--max-time",
|
|
||||||
"2",
|
|
||||||
url,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
code = (p.stdout or "").strip()
|
|
||||||
if code and code != "000":
|
|
||||||
return
|
|
||||||
time.sleep(1)
|
|
||||||
raise RuntimeError(f"Matomo did not become reachable: {url}")
|
|
||||||
|
|
||||||
|
|
||||||
class TestComposeBootstrapExit0(unittest.TestCase):
|
|
||||||
def test_bootstrap_exits_zero(self) -> None:
|
|
||||||
compose = ["docker", "compose", "-f", COMPOSE_FILE]
|
|
||||||
|
|
||||||
try:
|
|
||||||
# clean slate
|
|
||||||
run(compose + ["down", "-v"])
|
|
||||||
|
|
||||||
# start db + matomo (+ nix if present)
|
|
||||||
up = run(compose + ["up", "-d"])
|
|
||||||
self.assertEqual(
|
|
||||||
up.returncode,
|
|
||||||
0,
|
|
||||||
msg=f"compose up failed\nSTDOUT:\n{up.stdout}\nSTDERR:\n{up.stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# wait for host-published matomo port
|
|
||||||
wait_http_any("http://127.0.0.1:8080/", timeout_s=180)
|
|
||||||
|
|
||||||
# IMPORTANT:
|
|
||||||
# Run bootstrap via Nix container already defined in tests/e2e/docker-compose.yml
|
|
||||||
# (this avoids host python/venv completely).
|
|
||||||
script = r"""set -euo pipefail
|
|
||||||
|
|
||||||
export NIX_CONFIG='experimental-features = nix-command flakes'
|
|
||||||
export TERM='xterm'
|
|
||||||
mkdir -p "$HOME" "$HOME/.cache" "$HOME/.config" "$HOME/.local/share"
|
|
||||||
|
|
||||||
# Mark repo safe (root in container)
|
|
||||||
git config --global --add safe.directory /work
|
|
||||||
|
|
||||||
# Install browsers (cached in container volumes)
|
|
||||||
nix run --no-write-lock-file -L .#matomo-bootstrap-playwright-install >/dev/null
|
|
||||||
|
|
||||||
# Run bootstrap (must exit 0; stdout should be token-only)
|
|
||||||
nix run --no-write-lock-file -L .#matomo-bootstrap -- \
|
|
||||||
--base-url 'http://127.0.0.1:8080' \
|
|
||||||
--admin-user 'administrator' \
|
|
||||||
--admin-password 'AdminSecret123!' \
|
|
||||||
--admin-email 'administrator@example.org' \
|
|
||||||
--token-description 'e2e-compose-exit0'
|
|
||||||
"""
|
|
||||||
|
|
||||||
boot = run(compose + ["exec", "-T", "nix", "sh", "-lc", script])
|
|
||||||
self.assertEqual(
|
|
||||||
boot.returncode,
|
|
||||||
0,
|
|
||||||
msg=f"bootstrap failed\nSTDOUT:\n{boot.stdout}\nSTDERR:\n{boot.stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
token = (boot.stdout or "").strip()
|
|
||||||
self.assertRegex(
|
|
||||||
token,
|
|
||||||
r"^[a-f0-9]{32,64}$",
|
|
||||||
msg=f"expected token on stdout; got: {token!r}\nSTDERR:\n{boot.stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
run(compose + ["down", "-v"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
107
tests/e2e/test_docker_compose_stack.py
Normal file
107
tests/e2e/test_docker_compose_stack.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
COMPOSE_FILE = os.environ.get("MATOMO_STACK_COMPOSE_FILE", "docker-compose.yml")
|
||||||
|
MATOMO_HOST_URL = os.environ.get("MATOMO_STACK_URL", "http://127.0.0.1:8080")
|
||||||
|
|
||||||
|
# How long we wait for Matomo HTTP to respond at all (seconds)
|
||||||
|
WAIT_TIMEOUT_SECONDS = int(os.environ.get("MATOMO_STACK_WAIT_TIMEOUT", "180"))
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(
|
||||||
|
cmd,
|
||||||
|
check=check,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_cmd(*args: str) -> list[str]:
|
||||||
|
return ["docker", "compose", "-f", COMPOSE_FILE, *args]
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_http_any_status(url: str, timeout_s: int) -> None:
|
||||||
|
"""
|
||||||
|
Consider the service "up" once the HTTP server answers anything.
|
||||||
|
urllib raises HTTPError on 4xx/5xx, but that's still "reachable".
|
||||||
|
"""
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||||
|
_ = resp.read(64)
|
||||||
|
return
|
||||||
|
except Exception as exc: # includes HTTPError
|
||||||
|
last_exc = exc
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Matomo did not become reachable at {url} ({last_exc})")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRootDockerComposeStack(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
E2E test for repository root docker-compose.yml:
|
||||||
|
|
||||||
|
1) docker compose down -v
|
||||||
|
2) docker compose build bootstrap
|
||||||
|
3) docker compose up -d db matomo
|
||||||
|
4) wait for Matomo HTTP on host port (default 8080)
|
||||||
|
5) docker compose run --rm bootstrap -> token on stdout
|
||||||
|
6) validate token via Matomo API call
|
||||||
|
7) docker compose down -v (cleanup)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
# Always start from a clean slate (also clears volumes)
|
||||||
|
_run(_compose_cmd("down", "-v"), check=False)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
# Cleanup even if assertions fail
|
||||||
|
_run(_compose_cmd("down", "-v"), check=False)
|
||||||
|
|
||||||
|
def test_root_docker_compose_yml_stack_bootstraps_and_token_works(self) -> None:
|
||||||
|
# Build bootstrap image from Dockerfile (as defined in docker-compose.yml)
|
||||||
|
build = _run(_compose_cmd("build", "bootstrap"), check=True)
|
||||||
|
self.assertEqual(build.returncode, 0, build.stderr)
|
||||||
|
|
||||||
|
# Start db + matomo (bootstrap is one-shot and started via "run")
|
||||||
|
up = _run(_compose_cmd("up", "-d", "db", "matomo"), check=True)
|
||||||
|
self.assertEqual(up.returncode, 0, up.stderr)
|
||||||
|
|
||||||
|
# Wait until Matomo answers on the published port
|
||||||
|
_wait_for_http_any_status(MATOMO_HOST_URL + "/", WAIT_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
# Run bootstrap: it should print ONLY the token to stdout
|
||||||
|
boot = _run(_compose_cmd("run", "--rm", "bootstrap"), check=True)
|
||||||
|
|
||||||
|
token = (boot.stdout or "").strip()
|
||||||
|
self.assertRegex(
|
||||||
|
token,
|
||||||
|
r"^[a-f0-9]{32,64}$",
|
||||||
|
f"Expected token_auth on stdout, got stdout={boot.stdout!r} stderr={boot.stderr!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify token works against Matomo API
|
||||||
|
api_url = (
|
||||||
|
f"{MATOMO_HOST_URL}/index.php"
|
||||||
|
f"?module=API&method=SitesManager.getSitesWithAtLeastViewAccess"
|
||||||
|
f"&format=json&token_auth={token}"
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(api_url, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
self.assertIsInstance(data, list)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user