Compare commits
51 Commits
1a65077d0c
...
v1.1.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc9b706b70 | ||
|
|
b0593ab431 | ||
|
|
3d5bfd5401 | ||
|
|
fa6adea3c1 | ||
|
|
7ecb26cc92 | ||
|
|
5d4a2d59db | ||
|
|
1847c14b63 | ||
|
|
29e812f584 | ||
|
|
1c8de40a05 | ||
|
|
4bfa7433f4 | ||
|
|
00c012e553 | ||
|
|
1bebeb8abc | ||
|
|
01d1626cf2 | ||
|
|
5bbe78b272 | ||
|
|
865d5155d5 | ||
|
|
209037cd64 | ||
|
|
ba2d84b6cb | ||
|
|
4f5c41753f | ||
|
|
aac01810a1 | ||
|
|
fb42167b89 | ||
|
|
7836dbacf9 | ||
|
|
d380b1493c | ||
|
|
e81c5262b0 | ||
|
|
f35ea04d66 | ||
|
|
37a17b536d | ||
|
|
c80fdf8d01 | ||
|
|
276833bd16 | ||
|
|
9e267ec83f | ||
|
|
20274985bc | ||
|
|
cf473d4f3f | ||
|
|
84323bd2aa | ||
|
|
1a65ceb015 | ||
|
|
81746f4b26 | ||
|
|
45867ece38 | ||
|
|
33ed86adf1 | ||
|
|
f86f84ff93 | ||
|
|
b429644d9e | ||
|
|
9fbdce2972 | ||
|
|
1b3ee2c3fd | ||
|
|
1f448f4457 | ||
|
|
7fa8b580d2 | ||
|
|
bf69c110a7 | ||
|
|
a582e8be13 | ||
|
|
e38051a92f | ||
|
|
8162d337b5 | ||
|
|
bac453c435 | ||
|
|
a2010cd914 | ||
|
|
f270a5c7c6 | ||
|
|
5e5b6c8933 | ||
|
|
1af480ee91 | ||
|
|
4f7de18a11 |
70
.github/workflows/ci.yml
vendored
70
.github/workflows/ci.yml
vendored
@@ -1,12 +1,76 @@
|
||||
name: CI
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
tests:
|
||||
uses: ./.github/workflows/reusable-test.yml
|
||||
with:
|
||||
python-version: "3.12"
|
||||
matomo-token-description: "ci-token"
|
||||
|
||||
detect-release:
|
||||
# Only consider releases on main branch pushes (not PRs, not other branches)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_semver_tag: ${{ steps.detect.outputs.is_semver_tag }}
|
||||
version_tag: ${{ steps.detect.outputs.version_tag }}
|
||||
steps:
|
||||
- name: Checkout (full history for tags)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --tags --force
|
||||
|
||||
# Tags that point to the current commit
|
||||
TAGS="$(git tag --points-at "$GITHUB_SHA" || true)"
|
||||
|
||||
# Pick the first strict SemVer tag: vX.Y.Z
|
||||
VERSION_TAG="$(echo "$TAGS" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)"
|
||||
|
||||
if [ -n "$VERSION_TAG" ]; then
|
||||
echo "is_semver_tag=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version_tag=$VERSION_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Release tag detected on this commit: $VERSION_TAG"
|
||||
else
|
||||
echo "is_semver_tag=false" >> "$GITHUB_OUTPUT"
|
||||
echo "version_tag=" >> "$GITHUB_OUTPUT"
|
||||
echo "No SemVer tag on this commit."
|
||||
fi
|
||||
|
||||
publish-image:
|
||||
# Only on main, and only if detect-release found a SemVer tag on this commit
|
||||
if: needs.detect-release.outputs.is_semver_tag == 'true'
|
||||
needs: [tests, detect-release]
|
||||
uses: ./.github/workflows/publish-image.yml
|
||||
with:
|
||||
version_tag: ${{ needs.detect-release.outputs.version_tag }}
|
||||
sha: ${{ github.sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
tag-stable:
|
||||
# Only after tests + publish succeeded
|
||||
if: needs.detect-release.outputs.is_semver_tag == 'true'
|
||||
needs: [tests, detect-release, publish-image]
|
||||
uses: ./.github/workflows/stable-tag.yml
|
||||
with:
|
||||
version_tag: ${{ needs.detect-release.outputs.version_tag }}
|
||||
sha: ${{ github.sha }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
59
.github/workflows/publish-image.yml
vendored
Normal file
59
.github/workflows/publish-image.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: publish-image
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_tag:
|
||||
type: string
|
||||
required: true
|
||||
sha:
|
||||
type: string
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout (exact commit)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.sha }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/${{ github.repository }}"
|
||||
RAW_TAG="${{ inputs.version_tag }}" # e.g. v1.1.8
|
||||
TAG="${RAW_TAG#v}" # -> 1.1.8
|
||||
echo "tags=$IMAGE:$TAG,$IMAGE:latest" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
55
.github/workflows/reusable-test.yml
vendored
55
.github/workflows/reusable-test.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Reusable Test (ruff + e2e)
|
||||
name: reusable-test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -29,10 +29,47 @@ on:
|
||||
default: "ci-token"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
cache: "pip"
|
||||
|
||||
- name: Install lint deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ruff
|
||||
|
||||
- name: Ruff
|
||||
run: |
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Integration tests
|
||||
run: make test-integration
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -48,22 +85,16 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl
|
||||
|
||||
- name: Install Python deps
|
||||
- name: Install Python deps (editable + e2e)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[e2e]"
|
||||
pip install ruff
|
||||
|
||||
- name: Ruff
|
||||
run: |
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: |
|
||||
python -m playwright install --with-deps chromium
|
||||
python -m playwright install --with-deps --force chromium
|
||||
|
||||
- name: E2E (docker compose + installer + tests)
|
||||
- name: Run E2E (docker compose)
|
||||
env:
|
||||
MATOMO_URL: ${{ inputs.matomo-url }}
|
||||
MATOMO_ADMIN_USER: ${{ inputs.matomo-admin-user }}
|
||||
|
||||
29
.github/workflows/stable-tag.yml
vendored
29
.github/workflows/stable-tag.yml
vendored
@@ -1,24 +1,21 @@
|
||||
name: Stable Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_tag:
|
||||
type: string
|
||||
required: true
|
||||
sha:
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/reusable-test.yml
|
||||
with:
|
||||
python-version: "3.12"
|
||||
matomo-token-description: "stable-ci-token"
|
||||
|
||||
tag-stable:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
steps:
|
||||
- name: Checkout (full history for tags)
|
||||
@@ -26,17 +23,17 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Move stable tag to this version tag commit
|
||||
- name: Move stable tag to the release commit
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Triggered by tag: ${GITHUB_REF_NAME}"
|
||||
echo "Commit: ${GITHUB_SHA}"
|
||||
echo "Release tag: ${{ inputs.version_tag }}"
|
||||
echo "Commit: ${{ inputs.sha }}"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git fetch --tags --force
|
||||
|
||||
git tag -fa stable -m "stable -> ${GITHUB_REF_NAME} (${GITHUB_SHA})" "${GITHUB_SHA}"
|
||||
git tag -fa stable -m "stable -> ${{ inputs.version_tag }} (${{ inputs.sha }})" "${{ inputs.sha }}"
|
||||
git push --force origin stable
|
||||
|
||||
81
CHANGELOG.md
Normal file
81
CHANGELOG.md
Normal file
@@ -0,0 +1,81 @@
|
||||
## [1.1.13] - 2026-02-15
|
||||
|
||||
* This release fixes the intermittent setupSuperUser bootstrap timeout by making superuser-form detection and submission more robust across timing and DOM variations, with added integration coverage and full passing E2E/integration tests.
|
||||
|
||||
|
||||
## [1.1.12] - 2026-02-14
|
||||
|
||||
* This release fixes the intermittent Matomo installer failure in the setupSuperUser step by adding more robust waiting logic and introduces E2E tests for deployments under very tight resource constraints.
|
||||
|
||||
|
||||
## [1.1.11] - 2026-02-14
|
||||
|
||||
* This release improves matomo-bootstrap installer resilience by adding robust setupSuperUser field and button detection to prevent intermittent bootstrap failures.
|
||||
|
||||
|
||||
## [1.1.10] - 2026-02-14
|
||||
|
||||
* This release fixes a reproducible Playwright navigation race in the Matomo installer (setupSuperUser), hardens the Next/Continue flow, and adds integration tests for transient locator errors and progress detection without a visible Next button.
|
||||
|
||||
|
||||
## [1.1.9] - 2026-02-14
|
||||
|
||||
* Reworked CI to run on all branches while restricting Docker image publishing and stable tagging to tagged commits on main, using git-based SemVer detection.
|
||||
|
||||
|
||||
## [1.1.8] - 2026-02-14
|
||||
|
||||
* Refactored CI to use a single coordinator workflow with strict SemVer-based release gating and adjusted Docker image publishing to strip the leading v from version tags.
|
||||
|
||||
|
||||
## [1.1.7] - 2026-02-14
|
||||
|
||||
* Harden compose installer timeouts and e2e stack diagnostics
|
||||
|
||||
|
||||
## [1.1.6] - 2026-02-14
|
||||
|
||||
* Add installer table-step timeout env vars (MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S, MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S) to compose/docs and e2e checks.
|
||||
|
||||
|
||||
## [1.1.5] - 2026-02-14
|
||||
|
||||
* Harden web installer flow for nix e2e
|
||||
|
||||
|
||||
## [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.
|
||||
|
||||
|
||||
## [1.1.1] - 2025-12-24
|
||||
|
||||
* Improved Docker image publishing: automatic `vX.Y.Z`, `latest`, and `stable` tags for releases.
|
||||
|
||||
|
||||
## [1.1.0] - 2025-12-23
|
||||
|
||||
* Implemented bootstrap docker image to auto install matomo in docker compose
|
||||
|
||||
|
||||
## [1.0.1] - 2025-12-23
|
||||
|
||||
* * Support for running `matomo-bootstrap` **fully via Nix** in a clean, containerized environment.
|
||||
* A **token-only stdout contract**: the bootstrap command now prints only the API token, making it safe for automation.
|
||||
* Reproducible Nix builds via a pinned `flake.lock`.
|
||||
|
||||
|
||||
## [1.0.0] - 2025-12-23
|
||||
|
||||
* 🥳
|
||||
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# 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 constraints.txt /app/
|
||||
COPY src /app/src
|
||||
RUN pip install --no-cache-dir -c /app/constraints.txt .
|
||||
|
||||
# Default entrypoint: environment-driven bootstrap
|
||||
ENTRYPOINT ["matomo-bootstrap"]
|
||||
182
Makefile
182
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,26 @@ 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 \
|
||||
test-integration \
|
||||
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,15 +48,76 @@ 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 " test-integration Run integration tests (unittest)"
|
||||
@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))
|
||||
@$(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]"
|
||||
@@ -72,6 +156,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 +168,82 @@ logs:
|
||||
|
||||
clean: e2e-down
|
||||
rm -rf $(VENV_DIR)
|
||||
|
||||
# ----------------------------
|
||||
# Integration tests
|
||||
# ----------------------------
|
||||
|
||||
test-integration:
|
||||
PYTHONPATH=src $(PYTHON) -m unittest discover -s tests/integration -v
|
||||
|
||||
# ----------------------------
|
||||
# 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
|
||||
|
||||
282
README.md
282
README.md
@@ -2,78 +2,69 @@
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
||||
|
||||
|
||||
Headless bootstrap tooling for **Matomo**
|
||||
Automates **installation** (via recorded Playwright flow) and **API token provisioning** for fresh Matomo instances.
|
||||
|
||||
This tool is designed for **CI, containers, and reproducible environments**, where no interactive browser access is available.
|
||||
|
||||
Headless bootstrap tooling for **Matomo**. Automates **first-time installation** and **API token provisioning** for fresh Matomo instances.
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
* 🚀 **Fully headless Matomo installation**
|
||||
|
||||
* Drives the official Matomo web installer using **Playwright**
|
||||
* Automatically skips installation if Matomo is already installed
|
||||
* 🔐 **API token provisioning**
|
||||
|
||||
* Creates an *app-specific token* via authenticated Matomo session
|
||||
* Compatible with Matomo 5.3.x Docker images
|
||||
* 🧪 **E2E-tested**
|
||||
|
||||
* Docker-based end-to-end tests included
|
||||
* ❄️ **First-class Nix support**
|
||||
|
||||
* Flake-based packaging
|
||||
* Reproducible CLI and dev environments
|
||||
* 🐍 **Standard Python CLI**
|
||||
|
||||
* Installable via `pip`
|
||||
* Clean stdout (token only), logs on stderr
|
||||
- 🚀 **Fully headless Matomo installation**
|
||||
- Drives the official Matomo web installer using **Playwright**
|
||||
- Automatically skips the installer if Matomo is already installed
|
||||
- 🔐 **API token provisioning**
|
||||
- Creates an **app-specific token** via an authenticated Matomo session
|
||||
- Compatible with **Matomo 5.3.x** Docker images
|
||||
- 🧪 **E2E-tested**
|
||||
- Docker-based end-to-end tests included
|
||||
- ❄️ **First-class Nix support**
|
||||
- Flake-based packaging and pinned `flake.lock`
|
||||
- Uses `nixpkgs` browsers via `playwright-driver` (no Playwright downloads)
|
||||
- 🧼 **Token-only stdout contract**
|
||||
- **stdout contains only the token** (safe for scripting)
|
||||
- Logs go to **stderr**
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
* A running Matomo instance (e.g. Docker)
|
||||
* For fresh installs:
|
||||
|
||||
* Chromium (managed automatically by Playwright)
|
||||
- A running Matomo instance (e.g. via Docker)
|
||||
- For fresh installs:
|
||||
- Chromium (provided by Playwright or by the Playwright base container image)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Using **Nix** (recommended)
|
||||
### Nix (recommended)
|
||||
|
||||
If you use **Nix** with flakes:
|
||||
Run directly from the repository:
|
||||
|
||||
```bash
|
||||
nix run github:kevinveenbirkenbach/matomo-bootstrap
|
||||
```
|
||||
|
||||
Install Playwright’s Chromium browser (one-time):
|
||||
|
||||
```bash
|
||||
nix run github:kevinveenbirkenbach/matomo-bootstrap#matomo-bootstrap-playwright-install
|
||||
```
|
||||
|
||||
This installs Chromium into the user cache used by Playwright.
|
||||
In Nix mode, browsers are provided via `nixpkgs` (`playwright-driver`) and Playwright downloads are disabled.
|
||||
|
||||
---
|
||||
|
||||
### Using **Python / pip**
|
||||
### Python / pip
|
||||
|
||||
Requires **Python ≥ 3.10**
|
||||
Requires **Python ≥ 3.10**:
|
||||
|
||||
```bash
|
||||
pip install matomo-bootstrap
|
||||
python -m playwright install chromium
|
||||
```
|
||||
|
||||
Install Chromium for Playwright:
|
||||
---
|
||||
|
||||
### Docker image (GHCR)
|
||||
|
||||
Pull the prebuilt image:
|
||||
|
||||
```bash
|
||||
python -m playwright install chromium
|
||||
docker pull ghcr.io/kevinveenbirkenbach/matomo-bootstrap:stable
|
||||
# or:
|
||||
docker pull ghcr.io/kevinveenbirkenbach/matomo-bootstrap:latest
|
||||
```
|
||||
|
||||
---
|
||||
@@ -86,11 +77,12 @@ python -m playwright install chromium
|
||||
matomo-bootstrap \
|
||||
--base-url http://127.0.0.1:8080 \
|
||||
--admin-user administrator \
|
||||
--admin-password AdminSecret123! \
|
||||
--admin-email administrator@example.org
|
||||
--admin-password 'AdminSecret123!' \
|
||||
--admin-email administrator@example.org \
|
||||
--token-description my-ci-token
|
||||
```
|
||||
|
||||
On success, the command prints **only the API token** to stdout:
|
||||
On success, the command prints **only the token** to stdout:
|
||||
|
||||
```text
|
||||
6c7a8c2b0e9e4a3c8e1d0c4e8a6b9f21
|
||||
@@ -98,14 +90,14 @@ On success, the command prints **only the API token** to stdout:
|
||||
|
||||
---
|
||||
|
||||
### Environment Variables
|
||||
### Environment variables
|
||||
|
||||
All options can be provided via environment variables:
|
||||
|
||||
```bash
|
||||
export MATOMO_URL=http://127.0.0.1:8080
|
||||
export MATOMO_ADMIN_USER=administrator
|
||||
export MATOMO_ADMIN_PASSWORD=AdminSecret123!
|
||||
export MATOMO_ADMIN_PASSWORD='AdminSecret123!'
|
||||
export MATOMO_ADMIN_EMAIL=administrator@example.org
|
||||
export MATOMO_TOKEN_DESCRIPTION=my-ci-token
|
||||
|
||||
@@ -114,9 +106,9 @@ matomo-bootstrap
|
||||
|
||||
---
|
||||
|
||||
### Debug Mode
|
||||
### Debug mode
|
||||
|
||||
Enable verbose logs (stderr only):
|
||||
Enable verbose logs (**stderr only**):
|
||||
|
||||
```bash
|
||||
matomo-bootstrap --debug
|
||||
@@ -124,27 +116,180 @@ matomo-bootstrap --debug
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
## Docker Compose integration (one-shot bootstrap)
|
||||
|
||||
1. **Reachability check**
|
||||
### Why “one-shot”?
|
||||
|
||||
* Waits until Matomo responds over HTTP (any status)
|
||||
2. **Installation (if needed)**
|
||||
The bootstrap container is meant to:
|
||||
|
||||
* Uses a recorded Playwright flow to complete the Matomo web installer
|
||||
3. **Authentication**
|
||||
1. Run once,
|
||||
2. Print the token to stdout,
|
||||
3. Exit with code `0`.
|
||||
|
||||
* Logs in using the `Login.logme` controller
|
||||
4. **Token creation**
|
||||
You should **not** start it automatically on every `docker compose up`.
|
||||
Instead, start Matomo normally, then run the bootstrap via `docker compose run`.
|
||||
|
||||
* Calls `UsersManager.createAppSpecificTokenAuth`
|
||||
5. **Output**
|
||||
|
||||
* Prints the token to stdout (safe for scripting)
|
||||
The cleanest Compose pattern is to put `bootstrap` behind a **profile**.
|
||||
|
||||
---
|
||||
|
||||
## End-to-End Tests
|
||||
### Example `docker-compose.yml` (recommended: `profiles`)
|
||||
|
||||
```yaml
|
||||
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:
|
||||
- "${MATOMO_PORT:-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", "curl -fsS http://127.0.0.1/ >/dev/null || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 60
|
||||
|
||||
bootstrap:
|
||||
# This prevents automatic startup during a normal `docker compose up`
|
||||
profiles: ["bootstrap"]
|
||||
|
||||
# Option A: use the published image (recommended)
|
||||
image: ghcr.io/kevinveenbirkenbach/matomo-bootstrap:1.0.1
|
||||
|
||||
# Option B: build locally from the repository checkout
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# image: matomo-bootstrap:local
|
||||
|
||||
container_name: matomo-bootstrap
|
||||
depends_on:
|
||||
matomo:
|
||||
condition: service_started
|
||||
environment:
|
||||
# inside the compose network, Matomo is reachable via the service name
|
||||
MATOMO_URL: "http://matomo"
|
||||
|
||||
MATOMO_ADMIN_USER: "administrator"
|
||||
MATOMO_ADMIN_PASSWORD: "AdminSecret123!"
|
||||
MATOMO_ADMIN_EMAIL: "administrator@example.org"
|
||||
MATOMO_TOKEN_DESCRIPTION: "docker-compose-bootstrap"
|
||||
|
||||
# Values used by the recorded installer flow
|
||||
MATOMO_SITE_NAME: "Matomo (docker-compose)"
|
||||
MATOMO_SITE_URL: "http://127.0.0.1:${MATOMO_PORT:-8080}"
|
||||
MATOMO_TIMEZONE: "Germany - Berlin"
|
||||
|
||||
# Optional stability knobs
|
||||
MATOMO_TIMEOUT: "60"
|
||||
MATOMO_PLAYWRIGHT_HEADLESS: "1"
|
||||
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
|
||||
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
|
||||
MATOMO_INSTALLER_READY_TIMEOUT_S: "240"
|
||||
MATOMO_INSTALLER_STEP_TIMEOUT_S: "45"
|
||||
MATOMO_INSTALLER_STEP_DEADLINE_S: "240"
|
||||
MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S: "240"
|
||||
MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S: "180"
|
||||
MATOMO_INSTALLER_DEBUG_DIR: "/tmp/matomo-bootstrap"
|
||||
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
mariadb_data:
|
||||
matomo_data:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Commands
|
||||
|
||||
Start DB + Matomo **without** bootstrap:
|
||||
|
||||
```bash
|
||||
docker compose up -d db matomo
|
||||
```
|
||||
|
||||
Run bootstrap once (prints token to stdout):
|
||||
|
||||
```bash
|
||||
docker compose --profile bootstrap run --rm bootstrap
|
||||
```
|
||||
|
||||
Re-run bootstrap (creates a new token by default):
|
||||
|
||||
```bash
|
||||
docker compose --profile bootstrap run --rm bootstrap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Idempotency / avoiding new tokens on every run
|
||||
|
||||
By default, `UsersManager.createAppSpecificTokenAuth` creates a new token each time.
|
||||
|
||||
If you want strictly idempotent runs in automation, you can provide an existing token
|
||||
and make the bootstrap return it instead of creating a new one:
|
||||
|
||||
```bash
|
||||
export MATOMO_BOOTSTRAP_TOKEN_AUTH="0123456789abcdef..."
|
||||
matomo-bootstrap
|
||||
```
|
||||
|
||||
> This is useful for CI re-runs or configuration management tools.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Reachability check**
|
||||
|
||||
* waits until Matomo responds via HTTP (any status is considered “reachable”)
|
||||
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 Matomo’s `Login.logme` controller (cookie session)
|
||||
4. **Token creation**
|
||||
|
||||
* calls `UsersManager.createAppSpecificTokenAuth`
|
||||
5. **Output**
|
||||
|
||||
* prints the token to stdout (token-only contract)
|
||||
|
||||
---
|
||||
|
||||
## End-to-end tests
|
||||
|
||||
Run the full E2E cycle locally:
|
||||
|
||||
@@ -157,27 +302,18 @@ This will:
|
||||
1. Start Matomo + MariaDB via Docker
|
||||
2. Install Matomo headlessly
|
||||
3. Create an API token
|
||||
4. Validate the token via Matomo API
|
||||
4. Validate the token via the Matomo API
|
||||
5. Tear everything down again
|
||||
|
||||
---
|
||||
|
||||
## Project Status
|
||||
|
||||
* ✔ Stable for CI / automation
|
||||
* ✔ Tested against Matomo 5.3.x Docker images
|
||||
* ⚠ Installer flow is UI-recorded (robust, but may need updates for future Matomo UI changes)
|
||||
|
||||
---
|
||||
|
||||
## Author
|
||||
|
||||
**Kevin Veen-Birkenbach**
|
||||
🌐 [https://www.veen.world/](https://www.veen.world/)
|
||||
[https://www.veen.world/](https://www.veen.world/)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
See [LICENSE](LICENSE)
|
||||
MIT — see [LICENSE](LICENSE)
|
||||
|
||||
1
constraints.txt
Normal file
1
constraints.txt
Normal file
@@ -0,0 +1 @@
|
||||
playwright==1.46.0
|
||||
80
docker-compose.yml
Normal file
80
docker-compose.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
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:
|
||||
- "${MATOMO_PORT:-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", "curl -fsS 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_healthy
|
||||
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:${MATOMO_PORT:-8080}"
|
||||
MATOMO_TIMEZONE: "Germany - Berlin"
|
||||
|
||||
# Optional stability knobs
|
||||
MATOMO_TIMEOUT: "60"
|
||||
MATOMO_PLAYWRIGHT_HEADLESS: "1"
|
||||
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
|
||||
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
|
||||
MATOMO_INSTALLER_READY_TIMEOUT_S: "240"
|
||||
MATOMO_INSTALLER_STEP_TIMEOUT_S: "45"
|
||||
MATOMO_INSTALLER_STEP_DEADLINE_S: "240"
|
||||
MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S: "240"
|
||||
MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_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"
|
||||
|
||||
volumes:
|
||||
mariadb_data:
|
||||
matomo_data:
|
||||
37
env.sample
Normal file
37
env.sample
Normal file
@@ -0,0 +1,37 @@
|
||||
# --- 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
|
||||
|
||||
# Installer readiness / step guards
|
||||
# MATOMO_INSTALLER_READY_TIMEOUT_S=240
|
||||
# MATOMO_INSTALLER_STEP_TIMEOUT_S=45
|
||||
# MATOMO_INSTALLER_STEP_DEADLINE_S=240
|
||||
# MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S=240
|
||||
# MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S=180
|
||||
# MATOMO_INSTALLER_DEBUG_DIR=/tmp/matomo-bootstrap
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766309749,
|
||||
"narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
59
flake.nix
59
flake.nix
@@ -15,22 +15,47 @@
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
python = pkgs.python312;
|
||||
playwrightDriver = pkgs.playwright-driver;
|
||||
in
|
||||
rec {
|
||||
matomo-bootstrap = python.pkgs.buildPythonApplication {
|
||||
pname = "matomo-bootstrap";
|
||||
version = "0.1.0"; # keep in sync with pyproject.toml
|
||||
version = "1.1.13"; # keep in sync with pyproject.toml
|
||||
pyproject = true;
|
||||
src = self;
|
||||
|
||||
# Runtime deps (Python)
|
||||
# disable import-check phase (prevents Playwright/installer side effects)
|
||||
pythonImportsCheck = [ ];
|
||||
|
||||
nativeBuildInputs =
|
||||
(with python.pkgs; [
|
||||
setuptools
|
||||
wheel
|
||||
])
|
||||
++ [
|
||||
pkgs.makeWrapper
|
||||
];
|
||||
|
||||
propagatedBuildInputs = with python.pkgs; [
|
||||
playwright
|
||||
];
|
||||
|
||||
# Optional: keep tests off in nix build by default
|
||||
doCheck = false;
|
||||
|
||||
# IMPORTANT (Nix):
|
||||
# Do NOT let Playwright download ubuntu/fhs browser binaries into ~/.cache/ms-playwright.
|
||||
# Instead, point Playwright to nixpkgs-provided browsers (playwright-driver).
|
||||
#
|
||||
# This fixes errors like:
|
||||
# BrowserType.launch ... headless_shell ENOENT
|
||||
#
|
||||
# ...which happens when Playwright downloads a fallback ubuntu build that cannot run on NixOS.
|
||||
postFixup = ''
|
||||
wrapProgram "$out/bin/matomo-bootstrap" \
|
||||
--set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD 1 \
|
||||
--set PLAYWRIGHT_BROWSERS_PATH "${playwrightDriver.browsers}"
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Headless bootstrap tooling for Matomo (installation + API token provisioning)";
|
||||
homepage = "https://github.com/kevinveenbirkenbach/matomo-bootstrap";
|
||||
@@ -46,16 +71,34 @@
|
||||
apps = forAllSystems (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
python = pkgs.python312;
|
||||
playwrightDriver = pkgs.playwright-driver;
|
||||
|
||||
pythonPlaywright = python.withPackages (ps: [
|
||||
ps.playwright
|
||||
]);
|
||||
|
||||
matomo = self.packages.${system}.matomo-bootstrap;
|
||||
|
||||
playwright-install = pkgs.writeShellApplication {
|
||||
name = "matomo-bootstrap-playwright-install";
|
||||
runtimeInputs = [ matomo ];
|
||||
runtimeInputs = [ pythonPlaywright ];
|
||||
|
||||
text = ''
|
||||
# Installs the Playwright Chromium browser into the user cache.
|
||||
# This is needed when the Matomo instance is not installed yet and
|
||||
# the web installer must be driven via Playwright.
|
||||
exec ${matomo}/bin/python -m playwright install chromium
|
||||
# Nix mode: NO browser downloads.
|
||||
#
|
||||
# Playwright upstream "install" downloads ubuntu/fhs browser binaries into ~/.cache/ms-playwright.
|
||||
# Those binaries often don't run on NixOS, producing ENOENT on launch (missing loader/libs).
|
||||
#
|
||||
# We keep this app for backwards-compat (tests/docs call it), but it is intentionally a NO-OP.
|
||||
#
|
||||
# IMPORTANT: Do not print anything to stdout (tests expect token-only stdout).
|
||||
{
|
||||
echo "Playwright browsers are provided by nixpkgs (playwright-driver)."
|
||||
echo "Using PLAYWRIGHT_BROWSERS_PATH=${playwrightDriver.browsers}"
|
||||
echo "Set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 to prevent downloads."
|
||||
} 1>&2
|
||||
exit 0
|
||||
'';
|
||||
};
|
||||
in
|
||||
|
||||
@@ -4,17 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "matomo-bootstrap"
|
||||
version = "0.1.0"
|
||||
version = "1.1.13"
|
||||
description = "Headless bootstrap tooling for Matomo (installation + API token provisioning)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
|
||||
license = { text = "MIT" }
|
||||
license = "MIT"
|
||||
urls = { Homepage = "https://github.com/kevinveenbirkenbach/matomo-bootstrap" }
|
||||
|
||||
dependencies = [
|
||||
"playwright>=1.40.0",
|
||||
]
|
||||
dependencies = ["playwright>=1.46.0,<2"]
|
||||
|
||||
# Provides a stable CLI name for Nix + pip installs:
|
||||
[project.scripts]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
32
tests/e2e/docker-compose.slow.yml
Normal file
32
tests/e2e/docker-compose.slow.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
db:
|
||||
cpus: 0.35
|
||||
mem_reservation: 192m
|
||||
mem_limit: 320m
|
||||
healthcheck:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 90
|
||||
|
||||
matomo:
|
||||
cpus: 0.35
|
||||
mem_reservation: 192m
|
||||
mem_limit: 384m
|
||||
healthcheck:
|
||||
interval: 15s
|
||||
timeout: 8s
|
||||
retries: 120
|
||||
start_period: 120s
|
||||
|
||||
bootstrap:
|
||||
cpus: 0.75
|
||||
mem_reservation: 512m
|
||||
mem_limit: 1g
|
||||
environment:
|
||||
MATOMO_TIMEOUT: "120"
|
||||
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "120000"
|
||||
MATOMO_INSTALLER_READY_TIMEOUT_S: "420"
|
||||
MATOMO_INSTALLER_STEP_TIMEOUT_S: "120"
|
||||
MATOMO_INSTALLER_STEP_DEADLINE_S: "420"
|
||||
MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S: "360"
|
||||
MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S: "240"
|
||||
@@ -27,3 +27,50 @@ services:
|
||||
MATOMO_DATABASE_USERNAME: matomo
|
||||
MATOMO_DATABASE_PASSWORD: matomo_pw
|
||||
MATOMO_DATABASE_DBNAME: matomo
|
||||
|
||||
nix:
|
||||
image: nixos/nix:latest
|
||||
container_name: e2e-nix
|
||||
depends_on:
|
||||
matomo:
|
||||
condition: service_started
|
||||
|
||||
# Run as root to avoid /nix big-lock permission issues
|
||||
user: "0:0"
|
||||
working_dir: /work
|
||||
|
||||
volumes:
|
||||
# Project root as flake
|
||||
- ../../:/work:ro
|
||||
|
||||
# Nix store (removed by docker compose down -v)
|
||||
- e2e_nix_store:/nix
|
||||
|
||||
# HOME/XDG for nix + playwright
|
||||
- e2e_nix_home:/tmp/home
|
||||
|
||||
environment:
|
||||
NIX_CONFIG: "experimental-features = nix-command flakes"
|
||||
TERM: "xterm"
|
||||
|
||||
HOME: "/tmp/home"
|
||||
USER: "root"
|
||||
LOGNAME: "root"
|
||||
XDG_CACHE_HOME: "/tmp/home/.cache"
|
||||
XDG_CONFIG_HOME: "/tmp/home/.config"
|
||||
XDG_DATA_HOME: "/tmp/home/.local/share"
|
||||
|
||||
MATOMO_SITE_NAME: "Matomo E2E"
|
||||
MATOMO_SITE_URL: "http://127.0.0.1:8080"
|
||||
MATOMO_TIMEZONE: "Germany - Berlin"
|
||||
|
||||
command: >
|
||||
sh -lc "mkdir -p /tmp/home/.cache /tmp/home/.config /tmp/home/.local/share;
|
||||
tail -f /dev/null"
|
||||
|
||||
# Allow access to host-published Matomo port
|
||||
network_mode: host
|
||||
|
||||
volumes:
|
||||
e2e_nix_store:
|
||||
e2e_nix_home:
|
||||
|
||||
107
tests/e2e/test_bootstrap_nix.py
Normal file
107
tests/e2e/test_bootstrap_nix.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
|
||||
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", "administrator@example.org")
|
||||
TOKEN_RE = re.compile(r"^[a-f0-9]{32,64}$")
|
||||
|
||||
|
||||
class TestMatomoBootstrapE2ENix(unittest.TestCase):
|
||||
def test_bootstrap_creates_api_token_via_nix(self) -> None:
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
set -eux
|
||||
|
||||
export NIX_CONFIG='experimental-features = nix-command flakes'
|
||||
export TERM='xterm'
|
||||
# Improve CI resilience for slow installer pages.
|
||||
export MATOMO_INSTALLER_READY_TIMEOUT_S="${{MATOMO_INSTALLER_READY_TIMEOUT_S:-240}}"
|
||||
export MATOMO_INSTALLER_STEP_TIMEOUT_S="${{MATOMO_INSTALLER_STEP_TIMEOUT_S:-45}}"
|
||||
export MATOMO_INSTALLER_STEP_DEADLINE_S="${{MATOMO_INSTALLER_STEP_DEADLINE_S:-240}}"
|
||||
export MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S="${{MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S:-240}}"
|
||||
export MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S="${{MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S:-180}}"
|
||||
export MATOMO_INSTALLER_DEBUG_DIR="${{MATOMO_INSTALLER_DEBUG_DIR:-/tmp/matomo-bootstrap}}"
|
||||
|
||||
# Make sure we have a writable HOME (compose already sets HOME=/tmp/home)
|
||||
mkdir -p "$HOME" "$HOME/.cache" "$HOME/.config" "$HOME/.local/share"
|
||||
|
||||
# IMPORTANT:
|
||||
# Nix flakes read the local repo as git+file:///work.
|
||||
# Git refuses if the repo is not owned by the current user (root in the container).
|
||||
# Mark it as safe explicitly.
|
||||
git config --global --add safe.directory /work
|
||||
|
||||
# Preflight checks to surface "command not executable" failures (exit 126) clearly.
|
||||
playwright_app="$(nix eval --raw .#apps.x86_64-linux.matomo-bootstrap-playwright-install.program)"
|
||||
bootstrap_app="$(nix eval --raw .#apps.x86_64-linux.matomo-bootstrap.program)"
|
||||
if [ -e "$playwright_app" ]; then
|
||||
test -x "$playwright_app"
|
||||
fi
|
||||
if [ -e "$bootstrap_app" ]; then
|
||||
test -x "$bootstrap_app"
|
||||
fi
|
||||
|
||||
# 1) Install Playwright Chromium (cached in the container environment)
|
||||
nix run --no-write-lock-file -L .#matomo-bootstrap-playwright-install
|
||||
|
||||
# 2) Run bootstrap (must print ONLY token)
|
||||
nix run --no-write-lock-file -L .#matomo-bootstrap -- \\
|
||||
--base-url '{MATOMO_URL}' \\
|
||||
--admin-user '{ADMIN_USER}' \\
|
||||
--admin-password '{ADMIN_PASSWORD}' \\
|
||||
--admin-email '{ADMIN_EMAIL}' \\
|
||||
--token-description 'e2e-test-token-nix'
|
||||
"""
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
"tests/e2e/docker-compose.yml",
|
||||
# Use `run` instead of `exec` to avoid runtime-specific
|
||||
# `/etc/group` lookup issues seen with nix image + compose exec.
|
||||
"run",
|
||||
"--rm",
|
||||
"--no-deps",
|
||||
"-T",
|
||||
"nix",
|
||||
"sh",
|
||||
"-lc",
|
||||
script,
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
self.fail(
|
||||
"nix bootstrap command failed\n"
|
||||
f"exit={result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\n"
|
||||
f"stderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
stdout_lines = [
|
||||
line.strip() for line in result.stdout.splitlines() if line.strip()
|
||||
]
|
||||
token = stdout_lines[-1] if stdout_lines else ""
|
||||
self.assertRegex(
|
||||
token,
|
||||
TOKEN_RE,
|
||||
f"Expected token on last stdout line, got stdout={result.stdout!r}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
297
tests/e2e/test_docker_compose_stack.py
Normal file
297
tests/e2e/test_docker_compose_stack.py
Normal file
@@ -0,0 +1,297 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
import urllib.request
|
||||
|
||||
|
||||
COMPOSE_FILE = os.environ.get("MATOMO_STACK_COMPOSE_FILE", "docker-compose.yml")
|
||||
SLOW_COMPOSE_FILE = os.environ.get(
|
||||
"MATOMO_STACK_SLOW_COMPOSE_FILE", "tests/e2e/docker-compose.slow.yml"
|
||||
)
|
||||
|
||||
# Pick a non-default port to avoid collisions with other CI stacks that use 8080
|
||||
MATOMO_PORT = os.environ.get("MATOMO_PORT", "18080")
|
||||
MATOMO_HOST_URL = os.environ.get("MATOMO_STACK_URL", f"http://127.0.0.1:{MATOMO_PORT}")
|
||||
MATOMO_SLOW_PORT = os.environ.get("MATOMO_SLOW_PORT", "18081")
|
||||
MATOMO_SLOW_HOST_URL = os.environ.get(
|
||||
"MATOMO_SLOW_STACK_URL", f"http://127.0.0.1:{MATOMO_SLOW_PORT}"
|
||||
)
|
||||
|
||||
# How long we wait for Matomo HTTP to respond at all (seconds)
|
||||
WAIT_TIMEOUT_SECONDS = int(os.environ.get("MATOMO_STACK_WAIT_TIMEOUT", "180"))
|
||||
SLOW_WAIT_TIMEOUT_SECONDS = int(os.environ.get("MATOMO_SLOW_STACK_WAIT_TIMEOUT", "420"))
|
||||
|
||||
|
||||
def _run(
|
||||
cmd: list[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
check=check,
|
||||
env={**os.environ, **(extra_env or {})},
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _compose_cmd(*args: str, compose_files: list[str] | None = None) -> list[str]:
|
||||
files = compose_files or [COMPOSE_FILE]
|
||||
cmd = ["docker", "compose"]
|
||||
for compose_file in files:
|
||||
cmd.extend(["-f", compose_file])
|
||||
cmd.extend(args)
|
||||
return cmd
|
||||
|
||||
|
||||
def _wait_for_http_any_status(url: str, timeout_s: int) -> None:
|
||||
"""
|
||||
Consider the service "up" once the HTTP server answers anything.
|
||||
urllib raises HTTPError on 4xx/5xx, but that's still "reachable".
|
||||
"""
|
||||
deadline = time.time() + timeout_s
|
||||
last_exc: Exception | None = None
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||
_ = resp.read(64)
|
||||
return
|
||||
except Exception as exc: # includes HTTPError
|
||||
last_exc = exc
|
||||
time.sleep(1)
|
||||
|
||||
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:
|
||||
|
||||
1) docker compose down -v
|
||||
2) docker compose build bootstrap
|
||||
3) docker compose up -d db matomo
|
||||
4) wait for Matomo HTTP on host port (default 8080, overridden here)
|
||||
5) docker compose run --rm bootstrap -> token on stdout
|
||||
6) validate token via Matomo API call
|
||||
7) docker compose down -v (cleanup)
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
# Always start from a clean slate (also clears volumes)
|
||||
_run(
|
||||
_compose_cmd("down", "-v"),
|
||||
check=False,
|
||||
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
# Cleanup even if assertions fail
|
||||
_run(
|
||||
_compose_cmd("down", "-v"),
|
||||
check=False,
|
||||
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
||||
)
|
||||
|
||||
def _assert_stack_bootstraps_and_token_works(
|
||||
self,
|
||||
*,
|
||||
compose_files: list[str],
|
||||
matomo_port: str,
|
||||
matomo_host_url: str,
|
||||
wait_timeout_seconds: int,
|
||||
bootstrap_retries: int = 2,
|
||||
) -> None:
|
||||
build = _run(
|
||||
_compose_cmd("build", "bootstrap", compose_files=compose_files),
|
||||
check=False,
|
||||
extra_env={"MATOMO_PORT": matomo_port},
|
||||
)
|
||||
self.assertEqual(
|
||||
build.returncode,
|
||||
0,
|
||||
f"compose build failed\nstdout:\n{build.stdout}\nstderr:\n{build.stderr}",
|
||||
)
|
||||
|
||||
up = _run(
|
||||
_compose_cmd("up", "-d", "db", "matomo", compose_files=compose_files),
|
||||
check=False,
|
||||
extra_env={"MATOMO_PORT": matomo_port},
|
||||
)
|
||||
self.assertEqual(
|
||||
up.returncode,
|
||||
0,
|
||||
f"compose up failed\nstdout:\n{up.stdout}\nstderr:\n{up.stderr}",
|
||||
)
|
||||
|
||||
_wait_for_http_any_status(matomo_host_url + "/", wait_timeout_seconds)
|
||||
|
||||
boot_attempts: list[subprocess.CompletedProcess] = []
|
||||
for _ in range(bootstrap_retries):
|
||||
boot = _run(
|
||||
_compose_cmd("run", "--rm", "bootstrap", compose_files=compose_files),
|
||||
check=False,
|
||||
extra_env={"MATOMO_PORT": matomo_port},
|
||||
)
|
||||
boot_attempts.append(boot)
|
||||
if boot.returncode == 0:
|
||||
break
|
||||
time.sleep(5)
|
||||
|
||||
if boot.returncode != 0:
|
||||
matomo_logs = _run(
|
||||
_compose_cmd(
|
||||
"logs",
|
||||
"--no-color",
|
||||
"--tail=250",
|
||||
"matomo",
|
||||
compose_files=compose_files,
|
||||
),
|
||||
check=False,
|
||||
extra_env={"MATOMO_PORT": matomo_port},
|
||||
)
|
||||
db_logs = _run(
|
||||
_compose_cmd(
|
||||
"logs",
|
||||
"--no-color",
|
||||
"--tail=200",
|
||||
"db",
|
||||
compose_files=compose_files,
|
||||
),
|
||||
check=False,
|
||||
extra_env={"MATOMO_PORT": matomo_port},
|
||||
)
|
||||
attempts_dump = "\n\n".join(
|
||||
[
|
||||
(
|
||||
f"[attempt {i}] rc={attempt.returncode}\n"
|
||||
f"stdout:\n{attempt.stdout}\n"
|
||||
f"stderr:\n{attempt.stderr}"
|
||||
)
|
||||
for i, attempt in enumerate(boot_attempts, 1)
|
||||
]
|
||||
)
|
||||
self.fail(
|
||||
"bootstrap container failed after retry.\n"
|
||||
f"{attempts_dump}\n\n"
|
||||
f"[matomo logs]\n{matomo_logs.stdout}\n{matomo_logs.stderr}\n\n"
|
||||
f"[db logs]\n{db_logs.stdout}\n{db_logs.stderr}"
|
||||
)
|
||||
|
||||
token = (boot.stdout or "").strip()
|
||||
self.assertRegex(
|
||||
token,
|
||||
r"^[a-f0-9]{32,64}$",
|
||||
f"Expected token_auth on stdout, got stdout={boot.stdout!r} stderr={boot.stderr!r}",
|
||||
)
|
||||
|
||||
api_url = (
|
||||
f"{matomo_host_url}/index.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("utf-8", errors="replace"))
|
||||
|
||||
self.assertIsInstance(data, list)
|
||||
|
||||
def test_root_docker_compose_yml_stack_bootstraps_and_token_works(self) -> None:
|
||||
self._assert_stack_bootstraps_and_token_works(
|
||||
compose_files=[COMPOSE_FILE],
|
||||
matomo_port=MATOMO_PORT,
|
||||
matomo_host_url=MATOMO_HOST_URL,
|
||||
wait_timeout_seconds=WAIT_TIMEOUT_SECONDS,
|
||||
bootstrap_retries=2,
|
||||
)
|
||||
|
||||
def test_root_docker_compose_yml_stack_bootstraps_under_resource_pressure(
|
||||
self,
|
||||
) -> None:
|
||||
self._assert_stack_bootstraps_and_token_works(
|
||||
compose_files=[COMPOSE_FILE, SLOW_COMPOSE_FILE],
|
||||
matomo_port=MATOMO_SLOW_PORT,
|
||||
matomo_host_url=MATOMO_SLOW_HOST_URL,
|
||||
wait_timeout_seconds=SLOW_WAIT_TIMEOUT_SECONDS,
|
||||
bootstrap_retries=3,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
self.assertIn("MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S:", bootstrap_block)
|
||||
self.assertIn("MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_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)
|
||||
|
||||
def test_slow_override_sets_tight_resources_and_longer_timeouts(self) -> None:
|
||||
cfg = _run(
|
||||
_compose_cmd("config", compose_files=[COMPOSE_FILE, SLOW_COMPOSE_FILE]),
|
||||
check=True,
|
||||
extra_env={"MATOMO_PORT": MATOMO_SLOW_PORT},
|
||||
)
|
||||
self.assertEqual(cfg.returncode, 0, cfg.stderr)
|
||||
|
||||
matomo_block = _extract_service_block(cfg.stdout, "matomo")
|
||||
self.assertIn("cpus: 0.35", matomo_block)
|
||||
self.assertIn('mem_limit: "402653184"', matomo_block)
|
||||
self.assertIn("start_period: 2m0s", matomo_block)
|
||||
|
||||
db_block = _extract_service_block(cfg.stdout, "db")
|
||||
self.assertIn("cpus: 0.35", db_block)
|
||||
self.assertIn('mem_limit: "335544320"', db_block)
|
||||
|
||||
bootstrap_block = _extract_service_block(cfg.stdout, "bootstrap")
|
||||
self.assertIn("MATOMO_INSTALLER_STEP_TIMEOUT_S:", bootstrap_block)
|
||||
self.assertIn("MATOMO_INSTALLER_STEP_DEADLINE_S:", bootstrap_block)
|
||||
self.assertIn("MATOMO_INSTALLER_READY_TIMEOUT_S:", bootstrap_block)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
315
tests/integration/test_web_installer_locator_count.py
Normal file
315
tests/integration/test_web_installer_locator_count.py
Normal file
@@ -0,0 +1,315 @@
|
||||
import unittest
|
||||
|
||||
from matomo_bootstrap.installers.web import (
|
||||
_click_next_with_wait,
|
||||
_count_locator,
|
||||
_wait_for_superuser_login_field,
|
||||
)
|
||||
|
||||
|
||||
class _FlakyLocator:
|
||||
def __init__(self, outcomes):
|
||||
self._outcomes = list(outcomes)
|
||||
self.calls = 0
|
||||
|
||||
def count(self) -> int:
|
||||
self.calls += 1
|
||||
outcome = self._outcomes.pop(0)
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return int(outcome)
|
||||
|
||||
|
||||
class _StaticLocator:
|
||||
def __init__(self, page, selector: str):
|
||||
self._page = page
|
||||
self._selector = selector
|
||||
|
||||
def count(self) -> int:
|
||||
if self._selector == "#login-0":
|
||||
return 1 if self._page.login_visible else 0
|
||||
if self._selector == "form#generalsetupform":
|
||||
return 1 if getattr(self._page, "form_visible", False) else 0
|
||||
if self._selector == "#siteName-0":
|
||||
return 0
|
||||
return 0
|
||||
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
def is_visible(self) -> bool:
|
||||
return self.count() > 0
|
||||
|
||||
|
||||
class _RoleLocator:
|
||||
def __init__(self, count_value: int):
|
||||
self._count_value = count_value
|
||||
|
||||
def count(self) -> int:
|
||||
return self._count_value
|
||||
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
def is_visible(self) -> bool:
|
||||
return self._count_value > 0
|
||||
|
||||
|
||||
class _NameOnlyStaticLocator:
|
||||
def __init__(self, page, selector: str):
|
||||
self._page = page
|
||||
self._selector = selector
|
||||
|
||||
def count(self) -> int:
|
||||
if self._selector == "input[name='login']":
|
||||
return 1 if self._page.login_visible else 0
|
||||
if self._selector == "input[name='siteName']":
|
||||
return 0
|
||||
return 0
|
||||
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
def is_visible(self) -> bool:
|
||||
return self.count() > 0
|
||||
|
||||
|
||||
class _NoNextButLoginAppearsPage:
|
||||
def __init__(self):
|
||||
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
|
||||
self.login_visible = False
|
||||
self.form_visible = False
|
||||
self._wait_calls = 0
|
||||
|
||||
def locator(self, selector: str):
|
||||
return _StaticLocator(self, selector)
|
||||
|
||||
def get_by_role(self, role: str, name: str):
|
||||
return _RoleLocator(0)
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
return _RoleLocator(0)
|
||||
|
||||
def title(self) -> str:
|
||||
return "setupSuperUser"
|
||||
|
||||
def wait_for_load_state(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def wait_for_timeout(self, *_args, **_kwargs):
|
||||
self._wait_calls += 1
|
||||
if self._wait_calls >= 1:
|
||||
self.login_visible = True
|
||||
|
||||
|
||||
class _NoNextButNamedLoginAppearsPage:
|
||||
def __init__(self):
|
||||
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
|
||||
self.login_visible = False
|
||||
self._wait_calls = 0
|
||||
|
||||
def locator(self, selector: str):
|
||||
return _NameOnlyStaticLocator(self, selector)
|
||||
|
||||
def get_by_role(self, role: str, name: str):
|
||||
return _RoleLocator(0)
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
return _RoleLocator(0)
|
||||
|
||||
def title(self) -> str:
|
||||
return "setupSuperUser"
|
||||
|
||||
def wait_for_load_state(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def wait_for_timeout(self, *_args, **_kwargs):
|
||||
self._wait_calls += 1
|
||||
if self._wait_calls >= 1:
|
||||
self.login_visible = True
|
||||
|
||||
|
||||
class _NoNextButSuperuserFormContainerAppearsPage:
|
||||
def __init__(self):
|
||||
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
|
||||
self.login_visible = False
|
||||
self.form_visible = False
|
||||
self._wait_calls = 0
|
||||
|
||||
def locator(self, selector: str):
|
||||
return _StaticLocator(self, selector)
|
||||
|
||||
def get_by_role(self, role: str, name: str):
|
||||
return _RoleLocator(0)
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
return _RoleLocator(0)
|
||||
|
||||
def title(self) -> str:
|
||||
return "setupSuperUser"
|
||||
|
||||
def wait_for_load_state(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def wait_for_timeout(self, *_args, **_kwargs):
|
||||
self._wait_calls += 1
|
||||
if self._wait_calls >= 1:
|
||||
self.form_visible = True
|
||||
|
||||
|
||||
class _DelayedSuperuserLoginPage:
|
||||
def __init__(self, *, reveal_after_wait_calls: int | None):
|
||||
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
|
||||
self.login_visible = False
|
||||
self.form_visible = False
|
||||
self._wait_calls = 0
|
||||
self._reveal_after_wait_calls = reveal_after_wait_calls
|
||||
|
||||
def locator(self, selector: str):
|
||||
return _StaticLocator(self, selector)
|
||||
|
||||
def get_by_role(self, role: str, name: str):
|
||||
return _RoleLocator(0)
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
return _RoleLocator(0)
|
||||
|
||||
def title(self) -> str:
|
||||
return "setupSuperUser"
|
||||
|
||||
def wait_for_load_state(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def wait_for_timeout(self, *_args, **_kwargs):
|
||||
self._wait_calls += 1
|
||||
if (
|
||||
self._reveal_after_wait_calls is not None
|
||||
and self._wait_calls >= self._reveal_after_wait_calls
|
||||
):
|
||||
self.login_visible = True
|
||||
|
||||
|
||||
class _DelayedSuperuserFormContainerPage:
|
||||
def __init__(self, *, reveal_after_wait_calls: int | None):
|
||||
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
|
||||
self.login_visible = False
|
||||
self.form_visible = False
|
||||
self._wait_calls = 0
|
||||
self._reveal_after_wait_calls = reveal_after_wait_calls
|
||||
|
||||
def locator(self, selector: str):
|
||||
return _StaticLocator(self, selector)
|
||||
|
||||
def get_by_role(self, role: str, name: str):
|
||||
return _RoleLocator(0)
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
return _RoleLocator(0)
|
||||
|
||||
def title(self) -> str:
|
||||
return "setupSuperUser"
|
||||
|
||||
def wait_for_load_state(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def wait_for_timeout(self, *_args, **_kwargs):
|
||||
self._wait_calls += 1
|
||||
if (
|
||||
self._reveal_after_wait_calls is not None
|
||||
and self._wait_calls >= self._reveal_after_wait_calls
|
||||
):
|
||||
self.form_visible = True
|
||||
|
||||
|
||||
class TestWebInstallerLocatorCountIntegration(unittest.TestCase):
|
||||
def test_retries_transient_navigation_error(self) -> None:
|
||||
locator = _FlakyLocator(
|
||||
[
|
||||
RuntimeError(
|
||||
"Locator.count: Execution context was destroyed, most likely because of a navigation"
|
||||
),
|
||||
RuntimeError(
|
||||
"Locator.count: Execution context was destroyed, most likely because of a navigation"
|
||||
),
|
||||
1,
|
||||
]
|
||||
)
|
||||
|
||||
result = _count_locator(locator, timeout_s=0.5, retry_interval_s=0.0)
|
||||
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(locator.calls, 3)
|
||||
|
||||
def test_raises_non_transient_error_without_retry(self) -> None:
|
||||
locator = _FlakyLocator([RuntimeError("Locator is not attached to DOM")])
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
_count_locator(locator, timeout_s=0.5, retry_interval_s=0.0)
|
||||
|
||||
self.assertEqual(locator.calls, 1)
|
||||
|
||||
def test_click_next_wait_treats_login_form_as_progress(self) -> None:
|
||||
page = _NoNextButLoginAppearsPage()
|
||||
|
||||
step = _click_next_with_wait(page, timeout_s=1)
|
||||
|
||||
self.assertEqual(step, "Installation:setupSuperUser")
|
||||
self.assertTrue(page.login_visible)
|
||||
|
||||
def test_click_next_wait_treats_named_login_form_as_progress(self) -> None:
|
||||
page = _NoNextButNamedLoginAppearsPage()
|
||||
|
||||
step = _click_next_with_wait(page, timeout_s=1)
|
||||
|
||||
self.assertEqual(step, "Installation:setupSuperUser")
|
||||
self.assertTrue(page.login_visible)
|
||||
|
||||
def test_click_next_wait_treats_superuser_form_container_as_progress(self) -> None:
|
||||
page = _NoNextButSuperuserFormContainerAppearsPage()
|
||||
|
||||
step = _click_next_with_wait(page, timeout_s=1)
|
||||
|
||||
self.assertEqual(step, "Installation:setupSuperUser")
|
||||
self.assertTrue(page.form_visible)
|
||||
|
||||
def test_wait_for_superuser_login_field_allows_delayed_form(self) -> None:
|
||||
page = _DelayedSuperuserLoginPage(reveal_after_wait_calls=4)
|
||||
|
||||
visible = _wait_for_superuser_login_field(
|
||||
page,
|
||||
timeout_s=1.0,
|
||||
poll_interval_ms=1,
|
||||
)
|
||||
|
||||
self.assertTrue(visible)
|
||||
self.assertTrue(page.login_visible)
|
||||
|
||||
def test_wait_for_superuser_login_field_allows_delayed_form_container(self) -> None:
|
||||
page = _DelayedSuperuserFormContainerPage(reveal_after_wait_calls=4)
|
||||
|
||||
visible = _wait_for_superuser_login_field(
|
||||
page,
|
||||
timeout_s=1.0,
|
||||
poll_interval_ms=1,
|
||||
)
|
||||
|
||||
self.assertTrue(visible)
|
||||
self.assertTrue(page.form_visible)
|
||||
|
||||
def test_wait_for_superuser_login_field_times_out_when_absent(self) -> None:
|
||||
page = _DelayedSuperuserLoginPage(reveal_after_wait_calls=None)
|
||||
|
||||
visible = _wait_for_superuser_login_field(
|
||||
page,
|
||||
timeout_s=0.01,
|
||||
poll_interval_ms=1,
|
||||
)
|
||||
|
||||
self.assertFalse(visible)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
128
tests/integration/test_web_installer_warnings.py
Normal file
128
tests/integration/test_web_installer_warnings.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import io
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
|
||||
|
||||
# Import the function under test.
|
||||
# This keeps the test close to real integration behavior without requiring Playwright.
|
||||
from matomo_bootstrap.installers.web import _page_warnings
|
||||
|
||||
|
||||
class _FakeLocatorNth:
|
||||
def __init__(self, text: str):
|
||||
self._text = text
|
||||
|
||||
def inner_text(self) -> str:
|
||||
return self._text
|
||||
|
||||
|
||||
class _FakeLocator:
|
||||
def __init__(self, texts: list[str]):
|
||||
self._texts = texts
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._texts)
|
||||
|
||||
def nth(self, i: int) -> _FakeLocatorNth:
|
||||
return _FakeLocatorNth(self._texts[i])
|
||||
|
||||
|
||||
class _FakePage:
|
||||
"""
|
||||
Minimal Playwright-like page stub:
|
||||
- locator(selector) -> object with count() / nth(i).inner_text()
|
||||
- url, title()
|
||||
"""
|
||||
|
||||
def __init__(self, *, url: str, title: str, selector_texts: dict[str, list[str]]):
|
||||
self.url = url
|
||||
self._title = title
|
||||
self._selector_texts = selector_texts
|
||||
|
||||
def title(self) -> str:
|
||||
return self._title
|
||||
|
||||
def locator(self, selector: str) -> _FakeLocator:
|
||||
return _FakeLocator(self._selector_texts.get(selector, []))
|
||||
|
||||
|
||||
class TestWebInstallerWarningsIntegration(unittest.TestCase):
|
||||
def test_detects_bootstrap_alert_warning_block(self) -> None:
|
||||
"""
|
||||
Matomo installer commonly renders validation errors like:
|
||||
<div class="alert alert-warning"> ... <ul><li>...</li></ul> ... </div>
|
||||
We must detect and print those messages to stderr.
|
||||
"""
|
||||
page = _FakePage(
|
||||
url="http://matomo/index.php?action=setupSuperUser&module=Installation",
|
||||
title="Superuser",
|
||||
selector_texts={
|
||||
# The key selector from the observed DOM
|
||||
".alert.alert-warning": [
|
||||
"Please fix the following errors:\n"
|
||||
"Password required\n"
|
||||
"Password (repeat) required\n"
|
||||
"The email doesn't have a valid format."
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stderr(buf):
|
||||
warnings = _page_warnings(page, prefix="[install]")
|
||||
|
||||
# Function must return the warning text
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertIn("Please fix the following errors:", warnings[0])
|
||||
self.assertIn("The email doesn't have a valid format.", warnings[0])
|
||||
|
||||
# And it must print it to stderr (stdout must remain token-only in the app)
|
||||
out = buf.getvalue()
|
||||
self.assertIn("[install] page warnings/errors detected", out)
|
||||
self.assertIn("Superuser", out)
|
||||
self.assertIn("The email doesn't have a valid format.", out)
|
||||
|
||||
def test_deduplicates_repeated_warning_blocks(self) -> None:
|
||||
"""
|
||||
Some Matomo versions repeat the same alert in multiple containers.
|
||||
We must return/log each unique text only once.
|
||||
"""
|
||||
repeated = (
|
||||
"Please fix the following errors:\nThe email doesn't have a valid format."
|
||||
)
|
||||
page = _FakePage(
|
||||
url="http://matomo/index.php?action=setupSuperUser&module=Installation",
|
||||
title="Superuser",
|
||||
selector_texts={
|
||||
".alert.alert-warning": [repeated, repeated],
|
||||
},
|
||||
)
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stderr(buf):
|
||||
warnings = _page_warnings(page, prefix="[install]")
|
||||
|
||||
self.assertEqual(warnings, [repeated])
|
||||
|
||||
out = buf.getvalue()
|
||||
# Only a single numbered entry should be printed
|
||||
self.assertIn("[install] 1) ", out)
|
||||
self.assertNotIn("[install] 2) ", out)
|
||||
|
||||
def test_no_output_when_no_warnings(self) -> None:
|
||||
page = _FakePage(
|
||||
url="http://matomo/",
|
||||
title="Welcome",
|
||||
selector_texts={},
|
||||
)
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stderr(buf):
|
||||
warnings = _page_warnings(page, prefix="[install]")
|
||||
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertEqual(buf.getvalue(), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user