Initial draft
This commit is contained in:
45
Makefile
Normal file
45
Makefile
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
PYTHON ?= python3
|
||||||
|
COMPOSE_FILE := tests/e2e/docker-compose.yml
|
||||||
|
COMPOSE := docker compose -f $(COMPOSE_FILE)
|
||||||
|
|
||||||
|
.PHONY: help e2e-up e2e-install e2e-test e2e-down e2e logs clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " e2e-up Start Matomo + DB for E2E tests"
|
||||||
|
@echo " e2e-install Run Matomo installation (product code)"
|
||||||
|
@echo " e2e-test Run E2E tests (unittest)"
|
||||||
|
@echo " e2e-down Stop and remove E2E containers"
|
||||||
|
@echo " e2e Full cycle: up → install → test → down"
|
||||||
|
@echo " logs Show Matomo logs"
|
||||||
|
|
||||||
|
e2e-up:
|
||||||
|
$(COMPOSE) up -d
|
||||||
|
@echo "Waiting for Matomo to answer on http://127.0.0.1:8080/ ..."
|
||||||
|
@for i in $$(seq 1 180); do \
|
||||||
|
if curl -fsS --max-time 2 http://127.0.0.1:8080/ >/dev/null 2>&1; then \
|
||||||
|
echo "Matomo is reachable."; \
|
||||||
|
exit 0; \
|
||||||
|
fi; \
|
||||||
|
sleep 1; \
|
||||||
|
done; \
|
||||||
|
echo "Matomo did not become reachable on host port 8080."; \
|
||||||
|
$(COMPOSE) ps; \
|
||||||
|
$(COMPOSE) logs --no-color --tail=200 matomo; \
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
e2e-install:
|
||||||
|
PYTHONPATH=src $(PYTHON) -m matomo_bootstrap.install.web_installer
|
||||||
|
|
||||||
|
e2e-test:
|
||||||
|
PYTHONPATH=src $(PYTHON) -m unittest discover -s tests/e2e -v
|
||||||
|
|
||||||
|
e2e-down:
|
||||||
|
$(COMPOSE) down -v
|
||||||
|
|
||||||
|
e2e: e2e-up e2e-install e2e-test e2e-down
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(COMPOSE) logs -f matomo
|
||||||
|
|
||||||
|
clean: e2e-down
|
||||||
7
src/matomo_bootstrap/__init__.py
Normal file
7
src/matomo_bootstrap/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
matomo-bootstrap
|
||||||
|
Headless bootstrap tooling for Matomo:
|
||||||
|
- readiness checks
|
||||||
|
- admin/API token provisioning
|
||||||
|
"""
|
||||||
|
__all__ = []
|
||||||
23
src/matomo_bootstrap/__main__.py
Normal file
23
src/matomo_bootstrap/__main__.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from .cli import parse_args
|
||||||
|
from .bootstrap import run_bootstrap
|
||||||
|
from .errors import BootstrapError
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = run_bootstrap(args)
|
||||||
|
print(token)
|
||||||
|
return 0
|
||||||
|
except BootstrapError as exc:
|
||||||
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[FATAL] {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
36
src/matomo_bootstrap/api_tokens.py
Normal file
36
src/matomo_bootstrap/api_tokens.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import json
|
||||||
|
from .http import HttpClient
|
||||||
|
from .errors import TokenCreationError
|
||||||
|
|
||||||
|
|
||||||
|
def create_app_token(
|
||||||
|
client: HttpClient,
|
||||||
|
admin_user: str,
|
||||||
|
admin_password: str,
|
||||||
|
description: str,
|
||||||
|
) -> str:
|
||||||
|
status, body = client.post(
|
||||||
|
"/api.php",
|
||||||
|
{
|
||||||
|
"module": "API",
|
||||||
|
"method": "UsersManager.createAppSpecificTokenAuth",
|
||||||
|
"userLogin": admin_user,
|
||||||
|
"passwordConfirmation": admin_password,
|
||||||
|
"description": description,
|
||||||
|
"format": "json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if status != 200:
|
||||||
|
raise TokenCreationError(f"HTTP {status} during token creation")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise TokenCreationError("Invalid JSON from Matomo API") from exc
|
||||||
|
|
||||||
|
token = data.get("value")
|
||||||
|
if not token:
|
||||||
|
raise TokenCreationError(f"Unexpected response: {data}")
|
||||||
|
|
||||||
|
return token
|
||||||
35
src/matomo_bootstrap/bootstrap.py
Normal file
35
src/matomo_bootstrap/bootstrap.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from argparse import Namespace
|
||||||
|
from .health import assert_matomo_ready
|
||||||
|
from .http import HttpClient
|
||||||
|
from .api_tokens import create_app_token
|
||||||
|
from .install.web_installer import ensure_installed
|
||||||
|
|
||||||
|
|
||||||
|
def run_bootstrap(args: Namespace) -> str:
|
||||||
|
# 1. Matomo erreichbar?
|
||||||
|
assert_matomo_ready(args.base_url, timeout=args.timeout)
|
||||||
|
|
||||||
|
# 2. Installation sicherstellen (NO-OP wenn bereits installiert)
|
||||||
|
ensure_installed(
|
||||||
|
base_url=args.base_url,
|
||||||
|
admin_user=args.admin_user,
|
||||||
|
admin_password=args.admin_password,
|
||||||
|
admin_email=args.admin_email,
|
||||||
|
debug=args.debug,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. API-Token erzeugen
|
||||||
|
client = HttpClient(
|
||||||
|
base_url=args.base_url,
|
||||||
|
timeout=args.timeout,
|
||||||
|
debug=args.debug,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = create_app_token(
|
||||||
|
client=client,
|
||||||
|
admin_user=args.admin_user,
|
||||||
|
admin_password=args.admin_password,
|
||||||
|
description=args.token_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
return token
|
||||||
17
src/matomo_bootstrap/cli.py
Normal file
17
src/matomo_bootstrap/cli.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Headless bootstrap tool for Matomo (installation + API token provisioning)"
|
||||||
|
)
|
||||||
|
|
||||||
|
p.add_argument("--base-url", required=True, help="Matomo base URL")
|
||||||
|
p.add_argument("--admin-user", required=True)
|
||||||
|
p.add_argument("--admin-password", required=True)
|
||||||
|
p.add_argument("--admin-email", required=True)
|
||||||
|
p.add_argument("--token-description", default="matomo-bootstrap")
|
||||||
|
p.add_argument("--timeout", type=int, default=20)
|
||||||
|
p.add_argument("--debug", action="store_true")
|
||||||
|
|
||||||
|
return p.parse_args()
|
||||||
10
src/matomo_bootstrap/errors.py
Normal file
10
src/matomo_bootstrap/errors.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
class BootstrapError(RuntimeError):
|
||||||
|
"""Base error for matomo-bootstrap."""
|
||||||
|
|
||||||
|
|
||||||
|
class MatomoNotReadyError(BootstrapError):
|
||||||
|
"""Matomo is not reachable or not initialized."""
|
||||||
|
|
||||||
|
|
||||||
|
class TokenCreationError(BootstrapError):
|
||||||
|
"""Failed to create API token."""
|
||||||
13
src/matomo_bootstrap/health.py
Normal file
13
src/matomo_bootstrap/health.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import urllib.request
|
||||||
|
from .errors import MatomoNotReadyError
|
||||||
|
|
||||||
|
|
||||||
|
def assert_matomo_ready(base_url: str, timeout: int = 10) -> None:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(base_url, timeout=timeout) as resp:
|
||||||
|
html = resp.read().decode("utf-8", errors="replace")
|
||||||
|
except Exception as exc:
|
||||||
|
raise MatomoNotReadyError(f"Matomo not reachable: {exc}") from exc
|
||||||
|
|
||||||
|
if "Matomo" not in html and "piwik" not in html.lower():
|
||||||
|
raise MatomoNotReadyError("Matomo UI not detected at base URL")
|
||||||
28
src/matomo_bootstrap/http.py
Normal file
28
src/matomo_bootstrap/http.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import http.cookiejar
|
||||||
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class HttpClient:
|
||||||
|
def __init__(self, base_url: str, timeout: int = 20, debug: bool = False):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
self.debug = debug
|
||||||
|
|
||||||
|
self.cookies = http.cookiejar.CookieJar()
|
||||||
|
self.opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(self.cookies)
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, path: str, data: Dict[str, str]) -> Tuple[int, str]:
|
||||||
|
url = self.base_url + path
|
||||||
|
encoded = urllib.parse.urlencode(data).encode()
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
print(f"[HTTP] POST {url} keys={list(data.keys())}")
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=encoded, method="POST")
|
||||||
|
with self.opener.open(req, timeout=self.timeout) as resp:
|
||||||
|
body = resp.read().decode("utf-8", errors="replace")
|
||||||
|
return resp.status, body
|
||||||
0
src/matomo_bootstrap/install/__init__.py
Normal file
0
src/matomo_bootstrap/install/__init__.py
Normal file
160
src/matomo_bootstrap/install/web_installer.py
Normal file
160
src/matomo_bootstrap/install/web_installer.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
MATOMO_URL = os.environ.get("MATOMO_URL", "http://127.0.0.1:8080")
|
||||||
|
ADMIN_USER = os.environ.get("MATOMO_ADMIN_USER", "administrator")
|
||||||
|
ADMIN_PASSWORD = os.environ.get("MATOMO_ADMIN_PASSWORD", "AdminSecret123!")
|
||||||
|
ADMIN_EMAIL = os.environ.get("MATOMO_ADMIN_EMAIL", "admin@example.org")
|
||||||
|
|
||||||
|
DB_HOST = os.environ.get("MATOMO_DB_HOST", "db")
|
||||||
|
DB_USER = os.environ.get("MATOMO_DB_USER", "matomo")
|
||||||
|
DB_PASS = os.environ.get("MATOMO_DB_PASS", "matomo_pw")
|
||||||
|
DB_NAME = os.environ.get("MATOMO_DB_NAME", "matomo")
|
||||||
|
DB_PREFIX = os.environ.get("MATOMO_DB_PREFIX", "matomo_")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_http(url: str, timeout: int = 180) -> None:
|
||||||
|
print(f"[install] Waiting for Matomo HTTP at {url} ...")
|
||||||
|
for i in range(timeout):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||||
|
_ = resp.read(1024)
|
||||||
|
print("[install] Matomo HTTP reachable.")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
if i % 5 == 0:
|
||||||
|
print(f"[install] still waiting ({i}/{timeout}) …")
|
||||||
|
time.sleep(1)
|
||||||
|
raise RuntimeError(f"Matomo did not become reachable after {timeout}s: {url}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_installed(url: str) -> bool:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=3) as resp:
|
||||||
|
html = resp.read().decode(errors="ignore").lower()
|
||||||
|
return ("module=login" in html) or ("matomo › login" in html)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_installed(
|
||||||
|
base_url: str,
|
||||||
|
admin_user: str,
|
||||||
|
admin_password: str,
|
||||||
|
admin_email: str,
|
||||||
|
debug: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Ensure Matomo is installed.
|
||||||
|
NO-OP if already installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Propagate config to installer via ENV (single source of truth)
|
||||||
|
os.environ["MATOMO_URL"] = base_url
|
||||||
|
os.environ["MATOMO_ADMIN_USER"] = admin_user
|
||||||
|
os.environ["MATOMO_ADMIN_PASSWORD"] = admin_password
|
||||||
|
os.environ["MATOMO_ADMIN_EMAIL"] = admin_email
|
||||||
|
|
||||||
|
rc = main()
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError("Matomo installation failed")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
wait_http(MATOMO_URL)
|
||||||
|
|
||||||
|
if is_installed(MATOMO_URL):
|
||||||
|
print("[install] Matomo already installed. Skipping installer.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except Exception as exc:
|
||||||
|
print("[install] Playwright not available.", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"Install with: python3 -m pip install playwright && python3 -m playwright install chromium",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(f"Reason: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print("[install] Running Matomo web installer via headless browser...")
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(MATOMO_URL, wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
def click_next():
|
||||||
|
for label in ["Next", "Continue", "Start Installation", "Proceed"]:
|
||||||
|
btn = page.get_by_role("button", name=label)
|
||||||
|
if btn.count() > 0:
|
||||||
|
btn.first.click()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Welcome / system check
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
click_next()
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
click_next()
|
||||||
|
|
||||||
|
# Database setup
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
page.get_by_label("Database Server").fill(DB_HOST)
|
||||||
|
page.get_by_label("Login").fill(DB_USER)
|
||||||
|
page.get_by_label("Password").fill(DB_PASS)
|
||||||
|
page.get_by_label("Database Name").fill(DB_NAME)
|
||||||
|
try:
|
||||||
|
page.get_by_label("Tables Prefix").fill(DB_PREFIX)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
click_next()
|
||||||
|
|
||||||
|
# Tables creation
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
click_next()
|
||||||
|
|
||||||
|
# Super user
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
page.get_by_label("Login").fill(ADMIN_USER)
|
||||||
|
page.get_by_label("Password").fill(ADMIN_PASSWORD)
|
||||||
|
try:
|
||||||
|
page.get_by_label("Password (repeat)").fill(ADMIN_PASSWORD)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
page.get_by_label("Email").fill(ADMIN_EMAIL)
|
||||||
|
click_next()
|
||||||
|
|
||||||
|
# First website
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
try:
|
||||||
|
page.get_by_label("Name").fill("Bootstrap Site")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
page.get_by_label("URL").fill("http://example.invalid")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
click_next()
|
||||||
|
|
||||||
|
# Finish
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
click_next()
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
if not is_installed(MATOMO_URL):
|
||||||
|
print("[install] Installer did not reach installed state.", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
print("[install] Installation finished.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
27
tests/e2e/docker-compose.yml
Normal file
27
tests/e2e/docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:11
|
||||||
|
environment:
|
||||||
|
MARIADB_DATABASE: matomo
|
||||||
|
MARIADB_USER: matomo
|
||||||
|
MARIADB_PASSWORD: matomo_pw
|
||||||
|
MARIADB_ROOT_PASSWORD: root_pw
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mariadb-admin ping -uroot -proot_pw --silent"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 60
|
||||||
|
|
||||||
|
matomo:
|
||||||
|
image: matomo:5.3.2
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
environment:
|
||||||
|
MATOMO_DATABASE_HOST: db
|
||||||
|
MATOMO_DATABASE_ADAPTER: mysql
|
||||||
|
MATOMO_DATABASE_USERNAME: matomo
|
||||||
|
MATOMO_DATABASE_PASSWORD: matomo_pw
|
||||||
|
MATOMO_DATABASE_DBNAME: matomo
|
||||||
44
tests/e2e/test_bootstrap.py
Normal file
44
tests/e2e/test_bootstrap.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
MATOMO_URL = os.environ.get("MATOMO_URL", "http://127.0.0.1:8080")
|
||||||
|
ADMIN_USER = os.environ.get("MATOMO_ADMIN_USER", "administrator")
|
||||||
|
ADMIN_PASSWORD = os.environ.get("MATOMO_ADMIN_PASSWORD", "AdminSecret123!")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatomoBootstrapE2E(unittest.TestCase):
|
||||||
|
def test_bootstrap_creates_api_token(self) -> None:
|
||||||
|
cmd = [
|
||||||
|
"python3",
|
||||||
|
"-m",
|
||||||
|
"matomo_bootstrap",
|
||||||
|
"--base-url",
|
||||||
|
MATOMO_URL,
|
||||||
|
"--admin-user",
|
||||||
|
ADMIN_USER,
|
||||||
|
"--admin-password",
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
"--token-description",
|
||||||
|
"e2e-test-token",
|
||||||
|
]
|
||||||
|
|
||||||
|
token = subprocess.check_output(
|
||||||
|
cmd,
|
||||||
|
env={**os.environ, "PYTHONPATH": "src"},
|
||||||
|
).decode().strip()
|
||||||
|
|
||||||
|
self.assertRegex(token, r"^[a-f0-9]{32,64}$", f"Expected token_auth, got: {token}")
|
||||||
|
|
||||||
|
api_url = (
|
||||||
|
f"{MATOMO_URL}/api.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())
|
||||||
|
|
||||||
|
self.assertIsInstance(data, list)
|
||||||
Reference in New Issue
Block a user