feat(container): add pinned Playwright Docker image and compose stack for Matomo bootstrap
Some checks failed
CI / test (push) Has been cancelled

- Add Dockerfile based on pinned Playwright image (v1.46.0-jammy) for reproducible browser runtime
- Introduce docker-compose stack (MariaDB + Matomo + one-shot bootstrap)
- Extend Makefile with container image and stack management targets
- Add env.sample for environment-driven bootstrap configuration
- Relax Playwright dependency to >=1.46.0 to keep Nix builds compatible
- Add E2E test ensuring docker-compose bootstrap exits with 0 and prints token
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-23 19:41:31 +01:00
parent f270a5c7c6
commit a2010cd914
6 changed files with 356 additions and 8 deletions

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Playwright Python image with Chromium + all required OS dependencies
# Version should roughly match your playwright requirement
FROM mcr.microsoft.com/playwright/python:v1.46.0-jammy
# Keep stdout clean (token-only), logs go to stderr
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install matomo-bootstrap
# Option A: from PyPI (recommended once published)
# RUN pip install --no-cache-dir matomo-bootstrap==1.0.1
# Option B: build from source (current repo)
COPY pyproject.toml README.md LICENSE /app/
COPY src /app/src
RUN pip install --no-cache-dir .
# Default entrypoint: environment-driven bootstrap
ENTRYPOINT ["matomo-bootstrap"]

131
Makefile
View File

@@ -1,4 +1,8 @@
PYTHON ?= python3
# ----------------------------
# E2E (existing)
# ----------------------------
COMPOSE_FILE := tests/e2e/docker-compose.yml
COMPOSE := docker compose -f $(COMPOSE_FILE)
@@ -13,7 +17,25 @@ 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
# ----------------------------
# Container image (production-like)
# ----------------------------
IMAGE_NAME ?= ghcr.io/kevinveenbirkenbach/matomo-bootstrap
IMAGE_VERSION ?= 1.0.1
# Optional .env file for container runs
ENV_FILE ?= .env
# ----------------------------
# docker-compose stack (Matomo + MariaDB + Bootstrap)
# ----------------------------
COMPOSE_STACK_FILE ?= docker-compose.yml
COMPOSE_STACK := docker compose -f $(COMPOSE_STACK_FILE)
.PHONY: help \
venv deps-e2e playwright-install e2e-up e2e-install e2e-test e2e-down e2e logs clean \
image-build image-run image-shell image-push image-clean \
stack-up stack-down stack-logs stack-ps stack-bootstrap stack-rebootstrap stack-clean stack-reset
help:
@echo "Targets:"
@@ -25,11 +47,33 @@ help:
@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"
@echo " clean Stop containers + remove venv"
@echo " logs Show Matomo logs (E2E compose)"
@echo " clean Stop E2E 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"
@echo "Container image targets:"
@echo " image-build Build matomo-bootstrap container image"
@echo " image-run Run container bootstrap using $(ENV_FILE) (token-only stdout)"
@echo " image-shell Start interactive shell in container"
@echo " image-push Push image tags ($(IMAGE_VERSION) + latest)"
@echo " image-clean Remove local image tags"
@echo ""
@echo "docker-compose stack targets (docker-compose.yml):"
@echo " stack-up Start MariaDB + Matomo (no bootstrap)"
@echo " stack-bootstrap Run one-shot bootstrap (prints token to stdout)"
@echo " stack-reset Full reset: down -v → build → up → bootstrap"
@echo " stack-down Stop stack"
@echo " stack-clean Stop stack and REMOVE volumes (DANGER)"
@echo " stack-logs Follow Matomo logs (stack)"
@echo " stack-ps Show stack status"
@echo ""
@echo "Variables:"
@echo " E2E: MATOMO_URL, MATOMO_ADMIN_USER, MATOMO_ADMIN_PASSWORD, MATOMO_ADMIN_EMAIL, MATOMO_TOKEN_DESCRIPTION"
@echo " IMG: IMAGE_NAME, IMAGE_VERSION, ENV_FILE"
@echo " STK: COMPOSE_STACK_FILE"
# ----------------------------
# E2E targets
# ----------------------------
venv:
@test -x "$(VENV_PY)" || ($(PYTHON) -m venv $(VENV_DIR))
@@ -72,6 +116,11 @@ e2e-test: deps-e2e
e2e-down:
$(COMPOSE) down -v
e2e-nix:
docker compose -f tests/e2e/docker-compose.yml up -d
python3 -m unittest -v tests/e2e/test_bootstrap_nix.py
docker compose -f tests/e2e/docker-compose.yml down -v
e2e: e2e-up e2e-install e2e-test e2e-down
logs:
@@ -79,3 +128,75 @@ logs:
clean: e2e-down
rm -rf $(VENV_DIR)
# ----------------------------
# Container image workflow
# ----------------------------
image-build:
docker build -t $(IMAGE_NAME):$(IMAGE_VERSION) -t $(IMAGE_NAME):latest .
image-run:
@test -f "$(ENV_FILE)" || (echo "Missing $(ENV_FILE). Create it from env.sample."; exit 1)
docker run --rm \
--env-file "$(ENV_FILE)" \
--network host \
$(IMAGE_NAME):$(IMAGE_VERSION)
image-shell:
@test -f "$(ENV_FILE)" || (echo "Missing $(ENV_FILE). Create it from env.sample."; exit 1)
docker run --rm -it \
--env-file "$(ENV_FILE)" \
--network host \
--entrypoint /bin/bash \
$(IMAGE_NAME):$(IMAGE_VERSION)
image-push:
docker push $(IMAGE_NAME):$(IMAGE_VERSION)
docker push $(IMAGE_NAME):latest
image-clean:
docker rmi $(IMAGE_NAME):$(IMAGE_VERSION) $(IMAGE_NAME):latest || true
# ----------------------------
# docker-compose stack workflow
# ----------------------------
## Start MariaDB + Matomo (without bootstrap)
stack-up:
$(COMPOSE_STACK) up -d db matomo
@echo "Matomo is starting on http://127.0.0.1:8080"
## Run one-shot bootstrap (prints token to stdout)
stack-bootstrap:
$(COMPOSE_STACK) run --rm bootstrap
## Re-run bootstrap (forces a fresh one-shot run)
stack-rebootstrap:
$(COMPOSE_STACK) rm -f bootstrap || true
$(COMPOSE_STACK) run --rm bootstrap
## Follow Matomo logs (stack)
stack-logs:
$(COMPOSE_STACK) logs -f matomo
## Show running services (stack)
stack-ps:
$(COMPOSE_STACK) ps
## Stop stack
stack-down:
$(COMPOSE_STACK) down
## Stop stack and REMOVE volumes (DANGER)
stack-clean:
$(COMPOSE_STACK) down -v
## Full reset: down -v → rebuild bootstrap → up → bootstrap
stack-reset:
$(COMPOSE_STACK) down -v
$(COMPOSE_STACK) build --no-cache bootstrap
$(COMPOSE_STACK) up -d db matomo
@echo "Waiting for Matomo to become reachable..."
@sleep 10
$(COMPOSE_STACK) run --rm bootstrap

74
docker-compose.yml Normal file
View File

@@ -0,0 +1,74 @@
services:
db:
image: mariadb:11
container_name: matomo-db
restart: unless-stopped
environment:
MARIADB_DATABASE: matomo
MARIADB_USER: matomo
MARIADB_PASSWORD: matomo_pw
MARIADB_ROOT_PASSWORD: root_pw
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -uroot -proot_pw --silent"]
interval: 5s
timeout: 3s
retries: 60
matomo:
image: matomo:5.3.2
container_name: matomo
restart: unless-stopped
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
volumes:
- matomo_data:/var/www/html
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 60
bootstrap:
build:
context: .
dockerfile: Dockerfile
image: matomo-bootstrap:local
container_name: matomo-bootstrap
depends_on:
matomo:
condition: service_started
environment:
MATOMO_URL: "http://matomo"
MATOMO_ADMIN_USER: "administrator"
MATOMO_ADMIN_PASSWORD: "AdminSecret123!"
MATOMO_ADMIN_EMAIL: "administrator@example.org"
MATOMO_TOKEN_DESCRIPTION: "docker-compose-bootstrap"
# Installer flow values
MATOMO_SITE_NAME: "Matomo (docker-compose)"
MATOMO_SITE_URL: "http://127.0.0.1:8080"
MATOMO_TIMEZONE: "Germany - Berlin"
# Optional stability knobs
MATOMO_TIMEOUT: "30"
MATOMO_PLAYWRIGHT_HEADLESS: "1"
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
# 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"
volumes:
mariadb_data:
matomo_data:

29
env.sample Normal file
View File

@@ -0,0 +1,29 @@
# --- REQUIRED ---
MATOMO_URL=http://127.0.0.1:8080
MATOMO_ADMIN_USER=administrator
MATOMO_ADMIN_PASSWORD=AdminSecret123!
MATOMO_ADMIN_EMAIL=administrator@example.org
# --- OPTIONAL ---
# Description for the app-specific token
MATOMO_TOKEN_DESCRIPTION=ansible-bootstrap
# Timeout (seconds)
MATOMO_TIMEOUT=30
# Debug logs to stderr (stdout stays token-only)
# MATOMO_DEBUG=1
# If set, bootstrap will NOT create a new token
# but return this one instead (idempotent runs)
# MATOMO_BOOTSTRAP_TOKEN_AUTH=0123456789abcdef...
# Values used by the recorded installer flow
MATOMO_SITE_NAME=Matomo
MATOMO_SITE_URL=http://127.0.0.1:8080
MATOMO_TIMEZONE=Germany - Berlin
# Playwright knobs (usually not needed)
# MATOMO_PLAYWRIGHT_HEADLESS=1
# MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS=60000
# MATOMO_PLAYWRIGHT_SLOWMO_MS=0

View File

@@ -12,9 +12,7 @@ authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
license = { text = "MIT" }
urls = { Homepage = "https://github.com/kevinveenbirkenbach/matomo-bootstrap" }
dependencies = [
"playwright>=1.40.0",
]
dependencies = ["playwright>=1.46.0"]
# Provides a stable CLI name for Nix + pip installs:
[project.scripts]

View File

@@ -0,0 +1,106 @@
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()