diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c959333 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile index a138d22..5a805f1 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..78b1a67 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/env.sample b/env.sample new file mode 100644 index 0000000..55a05ca --- /dev/null +++ b/env.sample @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 5cb254f..516de74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/e2e/test_compose_stack_bootstrap.py b/tests/e2e/test_compose_stack_bootstrap.py new file mode 100644 index 0000000..4acb93f --- /dev/null +++ b/tests/e2e/test_compose_stack_bootstrap.py @@ -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()