diff --git a/galaxy.yml b/galaxy.yml index c208d8f..91cd337 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -19,7 +19,6 @@ readme: README.md authors: - Bert-Jan Fikse - Tobias Wüst -- Simon Bärlocher ### OPTIONAL but strongly recommended # A short summary description of the collection diff --git a/roles/bookstack/README.md b/roles/bookstack/README.md deleted file mode 100644 index 25fb789..0000000 --- a/roles/bookstack/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# Ansible Role: bookstack - -Deploys [BookStack](https://www.bookstackapp.com/) as a self-contained Docker -Compose stack behind Traefik, with its own MariaDB container, OIDC SSO -(Entra ID by default) and a daily systemd-timer driven backup of database -and uploads. - -## Requirements - -- Docker Engine + Compose plugin on the target host -- Traefik already running, with the external network referenced by - `bookstack_traefik_network` (default: `proxy`) -- `community.docker` collection on the controller -- DNS for `bookstack_domain` pointing at the Traefik host - -## Required variables - -The role asserts these are set; the play fails fast if any is empty: - -| Variable | Description | -|---|---| -| `bookstack_db_root_password` | MariaDB root password | -| `bookstack_db_password` | MariaDB user password | -| `bookstack_admin_password` | Initial local admin password | -| `bookstack_oidc_client_id` | Entra ID App Registration ID (if OIDC on) | -| `bookstack_oidc_client_secret` | Entra ID client secret (if OIDC on) | -| `bookstack_entra_tenant_id` | Entra tenant UUID (if OIDC on) | - -Provide via OpenBao lookup, Ansible Vault or `--extra-vars`. Never commit -real secrets. - -## Optional variables - -See `defaults/main.yml`. Frequently overridden: - -- `bookstack_domain`, `bookstack_base_url` -- `bookstack_image`, `bookstack_db_image` (pin in production) -- `bookstack_oidc_enabled` (set `false` to disable OIDC entirely) -- `bookstack_oidc_auto_initiate` (`true` redirects straight to IdP) -- `bookstack_oidc_user_to_groups` (`true` syncs roles from Entra groups) -- `bookstack_backup_enabled`, `bookstack_backup_schedule`, - `bookstack_backup_retention_days` - -## Entra ID app registration - -1. Azure Portal → Entra ID → App registrations → New registration -2. Redirect URI (Web): `https:///oidc/callback` -3. Front-channel logout URL: `https:///logout` -4. Certificates & secrets → New client secret → - `bookstack_oidc_client_secret` -5. For group sync (`bookstack_oidc_user_to_groups: true`): - - Token configuration → Add groups claim → Security groups - - In BookStack, create roles whose **External Auth ID** equals the - Entra group Object ID, so the mapping resolves on first login. - -## What the role does - -| Phase | Action | -|---|---| -| Validate | `assert` all required secrets are set | -| Prepare | install packages, create volume dirs, generate persistent `APP_KEY`, verify Traefik network | -| Deploy | render `docker-compose.yml`, pull images, bring stack up | -| Configure | wait for the app, create the initial local admin via `php artisan bookstack:create-admin` (idempotent) | -| Backup | render `/usr/local/bin/bookstack-backup.sh` + systemd timer (daily 03:00, 14-day retention) | - -## Example playbook - -```yaml -- name: Deploy BookStack service - hosts: bookstack_servers - become: true - roles: - - digitalboard.core.bookstack -``` - -With inventory variables: - -```yaml -# group_vars/bookstack_servers.yml -bookstack_domain: wiki.digitalboard.ch -bookstack_base_url: "https://wiki.digitalboard.ch" -bookstack_entra_tenant_id: "{{ lookup('community.hashi_vault.vault_kv2_get', - 'digitalboard/bookstack', - mount_point='kv').data.data.tenant_id }}" -bookstack_oidc_client_id: "{{ lookup('community.hashi_vault.vault_kv2_get', - 'digitalboard/bookstack', - mount_point='kv').data.data.client_id }}" -bookstack_oidc_client_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', - 'digitalboard/bookstack', - mount_point='kv').data.data.client_secret }}" -bookstack_db_root_password: "{{ lookup('community.hashi_vault.vault_kv2_get', - 'digitalboard/bookstack', - mount_point='kv').data.data.db_root_password }}" -bookstack_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', - 'digitalboard/bookstack', - mount_point='kv').data.data.db_password }}" -bookstack_admin_password: "{{ lookup('community.hashi_vault.vault_kv2_get', - 'digitalboard/bookstack', - mount_point='kv').data.data.admin_password }}" -``` - -## Backup / restore - -Backups land in `{{ bookstack_backup_dir }}` (default -`/srv/data/bookstack/backup`) with three files per run: - -- `bookstack-db-.sql.gz` — mariadb-dump -- `bookstack-files-.tar.gz` — uploads, attachments -- `bookstack-appkey-.txt` — APP_KEY (required for restore!) - -Manual trigger: `systemctl start bookstack-backup.service` -Timer status: `systemctl list-timers bookstack-backup.timer` - -Restore procedure: - -1. Stop the stack: `docker compose down` in `bookstack_docker_compose_dir` -2. Restore the APP_KEY: copy the `.txt` content to - `{{ bookstack_docker_volume_dir }}/.app_key` (the key MUST match or - encrypted DB values become unreadable) -3. Start only the DB container, then load the dump: - ```bash - gunzip -c bookstack-db-.sql.gz \ - | docker exec -i bookstack-db \ - mariadb -u root -p"" bookstack - ``` -4. Extract the files: `tar -xzf bookstack-files-.tar.gz -C - {{ bookstack_appdata_dir }}/www/` -5. Bring the stack back up: `docker compose up -d` - -## Notes - -- `bookstack_oidc_auto_initiate: false` (default) shows a login page - with an SSO button alongside the local login form. With `true`, users - go straight to the IdP — the local admin then has to use - `https:///login?email_login=1`. -- `bookstack_oidc_user_to_groups: true` only makes sense once BookStack - roles with the correct **External Auth IDs** (= Entra group Object - IDs) exist; otherwise users lose their role assignment on every login. -- Image tags default to pinned versions; bump them deliberately rather - than chasing `latest`. -- BookStack officially supports MySQL/MariaDB only — no PostgreSQL. - -## License - -MIT diff --git a/roles/bookstack/defaults/main.yml b/roles/bookstack/defaults/main.yml deleted file mode 100644 index 3efbadb..0000000 --- a/roles/bookstack/defaults/main.yml +++ /dev/null @@ -1,85 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# defaults file for bookstack - -# Base directory configuration (inherited from base role or defined here) -docker_compose_base_dir: /etc/docker/compose -docker_volume_base_dir: /srv/data - -# bookstack-specific configuration -bookstack_service_name: bookstack -bookstack_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ bookstack_service_name }}" -bookstack_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ bookstack_service_name }}" -bookstack_appdata_dir: "{{ bookstack_docker_volume_dir }}/appdata" -bookstack_db_data_dir: "{{ bookstack_docker_volume_dir }}/db" -bookstack_backup_dir: "{{ bookstack_docker_volume_dir }}/backup" - -# Service configuration -bookstack_domain: "wiki.local.test" -bookstack_base_url: "https://{{ bookstack_domain }}" - -# Images — pin via inventory in production -bookstack_image: "lscr.io/linuxserver/bookstack:version-v26.03.3" -bookstack_db_image: "lscr.io/linuxserver/mariadb:11.4.9" - -# Traefik configuration -bookstack_traefik_network: "proxy" -bookstack_traefik_certresolver: "le" - -# Timezone / UID -bookstack_tz: "Europe/Zurich" -bookstack_puid: "1000" -bookstack_pgid: "1000" - -# Database configuration -bookstack_db_name: "bookstack" -bookstack_db_user: "bookstack" - -# REQUIRED SECRETS — empty defaults force `assert` to fail until set. -# Provide via OpenBao lookup, Ansible Vault, or extra-vars. -# Never commit real secrets to version control. -# -# Generate with: -# bookstack_db_root_password: openssl rand -base64 32 | tr -d '/+=' -# bookstack_db_password: openssl rand -base64 32 | tr -d '/+=' -# bookstack_admin_password: openssl rand -base64 24 | tr -d '/+=' -bookstack_db_root_password: "" -bookstack_db_password: "" -bookstack_admin_password: "" -bookstack_oidc_client_secret: "" - -# APP_KEY is generated automatically on first run and persisted on the host. -# Set explicitly only if restoring an existing instance. -bookstack_app_key: "" - -# Initial local admin (fallback account, lives alongside OIDC) -bookstack_admin_name: "Admin" -bookstack_admin_email: "admin@local.test" -bookstack_artisan_path: "/app/www/artisan" - -# Mail configuration -bookstack_mail_driver: "smtp" -bookstack_mail_host: "smtp.local.test" -bookstack_mail_port: 587 -bookstack_mail_encryption: "tls" -bookstack_mail_from: "bookstack@local.test" -bookstack_mail_from_name: "BookStack" -bookstack_mail_username: "" -bookstack_mail_password: "" - -# OIDC configuration (Entra ID by default; override `bookstack_oidc_issuer` -# for Keycloak or any other provider) -bookstack_oidc_enabled: false -bookstack_oidc_name: "SSO" -bookstack_entra_tenant_id: "" -bookstack_oidc_issuer: "https://login.microsoftonline.com/{{ bookstack_entra_tenant_id }}/v2.0" -bookstack_oidc_client_id: "" -bookstack_oidc_auto_initiate: false -bookstack_oidc_user_to_groups: false -bookstack_oidc_groups_claim: "groups" -bookstack_oidc_additional_scopes: "openid profile email" - -# Backup configuration -bookstack_backup_enabled: true -bookstack_backup_retention_days: 14 -bookstack_backup_schedule: "*-*-* 03:00:00" diff --git a/roles/bookstack/handlers/main.yml b/roles/bookstack/handlers/main.yml deleted file mode 100644 index eb6a769..0000000 --- a/roles/bookstack/handlers/main.yml +++ /dev/null @@ -1,19 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# handlers file for bookstack - -- name: stop bookstack - community.docker.docker_compose_v2: - project_src: "{{ bookstack_docker_compose_dir }}" - state: stopped - listen: restart bookstack - -- name: start bookstack - community.docker.docker_compose_v2: - project_src: "{{ bookstack_docker_compose_dir }}" - state: present - listen: restart bookstack - -- name: reload systemd - ansible.builtin.systemd: - daemon_reload: true diff --git a/roles/bookstack/meta/argument_specs.yml b/roles/bookstack/meta/argument_specs.yml deleted file mode 100644 index 8546cde..0000000 --- a/roles/bookstack/meta/argument_specs.yml +++ /dev/null @@ -1,194 +0,0 @@ ---- -argument_specs: - main: - short_description: Deploy BookStack (LSIO image + MariaDB) via Docker Compose. - description: - - Renders a Compose stack for the linuxserver.io BookStack image - with a sibling MariaDB container behind Traefik, then bootstraps - the initial admin user via C(php artisan bookstack:create-admin) - and optionally enables OIDC SSO (Entra ID by default). - - "Persists the Laravel C(APP_KEY) on the host so the same key is - re-used across deploys (a fresh key would orphan all encrypted - database values: 2FA secrets, API tokens, OIDC client_secret)." - - Ships an optional systemd timer that backs up the database dump, - uploads tarball and APP_KEY daily with configurable retention. - options: - docker_compose_base_dir: - type: path - default: /etc/docker/compose - docker_volume_base_dir: - type: path - default: /srv/data - bookstack_service_name: - type: str - default: bookstack - bookstack_docker_compose_dir: - type: path - bookstack_docker_volume_dir: - type: path - bookstack_appdata_dir: - type: path - bookstack_db_data_dir: - type: path - bookstack_backup_dir: - type: path - - bookstack_domain: - type: str - default: wiki.local.test - description: Hostname used in the Traefik Host rule. - bookstack_base_url: - type: str - description: Defaults to C("https://{{ bookstack_domain }}"). - - bookstack_image: - type: str - default: "lscr.io/linuxserver/bookstack:version-v26.03.3" - bookstack_db_image: - type: str - default: "lscr.io/linuxserver/mariadb:11.4.9" - - bookstack_traefik_network: - type: str - default: proxy - bookstack_traefik_certresolver: - type: str - default: le - - bookstack_tz: - type: str - default: Europe/Zurich - bookstack_puid: - type: str - default: "1000" - bookstack_pgid: - type: str - default: "1000" - - bookstack_db_name: - type: str - default: bookstack - bookstack_db_user: - type: str - default: bookstack - bookstack_db_root_password: - type: str - required: true - description: MariaDB C(root) password. Override per-inventory. - bookstack_db_password: - type: str - required: true - description: MariaDB C(bookstack_db_user) password. Override per-inventory. - - bookstack_admin_password: - type: str - required: true - description: - - Password for the local admin user that the role creates via - C(bookstack:create-admin). Lives alongside any OIDC users. - - bookstack_app_key: - type: str - default: '' - description: - - When empty the role generates a persistent C(APP_KEY) on first - run and stores it under C({{ bookstack_docker_volume_dir }}/.app_key). - Override only when restoring an existing instance — a mismatching - key orphans all encrypted database values. - - bookstack_admin_name: - type: str - default: Admin - bookstack_admin_email: - type: str - default: admin@local.test - bookstack_artisan_path: - type: path - default: /app/www/artisan - description: - - Path to BookStack's C(artisan) script inside the container. The - LSIO image's C(WORKDIR) is not the app directory, so this must - be absolute. - - bookstack_mail_driver: - type: str - choices: [smtp, log, sendmail, mailgun, ses, postmark] - default: smtp - bookstack_mail_host: - type: str - default: smtp.local.test - bookstack_mail_port: - type: int - default: 587 - bookstack_mail_encryption: - type: str - choices: [tls, ssl, ''] - default: tls - bookstack_mail_from: - type: str - default: bookstack@local.test - bookstack_mail_from_name: - type: str - default: BookStack - bookstack_mail_username: - type: str - default: '' - bookstack_mail_password: - type: str - default: '' - - bookstack_oidc_enabled: - type: bool - default: false - bookstack_oidc_name: - type: str - default: SSO - description: Display name of the SSO button on the login page. - bookstack_entra_tenant_id: - type: str - default: '' - description: Entra tenant UUID. Required when C(bookstack_oidc_enabled=true). - bookstack_oidc_issuer: - type: str - description: - - OIDC issuer URL. Defaults to the Entra v2 issuer template - built from C(bookstack_entra_tenant_id). Override for - Keycloak or any other provider. - bookstack_oidc_client_id: - type: str - default: '' - description: Required when C(bookstack_oidc_enabled=true). - bookstack_oidc_client_secret: - type: str - default: '' - description: Required when C(bookstack_oidc_enabled=true). - bookstack_oidc_auto_initiate: - type: bool - default: false - description: - - When true users are redirected straight to the IdP and the - local login is reachable only via C(?email_login=1). - bookstack_oidc_user_to_groups: - type: bool - default: false - description: - - When true BookStack syncs roles from the IdP groups claim - on every login. Requires BookStack roles whose - C(External Auth ID) matches the IdP group's Object ID. - bookstack_oidc_groups_claim: - type: str - default: groups - bookstack_oidc_additional_scopes: - type: str - default: openid profile email - - bookstack_backup_enabled: - type: bool - default: true - bookstack_backup_retention_days: - type: int - default: 14 - bookstack_backup_schedule: - type: str - default: "*-*-* 03:00:00" - description: systemd C(OnCalendar) expression for the backup timer. diff --git a/roles/bookstack/meta/main.yml b/roles/bookstack/meta/main.yml deleted file mode 100644 index a6e941d..0000000 --- a/roles/bookstack/meta/main.yml +++ /dev/null @@ -1,25 +0,0 @@ -galaxy_info: - author: digitalboard - description: Deploy BookStack as a self-contained Docker Compose stack behind Traefik - company: digitalboard - license: MIT - - min_ansible_version: "2.14" - - platforms: - - name: Debian - versions: - - bookworm - - name: Ubuntu - versions: - - jammy - - noble - - galaxy_tags: - - docker - - bookstack - - wiki - - documentation - - digitalboard - -dependencies: [] diff --git a/roles/bookstack/tasks/main.yml b/roles/bookstack/tasks/main.yml deleted file mode 100644 index 1ea325b..0000000 --- a/roles/bookstack/tasks/main.yml +++ /dev/null @@ -1,223 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# tasks file for bookstack - -# ===================================================================== -# 1. VALIDATE REQUIRED SECRETS -# ===================================================================== - -- name: Assert required secrets are set - ansible.builtin.assert: - that: - - bookstack_db_root_password | length > 0 - - bookstack_db_password | length > 0 - - bookstack_admin_password | length > 0 - - (not bookstack_oidc_enabled) or (bookstack_oidc_client_id | length > 0) - - (not bookstack_oidc_enabled) or (bookstack_oidc_client_secret | length > 0) - - (not bookstack_oidc_enabled) or (bookstack_entra_tenant_id | length > 0) - fail_msg: >- - One or more required secrets are unset. Provide them via OpenBao - lookup, Ansible Vault or --extra-vars. See README for the full list. - quiet: true - -# ===================================================================== -# 2. PREPARATION: Packages, directories, APP_KEY -# ===================================================================== - -- name: Ensure required packages are installed - ansible.builtin.package: - name: - - python3-docker - - python3-requests - state: present - -- name: Create docker compose directory - ansible.builtin.file: - path: "{{ bookstack_docker_compose_dir }}" - state: directory - mode: '0755' - -- name: Create BookStack data directories - ansible.builtin.file: - path: "{{ item }}" - state: directory - owner: "{{ bookstack_puid }}" - group: "{{ bookstack_pgid }}" - mode: '0755' - loop: - - "{{ bookstack_docker_volume_dir }}" - - "{{ bookstack_appdata_dir }}" - - "{{ bookstack_db_data_dir }}" - - "{{ bookstack_backup_dir }}" - -- name: Verify Traefik network exists - community.docker.docker_network_info: - name: "{{ bookstack_traefik_network }}" - register: _traefik_net - failed_when: not _traefik_net.exists - -- name: Check whether APP_KEY has been generated before - ansible.builtin.stat: - path: "{{ bookstack_docker_volume_dir }}/.app_key" - register: _app_key_file - -- name: Generate persistent APP_KEY on first run - ansible.builtin.shell: | - set -o pipefail - umask 077 - echo "base64:$(openssl rand -base64 32)" > {{ bookstack_docker_volume_dir }}/.app_key - args: - executable: /bin/bash - creates: "{{ bookstack_docker_volume_dir }}/.app_key" - when: - - not _app_key_file.stat.exists - - bookstack_app_key | length == 0 - -- name: Write inventory-provided APP_KEY - ansible.builtin.copy: - content: "{{ bookstack_app_key }}\n" - dest: "{{ bookstack_docker_volume_dir }}/.app_key" - mode: '0600' - when: - - not _app_key_file.stat.exists - - bookstack_app_key | length > 0 - no_log: true - -- name: Read APP_KEY back into a fact - ansible.builtin.slurp: - src: "{{ bookstack_docker_volume_dir }}/.app_key" - register: _app_key_slurp - no_log: true - -- name: Register APP_KEY fact - ansible.builtin.set_fact: - bookstack_resolved_app_key: "{{ _app_key_slurp.content | b64decode | trim }}" - no_log: true - -# ===================================================================== -# 3. DEPLOY: Render compose, bring stack up -# ===================================================================== - -- name: Render docker-compose.yml for BookStack - ansible.builtin.template: - src: docker-compose.yml.j2 - dest: "{{ bookstack_docker_compose_dir }}/docker-compose.yml" - mode: '0640' - notify: restart bookstack - -- name: Start BookStack containers - community.docker.docker_compose_v2: - project_src: "{{ bookstack_docker_compose_dir }}" - state: present - pull: always - wait: true - -# ===================================================================== -# 4. CONFIGURE: Wait for app and seed initial admin user -# ===================================================================== - -- name: Wait for BookStack to be ready - ansible.builtin.command: - cmd: docker exec {{ bookstack_service_name }} curl -sf -o /dev/null -w "%{http_code}" http://localhost/login - register: _bookstack_health - retries: 30 - delay: 5 - until: _bookstack_health.stdout == "200" - changed_when: false - -- name: Wait for BookStack migrations to be complete - community.docker.docker_container_exec: - container: "{{ bookstack_service_name }}-db" - argv: - - mariadb - - --protocol=tcp - - -h - - 127.0.0.1 - - -u - - "{{ bookstack_db_user }}" - - "-p{{ bookstack_db_password }}" - - "{{ bookstack_db_name }}" - - -Nse - - "SHOW TABLES LIKE 'users';" - register: _users_table - retries: 30 - delay: 5 - until: _users_table.stdout | trim == 'users' - changed_when: false - no_log: true - -- name: Check whether the initial admin already exists - community.docker.docker_container_exec: - container: "{{ bookstack_service_name }}-db" - argv: - - mariadb - - --protocol=tcp - - -h - - 127.0.0.1 - - -u - - "{{ bookstack_db_user }}" - - "-p{{ bookstack_db_password }}" - - "{{ bookstack_db_name }}" - - -Nse - - "SELECT COUNT(*) FROM users WHERE email = '{{ bookstack_admin_email }}';" - register: _admin_exists - changed_when: false - no_log: true - -- name: Create initial admin user - community.docker.docker_container_exec: - container: "{{ bookstack_service_name }}" - argv: - - php - - "{{ bookstack_artisan_path }}" - - bookstack:create-admin - - "--email={{ bookstack_admin_email }}" - - "--name={{ bookstack_admin_name }}" - - "--password={{ bookstack_admin_password }}" - when: (_admin_exists.stdout | trim | int) == 0 - no_log: true - -# ===================================================================== -# 5. BACKUP: systemd timer for daily DB + uploads dump -# ===================================================================== - -- name: Render backup script - ansible.builtin.template: - src: backup.sh.j2 - dest: /usr/local/bin/bookstack-backup.sh - owner: root - group: root - mode: '0750' - when: bookstack_backup_enabled | bool - -- name: Render backup systemd service - ansible.builtin.template: - src: bookstack-backup.service.j2 - dest: /etc/systemd/system/bookstack-backup.service - mode: '0644' - when: bookstack_backup_enabled | bool - notify: reload systemd - -- name: Render backup systemd timer - ansible.builtin.template: - src: bookstack-backup.timer.j2 - dest: /etc/systemd/system/bookstack-backup.timer - mode: '0644' - when: bookstack_backup_enabled | bool - notify: reload systemd - -- name: Enable and start backup timer - ansible.builtin.systemd: - name: bookstack-backup.timer - enabled: true - state: started - daemon_reload: true - when: bookstack_backup_enabled | bool - -- name: Disable backup timer when feature is off - ansible.builtin.systemd: - name: bookstack-backup.timer - enabled: false - state: stopped - when: not (bookstack_backup_enabled | bool) - failed_when: false diff --git a/roles/bookstack/templates/backup.sh.j2 b/roles/bookstack/templates/backup.sh.j2 deleted file mode 100644 index 65217c2..0000000 --- a/roles/bookstack/templates/backup.sh.j2 +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash -# {{ ansible_managed }} -set -euo pipefail - -BACKUP_DIR="{{ bookstack_backup_dir }}" -RETENTION_DAYS={{ bookstack_backup_retention_days }} -APPDATA_DIR="{{ bookstack_appdata_dir }}" -STAMP="$(date +%Y%m%d-%H%M%S)" - -mkdir -p "$BACKUP_DIR" - -# --- DB dump (mariadb-dump from inside the DB container) --- -# Use the app user via TCP because root@localhost is unix_socket-auth only -# in the LSIO MariaDB image and root@% does not exist. -docker exec {{ bookstack_service_name }}-db \ - mariadb-dump \ - --protocol=tcp -h 127.0.0.1 \ - -u "{{ bookstack_db_user }}" -p"{{ bookstack_db_password }}" \ - --single-transaction --routines --triggers --quick \ - "{{ bookstack_db_name }}" \ - | gzip -9 > "$BACKUP_DIR/bookstack-db-$STAMP.sql.gz" - -# --- File uploads (images, attachments) --- -# LSIO BookStack stores user uploads under /config/www/{uploads,storage/uploads,files}. -tar --warning=no-file-changed \ - -czf "$BACKUP_DIR/bookstack-files-$STAMP.tar.gz" \ - -C "$APPDATA_DIR/www" \ - uploads storage/uploads files 2>/dev/null || true - -# --- APP_KEY backup (critical for restore!) --- -install -m 0600 "{{ bookstack_docker_volume_dir }}/.app_key" \ - "$BACKUP_DIR/bookstack-appkey-$STAMP.txt" - -# --- Retention --- -find "$BACKUP_DIR" -type f \ - \( -name 'bookstack-db-*.sql.gz' \ - -o -name 'bookstack-files-*.tar.gz' \ - -o -name 'bookstack-appkey-*.txt' \) \ - -mtime +"$RETENTION_DAYS" -delete - -echo "Backup complete: $STAMP" \ No newline at end of file diff --git a/roles/bookstack/templates/bookstack-backup.service.j2 b/roles/bookstack/templates/bookstack-backup.service.j2 deleted file mode 100644 index cb63795..0000000 --- a/roles/bookstack/templates/bookstack-backup.service.j2 +++ /dev/null @@ -1,12 +0,0 @@ -# {{ ansible_managed }} -[Unit] -Description=BookStack backup (DB + uploads) -Requires=docker.service -After=docker.service - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/bookstack-backup.sh -Nice=10 -IOSchedulingClass=best-effort -IOSchedulingPriority=7 diff --git a/roles/bookstack/templates/bookstack-backup.timer.j2 b/roles/bookstack/templates/bookstack-backup.timer.j2 deleted file mode 100644 index e13238d..0000000 --- a/roles/bookstack/templates/bookstack-backup.timer.j2 +++ /dev/null @@ -1,11 +0,0 @@ -# {{ ansible_managed }} -[Unit] -Description=Daily BookStack backup - -[Timer] -OnCalendar={{ bookstack_backup_schedule }} -Persistent=true -RandomizedDelaySec=300 - -[Install] -WantedBy=timers.target diff --git a/roles/bookstack/templates/docker-compose.yml.j2 b/roles/bookstack/templates/docker-compose.yml.j2 deleted file mode 100644 index 863e316..0000000 --- a/roles/bookstack/templates/docker-compose.yml.j2 +++ /dev/null @@ -1,87 +0,0 @@ -#---------------------------------------------------------------------# -# BookStack - Self-hosted wiki / knowledge base. # -#---------------------------------------------------------------------# ---- -services: - {{ bookstack_service_name }}: - image: {{ bookstack_image }} - container_name: {{ bookstack_service_name }} - restart: unless-stopped - environment: - PUID: "{{ bookstack_puid }}" - PGID: "{{ bookstack_pgid }}" - TZ: "{{ bookstack_tz }}" - APP_URL: "{{ bookstack_base_url }}" - APP_KEY: "{{ bookstack_resolved_app_key }}" - DB_HOST: "{{ bookstack_service_name }}-db" - DB_PORT: "3306" - DB_DATABASE: "{{ bookstack_db_name }}" - DB_USERNAME: "{{ bookstack_db_user }}" - DB_PASSWORD: "{{ bookstack_db_password }}" - MAIL_DRIVER: "{{ bookstack_mail_driver }}" - MAIL_HOST: "{{ bookstack_mail_host }}" - MAIL_PORT: "{{ bookstack_mail_port }}" - MAIL_USERNAME: "{{ bookstack_mail_username }}" - MAIL_PASSWORD: "{{ bookstack_mail_password }}" - MAIL_ENCRYPTION: "{{ bookstack_mail_encryption }}" - MAIL_FROM: "{{ bookstack_mail_from }}" - MAIL_FROM_NAME: "{{ bookstack_mail_from_name }}" -{% if bookstack_oidc_enabled %} - AUTH_METHOD: "oidc" - AUTH_AUTO_INITIATE: "{{ bookstack_oidc_auto_initiate | string | lower }}" - OIDC_NAME: "{{ bookstack_oidc_name }}" - OIDC_DISPLAY_NAME_CLAIMS: "name" - OIDC_CLIENT_ID: "{{ bookstack_oidc_client_id }}" - OIDC_CLIENT_SECRET: "{{ bookstack_oidc_client_secret }}" - OIDC_ISSUER: "{{ bookstack_oidc_issuer }}" - OIDC_ISSUER_DISCOVER: "true" - OIDC_END_SESSION_ENDPOINT: "true" - OIDC_ADDITIONAL_SCOPES: "{{ bookstack_oidc_additional_scopes }}" - OIDC_USER_TO_GROUPS: "{{ bookstack_oidc_user_to_groups | string | lower }}" - OIDC_GROUPS_CLAIM: "{{ bookstack_oidc_groups_claim }}" -{% endif %} - volumes: - - {{ bookstack_appdata_dir }}:/config - networks: - - {{ bookstack_traefik_network }} - - internal - depends_on: - {{ bookstack_service_name }}-db: - condition: service_healthy - labels: - - "traefik.enable=true" - - "traefik.docker.network={{ bookstack_traefik_network }}" - - "traefik.http.routers.{{ bookstack_service_name }}.rule=Host(`{{ bookstack_domain }}`)" - - "traefik.http.routers.{{ bookstack_service_name }}.entrypoints=websecure" - - "traefik.http.routers.{{ bookstack_service_name }}.tls=true" - - "traefik.http.routers.{{ bookstack_service_name }}.tls.certresolver={{ bookstack_traefik_certresolver }}" - - "traefik.http.services.{{ bookstack_service_name }}.loadbalancer.server.port=80" - - {{ bookstack_service_name }}-db: - image: {{ bookstack_db_image }} - container_name: {{ bookstack_service_name }}-db - restart: unless-stopped - environment: - PUID: "{{ bookstack_puid }}" - PGID: "{{ bookstack_pgid }}" - TZ: "{{ bookstack_tz }}" - MYSQL_ROOT_PASSWORD: "{{ bookstack_db_root_password }}" - MYSQL_DATABASE: "{{ bookstack_db_name }}" - MYSQL_USER: "{{ bookstack_db_user }}" - MYSQL_PASSWORD: "{{ bookstack_db_password }}" - volumes: - - {{ bookstack_db_data_dir }}:/config - networks: - - internal - healthcheck: - test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u root --password=\"$$MYSQL_ROOT_PASSWORD\" --silent"] - interval: 10s - timeout: 5s - retries: 12 - start_period: 60s - -networks: - {{ bookstack_traefik_network }}: - external: true - internal: - driver: bridge diff --git a/roles/bookstack/tests/inventory b/roles/bookstack/tests/inventory deleted file mode 100644 index 2fbb50c..0000000 --- a/roles/bookstack/tests/inventory +++ /dev/null @@ -1 +0,0 @@ -localhost diff --git a/roles/bookstack/tests/test.yml b/roles/bookstack/tests/test.yml deleted file mode 100644 index 15b9be3..0000000 --- a/roles/bookstack/tests/test.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -- hosts: localhost - remote_user: root - roles: - - bookstack diff --git a/roles/bookstack/vars/main.yml b/roles/bookstack/vars/main.yml deleted file mode 100644 index e04b89a..0000000 --- a/roles/bookstack/vars/main.yml +++ /dev/null @@ -1,3 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# vars file for bookstack diff --git a/plugins/filter/homarr_layout.py b/roles/homarr/filter_plugins/homarr_layout.py similarity index 100% rename from plugins/filter/homarr_layout.py rename to roles/homarr/filter_plugins/homarr_layout.py diff --git a/roles/homarr/filter_plugins/tests/test_homarr_layout.py b/roles/homarr/filter_plugins/tests/test_homarr_layout.py index a96d672..3a49f2b 100644 --- a/roles/homarr/filter_plugins/tests/test_homarr_layout.py +++ b/roles/homarr/filter_plugins/tests/test_homarr_layout.py @@ -15,11 +15,7 @@ import sys # Make the filter importable without having Ansible auto-discovery in # the way (it would only run during a real `ansible-playbook` invocation). -sys.path.insert( - 0, - os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', - 'plugins', 'filter') -) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) import pytest # noqa: E402 diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index ffb0bb7..9d00cde 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -136,7 +136,7 @@ - name: Compute Homarr app layouts ansible.builtin.set_fact: - homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" + homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}" - name: Show computed app layouts ansible.builtin.debug: diff --git a/roles/opnform/README.md b/roles/opnform/README.md deleted file mode 100644 index 2dfad2d..0000000 --- a/roles/opnform/README.md +++ /dev/null @@ -1,169 +0,0 @@ -# opnform - -Deploy [OpnForm](https://github.com/OpnForm/OpnForm) as a self-contained -Docker Compose stack behind Traefik. - -## What this role does - -- Deploys the full official OpnForm stack: `api`, `api-worker`, `api-scheduler`, - `ui`, `db` (Postgres), `redis`, and `ingress` (nginx) -- Configures all environment variables for self-hosted production use -- Integrates the ingress container with an existing Traefik proxy network -- Waits for the API container to become healthy before returning - -## What this role does NOT do (stage 1) - -- Does not pre-configure OIDC / identity_connections — set up via Admin UI - -## Architecture note: why two reverse proxies? - -``` -Browser → Traefik (TLS, host routing) → ingress-nginx → api (PHP-FPM) / ui (Nuxt) -``` - -The `ingress` container looks like a redundant proxy next to Traefik but -does a different job. OpnForm's `api` image is **PHP-FPM only** — it -speaks the FastCGI protocol on port 9000, not HTTP. Traefik cannot -translate FastCGI, so the ingress nginx is required to: - -- Translate HTTP `/api/*` requests into FastCGI calls to `api:9000` -- Rewrite request URIs via the `$api_uri` map -- Set Laravel-specific FastCGI params (`SCRIPT_FILENAME`, `REQUEST_URI`) -- Reverse-proxy `/` to the Nuxt UI container on port 3000 - -Both containers run on the same Docker network on the same host, so the -performance overhead of the extra hop is negligible (in-kernel memory -copy, not a real network round-trip). Removing the ingress would require -a custom OpnForm image with a built-in HTTP server, which is out of -scope for this role. - -## Required variables - -Provide via OpenBao, Ansible Vault, or extra-vars. **Never commit real -secrets to version control.** - -| Variable | Format | Generate with | -|---|---|---| -| `opnform_app_key` | `base64:<32 bytes base64>` | `echo "base64:$(openssl rand -base64 32)"` | -| `opnform_jwt_secret` | 32 bytes base64 | `openssl rand -base64 32` | -| `opnform_front_api_secret` | 32 bytes base64 | `openssl rand -base64 32` | -| `opnform_db_password` | strong password | `openssl rand -base64 24` | - -When `opnform_oidc_enabled` is `true`: - -| Variable | Source | -|---|---| -| `opnform_oidc_client_secret` | from your Keycloak/Authentik client | - -The `assert` task at the top of the role will fail fast if any secret is -missing or malformed. - -## First login - -OpnForm in self-hosted mode does **not** ship a pre-seeded admin user. -The first user to register becomes the owner of the default workspace, -and further public registration is disabled afterwards (additional -users must be invited via the Admin UI). - -This role supports two ways to create that first user: - -### Option A — automated bootstrap (recommended) - -Set `opnform_admin_email` and `opnform_admin_password` (ideally from -Vault / OpenBao). The role then POSTs to `/api/register` after the -API container is healthy, skipping the setup page entirely. The task -is idempotent: it does a login check first and only registers if the -user does not already exist. - -```yaml -opnform_admin_name: "Administrator" # default -opnform_admin_email: "admin@example.com" -opnform_admin_password: "{{ vault_opnform_admin_password }}" -``` - -Password rules enforced by OpnForm: minimum 8 characters, at least one -letter, one digit, and one of `@$!%*#?&-_+=.,:;<>^()[]{}|~`. - -### Option B — manual setup page - -Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit -`opnform_base_url` and complete the setup page in the browser. - -## OIDC setup - -Set `opnform_oidc_enabled: true` and the role creates an -IdentityConnection on the admin's default workspace via -`POST /api/open/workspaces/{id}/oidc-connections`. OpnForm enforces a -single OIDC connection per workspace, so the task is idempotent (GETs -existing connections first and skips if any exist). - -**Prerequisite**: the admin bootstrap must be configured -(`opnform_admin_email` + `opnform_admin_password`). The OIDC API -requires an authenticated admin token; the role logs in with those -credentials to make the call. The validation block fails fast if OIDC -is enabled without admin credentials. - -### Required when `opnform_oidc_enabled: true` - -| Variable | Notes | -|---|---| -| `opnform_oidc_client_secret` | from your IdP, never commit | -| `opnform_oidc_domain` | email domain that triggers OIDC (e.g. `example.com`) | - -### Tunables (defaults shown) - -```yaml -opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard" -opnform_oidc_client_id: "opnform-digitalboard" -opnform_oidc_client_name: "Digitalboard" # display name in UI -opnform_oidc_slug: "oidc" # used in /auth/{slug}/callback -opnform_oidc_scopes: [openid, profile, email, groups] -``` - -### Group → role mapping - -Two ways, the list takes precedence: - -```yaml -# Option 1: full list (any number of mappings) -opnform_oidc_group_role_mappings: - - idp_group: "opnform-admins" - role: admin - - idp_group: "opnform-editors" - role: editor - -# Option 2: convenience — single admin group -opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin -``` - -Valid roles: `owner`, `admin`, `editor`, `member`. - -## Example playbook - -```yaml -- name: Deploy OpnForm service - hosts: opnform_servers - become: true - roles: - - digitalboard.core.opnform -``` - -With inventory variables: - -```yaml -# group_vars/opnform_servers.yml -opnform_domain: forms.digitalboard.ch -opnform_base_url: "https://forms.digitalboard.ch" -opnform_app_key: "{{ lookup('community.hashi_vault.vault_kv2_get', - 'digitalboard/opnform', - mount_point='kv').data.data.app_key }}" -opnform_jwt_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', - 'digitalboard/opnform', - mount_point='kv').data.data.jwt_secret }}" -opnform_front_api_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', - 'digitalboard/opnform', - mount_point='kv').data.data.front_api_secret }}" -opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', - 'digitalboard/opnform', - mount_point='kv').data.data.db_password }}" -``` diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml deleted file mode 100644 index 0f61c3a..0000000 --- a/roles/opnform/defaults/main.yml +++ /dev/null @@ -1,109 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# defaults file for opnform - -# Base directory configuration (inherited from base role or defined here) -docker_compose_base_dir: /etc/docker/compose -docker_volume_base_dir: /srv/data - -# opnform-specific configuration -opnform_service_name: opnform -opnform_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ opnform_service_name }}" -opnform_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ opnform_service_name }}" -opnform_storage_dir: "{{ opnform_docker_volume_dir }}/storage" -opnform_db_data_dir: "{{ opnform_docker_volume_dir }}/db" -opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis" - -# Service configuration -opnform_domain: "forms.local.test" -opnform_base_url: "https://forms.local.test" - -# Images -opnform_api_image: "jhumanj/opnform-api:latest" -opnform_client_image: "jhumanj/opnform-client:latest" -opnform_redis_image: "redis:7" -opnform_db_image: "postgres:16" -opnform_ingress_image: "nginx:1" - -# REQUIRED SECRETS — must be overridden per-inventory. -# Provide via OpenBao lookup, Ansible Vault or extra-vars. -# Never commit real keys to version control. -# -# Generate with: -# opnform_app_key: echo "base64:$(openssl rand -base64 32)" -# opnform_jwt_secret: openssl rand -hex 32 -# opnform_front_api_secret: openssl rand -hex 32 -# -# opnform_app_key MUST start with the prefix "base64:" — the validate -# task at the top of tasks/main.yml enforces this. -opnform_app_key: "" -opnform_jwt_secret: "" -opnform_front_api_secret: "" - -# Database credentials. opnform_db_password must be overridden; the -# validate task fails fast on an empty value. -opnform_db_name: "opnform" -opnform_db_user: "opnform" -opnform_db_password: "" - -# Admin bootstrap — when email+password are set, the role creates the -# first user via OpnForm's /api/register endpoint, skipping the -# self-hosted setup page. Leave both empty to keep the manual setup flow. -# Password must satisfy OpnForm's rules: min 8 chars, contain a letter, -# a digit and one of @$!%*#?&-_+=.,:;<>^()[]{}|~ -# Provide via OpenBao, Ansible Vault or extra-vars. -opnform_admin_name: "Administrator" -opnform_admin_email: "" -opnform_admin_password: "" -opnform_admin_hear_about_us: "ansible" - -# PHP configuration -opnform_php_memory_limit: "1G" -opnform_php_max_execution_time: "600" -opnform_php_upload_max_filesize: "64M" -opnform_php_post_max_size: "64M" - -# Nginx ingress -opnform_nginx_max_body_size: "64m" - -# Mail configuration (optional — defaults to log driver) -opnform_mail_mailer: "log" -opnform_mail_host: "" -opnform_mail_port: "" -opnform_mail_username: "" -opnform_mail_password: "" -opnform_mail_encryption: "" -opnform_mail_from_address: "noreply@digitalboard.ch" -opnform_mail_from_name: "OpnForm" - -# OIDC configuration — when enabled, the role auto-creates an -# IdentityConnection in the first workspace via OpnForm's API after the -# admin bootstrap. Requires opnform_admin_email/_password to be set -# (the API call needs an authenticated admin token). -opnform_oidc_enabled: false -opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard" -opnform_oidc_client_id: "opnform-digitalboard" -opnform_oidc_client_secret: "" -opnform_oidc_client_name: "Digitalboard" -# OpnForm-side identifier used in /auth/{slug}/callback. Lowercase -# alphanumeric + hyphens, unique across all identity_connections. -opnform_oidc_slug: "oidc" -# Email domain that triggers OIDC login for matching users (e.g. users -# with @example.com emails are redirected to the IdP). Required when -# opnform_oidc_enabled is true. -opnform_oidc_domain: "" -opnform_oidc_scopes: - - openid - - profile - - email - - groups -# Convenience: maps a single IdP group to the OpnForm "admin" role. -# Ignored when opnform_oidc_group_role_mappings is non-empty. -opnform_oidc_admin_group: "opnform-admins" -# Full group-to-role mapping list. Takes precedence over the convenience -# var. Each item: {idp_group: "", role: "owner|admin|editor|member"} -opnform_oidc_group_role_mappings: [] - -# Traefik configuration -opnform_traefik_network: "proxy" -opnform_use_ssl: true diff --git a/roles/opnform/handlers/main.yml b/roles/opnform/handlers/main.yml deleted file mode 100644 index 1c0b422..0000000 --- a/roles/opnform/handlers/main.yml +++ /dev/null @@ -1,8 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# handlers file for opnform - -- name: restart opnform - community.docker.docker_compose_v2: - project_src: "{{ opnform_docker_compose_dir }}" - state: restarted diff --git a/roles/opnform/meta/argument_specs.yml b/roles/opnform/meta/argument_specs.yml deleted file mode 100644 index 9fbfc7a..0000000 --- a/roles/opnform/meta/argument_specs.yml +++ /dev/null @@ -1,220 +0,0 @@ ---- -argument_specs: - main: - short_description: Deploy OpnForm (api + ui + db + redis + ingress) via Docker Compose. - description: - - Renders a Compose stack for the full OpnForm setup (PHP-FPM api, - Nuxt ui, Postgres, Redis, nginx ingress) and exposes it through - Traefik. - - Optionally bootstraps the first admin user via the OpnForm - C(/api/register) endpoint (skipping the self-hosted setup page) - and provisions a single OIDC identity connection in the default - workspace via the workspace API. Both bootstraps are idempotent. - options: - docker_compose_base_dir: - type: path - default: /etc/docker/compose - docker_volume_base_dir: - type: path - default: /srv/data - opnform_service_name: - type: str - default: opnform - opnform_docker_compose_dir: - type: path - description: Defaults to C({{ docker_compose_base_dir }}/{{ opnform_service_name }}). - opnform_docker_volume_dir: - type: path - description: Defaults to C({{ docker_volume_base_dir }}/{{ opnform_service_name }}). - opnform_storage_dir: - type: path - description: OpnForm storage volume mounted into the api container. - opnform_db_data_dir: - type: path - opnform_redis_data_dir: - type: path - - opnform_domain: - type: str - default: forms.local.test - description: Hostname used in the traefik Host rule. - opnform_base_url: - type: str - default: https://forms.local.test - description: Public URL OpnForm uses for APP_URL and NUXT_PUBLIC_APP_URL. - - opnform_api_image: - type: str - default: jhumanj/opnform-api:latest - opnform_client_image: - type: str - default: jhumanj/opnform-client:latest - opnform_redis_image: - type: str - default: "redis:7" - opnform_db_image: - type: str - default: "postgres:16" - opnform_ingress_image: - type: str - default: "nginx:1" - - opnform_app_key: - type: str - required: true - description: - - Laravel application key. Must be prefixed with C(base64:). - Generate with C(echo "base64:$(openssl rand -base64 32)"). - Provide via OpenBao, Ansible Vault or extra-vars. - opnform_jwt_secret: - type: str - required: true - description: JWT signing secret. Generate with C(openssl rand -hex 32). - opnform_front_api_secret: - type: str - required: true - description: Shared secret between ui and api. Generate with C(openssl rand -hex 32). - - opnform_db_name: - type: str - default: opnform - opnform_db_user: - type: str - default: opnform - opnform_db_password: - type: str - required: true - - opnform_admin_name: - type: str - default: Administrator - opnform_admin_email: - type: str - default: '' - description: - - When non-empty (together with C(opnform_admin_password)) the role - bootstraps the first user via C(/api/register), skipping the - self-hosted setup page. Required when C(opnform_oidc_enabled=true). - opnform_admin_password: - type: str - default: '' - description: - - "Must satisfy OpnForm's policy: min 8 chars, letter + digit + - symbol from C(@$!%*#?&-_+=.,:;<>^()[]{}|~)." - opnform_admin_hear_about_us: - type: str - default: ansible - - opnform_php_memory_limit: - type: str - default: 1G - opnform_php_max_execution_time: - type: str - default: "600" - opnform_php_upload_max_filesize: - type: str - default: 64M - opnform_php_post_max_size: - type: str - default: 64M - opnform_nginx_max_body_size: - type: str - default: 64m - - opnform_mail_mailer: - type: str - default: log - choices: [log, smtp, ses, mailgun, postmark, sendmail] - opnform_mail_host: - type: str - default: '' - opnform_mail_port: - type: str - default: '' - opnform_mail_username: - type: str - default: '' - opnform_mail_password: - type: str - default: '' - opnform_mail_encryption: - type: str - default: '' - choices: ['', tls, ssl] - opnform_mail_from_address: - type: str - default: noreply@digitalboard.ch - opnform_mail_from_name: - type: str - default: OpnForm - - opnform_oidc_enabled: - type: bool - default: false - description: - - "When true the role calls the workspace API to create a single - OIDC C(identity_connection) on the default workspace after the - admin bootstrap. Requires C(opnform_admin_email) + - C(opnform_admin_password) so the role can authenticate. - Idempotent: skipped when any connection already exists." - opnform_oidc_issuer: - type: str - default: https://auth.digitalboard.ch/realms/Digitalboard - description: OIDC issuer URL. - opnform_oidc_client_id: - type: str - default: opnform-digitalboard - opnform_oidc_client_secret: - type: str - default: '' - description: Required when C(opnform_oidc_enabled=true). - opnform_oidc_client_name: - type: str - default: Digitalboard - description: Display name shown in the OpnForm UI. - opnform_oidc_slug: - type: str - default: oidc - description: - - OpnForm-side identifier used in C(/auth/{slug}/callback). Lowercase - alphanumeric + hyphens, unique across all C(identity_connections). - opnform_oidc_domain: - type: str - default: '' - description: - - Email domain that triggers OIDC for matching users. Required - when C(opnform_oidc_enabled=true). - opnform_oidc_scopes: - type: list - elements: str - default: [openid, profile, email, groups] - opnform_oidc_admin_group: - type: str - default: opnform-admins - description: - - Convenience setting that maps a single IdP group to the OpnForm - C(admin) role. Ignored when C(opnform_oidc_group_role_mappings) - is non-empty. - opnform_oidc_group_role_mappings: - type: list - elements: dict - default: [] - description: - - Full IdP-group -> OpnForm-role mapping. Takes precedence over - C(opnform_oidc_admin_group). - options: - idp_group: - type: str - required: true - description: Group name as it appears in the IdP groups claim. - role: - type: str - required: true - choices: [owner, admin, editor, member] - - opnform_traefik_network: - type: str - default: proxy - opnform_use_ssl: - type: bool - default: true diff --git a/roles/opnform/meta/main.yml b/roles/opnform/meta/main.yml deleted file mode 100644 index 8a56a7b..0000000 --- a/roles/opnform/meta/main.yml +++ /dev/null @@ -1,16 +0,0 @@ -#SPDX-License-Identifier: MIT-0 -galaxy_info: - author: Tobias Wüst - description: Deploy OpnForm self-hosted form builder via Docker Compose behind Traefik - company: Digitalboard - license: MIT-0 - min_ansible_version: "2.15" - - galaxy_tags: - - opnform - - forms - - docker - - traefik - - oidc - -dependencies: [] diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml deleted file mode 100644 index 68e093b..0000000 --- a/roles/opnform/tasks/main.yml +++ /dev/null @@ -1,265 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# tasks file for opnform - -# ===================================================================== -# 0. VALIDATION -# ===================================================================== - -- name: Validate required secrets - ansible.builtin.assert: - that: - - opnform_app_key | length > 0 - - opnform_app_key is match('^base64:[A-Za-z0-9+/=]+$') - - opnform_jwt_secret | length > 0 - - opnform_front_api_secret | length > 0 - - opnform_db_password | length > 0 - fail_msg: >- - OpnForm requires opnform_app_key (prefix 'base64:'), opnform_jwt_secret, - opnform_front_api_secret and opnform_db_password. - Generate with: openssl rand -base64 32 - The app_key MUST be prefixed with "base64:" - Provide via OpenBao, Ansible Vault or extra-vars. - success_msg: Secrets validation passed - -- name: Validate OIDC configuration when enabled - ansible.builtin.assert: - that: - - opnform_oidc_client_secret | length > 0 - - opnform_oidc_domain | length > 0 - - opnform_admin_email | length > 0 - - opnform_admin_password | length > 0 - fail_msg: >- - When opnform_oidc_enabled is true, you must set: - - opnform_oidc_client_secret - - opnform_oidc_domain (email domain that triggers OIDC) - - opnform_admin_email / opnform_admin_password - (the OIDC API requires an authenticated admin; the role logs in - with these credentials to POST the connection) - when: opnform_oidc_enabled | bool - -# ===================================================================== -# 1. PREPARATION -# ===================================================================== - -- name: Ensure required packages are installed - ansible.builtin.package: - name: - - python3-docker - state: present - -- name: Create docker compose directory - ansible.builtin.file: - path: "{{ opnform_docker_compose_dir }}" - state: directory - mode: '0755' - -- name: Create OpnForm data directories - ansible.builtin.file: - path: "{{ item }}" - state: directory - mode: "0755" - loop: - - "{{ opnform_docker_volume_dir }}" - - "{{ opnform_storage_dir }}" - - "{{ opnform_db_data_dir }}" - - "{{ opnform_redis_data_dir }}" - -# ===================================================================== -# 2. CONFIGURATION FILES -# ===================================================================== - -- name: Deploy nginx ingress configuration - ansible.builtin.template: - src: nginx.conf.j2 - dest: "{{ opnform_docker_compose_dir }}/nginx.conf" - mode: '0644' - notify: restart opnform - -- name: Deploy docker-compose file - ansible.builtin.template: - src: docker-compose.yml.j2 - dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" - mode: '0644' - notify: restart opnform - -# ===================================================================== -# 3. CONTAINER STARTUP -# ===================================================================== - -- name: Start opnform containers - community.docker.docker_compose_v2: - project_src: "{{ opnform_docker_compose_dir }}" - state: present - wait: true - wait_timeout: 180 - -# ===================================================================== -# 4. WAIT FOR API READINESS -# ===================================================================== - -- name: Wait for API container to be healthy - ansible.builtin.command: - cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api - register: api_health - until: api_health.stdout == "healthy" - retries: 30 - delay: 10 - changed_when: false - -# ===================================================================== -# 5. ADMIN BOOTSTRAP (optional) -# ===================================================================== -# Skips the self-hosted setup page by registering the first user via -# OpnForm's /api/register endpoint. Idempotent: a successful login -# attempt with the same credentials means the user already exists. - -- name: Check if OpnForm admin user already exists - ansible.builtin.uri: - url: "https://127.0.0.1/api/login" - method: POST - headers: - Host: "{{ opnform_domain }}" - body_format: json - body: - email: "{{ opnform_admin_email }}" - password: "{{ opnform_admin_password }}" - status_code: [200, 401, 422] - validate_certs: false - register: opnform_admin_login - when: - - opnform_admin_email | length > 0 - - opnform_admin_password | length > 0 - -- name: Create OpnForm admin user via /api/register - ansible.builtin.uri: - url: "https://127.0.0.1/api/register" - method: POST - headers: - Host: "{{ opnform_domain }}" - body_format: json - body: - name: "{{ opnform_admin_name }}" - email: "{{ opnform_admin_email }}" - password: "{{ opnform_admin_password }}" - password_confirmation: "{{ opnform_admin_password }}" - hear_about_us: "{{ opnform_admin_hear_about_us }}" - status_code: [200, 201] - validate_certs: false - no_log: true - when: - - opnform_admin_email | length > 0 - - opnform_admin_password | length > 0 - - opnform_admin_login.status != 200 - -# ===================================================================== -# 6. OIDC IDENTITY CONNECTION (optional) -# ===================================================================== -# Creates a single OIDC connection on the admin's default workspace. -# OpnForm enforces one OIDC connection per workspace, so this block is -# idempotent: we GET existing connections first and skip if any exists. - -- name: Log in as admin to obtain OIDC API token - ansible.builtin.uri: - url: "https://127.0.0.1/api/login" - method: POST - headers: - Host: "{{ opnform_domain }}" - body_format: json - body: - email: "{{ opnform_admin_email }}" - password: "{{ opnform_admin_password }}" - status_code: 200 - validate_certs: false - register: opnform_oidc_token - no_log: true - when: opnform_oidc_enabled | bool - -- name: Fetch admin's workspaces - ansible.builtin.uri: - url: "https://127.0.0.1/api/open/workspaces" - method: GET - headers: - Host: "{{ opnform_domain }}" - Authorization: "Bearer {{ opnform_oidc_token.json.token }}" - status_code: 200 - validate_certs: false - register: opnform_workspaces - no_log: true - when: opnform_oidc_enabled | bool - -- name: Fetch existing OIDC connections for the default workspace - ansible.builtin.uri: - url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" - method: GET - headers: - Host: "{{ opnform_domain }}" - Authorization: "Bearer {{ opnform_oidc_token.json.token }}" - status_code: 200 - validate_certs: false - register: opnform_existing_oidc - no_log: true - when: opnform_oidc_enabled | bool - -- name: Resolve OIDC group-role mappings - ansible.builtin.set_fact: - _opnform_oidc_group_role_mappings: >- - {{ - opnform_oidc_group_role_mappings - if (opnform_oidc_group_role_mappings | length > 0) - else - ([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}] - if (opnform_oidc_admin_group | length > 0) else []) - }} - when: opnform_oidc_enabled | bool - -- name: Create OIDC identity connection - ansible.builtin.uri: - url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" - method: POST - headers: - Host: "{{ opnform_domain }}" - Authorization: "Bearer {{ opnform_oidc_token.json.token }}" - body_format: json - body: - name: "{{ opnform_oidc_client_name }}" - slug: "{{ opnform_oidc_slug }}" - domain: "{{ opnform_oidc_domain }}" - issuer: "{{ opnform_oidc_issuer }}" - client_id: "{{ opnform_oidc_client_id }}" - client_secret: "{{ opnform_oidc_client_secret }}" - scopes: "{{ opnform_oidc_scopes }}" - enabled: true - options: - require_state: true - group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}" - status_code: [201] - validate_certs: false - no_log: true - when: - - opnform_oidc_enabled | bool - - opnform_existing_oidc.json | length == 0 - -- name: Display deployment info - ansible.builtin.debug: - msg: |- - OpnForm deployed at {{ opnform_base_url }} - - {% if opnform_admin_email | length > 0 %} - Admin user bootstrapped: - Email: {{ opnform_admin_email }} - Password: (from opnform_admin_password) - {% else %} - No admin bootstrap configured — visit {{ opnform_base_url }} and - complete the self-hosted setup page to create the first user. - Set opnform_admin_email + opnform_admin_password to automate this. - {% endif %} - - {% if opnform_oidc_enabled %} - OIDC: connection "{{ opnform_oidc_client_name }}" bootstrapped - (slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }}) - Users with @{{ opnform_oidc_domain }} addresses will be - redirected to {{ opnform_oidc_issuer }} on login. - {% else %} - OIDC: disabled (set opnform_oidc_enabled=true to auto-configure) - {% endif %} diff --git a/roles/opnform/templates/docker-compose.yml.j2 b/roles/opnform/templates/docker-compose.yml.j2 deleted file mode 100644 index de88a33..0000000 --- a/roles/opnform/templates/docker-compose.yml.j2 +++ /dev/null @@ -1,189 +0,0 @@ -#---------------------------------------------------------------------# -# OpnForm — Beautiful open-source form builder # -#---------------------------------------------------------------------# -services: - api: &api-service - image: {{ opnform_api_image }} - container_name: opnform-api - restart: unless-stopped - volumes: - - {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw - environment: &api-env - APP_ENV: production - APP_KEY: "{{ opnform_app_key }}" - APP_URL: "{{ opnform_base_url }}" - APP_DEBUG: "false" - SELF_HOSTED: "true" - - LOG_CHANNEL: errorlog - LOG_LEVEL: info - - DB_CONNECTION: pgsql - DB_HOST: db - DB_PORT: "5432" - DB_DATABASE: "{{ opnform_db_name }}" - DB_USERNAME: "{{ opnform_db_user }}" - DB_PASSWORD: "{{ opnform_db_password }}" - - REDIS_HOST: redis - REDIS_PORT: "6379" - - CACHE_STORE: redis - CACHE_DRIVER: redis - QUEUE_CONNECTION: redis - SESSION_DRIVER: redis - SESSION_LIFETIME: "120" - BROADCAST_CONNECTION: log - - FILESYSTEM_DISK: local - FILESYSTEM_DRIVER: local - LOCAL_FILESYSTEM_VISIBILITY: public - - MAIL_MAILER: "{{ opnform_mail_mailer }}" - MAIL_HOST: "{{ opnform_mail_host }}" - MAIL_PORT: "{{ opnform_mail_port }}" - MAIL_USERNAME: "{{ opnform_mail_username }}" - MAIL_PASSWORD: "{{ opnform_mail_password }}" - MAIL_ENCRYPTION: "{{ opnform_mail_encryption }}" - MAIL_FROM_ADDRESS: "{{ opnform_mail_from_address }}" - MAIL_FROM_NAME: "{{ opnform_mail_from_name }}" - - JWT_TTL: "1440" - JWT_SECRET: "{{ opnform_jwt_secret }}" - - PHP_MEMORY_LIMIT: "{{ opnform_php_memory_limit }}" - PHP_MAX_EXECUTION_TIME: "{{ opnform_php_max_execution_time }}" - PHP_UPLOAD_MAX_FILESIZE: "{{ opnform_php_upload_max_filesize }}" - PHP_POST_MAX_SIZE: "{{ opnform_php_post_max_size }}" - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"] - interval: 30s - timeout: 15s - retries: 3 - start_period: 60s - networks: - - opnform-internal - - api-worker: - <<: *api-service - container_name: opnform-api-worker - command: ["php", "artisan", "queue:work"] - environment: - <<: *api-env - IS_API_WORKER: "true" - healthcheck: - test: ["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"] - interval: 60s - timeout: 10s - retries: 3 - start_period: 30s - - api-scheduler: - <<: *api-service - container_name: opnform-api-scheduler - command: ["php", "artisan", "schedule:work"] - healthcheck: - test: - - "CMD-SHELL" - - "php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1" - interval: 60s - timeout: 30s - retries: 3 - start_period: 70s - - ui: - image: {{ opnform_client_image }} - container_name: opnform-ui - restart: unless-stopped - environment: - NUXT_PUBLIC_APP_URL: "{{ opnform_base_url }}" - NUXT_PUBLIC_API_BASE: "/api" - NUXT_PRIVATE_API_BASE: "http://ingress/api" - NUXT_PUBLIC_ENV: production - FRONT_API_SECRET: "{{ opnform_front_api_secret }}" - depends_on: - api: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 45s - networks: - - opnform-internal - - redis: - image: {{ opnform_redis_image }} - container_name: opnform-redis - restart: unless-stopped - volumes: - - {{ opnform_redis_data_dir }}:/data - healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] - interval: 30s - timeout: 5s - networks: - - opnform-internal - - db: - image: {{ opnform_db_image }} - container_name: opnform-db - restart: unless-stopped - environment: - POSTGRES_DB: "{{ opnform_db_name }}" - POSTGRES_USER: "{{ opnform_db_user }}" - POSTGRES_PASSWORD: "{{ opnform_db_password }}" - volumes: - - {{ opnform_db_data_dir }}:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U {{ opnform_db_user }}"] - interval: 30s - timeout: 5s - networks: - - opnform-internal - - ingress: - image: {{ opnform_ingress_image }} - container_name: opnform-ingress - restart: unless-stopped - volumes: - - ./nginx.conf:/etc/nginx/templates/default.conf.template:ro - environment: - NGINX_MAX_BODY_SIZE: "{{ opnform_nginx_max_body_size }}" - depends_on: - api: - condition: service_started - ui: - condition: service_started - healthcheck: - test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s - networks: - - opnform-internal - - {{ opnform_traefik_network }} - labels: - - traefik.enable=true - - traefik.docker.network={{ opnform_traefik_network }} - - traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`) -{% if opnform_use_ssl %} - - traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure - - traefik.http.routers.{{ opnform_service_name }}.tls=true -{% else %} - - traefik.http.routers.{{ opnform_service_name }}.entrypoints=web -{% endif %} - - traefik.http.services.{{ opnform_service_name }}.loadbalancer.server.port=80 - -networks: - opnform-internal: - driver: bridge - {{ opnform_traefik_network }}: - external: true diff --git a/roles/opnform/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 deleted file mode 100644 index fa3193b..0000000 --- a/roles/opnform/templates/nginx.conf.j2 +++ /dev/null @@ -1,43 +0,0 @@ -map $original_uri $api_uri { - ~^/api(/.*$) $1; - default $original_uri; -} - -server { - listen 80; - server_name {{ opnform_domain }}; - root /app/public; - - client_max_body_size {% raw %}${NGINX_MAX_BODY_SIZE}{% endraw %}; - - access_log /dev/stdout; - error_log /dev/stderr error; - - index index.html index.htm index.php; - - location / { - proxy_http_version 1.1; - proxy_pass http://ui:3000; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "Upgrade"; - } - - location ~/(api|open|local\/temp|forms\/assets)/ { - set $original_uri $uri; - try_files $uri $uri/ /index.php$is_args$args; - } - - location ~ \.php$ { - fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass api:9000; - fastcgi_index index.php; - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php; - fastcgi_param REQUEST_URI $api_uri; - } -} diff --git a/roles/opnform/tests/inventory b/roles/opnform/tests/inventory deleted file mode 100644 index 712db59..0000000 --- a/roles/opnform/tests/inventory +++ /dev/null @@ -1,2 +0,0 @@ -#SPDX-License-Identifier: MIT-0 -localhost diff --git a/roles/opnform/tests/test.yml b/roles/opnform/tests/test.yml deleted file mode 100644 index 3ff9caa..0000000 --- a/roles/opnform/tests/test.yml +++ /dev/null @@ -1,6 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -- hosts: localhost - remote_user: root - roles: - - opnform \ No newline at end of file diff --git a/roles/opnform/vars/main.yml b/roles/opnform/vars/main.yml deleted file mode 100644 index 94900f8..0000000 --- a/roles/opnform/vars/main.yml +++ /dev/null @@ -1,3 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# vars file for opnform \ No newline at end of file