test(e2e): add venv + playwright deps and robust Matomo API auth flow
- Add optional dependency group "e2e" with Playwright in pyproject.toml - Teach Makefile to create/use local .venv, install e2e deps, and fetch Chromium - Wait for Matomo HTTP response (any status) before running bootstrap - Switch API calls to /index.php and add HttpClient.get() - Use UsersManager.getTokenAuth (md5Password) to obtain token_auth for privileged calls - Make web installer more resilient to HTTPError/500 and locale/button variations - Update E2E test to pass admin email and call API via /index.php https://chatgpt.com/share/694a70b0-e520-800f-a3e4-eaf5e96530bd
This commit is contained in:
55
Makefile
55
Makefile
@@ -2,37 +2,71 @@ PYTHON ?= python3
|
|||||||
COMPOSE_FILE := tests/e2e/docker-compose.yml
|
COMPOSE_FILE := tests/e2e/docker-compose.yml
|
||||||
COMPOSE := docker compose -f $(COMPOSE_FILE)
|
COMPOSE := docker compose -f $(COMPOSE_FILE)
|
||||||
|
|
||||||
.PHONY: help e2e-up e2e-install e2e-test e2e-down e2e logs clean
|
VENV_DIR := .venv
|
||||||
|
VENV_PY := $(VENV_DIR)/bin/python
|
||||||
|
VENV_PIP := $(VENV_DIR)/bin/pip
|
||||||
|
|
||||||
|
# E2E defaults (override like: make e2e MATOMO_URL=http://127.0.0.1:8081)
|
||||||
|
MATOMO_URL ?= http://127.0.0.1:8080
|
||||||
|
MATOMO_ADMIN_USER ?= administrator
|
||||||
|
MATOMO_ADMIN_PASSWORD ?= AdminSecret123!
|
||||||
|
MATOMO_ADMIN_EMAIL ?= administrator@example.org
|
||||||
|
MATOMO_TOKEN_DESCRIPTION ?= e2e-make-token
|
||||||
|
|
||||||
|
.PHONY: help venv deps-e2e playwright-install e2e-up e2e-install e2e-test e2e-down e2e logs clean
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
|
@echo " venv Create local venv in $(VENV_DIR)"
|
||||||
|
@echo " deps-e2e Install package + E2E deps into venv"
|
||||||
|
@echo " playwright-install Install Chromium for Playwright (inside venv)"
|
||||||
@echo " e2e-up Start Matomo + DB for E2E tests"
|
@echo " e2e-up Start Matomo + DB for E2E tests"
|
||||||
@echo " e2e-install Run Matomo installation (product code)"
|
@echo " e2e-install Run Matomo bootstrap (product code)"
|
||||||
@echo " e2e-test Run E2E tests (unittest)"
|
@echo " e2e-test Run E2E tests (unittest)"
|
||||||
@echo " e2e-down Stop and remove E2E containers"
|
@echo " e2e-down Stop and remove E2E containers"
|
||||||
@echo " e2e Full cycle: up → install → test → down"
|
@echo " e2e Full cycle: up → install → test → down"
|
||||||
@echo " logs Show Matomo logs"
|
@echo " logs Show Matomo logs"
|
||||||
|
@echo " clean Stop containers + remove venv"
|
||||||
|
@echo ""
|
||||||
|
@echo "Variables (override like: make e2e MATOMO_URL=http://127.0.0.1:8081):"
|
||||||
|
@echo " MATOMO_URL, MATOMO_ADMIN_USER, MATOMO_ADMIN_PASSWORD, MATOMO_ADMIN_EMAIL, MATOMO_TOKEN_DESCRIPTION"
|
||||||
|
|
||||||
|
venv:
|
||||||
|
@test -x "$(VENV_PY)" || ($(PYTHON) -m venv $(VENV_DIR))
|
||||||
|
@$(VENV_PIP) -q install -U pip setuptools wheel >/dev/null
|
||||||
|
|
||||||
|
deps-e2e: venv
|
||||||
|
@$(VENV_PIP) install -e ".[e2e]"
|
||||||
|
|
||||||
|
playwright-install: deps-e2e
|
||||||
|
@$(VENV_PY) -m playwright install chromium
|
||||||
|
|
||||||
e2e-up:
|
e2e-up:
|
||||||
$(COMPOSE) up -d
|
$(COMPOSE) up -d
|
||||||
@echo "Waiting for Matomo to answer on http://127.0.0.1:8080/ ..."
|
@echo "Waiting for Matomo to answer (any HTTP code) on $(MATOMO_URL)/ ..."
|
||||||
@for i in $$(seq 1 180); do \
|
@for i in $$(seq 1 180); do \
|
||||||
if curl -fsS --max-time 2 http://127.0.0.1:8080/ >/dev/null 2>&1; then \
|
code=$$(curl -sS -o /dev/null -w "%{http_code}" --max-time 2 "$(MATOMO_URL)/" || true); \
|
||||||
echo "Matomo is reachable."; \
|
if [ "$$code" != "000" ]; then \
|
||||||
|
echo "Matomo answered with HTTP $$code."; \
|
||||||
exit 0; \
|
exit 0; \
|
||||||
fi; \
|
fi; \
|
||||||
sleep 1; \
|
sleep 1; \
|
||||||
done; \
|
done; \
|
||||||
echo "Matomo did not become reachable on host port 8080."; \
|
echo "Matomo did not answer on $(MATOMO_URL)"; \
|
||||||
$(COMPOSE) ps; \
|
$(COMPOSE) ps; \
|
||||||
$(COMPOSE) logs --no-color --tail=200 matomo; \
|
$(COMPOSE) logs --no-color --tail=200 matomo; \
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
e2e-install:
|
e2e-install: playwright-install
|
||||||
PYTHONPATH=src $(PYTHON) -m matomo_bootstrap.install.web_installer
|
MATOMO_URL="$(MATOMO_URL)" \
|
||||||
|
MATOMO_ADMIN_USER="$(MATOMO_ADMIN_USER)" \
|
||||||
|
MATOMO_ADMIN_PASSWORD="$(MATOMO_ADMIN_PASSWORD)" \
|
||||||
|
MATOMO_ADMIN_EMAIL="$(MATOMO_ADMIN_EMAIL)" \
|
||||||
|
MATOMO_TOKEN_DESCRIPTION="$(MATOMO_TOKEN_DESCRIPTION)" \
|
||||||
|
PYTHONPATH=src $(VENV_PY) -m matomo_bootstrap
|
||||||
|
|
||||||
e2e-test:
|
e2e-test: deps-e2e
|
||||||
PYTHONPATH=src $(PYTHON) -m unittest discover -s tests/e2e -v
|
PYTHONPATH=src $(VENV_PY) -m unittest discover -s tests/e2e -v
|
||||||
|
|
||||||
e2e-down:
|
e2e-down:
|
||||||
$(COMPOSE) down -v
|
$(COMPOSE) down -v
|
||||||
@@ -43,3 +77,4 @@ logs:
|
|||||||
$(COMPOSE) logs -f matomo
|
$(COMPOSE) logs -f matomo
|
||||||
|
|
||||||
clean: e2e-down
|
clean: e2e-down
|
||||||
|
rm -rf $(VENV_DIR)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "matomo-bootstrap"
|
name = "matomo-bootstrap"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = ""
|
description = "Headless bootstrap tooling for Matomo (installation + API token provisioning)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
|
authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
|
||||||
@@ -14,8 +14,13 @@ urls = { Homepage = "https://github.com/kevinveenbirkenbach/matomo-bootstrap" }
|
|||||||
|
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
e2e = [
|
||||||
|
"playwright>=1.40.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = {"" = "src"}
|
package-dir = { "" = "src" }
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
20
src/matomo_bootstrap.egg-info/PKG-INFO
Normal file
20
src/matomo_bootstrap.egg-info/PKG-INFO
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: matomo-bootstrap
|
||||||
|
Version: 0.1.0
|
||||||
|
Summary: Headless bootstrap tooling for Matomo (installation + API token provisioning)
|
||||||
|
Author-email: Kevin Veen-Birkenbach <kevin@veen.world>
|
||||||
|
License: All rights reserved by Kevin Veen-Birkenbach
|
||||||
|
Project-URL: Homepage, https://github.com/kevinveenbirkenbach/matomo-bootstrap
|
||||||
|
Requires-Python: >=3.10
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
License-File: LICENSE
|
||||||
|
Provides-Extra: e2e
|
||||||
|
Requires-Dist: playwright>=1.40.0; extra == "e2e"
|
||||||
|
Dynamic: license-file
|
||||||
|
|
||||||
|
# matomo-bootstrap
|
||||||
|
|
||||||
|
Homepage: https://github.com/kevinveenbirkenbach/matomo-bootstrap
|
||||||
|
|
||||||
|
## Author
|
||||||
|
Kevin Veen-Birkenbach <kevin@veen.world>
|
||||||
19
src/matomo_bootstrap.egg-info/SOURCES.txt
Normal file
19
src/matomo_bootstrap.egg-info/SOURCES.txt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
|
pyproject.toml
|
||||||
|
src/matomo_bootstrap/__init__.py
|
||||||
|
src/matomo_bootstrap/__main__.py
|
||||||
|
src/matomo_bootstrap/api_tokens.py
|
||||||
|
src/matomo_bootstrap/bootstrap.py
|
||||||
|
src/matomo_bootstrap/cli.py
|
||||||
|
src/matomo_bootstrap/errors.py
|
||||||
|
src/matomo_bootstrap/health.py
|
||||||
|
src/matomo_bootstrap/http.py
|
||||||
|
src/matomo_bootstrap.egg-info/PKG-INFO
|
||||||
|
src/matomo_bootstrap.egg-info/SOURCES.txt
|
||||||
|
src/matomo_bootstrap.egg-info/dependency_links.txt
|
||||||
|
src/matomo_bootstrap.egg-info/requires.txt
|
||||||
|
src/matomo_bootstrap.egg-info/top_level.txt
|
||||||
|
src/matomo_bootstrap/install/__init__.py
|
||||||
|
src/matomo_bootstrap/install/container_installer.py
|
||||||
|
src/matomo_bootstrap/install/web_installer.py
|
||||||
1
src/matomo_bootstrap.egg-info/dependency_links.txt
Normal file
1
src/matomo_bootstrap.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
3
src/matomo_bootstrap.egg-info/requires.txt
Normal file
3
src/matomo_bootstrap.egg-info/requires.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
[e2e]
|
||||||
|
playwright>=1.40.0
|
||||||
1
src/matomo_bootstrap.egg-info/top_level.txt
Normal file
1
src/matomo_bootstrap.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
matomo_bootstrap
|
||||||
@@ -1,16 +1,58 @@
|
|||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from .http import HttpClient
|
|
||||||
from .errors import TokenCreationError
|
from .errors import TokenCreationError
|
||||||
|
from .http import HttpClient
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_auth(client: HttpClient, admin_user: str, admin_password: str) -> str:
|
||||||
|
"""
|
||||||
|
Get the user's token_auth via UsersManager.getTokenAuth.
|
||||||
|
|
||||||
|
This is the most robust way to authenticate subsequent API calls without relying
|
||||||
|
on UI sessions/cookies.
|
||||||
|
"""
|
||||||
|
md5_password = hashlib.md5(admin_password.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
status, body = client.get(
|
||||||
|
"/index.php",
|
||||||
|
{
|
||||||
|
"module": "API",
|
||||||
|
"method": "UsersManager.getTokenAuth",
|
||||||
|
"userLogin": admin_user,
|
||||||
|
"md5Password": md5_password,
|
||||||
|
"format": "json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if status != 200:
|
||||||
|
raise TokenCreationError(f"HTTP {status} during getTokenAuth: {body[:200]}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise TokenCreationError(f"Invalid JSON from getTokenAuth: {body[:200]}") from exc
|
||||||
|
|
||||||
|
# Matomo returns either {"value": "..."} or sometimes a plain string depending on setup/version
|
||||||
|
if isinstance(data, dict) and data.get("value"):
|
||||||
|
return str(data["value"])
|
||||||
|
if isinstance(data, str) and data:
|
||||||
|
return data
|
||||||
|
|
||||||
|
raise TokenCreationError(f"Unexpected getTokenAuth response: {data}")
|
||||||
|
|
||||||
|
|
||||||
def create_app_token(
|
def create_app_token(
|
||||||
client: HttpClient,
|
client: HttpClient,
|
||||||
|
admin_token_auth: str,
|
||||||
admin_user: str,
|
admin_user: str,
|
||||||
admin_password: str,
|
admin_password: str,
|
||||||
description: str,
|
description: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create an app-specific token using token_auth authentication.
|
||||||
|
"""
|
||||||
status, body = client.post(
|
status, body = client.post(
|
||||||
"/api.php",
|
"/index.php",
|
||||||
{
|
{
|
||||||
"module": "API",
|
"module": "API",
|
||||||
"method": "UsersManager.createAppSpecificTokenAuth",
|
"method": "UsersManager.createAppSpecificTokenAuth",
|
||||||
@@ -18,19 +60,20 @@ def create_app_token(
|
|||||||
"passwordConfirmation": admin_password,
|
"passwordConfirmation": admin_password,
|
||||||
"description": description,
|
"description": description,
|
||||||
"format": "json",
|
"format": "json",
|
||||||
|
"token_auth": admin_token_auth,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if status != 200:
|
if status != 200:
|
||||||
raise TokenCreationError(f"HTTP {status} during token creation")
|
raise TokenCreationError(f"HTTP {status} during token creation: {body[:200]}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(body)
|
data = json.loads(body)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise TokenCreationError("Invalid JSON from Matomo API") from exc
|
raise TokenCreationError(f"Invalid JSON from Matomo API: {body[:200]}") from exc
|
||||||
|
|
||||||
token = data.get("value")
|
token = data.get("value") if isinstance(data, dict) else None
|
||||||
if not token:
|
if not token:
|
||||||
raise TokenCreationError(f"Unexpected response: {data}")
|
raise TokenCreationError(f"Unexpected response: {data}")
|
||||||
|
|
||||||
return token
|
return str(token)
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
|
|
||||||
|
from .api_tokens import create_app_token, get_token_auth
|
||||||
from .health import assert_matomo_ready
|
from .health import assert_matomo_ready
|
||||||
from .http import HttpClient
|
from .http import HttpClient
|
||||||
from .api_tokens import create_app_token
|
|
||||||
from .install.web_installer import ensure_installed
|
from .install.web_installer import ensure_installed
|
||||||
|
|
||||||
|
|
||||||
def run_bootstrap(args: Namespace) -> str:
|
def run_bootstrap(args: Namespace) -> str:
|
||||||
# 1. Matomo erreichbar?
|
# 1) Ensure Matomo is installed (NO-OP if already installed)
|
||||||
assert_matomo_ready(args.base_url, timeout=args.timeout)
|
|
||||||
|
|
||||||
# 2. Installation sicherstellen (NO-OP wenn bereits installiert)
|
|
||||||
ensure_installed(
|
ensure_installed(
|
||||||
base_url=args.base_url,
|
base_url=args.base_url,
|
||||||
admin_user=args.admin_user,
|
admin_user=args.admin_user,
|
||||||
@@ -18,15 +16,25 @@ def run_bootstrap(args: Namespace) -> str:
|
|||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. API-Token erzeugen
|
# 2) Now the UI/API should be reachable and "installed"
|
||||||
|
assert_matomo_ready(args.base_url, timeout=args.timeout)
|
||||||
|
|
||||||
|
# 3) Create authenticated API token flow (no UI session needed)
|
||||||
client = HttpClient(
|
client = HttpClient(
|
||||||
base_url=args.base_url,
|
base_url=args.base_url,
|
||||||
timeout=args.timeout,
|
timeout=args.timeout,
|
||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
admin_token_auth = get_token_auth(
|
||||||
|
client=client,
|
||||||
|
admin_user=args.admin_user,
|
||||||
|
admin_password=args.admin_password,
|
||||||
|
)
|
||||||
|
|
||||||
token = create_app_token(
|
token = create_app_token(
|
||||||
client=client,
|
client=client,
|
||||||
|
admin_token_auth=admin_token_auth,
|
||||||
admin_user=args.admin_user,
|
admin_user=args.admin_user,
|
||||||
admin_password=args.admin_password,
|
admin_password=args.admin_password,
|
||||||
description=args.token_description,
|
description=args.token_description,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
@@ -6,12 +7,46 @@ def parse_args() -> argparse.Namespace:
|
|||||||
description="Headless bootstrap tool for Matomo (installation + API token provisioning)"
|
description="Headless bootstrap tool for Matomo (installation + API token provisioning)"
|
||||||
)
|
)
|
||||||
|
|
||||||
p.add_argument("--base-url", required=True, help="Matomo base URL")
|
p.add_argument(
|
||||||
p.add_argument("--admin-user", required=True)
|
"--base-url",
|
||||||
p.add_argument("--admin-password", required=True)
|
default=os.environ.get("MATOMO_URL"),
|
||||||
p.add_argument("--admin-email", required=True)
|
help="Matomo base URL (or MATOMO_URL env)",
|
||||||
p.add_argument("--token-description", default="matomo-bootstrap")
|
)
|
||||||
p.add_argument("--timeout", type=int, default=20)
|
p.add_argument(
|
||||||
|
"--admin-user",
|
||||||
|
default=os.environ.get("MATOMO_ADMIN_USER"),
|
||||||
|
help="Admin login (or MATOMO_ADMIN_USER env)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--admin-password",
|
||||||
|
default=os.environ.get("MATOMO_ADMIN_PASSWORD"),
|
||||||
|
help="Admin password (or MATOMO_ADMIN_PASSWORD env)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--admin-email",
|
||||||
|
default=os.environ.get("MATOMO_ADMIN_EMAIL"),
|
||||||
|
help="Admin email (or MATOMO_ADMIN_EMAIL env)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--token-description",
|
||||||
|
default=os.environ.get("MATOMO_TOKEN_DESCRIPTION", "matomo-bootstrap"),
|
||||||
|
)
|
||||||
|
p.add_argument("--timeout", type=int, default=int(os.environ.get("MATOMO_TIMEOUT", "20")))
|
||||||
p.add_argument("--debug", action="store_true")
|
p.add_argument("--debug", action="store_true")
|
||||||
|
|
||||||
return p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
if not args.base_url:
|
||||||
|
missing.append("--base-url (or MATOMO_URL)")
|
||||||
|
if not args.admin_user:
|
||||||
|
missing.append("--admin-user (or MATOMO_ADMIN_USER)")
|
||||||
|
if not args.admin_password:
|
||||||
|
missing.append("--admin-password (or MATOMO_ADMIN_PASSWORD)")
|
||||||
|
if not args.admin_email:
|
||||||
|
missing.append("--admin-email (or MATOMO_ADMIN_EMAIL)")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
p.error("missing required values: " + ", ".join(missing))
|
||||||
|
|
||||||
|
return args
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import urllib.request
|
|
||||||
import urllib.parse
|
|
||||||
import http.cookiejar
|
import http.cookiejar
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from typing import Dict, Tuple
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +15,18 @@ class HttpClient:
|
|||||||
urllib.request.HTTPCookieProcessor(self.cookies)
|
urllib.request.HTTPCookieProcessor(self.cookies)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get(self, path: str, params: Dict[str, str]) -> Tuple[int, str]:
|
||||||
|
qs = urllib.parse.urlencode(params)
|
||||||
|
url = f"{self.base_url}{path}?{qs}"
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
print(f"[HTTP] GET {url}")
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with self.opener.open(req, timeout=self.timeout) as resp:
|
||||||
|
body = resp.read().decode("utf-8", errors="replace")
|
||||||
|
return resp.status, body
|
||||||
|
|
||||||
def post(self, path: str, data: Dict[str, str]) -> Tuple[int, str]:
|
def post(self, path: str, data: Dict[str, str]) -> Tuple[int, str]:
|
||||||
url = self.base_url + path
|
url = self.base_url + path
|
||||||
encoded = urllib.parse.urlencode(data).encode()
|
encoded = urllib.parse.urlencode(data).encode()
|
||||||
|
|||||||
34
src/matomo_bootstrap/install/container_installer.py
Normal file
34
src/matomo_bootstrap/install/container_installer.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_installed(
|
||||||
|
container_name: str = "e2e-matomo-1",
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Ensure Matomo is installed by executing PHP bootstrap inside container.
|
||||||
|
Idempotent: safe to run multiple times.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
container_name,
|
||||||
|
"php",
|
||||||
|
"-r",
|
||||||
|
r"""
|
||||||
|
if (file_exists('/var/www/html/config/config.ini.php')) {
|
||||||
|
echo "Matomo already installed\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
require '/var/www/html/core/bootstrap.php';
|
||||||
|
|
||||||
|
\Piwik\FrontController::getInstance()->init();
|
||||||
|
\Piwik\Plugins\Installation\Installation::install();
|
||||||
|
|
||||||
|
echo "Matomo installed\n";
|
||||||
|
"""
|
||||||
|
]
|
||||||
|
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
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_HOST = os.environ.get("MATOMO_DB_HOST", "db")
|
||||||
DB_USER = os.environ.get("MATOMO_DB_USER", "matomo")
|
DB_USER = os.environ.get("MATOMO_DB_USER", "matomo")
|
||||||
DB_PASS = os.environ.get("MATOMO_DB_PASS", "matomo_pw")
|
DB_PASS = os.environ.get("MATOMO_DB_PASS", "matomo_pw")
|
||||||
@@ -17,25 +13,49 @@ DB_PREFIX = os.environ.get("MATOMO_DB_PREFIX", "matomo_")
|
|||||||
|
|
||||||
|
|
||||||
def wait_http(url: str, timeout: int = 180) -> None:
|
def wait_http(url: str, timeout: int = 180) -> None:
|
||||||
|
"""
|
||||||
|
Consider Matomo 'reachable' as soon as the HTTP server answers - even with 500.
|
||||||
|
urllib raises HTTPError for 4xx/5xx, so we must treat that as reachability too.
|
||||||
|
"""
|
||||||
print(f"[install] Waiting for Matomo HTTP at {url} ...")
|
print(f"[install] Waiting for Matomo HTTP at {url} ...")
|
||||||
|
last_err: Exception | None = None
|
||||||
|
|
||||||
for i in range(timeout):
|
for i in range(timeout):
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=2) as resp:
|
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||||
_ = resp.read(1024)
|
_ = resp.read(128)
|
||||||
print("[install] Matomo HTTP reachable.")
|
print("[install] Matomo HTTP reachable (2xx/3xx).")
|
||||||
return
|
return
|
||||||
except Exception:
|
except urllib.error.HTTPError as exc:
|
||||||
|
# 4xx/5xx means the server answered -> reachable
|
||||||
|
print(f"[install] Matomo HTTP reachable (HTTP {exc.code}).")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
last_err = exc
|
||||||
if i % 5 == 0:
|
if i % 5 == 0:
|
||||||
print(f"[install] still waiting ({i}/{timeout}) …")
|
print(f"[install] still waiting ({i}/{timeout}) … ({type(exc).__name__})")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
raise RuntimeError(f"Matomo did not become reachable after {timeout}s: {url}")
|
|
||||||
|
raise RuntimeError(f"Matomo did not become reachable after {timeout}s: {url} ({last_err})")
|
||||||
|
|
||||||
|
|
||||||
def is_installed(url: str) -> bool:
|
def is_installed(url: str) -> bool:
|
||||||
|
"""
|
||||||
|
Heuristic:
|
||||||
|
- installed instances typically render login module links
|
||||||
|
- installer renders 'installation' wizard content
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=3) as resp:
|
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||||
html = resp.read().decode(errors="ignore").lower()
|
html = resp.read().decode(errors="ignore").lower()
|
||||||
return ("module=login" in html) or ("matomo › login" in html)
|
return ("module=login" in html) or ("matomo › login" in html) or ("matomo/login" in html)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
# Even if it's 500, read body and try heuristic.
|
||||||
|
try:
|
||||||
|
html = exc.read().decode(errors="ignore").lower()
|
||||||
|
return ("module=login" in html) or ("matomo › login" in html) or ("matomo/login" in html)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -51,24 +71,12 @@ def ensure_installed(
|
|||||||
Ensure Matomo is installed.
|
Ensure Matomo is installed.
|
||||||
NO-OP if already installed.
|
NO-OP if already installed.
|
||||||
"""
|
"""
|
||||||
|
wait_http(base_url)
|
||||||
|
|
||||||
# Propagate config to installer via ENV (single source of truth)
|
if is_installed(base_url):
|
||||||
os.environ["MATOMO_URL"] = base_url
|
if debug:
|
||||||
os.environ["MATOMO_ADMIN_USER"] = admin_user
|
print("[install] Matomo already looks installed. Skipping web installer.")
|
||||||
os.environ["MATOMO_ADMIN_PASSWORD"] = admin_password
|
return
|
||||||
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:
|
try:
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
@@ -78,32 +86,40 @@ def main() -> int:
|
|||||||
"Install with: python3 -m pip install playwright && python3 -m playwright install chromium",
|
"Install with: python3 -m pip install playwright && python3 -m playwright install chromium",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
print(f"Reason: {exc}", file=sys.stderr)
|
raise RuntimeError(f"Playwright missing: {exc}") from exc
|
||||||
return 2
|
|
||||||
|
|
||||||
print("[install] Running Matomo web installer via headless browser...")
|
print("[install] Running Matomo web installer via headless browser...")
|
||||||
|
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
browser = p.chromium.launch(headless=True)
|
browser = p.chromium.launch(headless=True)
|
||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
page.goto(MATOMO_URL, wait_until="domcontentloaded")
|
|
||||||
|
|
||||||
def click_next():
|
# Load installer (may be 500 in curl, but browser can still render the Matomo error/installer flow)
|
||||||
for label in ["Next", "Continue", "Start Installation", "Proceed"]:
|
page.goto(base_url, wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
def click_next() -> None:
|
||||||
|
# Buttons vary slightly with locales/versions
|
||||||
|
for label in ["Next", "Continue", "Start Installation", "Proceed", "Weiter", "Fortfahren"]:
|
||||||
btn = page.get_by_role("button", name=label)
|
btn = page.get_by_role("button", name=label)
|
||||||
if btn.count() > 0:
|
if btn.count() > 0:
|
||||||
btn.first.click()
|
btn.first.click()
|
||||||
return True
|
return
|
||||||
return False
|
# Sometimes it's a link styled as button
|
||||||
|
for text in ["Next", "Continue", "Start Installation", "Proceed", "Weiter", "Fortfahren"]:
|
||||||
|
a = page.get_by_text(text, exact=False)
|
||||||
|
if a.count() > 0:
|
||||||
|
a.first.click()
|
||||||
|
return
|
||||||
|
raise RuntimeError("Could not find a 'Next/Continue' control in installer UI.")
|
||||||
|
|
||||||
# Welcome / system check
|
# Welcome / System check
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(700)
|
||||||
click_next()
|
click_next()
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(700)
|
||||||
click_next()
|
click_next()
|
||||||
|
|
||||||
# Database setup
|
# Database setup
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(700)
|
||||||
page.get_by_label("Database Server").fill(DB_HOST)
|
page.get_by_label("Database Server").fill(DB_HOST)
|
||||||
page.get_by_label("Login").fill(DB_USER)
|
page.get_by_label("Login").fill(DB_USER)
|
||||||
page.get_by_label("Password").fill(DB_PASS)
|
page.get_by_label("Password").fill(DB_PASS)
|
||||||
@@ -115,22 +131,22 @@ def main() -> int:
|
|||||||
click_next()
|
click_next()
|
||||||
|
|
||||||
# Tables creation
|
# Tables creation
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(700)
|
||||||
click_next()
|
click_next()
|
||||||
|
|
||||||
# Super user
|
# Super user
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(700)
|
||||||
page.get_by_label("Login").fill(ADMIN_USER)
|
page.get_by_label("Login").fill(admin_user)
|
||||||
page.get_by_label("Password").fill(ADMIN_PASSWORD)
|
page.get_by_label("Password").fill(admin_password)
|
||||||
try:
|
try:
|
||||||
page.get_by_label("Password (repeat)").fill(ADMIN_PASSWORD)
|
page.get_by_label("Password (repeat)").fill(admin_password)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
page.get_by_label("Email").fill(ADMIN_EMAIL)
|
page.get_by_label("Email").fill(admin_email)
|
||||||
click_next()
|
click_next()
|
||||||
|
|
||||||
# First website
|
# First website
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(700)
|
||||||
try:
|
try:
|
||||||
page.get_by_label("Name").fill("Bootstrap Site")
|
page.get_by_label("Name").fill("Bootstrap Site")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -142,19 +158,14 @@ def main() -> int:
|
|||||||
click_next()
|
click_next()
|
||||||
|
|
||||||
# Finish
|
# Finish
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(700)
|
||||||
click_next()
|
click_next()
|
||||||
|
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
||||||
|
# Verify installed
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
if not is_installed(MATOMO_URL):
|
if not is_installed(base_url):
|
||||||
print("[install] Installer did not reach installed state.", file=sys.stderr)
|
raise RuntimeError("[install] Installer did not reach installed state.")
|
||||||
return 3
|
|
||||||
|
|
||||||
print("[install] Installation finished.")
|
print("[install] Installation finished.")
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import urllib.request
|
|||||||
MATOMO_URL = os.environ.get("MATOMO_URL", "http://127.0.0.1:8080")
|
MATOMO_URL = os.environ.get("MATOMO_URL", "http://127.0.0.1:8080")
|
||||||
ADMIN_USER = os.environ.get("MATOMO_ADMIN_USER", "administrator")
|
ADMIN_USER = os.environ.get("MATOMO_ADMIN_USER", "administrator")
|
||||||
ADMIN_PASSWORD = os.environ.get("MATOMO_ADMIN_PASSWORD", "AdminSecret123!")
|
ADMIN_PASSWORD = os.environ.get("MATOMO_ADMIN_PASSWORD", "AdminSecret123!")
|
||||||
|
ADMIN_EMAIL = os.environ.get("MATOMO_ADMIN_EMAIL", "administrator@example.org")
|
||||||
|
|
||||||
|
|
||||||
class TestMatomoBootstrapE2E(unittest.TestCase):
|
class TestMatomoBootstrapE2E(unittest.TestCase):
|
||||||
@@ -22,6 +23,8 @@ class TestMatomoBootstrapE2E(unittest.TestCase):
|
|||||||
ADMIN_USER,
|
ADMIN_USER,
|
||||||
"--admin-password",
|
"--admin-password",
|
||||||
ADMIN_PASSWORD,
|
ADMIN_PASSWORD,
|
||||||
|
"--admin-email",
|
||||||
|
ADMIN_EMAIL,
|
||||||
"--token-description",
|
"--token-description",
|
||||||
"e2e-test-token",
|
"e2e-test-token",
|
||||||
]
|
]
|
||||||
@@ -34,7 +37,7 @@ class TestMatomoBootstrapE2E(unittest.TestCase):
|
|||||||
self.assertRegex(token, r"^[a-f0-9]{32,64}$", f"Expected token_auth, got: {token}")
|
self.assertRegex(token, r"^[a-f0-9]{32,64}$", f"Expected token_auth, got: {token}")
|
||||||
|
|
||||||
api_url = (
|
api_url = (
|
||||||
f"{MATOMO_URL}/api.php"
|
f"{MATOMO_URL}/index.php"
|
||||||
f"?module=API&method=SitesManager.getSitesWithAtLeastViewAccess"
|
f"?module=API&method=SitesManager.getSitesWithAtLeastViewAccess"
|
||||||
f"&format=json&token_auth={token}"
|
f"&format=json&token_auth={token}"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user