Compare commits
30 Commits
b63ea71902
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a65ceb015 | ||
|
|
81746f4b26 | ||
|
|
45867ece38 | ||
|
|
33ed86adf1 | ||
|
|
f86f84ff93 | ||
|
|
b429644d9e | ||
|
|
9fbdce2972 | ||
|
|
1b3ee2c3fd | ||
|
|
1f448f4457 | ||
|
|
7fa8b580d2 | ||
|
|
bf69c110a7 | ||
|
|
a582e8be13 | ||
|
|
e38051a92f | ||
|
|
8162d337b5 | ||
|
|
bac453c435 | ||
|
|
a2010cd914 | ||
|
|
f270a5c7c6 | ||
|
|
5e5b6c8933 | ||
|
|
1af480ee91 | ||
|
|
4f7de18a11 | ||
|
|
1a65077d0c | ||
|
|
83967ab61f | ||
|
|
50914dea8b | ||
|
|
c9d4a5a9a4 | ||
|
|
5f645cbfbf | ||
|
|
47dc84238f | ||
|
|
d41129b6bd | ||
|
|
482ac3377d | ||
|
|
5dbb1857c9 | ||
|
|
92a2ee1d96 |
7
.github/FUNDING.yml
vendored
Normal file
7
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
github: kevinveenbirkenbach
|
||||
|
||||
patreon: kevinveenbirkenbach
|
||||
|
||||
buy_me_a_coffee: kevinveenbirkenbach
|
||||
|
||||
custom: https://s.veen.world/paypaldonate
|
||||
11
.github/workflows/ci.yml
vendored
Normal file
11
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
uses: ./.github/workflows/reusable-test.yml
|
||||
67
.github/workflows/publish-image.yml
vendored
Normal file
67
.github/workflows/publish-image.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: publish-image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
workflow_run:
|
||||
workflows: ["Stable Tag"] # MUST match stable-tag.yml -> name: Stable Tag
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
if: |
|
||||
(github.event_name == 'push') ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# push: checks out the tag ref
|
||||
# workflow_run: checks out the exact commit that the Stable Tag workflow ran on
|
||||
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }}
|
||||
|
||||
- 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 }}"
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
TAG="${{ github.ref_name }}" # e.g. v1.1.0
|
||||
echo "tags=$IMAGE:$TAG,$IMAGE:latest" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tags=$IMAGE:stable" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- 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
|
||||
102
.github/workflows/reusable-test.yml
vendored
Normal file
102
.github/workflows/reusable-test.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: reusable-test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
python-version:
|
||||
type: string
|
||||
required: false
|
||||
default: "3.12"
|
||||
matomo-url:
|
||||
type: string
|
||||
required: false
|
||||
default: "http://127.0.0.1:8080"
|
||||
matomo-admin-user:
|
||||
type: string
|
||||
required: false
|
||||
default: "administrator"
|
||||
matomo-admin-password:
|
||||
type: string
|
||||
required: false
|
||||
default: "AdminSecret123!"
|
||||
matomo-admin-email:
|
||||
type: string
|
||||
required: false
|
||||
default: "administrator@example.org"
|
||||
matomo-token-description:
|
||||
type: string
|
||||
required: false
|
||||
default: "ci-token"
|
||||
|
||||
jobs:
|
||||
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 .
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
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 system deps (curl)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl
|
||||
|
||||
- name: Install Python deps (editable + e2e)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[e2e]"
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: |
|
||||
python -m playwright install --with-deps --force chromium
|
||||
|
||||
- name: Run E2E (docker compose)
|
||||
env:
|
||||
MATOMO_URL: ${{ inputs.matomo-url }}
|
||||
MATOMO_ADMIN_USER: ${{ inputs.matomo-admin-user }}
|
||||
MATOMO_ADMIN_PASSWORD: ${{ inputs.matomo-admin-password }}
|
||||
MATOMO_ADMIN_EMAIL: ${{ inputs.matomo-admin-email }}
|
||||
MATOMO_TOKEN_DESCRIPTION: ${{ inputs.matomo-token-description }}
|
||||
run: |
|
||||
make e2e
|
||||
|
||||
- name: Docker logs (on failure)
|
||||
if: failure()
|
||||
run: |
|
||||
docker compose -f tests/e2e/docker-compose.yml ps || true
|
||||
docker compose -f tests/e2e/docker-compose.yml logs --no-color --tail=300 matomo || true
|
||||
docker compose -f tests/e2e/docker-compose.yml logs --no-color --tail=300 db || true
|
||||
|
||||
- name: Cleanup (always)
|
||||
if: always()
|
||||
run: |
|
||||
docker compose -f tests/e2e/docker-compose.yml down -v || true
|
||||
42
.github/workflows/stable-tag.yml
vendored
Normal file
42
.github/workflows/stable-tag.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Stable Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
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)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Move stable tag to this version tag commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Triggered by tag: ${GITHUB_REF_NAME}"
|
||||
echo "Commit: ${GITHUB_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 push --force origin stable
|
||||
21
CHANGELOG.md
Normal file
21
CHANGELOG.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## [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"]
|
||||
8
LICENSE
8
LICENSE
@@ -1 +1,7 @@
|
||||
All rights reserved by Kevin Veen-Birkenbach
|
||||
Copyright 2025 Kevin Veen-Birkenbach
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
1
MIRRORS
1
MIRRORS
@@ -1,2 +1,3 @@
|
||||
git@github.com:kevinveenbirkenbach/matomo-bootstrap.git
|
||||
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/matomo-bootstrap.git
|
||||
https://pypi.org/project/matomo-bootstrap/
|
||||
131
Makefile
131
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
|
||||
|
||||
307
README.md
307
README.md
@@ -1,6 +1,309 @@
|
||||
# matomo-bootstrap
|
||||
|
||||
Homepage: https://github.com/kevinveenbirkenbach/matomo-bootstrap
|
||||
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 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. via Docker)
|
||||
- For fresh installs:
|
||||
- Chromium (provided by Playwright or by the Playwright base container image)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Nix (recommended)
|
||||
|
||||
Run directly from the repository:
|
||||
|
||||
```bash
|
||||
nix run github:kevinveenbirkenbach/matomo-bootstrap
|
||||
```
|
||||
|
||||
In Nix mode, browsers are provided via `nixpkgs` (`playwright-driver`) and Playwright downloads are disabled.
|
||||
|
||||
---
|
||||
|
||||
### Python / pip
|
||||
|
||||
Requires **Python ≥ 3.10**:
|
||||
|
||||
```bash
|
||||
pip install matomo-bootstrap
|
||||
python -m playwright install chromium
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Docker image (GHCR)
|
||||
|
||||
Pull the prebuilt image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/kevinveenbirkenbach/matomo-bootstrap:stable
|
||||
# or:
|
||||
docker pull ghcr.io/kevinveenbirkenbach/matomo-bootstrap:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
matomo-bootstrap \
|
||||
--base-url http://127.0.0.1:8080 \
|
||||
--admin-user administrator \
|
||||
--admin-password 'AdminSecret123!' \
|
||||
--admin-email administrator@example.org \
|
||||
--token-description my-ci-token
|
||||
```
|
||||
|
||||
On success, the command prints **only the token** to stdout:
|
||||
|
||||
```text
|
||||
6c7a8c2b0e9e4a3c8e1d0c4e8a6b9f21
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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_EMAIL=administrator@example.org
|
||||
export MATOMO_TOKEN_DESCRIPTION=my-ci-token
|
||||
|
||||
matomo-bootstrap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Debug mode
|
||||
|
||||
Enable verbose logs (**stderr only**):
|
||||
|
||||
```bash
|
||||
matomo-bootstrap --debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose integration (one-shot bootstrap)
|
||||
|
||||
### Why “one-shot”?
|
||||
|
||||
The bootstrap container is meant to:
|
||||
|
||||
1. Run once,
|
||||
2. Print the token to stdout,
|
||||
3. Exit with code `0`.
|
||||
|
||||
You should **not** start it automatically on every `docker compose up`.
|
||||
Instead, start Matomo normally, then run the bootstrap via `docker compose run`.
|
||||
|
||||
The cleanest Compose pattern is to put `bootstrap` behind a **profile**.
|
||||
|
||||
---
|
||||
|
||||
### 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", "wget -qO- 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: "30"
|
||||
MATOMO_PLAYWRIGHT_HEADLESS: "1"
|
||||
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
|
||||
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
```bash
|
||||
make e2e
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Start Matomo + MariaDB via Docker
|
||||
2. Install Matomo headlessly
|
||||
3. Create an API token
|
||||
4. Validate the token via the Matomo API
|
||||
5. Tear everything down again
|
||||
|
||||
---
|
||||
|
||||
## Author
|
||||
Kevin Veen-Birkenbach <kevin@veen.world>
|
||||
|
||||
**Kevin Veen-Birkenbach**
|
||||
[https://www.veen.world/](https://www.veen.world/)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE)
|
||||
|
||||
1
constraints.txt
Normal file
1
constraints.txt
Normal file
@@ -0,0 +1 @@
|
||||
playwright==1.46.0
|
||||
74
docker-compose.yml
Normal file
74
docker-compose.yml
Normal 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:
|
||||
- "${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", "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:${MATOMO_PORT:-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
29
env.sample
Normal 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
|
||||
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
|
||||
}
|
||||
136
flake.nix
136
flake.nix
@@ -1,11 +1,135 @@
|
||||
{
|
||||
description = "matomo-bootstrap";
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; };
|
||||
in {
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
packages = with pkgs; [ python312 python312Packages.pytest python312Packages.ruff ];
|
||||
};
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
python = pkgs.python312;
|
||||
playwrightDriver = pkgs.playwright-driver;
|
||||
in
|
||||
rec {
|
||||
matomo-bootstrap = python.pkgs.buildPythonApplication {
|
||||
pname = "matomo-bootstrap";
|
||||
version = "1.1.1"; # keep in sync with pyproject.toml
|
||||
pyproject = true;
|
||||
src = self;
|
||||
|
||||
# disable import-check phase (prevents Playwright/installer side effects)
|
||||
pythonImportsCheck = [ ];
|
||||
|
||||
nativeBuildInputs =
|
||||
(with python.pkgs; [
|
||||
setuptools
|
||||
wheel
|
||||
])
|
||||
++ [
|
||||
pkgs.makeWrapper
|
||||
];
|
||||
|
||||
propagatedBuildInputs = with python.pkgs; [
|
||||
playwright
|
||||
];
|
||||
|
||||
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";
|
||||
license = licenses.mit;
|
||||
mainProgram = "matomo-bootstrap";
|
||||
};
|
||||
};
|
||||
|
||||
default = matomo-bootstrap;
|
||||
}
|
||||
);
|
||||
|
||||
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 = [ pythonPlaywright ];
|
||||
|
||||
text = ''
|
||||
# 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
|
||||
{
|
||||
matomo-bootstrap = {
|
||||
type = "app";
|
||||
program = "${matomo}/bin/matomo-bootstrap";
|
||||
};
|
||||
|
||||
matomo-bootstrap-playwright-install = {
|
||||
type = "app";
|
||||
program = "${playwright-install}/bin/matomo-bootstrap-playwright-install";
|
||||
};
|
||||
|
||||
default = self.apps.${system}.matomo-bootstrap;
|
||||
}
|
||||
);
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
python = pkgs.python312;
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
python
|
||||
python.pkgs.ruff
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,22 +4,27 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "matomo-bootstrap"
|
||||
version = "0.1.0"
|
||||
version = "1.1.1"
|
||||
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 = "All rights reserved by Kevin Veen-Birkenbach" }
|
||||
license = { text = "MIT" }
|
||||
urls = { Homepage = "https://github.com/kevinveenbirkenbach/matomo-bootstrap" }
|
||||
|
||||
# Playwright is needed at runtime to run the web installer when Matomo is not yet installed.
|
||||
dependencies = [
|
||||
"playwright>=1.40.0",
|
||||
]
|
||||
dependencies = ["playwright>=1.46.0,<2"]
|
||||
|
||||
# Provides a stable CLI name for Nix + pip installs:
|
||||
[project.scripts]
|
||||
matomo-bootstrap = "matomo_bootstrap.__main__:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
e2e = []
|
||||
|
||||
dev = [
|
||||
"ruff",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "src" }
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ Headless bootstrap tooling for Matomo:
|
||||
- readiness checks
|
||||
- admin/API token provisioning
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
from .cli import parse_args
|
||||
from .bootstrap import run_bootstrap
|
||||
from .errors import BootstrapError
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from .cli import parse_args
|
||||
from .config import config_from_env_and_args
|
||||
from .errors import BootstrapError
|
||||
from .service import run
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
try:
|
||||
token = run_bootstrap(args)
|
||||
config = config_from_env_and_args(args)
|
||||
token = run(config)
|
||||
print(token)
|
||||
return 0
|
||||
except ValueError as exc:
|
||||
# config validation errors
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
return 2
|
||||
except BootstrapError as exc:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
|
||||
from .errors import TokenCreationError
|
||||
from .http import HttpClient
|
||||
|
||||
|
||||
def _md5(text: str) -> str:
|
||||
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _try_json(body: str) -> object:
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise TokenCreationError(f"Invalid JSON from Matomo API: {body[:400]}") from exc
|
||||
|
||||
|
||||
def _login_via_logme(client: HttpClient, admin_user: str, admin_password: str, debug: bool) -> None:
|
||||
"""
|
||||
Create an authenticated Matomo session (cookie jar) using the classic Login controller.
|
||||
|
||||
Matomo accepts the md5 hashed password in the `password` parameter for action=logme.
|
||||
We rely on urllib's opener to follow redirects and store cookies.
|
||||
|
||||
If this ever stops working in a future Matomo version, the next step would be:
|
||||
- GET the login page, extract CSRF/nonce, then POST the login form.
|
||||
"""
|
||||
md5_password = _md5(admin_password)
|
||||
|
||||
# Hit the login endpoint; cookies should be set in the client's CookieJar.
|
||||
# We treat any HTTP response as "we reached the login controller" – later API call will tell us if session is valid.
|
||||
try:
|
||||
status, body = client.get(
|
||||
"/index.php",
|
||||
{
|
||||
"module": "Login",
|
||||
"action": "logme",
|
||||
"login": admin_user,
|
||||
"password": md5_password,
|
||||
},
|
||||
)
|
||||
if debug:
|
||||
print(f"[auth] login via logme returned HTTP {status} (body preview: {body[:120]!r})")
|
||||
except urllib.error.HTTPError as exc:
|
||||
# Even 4xx/5xx can still set cookies; continue and let the API call validate.
|
||||
if debug:
|
||||
try:
|
||||
err_body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
err_body = ""
|
||||
print(f"[auth] login via logme raised HTTPError {exc.code} (body preview: {err_body[:120]!r})")
|
||||
|
||||
|
||||
def create_app_token_via_session(
|
||||
*,
|
||||
client: HttpClient,
|
||||
admin_user: str,
|
||||
admin_password: str,
|
||||
description: str,
|
||||
debug: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Create an app-specific token using an authenticated SESSION (cookies),
|
||||
not via UsersManager.getTokenAuth (removed/not available in Matomo 5.3.x images).
|
||||
|
||||
If MATOMO_BOOTSTRAP_TOKEN_AUTH is already set, we return it.
|
||||
"""
|
||||
env_token = os.environ.get("MATOMO_BOOTSTRAP_TOKEN_AUTH")
|
||||
if env_token:
|
||||
if debug:
|
||||
print("[auth] Using MATOMO_BOOTSTRAP_TOKEN_AUTH from environment.")
|
||||
return env_token
|
||||
|
||||
# 1) Establish logged-in session
|
||||
_login_via_logme(client, admin_user, admin_password, debug=debug)
|
||||
|
||||
# 2) Use the session cookie to create an app specific token
|
||||
status, body = client.post(
|
||||
"/index.php",
|
||||
{
|
||||
"module": "API",
|
||||
"method": "UsersManager.createAppSpecificTokenAuth",
|
||||
"userLogin": admin_user,
|
||||
"passwordConfirmation": admin_password,
|
||||
"description": description,
|
||||
"format": "json",
|
||||
},
|
||||
)
|
||||
|
||||
if debug:
|
||||
print(f"[auth] createAppSpecificTokenAuth HTTP {status} body[:200]={body[:200]!r}")
|
||||
|
||||
if status != 200:
|
||||
raise TokenCreationError(f"HTTP {status} during token creation: {body[:400]}")
|
||||
|
||||
data = _try_json(body)
|
||||
|
||||
token = data.get("value") if isinstance(data, dict) else None
|
||||
if not token:
|
||||
# Matomo may return {"result":"error","message":"..."}.
|
||||
raise TokenCreationError(f"Unexpected response from token creation: {data}")
|
||||
|
||||
return str(token)
|
||||
@@ -1,37 +0,0 @@
|
||||
from argparse import Namespace
|
||||
|
||||
from .api_tokens import create_app_token_via_session
|
||||
from .health import assert_matomo_ready
|
||||
from .http import HttpClient
|
||||
from .install.web_installer import ensure_installed
|
||||
|
||||
|
||||
def run_bootstrap(args: Namespace) -> str:
|
||||
# 1) Ensure Matomo is installed (NO-OP if already installed)
|
||||
ensure_installed(
|
||||
base_url=args.base_url,
|
||||
admin_user=args.admin_user,
|
||||
admin_password=args.admin_password,
|
||||
admin_email=args.admin_email,
|
||||
debug=args.debug,
|
||||
)
|
||||
|
||||
# 2) Now the UI/API should be reachable and "installed"
|
||||
assert_matomo_ready(args.base_url, timeout=args.timeout)
|
||||
|
||||
# 3) Create app-specific token via authenticated session (cookie-based)
|
||||
client = HttpClient(
|
||||
base_url=args.base_url,
|
||||
timeout=args.timeout,
|
||||
debug=args.debug,
|
||||
)
|
||||
|
||||
token = create_app_token_via_session(
|
||||
client=client,
|
||||
admin_user=args.admin_user,
|
||||
admin_password=args.admin_password,
|
||||
description=args.token_description,
|
||||
debug=args.debug,
|
||||
)
|
||||
|
||||
return token
|
||||
@@ -30,23 +30,21 @@ def parse_args() -> argparse.Namespace:
|
||||
p.add_argument(
|
||||
"--token-description",
|
||||
default=os.environ.get("MATOMO_TOKEN_DESCRIPTION", "matomo-bootstrap"),
|
||||
help="App token description",
|
||||
)
|
||||
p.add_argument("--timeout", type=int, default=int(os.environ.get("MATOMO_TIMEOUT", "20")))
|
||||
p.add_argument("--debug", action="store_true")
|
||||
p.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=int(os.environ.get("MATOMO_TIMEOUT", "20")),
|
||||
help="Network timeout in seconds (or MATOMO_TIMEOUT env)",
|
||||
)
|
||||
p.add_argument("--debug", action="store_true", help="Enable debug logs on stderr")
|
||||
|
||||
args = p.parse_args()
|
||||
# Optional (future use)
|
||||
p.add_argument(
|
||||
"--matomo-container-name",
|
||||
default=os.environ.get("MATOMO_CONTAINER_NAME"),
|
||||
help="Matomo container name (optional; also MATOMO_CONTAINER_NAME env)",
|
||||
)
|
||||
|
||||
missing = []
|
||||
if not args.base_url:
|
||||
missing.append("--base-url (or MATOMO_URL)")
|
||||
if not args.admin_user:
|
||||
missing.append("--admin-user (or MATOMO_ADMIN_USER)")
|
||||
if not args.admin_password:
|
||||
missing.append("--admin-password (or MATOMO_ADMIN_PASSWORD)")
|
||||
if not args.admin_email:
|
||||
missing.append("--admin-email (or MATOMO_ADMIN_EMAIL)")
|
||||
|
||||
if missing:
|
||||
p.error("missing required values: " + ", ".join(missing))
|
||||
|
||||
return args
|
||||
return p.parse_args()
|
||||
|
||||
75
src/matomo_bootstrap/config.py
Normal file
75
src/matomo_bootstrap/config.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
base_url: str
|
||||
admin_user: str
|
||||
admin_password: str
|
||||
admin_email: str
|
||||
token_description: str = "matomo-bootstrap"
|
||||
timeout: int = 20
|
||||
debug: bool = False
|
||||
matomo_container_name: str | None = (
|
||||
None # optional, for future console installer usage
|
||||
)
|
||||
|
||||
|
||||
def config_from_env_and_args(args) -> Config:
|
||||
"""
|
||||
Build a Config object from CLI args (preferred) and environment variables (fallback).
|
||||
"""
|
||||
base_url = getattr(args, "base_url", None) or os.environ.get("MATOMO_URL")
|
||||
admin_user = getattr(args, "admin_user", None) or os.environ.get(
|
||||
"MATOMO_ADMIN_USER"
|
||||
)
|
||||
admin_password = getattr(args, "admin_password", None) or os.environ.get(
|
||||
"MATOMO_ADMIN_PASSWORD"
|
||||
)
|
||||
admin_email = getattr(args, "admin_email", None) or os.environ.get(
|
||||
"MATOMO_ADMIN_EMAIL"
|
||||
)
|
||||
|
||||
token_description = (
|
||||
getattr(args, "token_description", None)
|
||||
or os.environ.get("MATOMO_TOKEN_DESCRIPTION")
|
||||
or "matomo-bootstrap"
|
||||
)
|
||||
|
||||
timeout = int(
|
||||
getattr(args, "timeout", None) or os.environ.get("MATOMO_TIMEOUT") or "20"
|
||||
)
|
||||
debug = bool(getattr(args, "debug", False))
|
||||
|
||||
matomo_container_name = (
|
||||
getattr(args, "matomo_container_name", None)
|
||||
or os.environ.get("MATOMO_CONTAINER_NAME")
|
||||
or None
|
||||
)
|
||||
|
||||
missing: list[str] = []
|
||||
if not base_url:
|
||||
missing.append("--base-url (or MATOMO_URL)")
|
||||
if not admin_user:
|
||||
missing.append("--admin-user (or MATOMO_ADMIN_USER)")
|
||||
if not admin_password:
|
||||
missing.append("--admin-password (or MATOMO_ADMIN_PASSWORD)")
|
||||
if not admin_email:
|
||||
missing.append("--admin-email (or MATOMO_ADMIN_EMAIL)")
|
||||
|
||||
if missing:
|
||||
raise ValueError("missing required values: " + ", ".join(missing))
|
||||
|
||||
return Config(
|
||||
base_url=str(base_url),
|
||||
admin_user=str(admin_user),
|
||||
admin_password=str(admin_password),
|
||||
admin_email=str(admin_email),
|
||||
token_description=str(token_description),
|
||||
timeout=timeout,
|
||||
debug=debug,
|
||||
matomo_container_name=matomo_container_name,
|
||||
)
|
||||
@@ -1,4 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.request
|
||||
|
||||
from .errors import MatomoNotReadyError
|
||||
|
||||
|
||||
@@ -9,5 +12,6 @@ def assert_matomo_ready(base_url: str, timeout: int = 10) -> None:
|
||||
except Exception as exc:
|
||||
raise MatomoNotReadyError(f"Matomo not reachable: {exc}") from exc
|
||||
|
||||
if "Matomo" not in html and "piwik" not in html.lower():
|
||||
lower = html.lower()
|
||||
if "matomo" not in lower and "piwik" not in lower:
|
||||
raise MatomoNotReadyError("Matomo UI not detected at base URL")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.cookiejar
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -16,15 +19,11 @@ class HttpClient:
|
||||
urllib.request.HTTPCookieProcessor(self.cookies)
|
||||
)
|
||||
|
||||
def get(self, path: str, params: Dict[str, str]) -> Tuple[int, str]:
|
||||
qs = urllib.parse.urlencode(params)
|
||||
url = f"{self.base_url}{path}?{qs}"
|
||||
|
||||
def _dbg(self, msg: str) -> None:
|
||||
if self.debug:
|
||||
print(f"[HTTP] GET {url}")
|
||||
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
def _open(self, req: urllib.request.Request) -> Tuple[int, str]:
|
||||
try:
|
||||
with self.opener.open(req, timeout=self.timeout) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
@@ -37,22 +36,25 @@ class HttpClient:
|
||||
body = str(exc)
|
||||
return exc.code, body
|
||||
|
||||
def get(self, path: str, params: Dict[str, str]) -> Tuple[int, str]:
|
||||
qs = urllib.parse.urlencode(params)
|
||||
if path == "/":
|
||||
url = f"{self.base_url}/"
|
||||
else:
|
||||
url = f"{self.base_url}{path}"
|
||||
if qs:
|
||||
url = f"{url}?{qs}"
|
||||
|
||||
self._dbg(f"[HTTP] GET {url}")
|
||||
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
return self._open(req)
|
||||
|
||||
def post(self, path: str, data: Dict[str, str]) -> Tuple[int, str]:
|
||||
url = self.base_url + path
|
||||
encoded = urllib.parse.urlencode(data).encode()
|
||||
|
||||
if self.debug:
|
||||
print(f"[HTTP] POST {url} keys={list(data.keys())}")
|
||||
self._dbg(f"[HTTP] POST {url} keys={list(data.keys())}")
|
||||
|
||||
req = urllib.request.Request(url, data=encoded, method="POST")
|
||||
|
||||
try:
|
||||
with self.opener.open(req, timeout=self.timeout) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
return resp.status, body
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
body = str(exc)
|
||||
return exc.code, body
|
||||
return self._open(req)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
MATOMO_ROOT = "/var/www/html"
|
||||
CONSOLE = f"{MATOMO_ROOT}/console"
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, text=True, capture_output=True)
|
||||
|
||||
|
||||
def _container_state(container_name: str) -> str:
|
||||
res = _run(["docker", "inspect", "-f", "{{.State.Status}}", container_name])
|
||||
return (res.stdout or "").strip()
|
||||
|
||||
|
||||
def _wait_container_running(container_name: str, timeout: int = 60) -> None:
|
||||
last = ""
|
||||
for _ in range(timeout):
|
||||
state = _container_state(container_name)
|
||||
last = state
|
||||
if state == "running":
|
||||
return
|
||||
time.sleep(1)
|
||||
raise RuntimeError(f"Container '{container_name}' did not become running (last state: {last})")
|
||||
|
||||
|
||||
def _exec(container_name: str, argv: list[str]) -> subprocess.CompletedProcess:
|
||||
return _run(["docker", "exec", container_name, *argv])
|
||||
|
||||
|
||||
def _sh(container_name: str, script: str) -> subprocess.CompletedProcess:
|
||||
# Use sh -lc so PATH + cwd behave more like interactive container sessions
|
||||
return _exec(container_name, ["sh", "-lc", script])
|
||||
|
||||
|
||||
def _console_exists(container_name: str) -> bool:
|
||||
res = _sh(container_name, f"test -x {CONSOLE} && echo yes || echo no")
|
||||
return (res.stdout or "").strip() == "yes"
|
||||
|
||||
|
||||
def _is_installed(container_name: str) -> bool:
|
||||
res = _sh(container_name, f"test -f {MATOMO_ROOT}/config/config.ini.php && echo yes || echo no")
|
||||
return (res.stdout or "").strip() == "yes"
|
||||
|
||||
|
||||
def _console_list(container_name: str) -> str:
|
||||
# --no-ansi for stable parsing
|
||||
res = _sh(container_name, f"{CONSOLE} list --no-ansi 2>/dev/null || true")
|
||||
return (res.stdout or "") + "\n" + (res.stderr or "")
|
||||
|
||||
|
||||
def _has_command(console_list_output: str, command: str) -> bool:
|
||||
# cheap but robust enough
|
||||
return f" {command} " in console_list_output or f"\n{command}\n" in console_list_output or command in console_list_output
|
||||
|
||||
|
||||
def ensure_installed_via_console(
|
||||
*,
|
||||
container_name: str,
|
||||
admin_user: str,
|
||||
admin_password: str,
|
||||
admin_email: str,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Ensure Matomo is installed using the container's console if possible.
|
||||
If no known install command exists, we do NOT guess: we raise with diagnostics.
|
||||
"""
|
||||
_wait_container_running(container_name, timeout=90)
|
||||
|
||||
if _is_installed(container_name):
|
||||
if debug:
|
||||
print("[install] Matomo already installed (config.ini.php exists).")
|
||||
return
|
||||
|
||||
if not _console_exists(container_name):
|
||||
raise RuntimeError(f"Matomo console not found/executable at {CONSOLE} inside container '{container_name}'.")
|
||||
|
||||
listing = _console_list(container_name)
|
||||
if debug:
|
||||
print("[install] Matomo console list obtained.")
|
||||
|
||||
# Matomo versions differ; we discover what exists.
|
||||
# Historically: core:install. Your earlier log showed it does NOT exist in 5.3.2 image.
|
||||
# Therefore we refuse to guess and provide the list in the exception.
|
||||
if _has_command(listing, "core:install"):
|
||||
# If this ever exists, use it.
|
||||
cmd = (
|
||||
f"{CONSOLE} core:install --no-ansi "
|
||||
f"--database-host=db "
|
||||
f"--database-username=matomo "
|
||||
f"--database-password=matomo_pw "
|
||||
f"--database-name=matomo "
|
||||
f"--login={admin_user} "
|
||||
f"--password={admin_password} "
|
||||
f"--email={admin_email} "
|
||||
f"--url=http://localhost "
|
||||
)
|
||||
res = _sh(container_name, cmd)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(f"Matomo CLI install failed.\nexit={res.returncode}\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}")
|
||||
return
|
||||
|
||||
# No install command -> fail with diagnostics (don’t keep burning time).
|
||||
raise RuntimeError(
|
||||
"Matomo is not installed yet, but no supported CLI install command was found in this image.\n"
|
||||
"This Matomo image likely expects the web installer.\n"
|
||||
"\n[console list]\n"
|
||||
f"{listing}\n"
|
||||
)
|
||||
@@ -1,238 +0,0 @@
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
# Optional knobs (mostly for debugging / CI stability)
|
||||
PLAYWRIGHT_HEADLESS = os.environ.get("MATOMO_PLAYWRIGHT_HEADLESS", "1").strip() not in ("0", "false", "False")
|
||||
PLAYWRIGHT_SLOWMO_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_SLOWMO_MS", "0"))
|
||||
PLAYWRIGHT_NAV_TIMEOUT_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000"))
|
||||
|
||||
# Values used by the installer flow (recorded)
|
||||
DEFAULT_SITE_NAME = os.environ.get("MATOMO_SITE_NAME", "localhost")
|
||||
DEFAULT_SITE_URL = os.environ.get("MATOMO_SITE_URL", "http://localhost")
|
||||
DEFAULT_TIMEZONE = os.environ.get("MATOMO_TIMEZONE", "Germany - Berlin")
|
||||
DEFAULT_ECOMMERCE = os.environ.get("MATOMO_ECOMMERCE", "Ecommerce enabled")
|
||||
|
||||
|
||||
def wait_http(url: str, timeout: int = 180) -> None:
|
||||
"""
|
||||
Consider Matomo 'reachable' as soon as the HTTP server answers - even with 500.
|
||||
urllib raises HTTPError for 4xx/5xx, so we must treat that as reachability too.
|
||||
"""
|
||||
print(f"[install] Waiting for Matomo HTTP at {url} ...")
|
||||
last_err: Exception | None = None
|
||||
|
||||
for i in range(timeout):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||
_ = resp.read(128)
|
||||
print("[install] Matomo HTTP reachable (2xx/3xx).")
|
||||
return
|
||||
except urllib.error.HTTPError as exc:
|
||||
print(f"[install] Matomo HTTP reachable (HTTP {exc.code}).")
|
||||
return
|
||||
except Exception as exc:
|
||||
last_err = exc
|
||||
if i % 5 == 0:
|
||||
print(f"[install] still waiting ({i}/{timeout}) … ({type(exc).__name__})")
|
||||
time.sleep(1)
|
||||
|
||||
raise RuntimeError(f"Matomo did not become reachable after {timeout}s: {url} ({last_err})")
|
||||
|
||||
|
||||
def is_installed(url: str) -> bool:
|
||||
"""
|
||||
Heuristic:
|
||||
- installed instances typically render login module links
|
||||
- installer renders 'installation' wizard content
|
||||
"""
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||
html = resp.read().decode(errors="ignore").lower()
|
||||
return ("module=login" in html) or ("matomo › login" in html) or ("matomo/login" in html)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
html = exc.read().decode(errors="ignore").lower()
|
||||
return ("module=login" in html) or ("matomo › login" in html) or ("matomo/login" in html)
|
||||
except Exception:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_installed(
|
||||
base_url: str,
|
||||
admin_user: str,
|
||||
admin_password: str,
|
||||
admin_email: str,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Ensure Matomo is installed.
|
||||
NO-OP if already installed.
|
||||
|
||||
This implementation ONLY uses the Playwright web installer (recorded flow).
|
||||
"""
|
||||
wait_http(base_url)
|
||||
|
||||
if is_installed(base_url):
|
||||
if debug:
|
||||
print("[install] Matomo already looks installed. Skipping installer.")
|
||||
return
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
print("[install] Running Matomo web installer via Playwright (recorded flow)...")
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=PLAYWRIGHT_HEADLESS,
|
||||
slow_mo=PLAYWRIGHT_SLOWMO_MS if PLAYWRIGHT_SLOWMO_MS > 0 else None,
|
||||
)
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
page.set_default_navigation_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
|
||||
page.set_default_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
|
||||
|
||||
def _dbg(msg: str) -> None:
|
||||
if debug:
|
||||
print(f"[install] {msg}")
|
||||
|
||||
def click_next() -> None:
|
||||
"""
|
||||
Matomo installer mixes link/button variants and sometimes includes '»'.
|
||||
We try common variants in a robust order.
|
||||
"""
|
||||
candidates = [
|
||||
("link", "Next »"),
|
||||
("button", "Next »"),
|
||||
("link", "Next"),
|
||||
("button", "Next"),
|
||||
("link", "Continue"),
|
||||
("button", "Continue"),
|
||||
("link", "Proceed"),
|
||||
("button", "Proceed"),
|
||||
("link", "Start Installation"),
|
||||
("button", "Start Installation"),
|
||||
("link", "Weiter"),
|
||||
("button", "Weiter"),
|
||||
("link", "Fortfahren"),
|
||||
("button", "Fortfahren"),
|
||||
]
|
||||
|
||||
for role, name in candidates:
|
||||
loc = page.get_by_role(role, name=name)
|
||||
if loc.count() > 0:
|
||||
_dbg(f"click_next(): {role} '{name}'")
|
||||
loc.first.click()
|
||||
return
|
||||
|
||||
# last resort: some pages use same text but different element types
|
||||
loc = page.get_by_text("Next", exact=False)
|
||||
if loc.count() > 0:
|
||||
_dbg("click_next(): fallback text 'Next'")
|
||||
loc.first.click()
|
||||
return
|
||||
|
||||
raise RuntimeError("Could not find a Next/Continue control in the installer UI.")
|
||||
|
||||
# --- Recorded-ish flow, but made variable-based + more stable ---
|
||||
page.goto(base_url, wait_until="domcontentloaded")
|
||||
|
||||
# The first few screens can vary slightly (welcome/system check/db etc.).
|
||||
# In your recording, you clicked through multiple Next pages without DB input (env already set in container).
|
||||
# We mimic that: keep clicking "Next" until we see the superuser fields.
|
||||
#
|
||||
# Stop condition: superuser login field appears.
|
||||
def superuser_form_visible() -> bool:
|
||||
# In your recording, the superuser "Login" field was "#login-0".
|
||||
return page.locator("#login-0").count() > 0
|
||||
|
||||
# Click next until the superuser page shows up (cap to avoid infinite loops).
|
||||
for _ in range(12):
|
||||
if superuser_form_visible():
|
||||
break
|
||||
click_next()
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
page.wait_for_timeout(200)
|
||||
else:
|
||||
raise RuntimeError("Installer did not reach superuser step (login-0 not found).")
|
||||
|
||||
# Superuser step
|
||||
page.locator("#login-0").click()
|
||||
page.locator("#login-0").fill(admin_user)
|
||||
|
||||
page.locator("#password-0").click()
|
||||
page.locator("#password-0").fill(admin_password)
|
||||
|
||||
# Repeat password (some versions have it)
|
||||
if page.locator("#password_bis-0").count() > 0:
|
||||
page.locator("#password_bis-0").click()
|
||||
page.locator("#password_bis-0").fill(admin_password)
|
||||
|
||||
page.locator("#email-0").click()
|
||||
page.locator("#email-0").fill(admin_email)
|
||||
|
||||
# Next
|
||||
page.get_by_role("button", name="Next »").click()
|
||||
|
||||
# First website
|
||||
if page.locator("#siteName-0").count() > 0:
|
||||
page.locator("#siteName-0").click()
|
||||
page.locator("#siteName-0").fill(DEFAULT_SITE_NAME)
|
||||
|
||||
if page.locator("#url-0").count() > 0:
|
||||
page.locator("#url-0").click()
|
||||
page.locator("#url-0").fill(DEFAULT_SITE_URL)
|
||||
|
||||
# Timezone dropdown (best-effort)
|
||||
try:
|
||||
# recording: page.get_by_role("combobox").first.click() then listbox text
|
||||
page.get_by_role("combobox").first.click()
|
||||
page.get_by_role("listbox").get_by_text(DEFAULT_TIMEZONE).click()
|
||||
except Exception:
|
||||
_dbg("Timezone selection skipped (not found / changed UI).")
|
||||
|
||||
# Ecommerce dropdown (best-effort)
|
||||
try:
|
||||
# recording: combobox nth(2)
|
||||
page.get_by_role("combobox").nth(2).click()
|
||||
page.get_by_role("listbox").get_by_text(DEFAULT_ECOMMERCE).click()
|
||||
except Exception:
|
||||
_dbg("Ecommerce selection skipped (not found / changed UI).")
|
||||
|
||||
# Next pages to finish
|
||||
click_next()
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
# In recording: Next link, then Continue to Matomo button
|
||||
if page.get_by_role("link", name="Next »").count() > 0:
|
||||
page.get_by_role("link", name="Next »").click()
|
||||
|
||||
if page.get_by_role("button", name="Continue to Matomo »").count() > 0:
|
||||
page.get_by_role("button", name="Continue to Matomo »").click()
|
||||
|
||||
# Optional: login once (not strictly required for token flow, but harmless and matches your recording).
|
||||
# Some UIs have fancy-icon labels; we follow your recorded selectors best-effort.
|
||||
try:
|
||||
user_box = page.get_by_role("textbox", name=" Username or e-mail")
|
||||
pass_box = page.get_by_role("textbox", name=" Password")
|
||||
if user_box.count() > 0 and pass_box.count() > 0:
|
||||
user_box.click()
|
||||
user_box.fill(admin_user)
|
||||
pass_box.fill(admin_password)
|
||||
if page.get_by_role("button", name="Sign in").count() > 0:
|
||||
page.get_by_role("button", name="Sign in").click()
|
||||
except Exception:
|
||||
_dbg("Post-install login skipped (UI differs).")
|
||||
|
||||
context.close()
|
||||
browser.close()
|
||||
|
||||
time.sleep(1)
|
||||
if not is_installed(base_url):
|
||||
raise RuntimeError("[install] Installer did not reach installed state.")
|
||||
|
||||
print("[install] Installation finished.")
|
||||
1
src/matomo_bootstrap/installers/__init__.py
Normal file
1
src/matomo_bootstrap/installers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__ = []
|
||||
11
src/matomo_bootstrap/installers/base.py
Normal file
11
src/matomo_bootstrap/installers/base.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..config import Config
|
||||
|
||||
|
||||
class Installer(ABC):
|
||||
@abstractmethod
|
||||
def ensure_installed(self, config: Config) -> None:
|
||||
raise NotImplementedError
|
||||
236
src/matomo_bootstrap/installers/web.py
Normal file
236
src/matomo_bootstrap/installers/web.py
Normal file
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .base import Installer
|
||||
from ..config import Config
|
||||
|
||||
|
||||
# Optional knobs (mostly for debugging / CI stability)
|
||||
PLAYWRIGHT_HEADLESS = os.environ.get("MATOMO_PLAYWRIGHT_HEADLESS", "1").strip() not in (
|
||||
"0",
|
||||
"false",
|
||||
"False",
|
||||
)
|
||||
PLAYWRIGHT_SLOWMO_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_SLOWMO_MS", "0"))
|
||||
PLAYWRIGHT_NAV_TIMEOUT_MS = int(
|
||||
os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000")
|
||||
)
|
||||
|
||||
# Values used by the installer flow (recorded)
|
||||
DEFAULT_SITE_NAME = os.environ.get("MATOMO_SITE_NAME", "localhost")
|
||||
DEFAULT_SITE_URL = os.environ.get("MATOMO_SITE_URL", "http://localhost")
|
||||
DEFAULT_TIMEZONE = os.environ.get("MATOMO_TIMEZONE", "Germany - Berlin")
|
||||
DEFAULT_ECOMMERCE = os.environ.get("MATOMO_ECOMMERCE", "Ecommerce enabled")
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
# IMPORTANT: logs must not pollute stdout (tests expect only token on stdout)
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def wait_http(url: str, timeout: int = 180) -> None:
|
||||
"""
|
||||
Consider Matomo 'reachable' as soon as the HTTP server answers - even with 500.
|
||||
urllib raises HTTPError for 4xx/5xx, so we must treat that as reachability too.
|
||||
"""
|
||||
_log(f"[install] Waiting for Matomo HTTP at {url} ...")
|
||||
last_err: Exception | None = None
|
||||
|
||||
for i in range(timeout):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||
_ = resp.read(128)
|
||||
_log("[install] Matomo HTTP reachable (2xx/3xx).")
|
||||
return
|
||||
except urllib.error.HTTPError as exc:
|
||||
_log(f"[install] Matomo HTTP reachable (HTTP {exc.code}).")
|
||||
return
|
||||
except Exception as exc:
|
||||
last_err = exc
|
||||
if i % 5 == 0:
|
||||
_log(
|
||||
f"[install] still waiting ({i}/{timeout}) … ({type(exc).__name__})"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Matomo did not become reachable after {timeout}s: {url} ({last_err})"
|
||||
)
|
||||
|
||||
|
||||
def is_installed(url: str) -> bool:
|
||||
"""
|
||||
Heuristic:
|
||||
- installed instances typically render login module links
|
||||
- installer renders 'installation' wizard content
|
||||
"""
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||
html = resp.read().decode(errors="ignore").lower()
|
||||
return (
|
||||
("module=login" in html)
|
||||
or ("matomo › login" in html)
|
||||
or ("matomo/login" in html)
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
html = exc.read().decode(errors="ignore").lower()
|
||||
return (
|
||||
("module=login" in html)
|
||||
or ("matomo › login" in html)
|
||||
or ("matomo/login" in html)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class WebInstaller(Installer):
|
||||
def ensure_installed(self, config: Config) -> None:
|
||||
"""
|
||||
Ensure Matomo is installed. NO-OP if already installed.
|
||||
Uses Playwright to drive the web installer (recorded flow).
|
||||
"""
|
||||
base_url = config.base_url
|
||||
|
||||
wait_http(base_url)
|
||||
|
||||
if is_installed(base_url):
|
||||
if config.debug:
|
||||
_log("[install] Matomo already looks installed. Skipping installer.")
|
||||
return
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
_log("[install] Running Matomo web installer via Playwright (recorded flow)...")
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=PLAYWRIGHT_HEADLESS,
|
||||
slow_mo=PLAYWRIGHT_SLOWMO_MS if PLAYWRIGHT_SLOWMO_MS > 0 else None,
|
||||
)
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
page.set_default_navigation_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
|
||||
page.set_default_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
|
||||
|
||||
def _dbg(msg: str) -> None:
|
||||
if config.debug:
|
||||
_log(f"[install] {msg}")
|
||||
|
||||
def click_next() -> None:
|
||||
"""
|
||||
Matomo installer mixes link/button variants and sometimes includes '»'.
|
||||
We try common variants in a robust order.
|
||||
"""
|
||||
candidates = [
|
||||
("link", "Next »"),
|
||||
("button", "Next »"),
|
||||
("link", "Next"),
|
||||
("button", "Next"),
|
||||
("link", "Continue"),
|
||||
("button", "Continue"),
|
||||
("link", "Proceed"),
|
||||
("button", "Proceed"),
|
||||
("link", "Start Installation"),
|
||||
("button", "Start Installation"),
|
||||
("link", "Weiter"),
|
||||
("button", "Weiter"),
|
||||
("link", "Fortfahren"),
|
||||
("button", "Fortfahren"),
|
||||
]
|
||||
|
||||
for role, name in candidates:
|
||||
loc = page.get_by_role(role, name=name)
|
||||
if loc.count() > 0:
|
||||
_dbg(f"click_next(): {role} '{name}'")
|
||||
loc.first.click()
|
||||
return
|
||||
|
||||
loc = page.get_by_text("Next", exact=False)
|
||||
if loc.count() > 0:
|
||||
_dbg("click_next(): fallback text 'Next'")
|
||||
loc.first.click()
|
||||
return
|
||||
|
||||
raise RuntimeError(
|
||||
"Could not find a Next/Continue control in the installer UI."
|
||||
)
|
||||
|
||||
page.goto(base_url, wait_until="domcontentloaded")
|
||||
|
||||
def superuser_form_visible() -> bool:
|
||||
return page.locator("#login-0").count() > 0
|
||||
|
||||
for _ in range(12):
|
||||
if superuser_form_visible():
|
||||
break
|
||||
click_next()
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
page.wait_for_timeout(200)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Installer did not reach superuser step (login-0 not found)."
|
||||
)
|
||||
|
||||
page.locator("#login-0").click()
|
||||
page.locator("#login-0").fill(config.admin_user)
|
||||
|
||||
page.locator("#password-0").click()
|
||||
page.locator("#password-0").fill(config.admin_password)
|
||||
|
||||
if page.locator("#password_bis-0").count() > 0:
|
||||
page.locator("#password_bis-0").click()
|
||||
page.locator("#password_bis-0").fill(config.admin_password)
|
||||
|
||||
page.locator("#email-0").click()
|
||||
page.locator("#email-0").fill(config.admin_email)
|
||||
|
||||
if page.get_by_role("button", name="Next »").count() > 0:
|
||||
page.get_by_role("button", name="Next »").click()
|
||||
else:
|
||||
click_next()
|
||||
|
||||
if page.locator("#siteName-0").count() > 0:
|
||||
page.locator("#siteName-0").click()
|
||||
page.locator("#siteName-0").fill(DEFAULT_SITE_NAME)
|
||||
|
||||
if page.locator("#url-0").count() > 0:
|
||||
page.locator("#url-0").click()
|
||||
page.locator("#url-0").fill(DEFAULT_SITE_URL)
|
||||
|
||||
try:
|
||||
page.get_by_role("combobox").first.click()
|
||||
page.get_by_role("listbox").get_by_text(DEFAULT_TIMEZONE).click()
|
||||
except Exception:
|
||||
_dbg("Timezone selection skipped (not found / changed UI).")
|
||||
|
||||
try:
|
||||
page.get_by_role("combobox").nth(2).click()
|
||||
page.get_by_role("listbox").get_by_text(DEFAULT_ECOMMERCE).click()
|
||||
except Exception:
|
||||
_dbg("Ecommerce selection skipped (not found / changed UI).")
|
||||
|
||||
click_next()
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
if page.get_by_role("link", name="Next »").count() > 0:
|
||||
page.get_by_role("link", name="Next »").click()
|
||||
|
||||
if page.get_by_role("button", name="Continue to Matomo »").count() > 0:
|
||||
page.get_by_role("button", name="Continue to Matomo »").click()
|
||||
|
||||
context.close()
|
||||
browser.close()
|
||||
|
||||
time.sleep(1)
|
||||
if not is_installed(base_url):
|
||||
raise RuntimeError("[install] Installer did not reach installed state.")
|
||||
|
||||
_log("[install] Installation finished.")
|
||||
125
src/matomo_bootstrap/matomo_api.py
Normal file
125
src/matomo_bootstrap/matomo_api.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
|
||||
from .errors import MatomoNotReadyError, TokenCreationError
|
||||
from .http import HttpClient
|
||||
|
||||
|
||||
def _md5(text: str) -> str:
|
||||
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _try_json(body: str) -> object:
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise TokenCreationError(f"Invalid JSON from Matomo API: {body[:400]}") from exc
|
||||
|
||||
|
||||
def _dbg(msg: str, enabled: bool) -> None:
|
||||
if enabled:
|
||||
# Keep stdout clean (tests expect only token on stdout).
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
class MatomoApi:
|
||||
def __init__(self, *, client: HttpClient, debug: bool = False):
|
||||
self.client = client
|
||||
self.debug = debug
|
||||
|
||||
def assert_ready(self, timeout: int = 10) -> None:
|
||||
"""
|
||||
Minimal readiness check: Matomo UI should be reachable and look like Matomo.
|
||||
"""
|
||||
try:
|
||||
status, body = self.client.get("/", {})
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise MatomoNotReadyError(f"Matomo not reachable: {exc}") from exc
|
||||
|
||||
_dbg(f"[ready] GET / -> HTTP {status}", self.debug)
|
||||
|
||||
html = (body or "").lower()
|
||||
if "matomo" not in html and "piwik" not in html:
|
||||
raise MatomoNotReadyError("Matomo UI not detected at base URL")
|
||||
|
||||
def login_via_logme(self, admin_user: str, admin_password: str) -> None:
|
||||
"""
|
||||
Create an authenticated Matomo session (cookie jar) using Login controller.
|
||||
Matomo accepts md5 hashed password in `password` parameter for action=logme.
|
||||
"""
|
||||
md5_password = _md5(admin_password)
|
||||
try:
|
||||
status, body = self.client.get(
|
||||
"/index.php",
|
||||
{
|
||||
"module": "Login",
|
||||
"action": "logme",
|
||||
"login": admin_user,
|
||||
"password": md5_password,
|
||||
},
|
||||
)
|
||||
_dbg(f"[auth] logme HTTP {status} body[:120]={body[:120]!r}", self.debug)
|
||||
except urllib.error.HTTPError as exc:
|
||||
# Even 4xx/5xx can still set cookies; continue and let the API call validate.
|
||||
try:
|
||||
err_body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
err_body = ""
|
||||
_dbg(
|
||||
f"[auth] logme HTTPError {exc.code} body[:120]={err_body[:120]!r}",
|
||||
self.debug,
|
||||
)
|
||||
|
||||
def create_app_specific_token(
|
||||
self,
|
||||
*,
|
||||
admin_user: str,
|
||||
admin_password: str,
|
||||
description: str,
|
||||
) -> str:
|
||||
"""
|
||||
Create an app-specific token using an authenticated session (cookies),
|
||||
not UsersManager.getTokenAuth (not available in Matomo 5.3.x images).
|
||||
"""
|
||||
env_token = os.environ.get("MATOMO_BOOTSTRAP_TOKEN_AUTH")
|
||||
if env_token:
|
||||
_dbg(
|
||||
"[auth] Using MATOMO_BOOTSTRAP_TOKEN_AUTH from environment.", self.debug
|
||||
)
|
||||
return env_token
|
||||
|
||||
self.login_via_logme(admin_user, admin_password)
|
||||
|
||||
status, body = self.client.post(
|
||||
"/index.php",
|
||||
{
|
||||
"module": "API",
|
||||
"method": "UsersManager.createAppSpecificTokenAuth",
|
||||
"userLogin": admin_user,
|
||||
"passwordConfirmation": admin_password,
|
||||
"description": description,
|
||||
"format": "json",
|
||||
},
|
||||
)
|
||||
|
||||
_dbg(
|
||||
f"[auth] createAppSpecificTokenAuth HTTP {status} body[:200]={body[:200]!r}",
|
||||
self.debug,
|
||||
)
|
||||
|
||||
if status != 200:
|
||||
raise TokenCreationError(
|
||||
f"HTTP {status} during token creation: {body[:400]}"
|
||||
)
|
||||
|
||||
data = _try_json(body)
|
||||
token = data.get("value") if isinstance(data, dict) else None
|
||||
if not token:
|
||||
raise TokenCreationError(f"Unexpected response from token creation: {data}")
|
||||
|
||||
return str(token)
|
||||
33
src/matomo_bootstrap/service.py
Normal file
33
src/matomo_bootstrap/service.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .config import Config
|
||||
from .http import HttpClient
|
||||
from .matomo_api import MatomoApi
|
||||
from .installers.web import WebInstaller
|
||||
|
||||
|
||||
def run(config: Config) -> str:
|
||||
"""
|
||||
Orchestrate:
|
||||
1) Ensure Matomo is installed (NO-OP if installed)
|
||||
2) Ensure Matomo is reachable/ready
|
||||
3) Create an app-specific token using an authenticated session
|
||||
"""
|
||||
installer = WebInstaller()
|
||||
installer.ensure_installed(config)
|
||||
|
||||
client = HttpClient(
|
||||
base_url=config.base_url,
|
||||
timeout=config.timeout,
|
||||
debug=config.debug,
|
||||
)
|
||||
api = MatomoApi(client=client, debug=config.debug)
|
||||
|
||||
api.assert_ready(timeout=config.timeout)
|
||||
|
||||
token = api.create_app_specific_token(
|
||||
admin_user=config.admin_user,
|
||||
admin_password=config.admin_password,
|
||||
description=config.token_description,
|
||||
)
|
||||
return token
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
import urllib.request
|
||||
|
||||
@@ -14,7 +15,7 @@ ADMIN_EMAIL = os.environ.get("MATOMO_ADMIN_EMAIL", "administrator@example.org")
|
||||
class TestMatomoBootstrapE2E(unittest.TestCase):
|
||||
def test_bootstrap_creates_api_token(self) -> None:
|
||||
cmd = [
|
||||
"python3",
|
||||
sys.executable,
|
||||
"-m",
|
||||
"matomo_bootstrap",
|
||||
"--base-url",
|
||||
@@ -29,12 +30,18 @@ class TestMatomoBootstrapE2E(unittest.TestCase):
|
||||
"e2e-test-token",
|
||||
]
|
||||
|
||||
token = subprocess.check_output(
|
||||
cmd,
|
||||
env={**os.environ, "PYTHONPATH": "src"},
|
||||
).decode().strip()
|
||||
token = (
|
||||
subprocess.check_output(
|
||||
cmd,
|
||||
env={**os.environ, "PYTHONPATH": "src"},
|
||||
)
|
||||
.decode()
|
||||
.strip()
|
||||
)
|
||||
|
||||
self.assertRegex(token, r"^[a-f0-9]{32,64}$", f"Expected token_auth, got: {token}")
|
||||
self.assertRegex(
|
||||
token, r"^[a-f0-9]{32,64}$", f"Expected token_auth, got: {token}"
|
||||
)
|
||||
|
||||
api_url = (
|
||||
f"{MATOMO_URL}/index.php"
|
||||
|
||||
58
tests/e2e/test_bootstrap_nix.py
Normal file
58
tests/e2e/test_bootstrap_nix.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
import subprocess
|
||||
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")
|
||||
|
||||
|
||||
class TestMatomoBootstrapE2ENix(unittest.TestCase):
|
||||
def test_bootstrap_creates_api_token_via_nix(self) -> None:
|
||||
script = f"""set -euo pipefail
|
||||
|
||||
export NIX_CONFIG='experimental-features = nix-command flakes'
|
||||
export TERM='xterm'
|
||||
|
||||
# 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
|
||||
|
||||
# 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",
|
||||
"exec",
|
||||
"-T",
|
||||
"nix",
|
||||
"sh",
|
||||
"-lc",
|
||||
script,
|
||||
]
|
||||
|
||||
token = subprocess.check_output(cmd).decode().strip()
|
||||
self.assertRegex(token, r"^[a-f0-9]{32,64}$")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
135
tests/e2e/test_docker_compose_stack.py
Normal file
135
tests/e2e/test_docker_compose_stack.py
Normal file
@@ -0,0 +1,135 @@
|
||||
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")
|
||||
|
||||
# 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}")
|
||||
|
||||
# How long we wait for Matomo HTTP to respond at all (seconds)
|
||||
WAIT_TIMEOUT_SECONDS = int(os.environ.get("MATOMO_STACK_WAIT_TIMEOUT", "180"))
|
||||
|
||||
|
||||
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) -> list[str]:
|
||||
return ["docker", "compose", "-f", COMPOSE_FILE, *args]
|
||||
|
||||
|
||||
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})")
|
||||
|
||||
|
||||
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 test_root_docker_compose_yml_stack_bootstraps_and_token_works(self) -> None:
|
||||
# Build bootstrap image from Dockerfile (as defined in docker-compose.yml)
|
||||
build = _run(
|
||||
_compose_cmd("build", "bootstrap"),
|
||||
check=True,
|
||||
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
||||
)
|
||||
self.assertEqual(build.returncode, 0, build.stderr)
|
||||
|
||||
# Start db + matomo (bootstrap is one-shot and started via "run")
|
||||
up = _run(
|
||||
_compose_cmd("up", "-d", "db", "matomo"),
|
||||
check=True,
|
||||
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
||||
)
|
||||
self.assertEqual(up.returncode, 0, up.stderr)
|
||||
|
||||
# Wait until Matomo answers on the published port
|
||||
_wait_for_http_any_status(MATOMO_HOST_URL + "/", WAIT_TIMEOUT_SECONDS)
|
||||
|
||||
# Run bootstrap: it should print ONLY the token to stdout
|
||||
boot = _run(
|
||||
_compose_cmd("run", "--rm", "bootstrap"),
|
||||
check=True,
|
||||
extra_env={"MATOMO_PORT": MATOMO_PORT},
|
||||
)
|
||||
|
||||
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}",
|
||||
)
|
||||
|
||||
# Verify token works against Matomo API
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user