2025-12-23 21:06:16 +01:00
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import subprocess
|
|
|
|
|
import time
|
|
|
|
|
import unittest
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
COMPOSE_FILE = os.environ.get("MATOMO_STACK_COMPOSE_FILE", "docker-compose.yml")
|
2025-12-23 21:16:51 +01:00
|
|
|
|
|
|
|
|
# Pick a non-default port to avoid collisions with other CI stacks that use 8080
|
|
|
|
|
MATOMO_PORT = os.environ.get("MATOMO_PORT", "18080")
|
|
|
|
|
MATOMO_HOST_URL = os.environ.get("MATOMO_STACK_URL", f"http://127.0.0.1:{MATOMO_PORT}")
|
2025-12-23 21:06:16 +01:00
|
|
|
|
|
|
|
|
# How long we wait for Matomo HTTP to respond at all (seconds)
|
|
|
|
|
WAIT_TIMEOUT_SECONDS = int(os.environ.get("MATOMO_STACK_WAIT_TIMEOUT", "180"))
|
|
|
|
|
|
|
|
|
|
|
2025-12-23 21:16:51 +01:00
|
|
|
def _run(
|
|
|
|
|
cmd: list[str],
|
|
|
|
|
*,
|
|
|
|
|
check: bool = True,
|
|
|
|
|
extra_env: dict[str, str] | None = None,
|
|
|
|
|
) -> subprocess.CompletedProcess:
|
2025-12-23 21:06:16 +01:00
|
|
|
return subprocess.run(
|
|
|
|
|
cmd,
|
|
|
|
|
check=check,
|
2025-12-23 21:16:51 +01:00
|
|
|
env={**os.environ, **(extra_env or {})},
|
2025-12-23 21:06:16 +01:00
|
|
|
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})")
|
|
|
|
|
|
|
|
|
|
|
2026-02-13 15:20:18 +01:00
|
|
|
def _extract_service_block(compose_config: str, service_name: str) -> str:
|
|
|
|
|
lines = compose_config.splitlines()
|
|
|
|
|
marker = f" {service_name}:"
|
|
|
|
|
start = -1
|
|
|
|
|
for idx, line in enumerate(lines):
|
|
|
|
|
if line == marker:
|
|
|
|
|
start = idx
|
|
|
|
|
break
|
|
|
|
|
if start < 0:
|
|
|
|
|
raise AssertionError(
|
|
|
|
|
f"service block not found in compose config: {service_name}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
end = len(lines)
|
|
|
|
|
for idx in range(start + 1, len(lines)):
|
|
|
|
|
line = lines[idx]
|
|
|
|
|
if line.startswith(" ") and not line.startswith(" "):
|
|
|
|
|
end = idx
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
return "\n".join(lines[start:end])
|
|
|
|
|
|
|
|
|
|
|
2025-12-23 21:06:16 +01:00
|
|
|
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
|
2025-12-23 21:16:51 +01:00
|
|
|
4) wait for Matomo HTTP on host port (default 8080, overridden here)
|
2025-12-23 21:06:16 +01:00
|
|
|
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)
|
2025-12-23 21:16:51 +01:00
|
|
|
_run(
|
|
|
|
|
_compose_cmd("down", "-v"),
|
|
|
|
|
check=False,
|
|
|
|
|
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
|
|
|
|
)
|
2025-12-23 21:06:16 +01:00
|
|
|
|
|
|
|
|
def tearDown(self) -> None:
|
|
|
|
|
# Cleanup even if assertions fail
|
2025-12-23 21:16:51 +01:00
|
|
|
_run(
|
|
|
|
|
_compose_cmd("down", "-v"),
|
|
|
|
|
check=False,
|
|
|
|
|
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
|
|
|
|
)
|
2025-12-23 21:06:16 +01:00
|
|
|
|
|
|
|
|
def test_root_docker_compose_yml_stack_bootstraps_and_token_works(self) -> None:
|
|
|
|
|
# Build bootstrap image from Dockerfile (as defined in docker-compose.yml)
|
2025-12-23 21:16:51 +01:00
|
|
|
build = _run(
|
|
|
|
|
_compose_cmd("build", "bootstrap"),
|
2026-02-14 05:37:29 +01:00
|
|
|
check=False,
|
2025-12-23 21:16:51 +01:00
|
|
|
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
|
|
|
|
)
|
2026-02-14 05:37:29 +01:00
|
|
|
self.assertEqual(
|
|
|
|
|
build.returncode,
|
|
|
|
|
0,
|
|
|
|
|
f"compose build failed\nstdout:\n{build.stdout}\nstderr:\n{build.stderr}",
|
|
|
|
|
)
|
2025-12-23 21:06:16 +01:00
|
|
|
|
|
|
|
|
# Start db + matomo (bootstrap is one-shot and started via "run")
|
2025-12-23 21:16:51 +01:00
|
|
|
up = _run(
|
|
|
|
|
_compose_cmd("up", "-d", "db", "matomo"),
|
2026-02-14 05:37:29 +01:00
|
|
|
check=False,
|
2025-12-23 21:16:51 +01:00
|
|
|
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
|
|
|
|
)
|
2026-02-14 05:37:29 +01:00
|
|
|
self.assertEqual(
|
|
|
|
|
up.returncode, 0, f"compose up failed\nstdout:\n{up.stdout}\nstderr:\n{up.stderr}"
|
|
|
|
|
)
|
2025-12-23 21:06:16 +01:00
|
|
|
|
|
|
|
|
# Wait until Matomo answers on the published port
|
|
|
|
|
_wait_for_http_any_status(MATOMO_HOST_URL + "/", WAIT_TIMEOUT_SECONDS)
|
|
|
|
|
|
2026-02-14 05:37:29 +01:00
|
|
|
# Run bootstrap: it should print ONLY the token to stdout.
|
|
|
|
|
# Retry once because first-run installer startup can be flaky on slow CI.
|
|
|
|
|
boot_attempts: list[subprocess.CompletedProcess] = []
|
|
|
|
|
for _ in range(2):
|
|
|
|
|
boot = _run(
|
|
|
|
|
_compose_cmd("run", "--rm", "bootstrap"),
|
|
|
|
|
check=False,
|
|
|
|
|
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
|
|
|
|
)
|
|
|
|
|
boot_attempts.append(boot)
|
|
|
|
|
if boot.returncode == 0:
|
|
|
|
|
break
|
|
|
|
|
time.sleep(5)
|
|
|
|
|
|
|
|
|
|
if boot.returncode != 0:
|
|
|
|
|
matomo_logs = _run(
|
|
|
|
|
_compose_cmd("logs", "--no-color", "--tail=200", "matomo"),
|
|
|
|
|
check=False,
|
|
|
|
|
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
|
|
|
|
)
|
|
|
|
|
attempts_dump = "\n\n".join(
|
|
|
|
|
[
|
|
|
|
|
(
|
|
|
|
|
f"[attempt {i}] rc={attempt.returncode}\n"
|
|
|
|
|
f"stdout:\n{attempt.stdout}\n"
|
|
|
|
|
f"stderr:\n{attempt.stderr}"
|
|
|
|
|
)
|
|
|
|
|
for i, attempt in enumerate(boot_attempts, 1)
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
self.fail(
|
|
|
|
|
"bootstrap container failed after retry.\n"
|
|
|
|
|
f"{attempts_dump}\n\n"
|
|
|
|
|
f"[matomo logs]\n{matomo_logs.stdout}\n{matomo_logs.stderr}"
|
|
|
|
|
)
|
2025-12-23 21:06:16 +01:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-02-13 15:20:18 +01:00
|
|
|
class TestRootDockerComposeDefinition(unittest.TestCase):
|
|
|
|
|
def test_bootstrap_service_waits_for_healthy_matomo_and_has_readiness_knobs(
|
|
|
|
|
self,
|
|
|
|
|
) -> None:
|
|
|
|
|
cfg = _run(
|
|
|
|
|
_compose_cmd("config"),
|
|
|
|
|
check=True,
|
|
|
|
|
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(cfg.returncode, 0, cfg.stderr)
|
|
|
|
|
|
|
|
|
|
bootstrap_block = _extract_service_block(cfg.stdout, "bootstrap")
|
|
|
|
|
|
|
|
|
|
self.assertIn("depends_on:", bootstrap_block)
|
|
|
|
|
self.assertIn("matomo:", bootstrap_block)
|
|
|
|
|
self.assertIn("condition: service_healthy", bootstrap_block)
|
|
|
|
|
self.assertIn("MATOMO_INSTALLER_READY_TIMEOUT_S:", bootstrap_block)
|
|
|
|
|
self.assertIn("MATOMO_INSTALLER_STEP_TIMEOUT_S:", bootstrap_block)
|
|
|
|
|
self.assertIn("MATOMO_INSTALLER_STEP_DEADLINE_S:", bootstrap_block)
|
2026-02-14 04:52:26 +01:00
|
|
|
self.assertIn("MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S:", bootstrap_block)
|
|
|
|
|
self.assertIn("MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S:", bootstrap_block)
|
2026-02-13 15:20:18 +01:00
|
|
|
|
|
|
|
|
matomo_block = _extract_service_block(cfg.stdout, "matomo")
|
|
|
|
|
self.assertIn("healthcheck:", matomo_block)
|
|
|
|
|
self.assertIn("curl -fsS http://127.0.0.1/ >/dev/null || exit 1", matomo_block)
|
|
|
|
|
|
|
|
|
|
|
2025-12-23 21:06:16 +01:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|