6 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
e81c5262b0 Release version 1.1.4
Some checks failed
ci / tests (push) Has been cancelled
publish-image / build-and-push (push) Has been cancelled
Stable Tag / test (push) Has been cancelled
Stable Tag / tag-stable (push) Has been cancelled
2026-02-13 18:32:19 +01:00
Kevin Veen-Birkenbach
f35ea04d66 Resolve merge conflict and preserve installer hardening 2026-02-13 18:24:34 +01:00
Kevin Veen-Birkenbach
37a17b536d Harden installer readiness and fix e2e healthcheck 2026-02-13 15:20:18 +01:00
Kevin Veen-Birkenbach
c80fdf8d01 Release version 1.1.3 2026-02-12 01:27:16 +01:00
Kevin Veen-Birkenbach
276833bd16 fix(matomo-bootstrap): increase Playwright step wait from 200ms to 1000ms to reduce CI flakiness
Increase page.wait_for_timeout from 200ms to 1000ms in WebInstaller to mitigate race conditions during Matomo web installation steps in slower CI environments.

https://chatgpt.com/share/698d1e2f-1f40-800f-92bc-10a736358b40
2026-02-12 01:26:09 +01:00
Kevin Veen-Birkenbach
9e267ec83f Added github sponsor buttons
Some checks failed
ci / tests (push) Has been cancelled
2026-01-02 13:00:00 +01:00
9 changed files with 372 additions and 123 deletions

View File

@@ -1,3 +1,13 @@
## [1.1.4] - 2026-02-13
* This release hardens Matomo bootstrap by adding installer UI readiness waits/retries.
## [1.1.3] - 2026-02-12
* Increase Playwright step wait from 200ms to 1000ms to improve CI stability during Matomo installation.
## [1.1.2] - 2025-12-24
* **Improved error visibility during Matomo installation**: When the setup fails (for example due to an invalid admin email or missing required fields), the installer now **prints the actual Matomo error messages to the logs**, instead of failing with a generic error.

View File

@@ -78,8 +78,46 @@ help:
# ----------------------------
venv:
@test -x "$(VENV_PY)" || ($(PYTHON) -m venv $(VENV_DIR))
@$(VENV_PIP) -q install -U pip setuptools wheel >/dev/null
@set -e; \
if [ ! -d "$(VENV_DIR)" ]; then \
echo "Creating $(VENV_DIR) ..."; \
$(PYTHON) -m venv "$(VENV_DIR)"; \
fi; \
if ! [ -x "$(VENV_PY)" ] || ! "$(VENV_PY)" -V >/dev/null 2>&1; then \
echo "Repairing $(VENV_PY) symlink ..."; \
fix_target=""; \
for cand in "$(VENV_DIR)/bin/python3.14" "$(VENV_DIR)/bin/python3.13" "$(VENV_DIR)/bin/python3.12" "$(VENV_DIR)/bin/python3.11" "$(VENV_DIR)/bin/python3.10"; do \
if [ -x "$$cand" ]; then \
fix_target="$$(basename "$$cand")"; \
break; \
fi; \
done; \
if [ -z "$$fix_target" ] && [ -x "$(VENV_PIP)" ]; then \
shebang="$$(head -n1 "$(VENV_PIP)" | sed 's/^#!//')"; \
if [ -n "$$shebang" ] && [ -x "$$shebang" ]; then \
fix_target="$$(basename "$$shebang")"; \
fi; \
fi; \
if [ -n "$$fix_target" ] && [ -x "$(VENV_DIR)/bin/$$fix_target" ]; then \
ln -sfn "$$fix_target" "$(VENV_PY)"; \
ln -sfn "$$fix_target" "$(VENV_DIR)/bin/python3"; \
fi; \
fi; \
if ! [ -x "$(VENV_PIP)" ] || ! "$(VENV_PIP)" --version >/dev/null 2>&1; then \
echo "Repairing pip via ensurepip ..."; \
"$(VENV_PY)" -m ensurepip --upgrade >/dev/null 2>&1 || true; \
fi; \
if ! [ -x "$(VENV_PY)" ] || ! "$(VENV_PY)" -V >/dev/null 2>&1; then \
echo "ERROR: Could not repair $(VENV_PY) in existing $(VENV_DIR)."; \
echo "Run 'make clean' once or remove $(VENV_DIR) manually."; \
exit 2; \
fi; \
if ! [ -x "$(VENV_PIP)" ] || ! "$(VENV_PIP)" --version >/dev/null 2>&1; then \
echo "ERROR: Could not repair $(VENV_PIP) in existing $(VENV_DIR)."; \
echo "Run 'make clean' once or remove $(VENV_DIR) manually."; \
exit 2; \
fi; \
"$(VENV_PIP)" -q install -U pip setuptools wheel >/dev/null
deps-e2e: venv
@$(VENV_PIP) install -e ".[e2e]"

View File

@@ -1,4 +1,6 @@
# matomo-bootstrap
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
Headless bootstrap tooling for **Matomo**. Automates **first-time installation** and **API token provisioning** for fresh Matomo instances.
---
@@ -170,7 +172,7 @@ services:
volumes:
- matomo_data:/var/www/html
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null || exit 1"]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/ >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 60
@@ -211,6 +213,10 @@ services:
MATOMO_PLAYWRIGHT_HEADLESS: "1"
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
MATOMO_INSTALLER_READY_TIMEOUT_S: "180"
MATOMO_INSTALLER_STEP_TIMEOUT_S: "30"
MATOMO_INSTALLER_STEP_DEADLINE_S: "180"
MATOMO_INSTALLER_DEBUG_DIR: "/tmp/matomo-bootstrap"
restart: "no"
@@ -267,6 +273,8 @@ matomo-bootstrap
2. **Installation (if needed)**
* uses a recorded Playwright flow to complete the Matomo web installer
* waits until installer controls are interactive before clicking next steps
* writes screenshot/HTML debug artifacts on installer failure
3. **Authentication**
* logs in using Matomos `Login.logme` controller (cookie session)

View File

@@ -34,7 +34,7 @@ services:
volumes:
- matomo_data:/var/www/html
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null || exit 1"]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/ >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 60
@@ -47,7 +47,7 @@ services:
container_name: matomo-bootstrap
depends_on:
matomo:
condition: service_started
condition: service_healthy
environment:
MATOMO_URL: "http://matomo"
MATOMO_ADMIN_USER: "administrator"
@@ -65,6 +65,10 @@ services:
MATOMO_PLAYWRIGHT_HEADLESS: "1"
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
MATOMO_INSTALLER_READY_TIMEOUT_S: "180"
MATOMO_INSTALLER_STEP_TIMEOUT_S: "30"
MATOMO_INSTALLER_STEP_DEADLINE_S: "180"
MATOMO_INSTALLER_DEBUG_DIR: "/tmp/matomo-bootstrap"
# bootstrap is a one-shot command that prints the token and exits
# if you want to re-run, do: docker compose run --rm bootstrap
restart: "no"

View File

@@ -27,3 +27,9 @@ MATOMO_TIMEZONE=Germany - Berlin
# MATOMO_PLAYWRIGHT_HEADLESS=1
# MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS=60000
# MATOMO_PLAYWRIGHT_SLOWMO_MS=0
# Installer readiness / step guards
# MATOMO_INSTALLER_READY_TIMEOUT_S=180
# MATOMO_INSTALLER_STEP_TIMEOUT_S=30
# MATOMO_INSTALLER_STEP_DEADLINE_S=180
# MATOMO_INSTALLER_DEBUG_DIR=/tmp/matomo-bootstrap

View File

@@ -20,7 +20,7 @@
rec {
matomo-bootstrap = python.pkgs.buildPythonApplication {
pname = "matomo-bootstrap";
version = "1.1.2"; # keep in sync with pyproject.toml
version = "1.1.4"; # keep in sync with pyproject.toml
pyproject = true;
src = self;

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "matomo-bootstrap"
version = "1.1.2"
version = "1.1.4"
description = "Headless bootstrap tooling for Matomo (installation + API token provisioning)"
readme = "README.md"
requires-python = ">=3.10"

View File

@@ -4,6 +4,7 @@ import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from .base import Installer
@@ -20,6 +21,16 @@ PLAYWRIGHT_SLOWMO_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_SLOWMO_MS", "0"))
PLAYWRIGHT_NAV_TIMEOUT_MS = int(
os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000")
)
INSTALLER_READY_TIMEOUT_S = int(
os.environ.get("MATOMO_INSTALLER_READY_TIMEOUT_S", "180")
)
INSTALLER_STEP_TIMEOUT_S = int(os.environ.get("MATOMO_INSTALLER_STEP_TIMEOUT_S", "30"))
INSTALLER_STEP_DEADLINE_S = int(
os.environ.get("MATOMO_INSTALLER_STEP_DEADLINE_S", "180")
)
INSTALLER_DEBUG_DIR = os.environ.get(
"MATOMO_INSTALLER_DEBUG_DIR", "/tmp/matomo-bootstrap"
).rstrip("/")
# Values used by the installer flow (recorded)
DEFAULT_SITE_NAME = os.environ.get("MATOMO_SITE_NAME", "localhost")
@@ -27,6 +38,23 @@ DEFAULT_SITE_URL = os.environ.get("MATOMO_SITE_URL", "http://localhost")
DEFAULT_TIMEZONE = os.environ.get("MATOMO_TIMEZONE", "Germany - Berlin")
DEFAULT_ECOMMERCE = os.environ.get("MATOMO_ECOMMERCE", "Ecommerce enabled")
NEXT_BUTTON_CANDIDATES: list[tuple[str, str]] = [
("link", "Next »"),
("button", "Next »"),
("link", "Next"),
("button", "Next"),
("link", "Continue"),
("button", "Continue"),
("link", "Proceed"),
("button", "Proceed"),
("link", "Start Installation"),
("button", "Start Installation"),
("link", "Weiter"),
("button", "Weiter"),
("link", "Fortfahren"),
("button", "Fortfahren"),
]
def _log(msg: str) -> None:
# IMPORTANT: logs must not pollute stdout (tests expect only token on stdout)
@@ -127,6 +155,158 @@ def _page_warnings(page, *, prefix: str = "[install]") -> list[str]:
return out
def _wait_dom_settled(page) -> None:
try:
page.wait_for_load_state("domcontentloaded")
except Exception:
pass
try:
# Best effort: helps when the UI needs a bit more rendering time.
page.wait_for_load_state("networkidle", timeout=2_000)
except Exception:
pass
page.wait_for_timeout(250)
def _get_step_hint(url: str) -> str:
try:
parsed = urllib.parse.urlparse(url)
qs = urllib.parse.parse_qs(parsed.query)
module = (qs.get("module") or [""])[0]
action = (qs.get("action") or [""])[0]
if module or action:
return f"{module}:{action}"
return parsed.path or url
except Exception:
return url
def _safe_page_snapshot_name() -> str:
return time.strftime("%Y%m%d-%H%M%S")
def _dump_failure_artifacts(page, reason: str) -> None:
os.makedirs(INSTALLER_DEBUG_DIR, exist_ok=True)
stamp = _safe_page_snapshot_name()
base = f"{INSTALLER_DEBUG_DIR}/installer-failure-{stamp}"
screenshot_path = f"{base}.png"
html_path = f"{base}.html"
meta_path = f"{base}.txt"
try:
page.screenshot(path=screenshot_path, full_page=True)
except Exception as exc:
_log(f"[install] Could not write screenshot: {exc}")
screenshot_path = "<unavailable>"
try:
html = page.content()
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
except Exception as exc:
_log(f"[install] Could not write HTML snapshot: {exc}")
html_path = "<unavailable>"
try:
url = page.url
except Exception:
url = "<unknown-url>"
try:
title = page.title()
except Exception:
title = "<unknown-title>"
try:
with open(meta_path, "w", encoding="utf-8") as f:
f.write(f"reason: {reason}\n")
f.write(f"url: {url}\n")
f.write(f"title: {title}\n")
f.write(f"step_hint: {_get_step_hint(url)}\n")
except Exception as exc:
_log(f"[install] Could not write metadata snapshot: {exc}")
meta_path = "<unavailable>"
_log("[install] Debug artifacts written:")
_log(f"[install] screenshot: {screenshot_path}")
_log(f"[install] html: {html_path}")
_log(f"[install] meta: {meta_path}")
def _first_next_locator(page):
for role, name in NEXT_BUTTON_CANDIDATES:
loc = page.get_by_role(role, name=name)
try:
if loc.count() > 0 and loc.first.is_visible():
return loc.first, f"{role}:{name}"
except Exception:
continue
text_loc = page.get_by_text("Next", exact=False)
try:
if text_loc.count() > 0 and text_loc.first.is_visible():
return text_loc.first, "text:Next*"
except Exception:
pass
return None, ""
def _installer_interactive(page) -> bool:
checks = [
page.locator("#login-0").count() > 0,
page.locator("#siteName-0").count() > 0,
page.get_by_role("button", name="Continue to Matomo »").count() > 0,
]
loc, _ = _first_next_locator(page)
return any(checks) or loc is not None
def _wait_for_installer_interactive(page, *, timeout_s: int) -> None:
_log(f"[install] Waiting for interactive installer UI (timeout={timeout_s}s)...")
deadline = time.time() + timeout_s
while time.time() < deadline:
_wait_dom_settled(page)
if _installer_interactive(page):
_log("[install] Installer UI looks interactive.")
return
page.wait_for_timeout(300)
raise RuntimeError(
f"Installer UI did not become interactive within {timeout_s}s "
f"(url={page.url}, step={_get_step_hint(page.url)})."
)
def _click_next_with_wait(page, *, timeout_s: int) -> str:
deadline = time.time() + timeout_s
while time.time() < deadline:
loc, label = _first_next_locator(page)
if loc is not None:
before_url = page.url
before_step = _get_step_hint(before_url)
try:
loc.click(timeout=2_000)
except Exception:
page.wait_for_timeout(250)
continue
_wait_dom_settled(page)
after_url = page.url
after_step = _get_step_hint(after_url)
_log(
f"[install] Clicked {label}; step {before_step} -> {after_step} "
f"(url {before_url} -> {after_url})"
)
return after_step
page.wait_for_timeout(300)
raise RuntimeError(
"Could not find a Next/Continue control in the installer UI "
f"within {timeout_s}s (url={page.url}, step={_get_step_hint(page.url)})."
)
def wait_http(url: str, timeout: int = 180) -> None:
"""
Consider Matomo 'reachable' as soon as the HTTP server answers - even with 500.
@@ -213,60 +393,22 @@ class WebInstaller(Installer):
page.set_default_navigation_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
page.set_default_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
def click_next() -> None:
"""
Matomo installer mixes link/button variants and sometimes includes '»'.
We try common variants in a robust order.
"""
candidates = [
("link", "Next »"),
("button", "Next »"),
("link", "Next"),
("button", "Next"),
("link", "Continue"),
("button", "Continue"),
("link", "Proceed"),
("button", "Proceed"),
("link", "Start Installation"),
("button", "Start Installation"),
("link", "Weiter"),
("button", "Weiter"),
("link", "Fortfahren"),
("button", "Fortfahren"),
]
for role, name in candidates:
loc = page.get_by_role(role, name=name)
if loc.count() > 0:
loc.first.click()
return
loc = page.get_by_text("Next", exact=False)
if loc.count() > 0:
loc.first.click()
return
raise RuntimeError(
"Could not find a Next/Continue control in the installer UI."
)
try:
page.goto(base_url, wait_until="domcontentloaded")
_wait_for_installer_interactive(page, timeout_s=INSTALLER_READY_TIMEOUT_S)
_page_warnings(page)
def superuser_form_visible() -> bool:
return page.locator("#login-0").count() > 0
progress_deadline = time.time() + INSTALLER_STEP_DEADLINE_S
for _ in range(12):
if superuser_form_visible():
break
click_next()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
_page_warnings(page)
else:
while page.locator("#login-0").count() == 0:
if time.time() >= progress_deadline:
raise RuntimeError(
"Installer did not reach superuser step (login-0 not found)."
"Installer did not reach superuser step "
f"within {INSTALLER_STEP_DEADLINE_S}s "
f"(url={page.url}, step={_get_step_hint(page.url)})."
)
_click_next_with_wait(page, timeout_s=INSTALLER_STEP_TIMEOUT_S)
_page_warnings(page)
page.locator("#login-0").click()
page.locator("#login-0").fill(config.admin_user)
@@ -282,13 +424,7 @@ class WebInstaller(Installer):
page.locator("#email-0").fill(config.admin_email)
_page_warnings(page)
if page.get_by_role("button", name="Next »").count() > 0:
page.get_by_role("button", name="Next »").click()
else:
click_next()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
_click_next_with_wait(page, timeout_s=INSTALLER_STEP_TIMEOUT_S)
_page_warnings(page)
if page.locator("#siteName-0").count() > 0:
@@ -315,23 +451,22 @@ class WebInstaller(Installer):
_page_warnings(page)
click_next()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
_click_next_with_wait(page, timeout_s=INSTALLER_STEP_TIMEOUT_S)
_page_warnings(page)
if page.get_by_role("link", name="Next »").count() > 0:
page.get_by_role("link", name="Next »").click()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
_wait_dom_settled(page)
_page_warnings(page)
if page.get_by_role("button", name="Continue to Matomo »").count() > 0:
page.get_by_role("button", name="Continue to Matomo »").click()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
_wait_dom_settled(page)
_page_warnings(page)
except Exception as exc:
_dump_failure_artifacts(page, reason=str(exc))
raise
finally:
context.close()
browser.close()

View File

@@ -56,6 +56,29 @@ def _wait_for_http_any_status(url: str, timeout_s: int) -> None:
raise RuntimeError(f"Matomo did not become reachable at {url} ({last_exc})")
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])
class TestRootDockerComposeStack(unittest.TestCase):
"""
E2E test for repository root docker-compose.yml:
@@ -131,5 +154,30 @@ class TestRootDockerComposeStack(unittest.TestCase):
self.assertIsInstance(data, list)
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)
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)
if __name__ == "__main__":
unittest.main()