feat(bookstack): add role for self-hosted BookStack deployment

Deploy BookStack with linuxserver.io images behind Traefik, including
Entra ID OIDC SSO support and a daily backup timer.

Stack:
- lscr.io/linuxserver/bookstack:version-v26.03.3
- lscr.io/linuxserver/mariadb:11.4.9
- Traefik labels for websecure entrypoint on internal network
- Healthcheck via mariadb-admin ping (LSIO image lacks healthcheck.sh)

Features:
- Persistent APP_KEY generated on first run, stored in volume dir
- Optional OIDC SSO via Microsoft Entra ID (configurable per-instance)
- Idempotent admin user creation with DB-based existence check
- Daily systemd timer backup (DB dump + uploads tar + APP_KEY)
  with configurable retention

Implementation notes:
- DB queries use --protocol=tcp with the app user because root@localhost
  uses unix_socket auth in the LSIO MariaDB image (no password) and
  root@% does not exist
- docker_container_exec uses argv: (list) instead of command: (string)
  to avoid argument-splitting issues
- Migration-wait task ensures users table exists before admin check,
  since /login returns 200 before Laravel migrations complete
- no_log: true on all tasks that reference DB or admin passwords
- artisan absolute path (/app/www/artisan) because LSIO image WORKDIR
  is not the app directory

Adds bookstack route to DMZ Traefik service registry.
This commit is contained in:
Tobias Wüst 2026-05-20 17:39:16 +02:00
parent 78095cca1d
commit 9d539d0da4
16 changed files with 659 additions and 2 deletions

145
roles/bookstack/README.md Normal file
View file

@ -0,0 +1,145 @@
# 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://<bookstack_domain>/oidc/callback`
3. Front-channel logout URL: `https://<bookstack_domain>/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-<stamp>.sql.gz` — mariadb-dump
- `bookstack-files-<stamp>.tar.gz` — uploads, attachments
- `bookstack-appkey-<stamp>.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-<stamp>.sql.gz \
| docker exec -i bookstack-db \
mariadb -u root -p"<root-pw>" bookstack
```
4. Extract the files: `tar -xzf bookstack-files-<stamp>.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://<domain>/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

View file

@ -0,0 +1,80 @@
#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.
bookstack_db_root_password: "txwmMJD9xTNz3Y73fPWSMPZTR2fEpfF5"
bookstack_db_password: "DgLYFudJg324yLydLxS3vmgux9LQL9bb"
bookstack_admin_password: "NE7TN7cTjCnLHJ2Y4xfiTp"
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"

View file

@ -0,0 +1,19 @@
#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

View file

@ -0,0 +1,25 @@
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: []

View file

@ -0,0 +1,223 @@
#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

View file

@ -0,0 +1,41 @@
#!/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"

View file

@ -0,0 +1,12 @@
# {{ 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

View file

@ -0,0 +1,11 @@
# {{ ansible_managed }}
[Unit]
Description=Daily BookStack backup
[Timer]
OnCalendar={{ bookstack_backup_schedule }}
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,87 @@
#---------------------------------------------------------------------#
# 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

View file

@ -0,0 +1 @@
localhost

View file

@ -0,0 +1,5 @@
---
- hosts: localhost
remote_user: root
roles:
- bookstack

View file

@ -0,0 +1,3 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for bookstack

View file

@ -1,140 +0,0 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT-0
"""Custom Ansible filter plugin for computing Homarr grid layouts.
The Homarr SQL seed needs item_layout rows for three breakpoints
(desktop / tablet / mobile). Rather than embedding the packing
algorithm in Jinja with namespace gymnastics, this filter does the
computation in Python and hands the seed template a ready-to-render
data structure.
Usage in tasks:
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
The result is a dict with two keys:
apps original homarr_apps in order, each enriched with
'desktop', 'tablet', 'mobile' sub-dicts of
{'x', 'y', 'w', 'h'} ready for SQL templating.
section_height dict with 'desktop', 'tablet', 'mobile' keys
giving the minimum height (in grid cells) the
parent section must have to fit all tiles.
"""
from ansible.errors import AnsibleFilterError
def _pack(apps, cols):
"""Greedy left-to-right packing into a fixed-column grid.
Width values larger than the grid are clamped to the grid width
rather than overflowing so a tile declared with width=8 still
renders on the 6-column tablet grid (as a full-width tile) and on
the 2-column mobile grid (as a full-width tile) without breaking
the layout.
Returns (placements, total_height) where placements is a list of
{'id', 'x', 'y', 'w', 'h'} dicts, one per input app, in the same
order. total_height is the y-coordinate of the bottom of the last
occupied row (i.e. max(y + h) across placements).
"""
x = 0
y = 0
row_h = 0
max_y = 0
placements = []
for app in apps:
w = min(int(app.get('width', 1)), cols)
h = int(app.get('height', 1))
# Wrap to the next row when the tile would overflow the grid.
if x + w > cols:
x = 0
y += row_h
row_h = 0
placements.append({
'id': app['id'],
'x': x,
'y': y,
'w': w,
'h': h,
})
x += w
if h > row_h:
row_h = h
if y + h > max_y:
max_y = y + h
return placements, max_y
def homarr_compute_layouts(apps, desktop_cols=10, tablet_cols=6,
mobile_cols=2):
"""Compute responsive layouts for a list of Homarr apps.
Input validation is intentionally strict a malformed apps list
should fail the play with a clear message rather than produce a
broken SQL seed and a silently misconfigured dashboard.
Note: uniqueness of app ids is NOT checked here. The role's
`Validate homarr_apps have unique ids` assert task runs earlier
and is the single source of truth for that check.
"""
if not isinstance(apps, list):
raise AnsibleFilterError(
"homarr_compute_layouts: expected a list of apps, "
"got {0}".format(type(apps).__name__)
)
for index, app in enumerate(apps):
if not isinstance(app, dict):
raise AnsibleFilterError(
"homarr_compute_layouts: app at index {0} is not a "
"dict (got {1})".format(index, type(app).__name__)
)
for required in ('id', 'width'):
if required not in app:
raise AnsibleFilterError(
"homarr_compute_layouts: app at index {0} is "
"missing required key '{1}'".format(index, required)
)
desktop, h_desktop = _pack(apps, desktop_cols)
tablet, h_tablet = _pack(apps, tablet_cols)
mobile, h_mobile = _pack(apps, mobile_cols)
enriched = []
for src, d, t, m in zip(apps, desktop, tablet, mobile):
enriched.append({
**src,
'desktop': {'x': d['x'], 'y': d['y'], 'w': d['w'], 'h': d['h']},
'tablet': {'x': t['x'], 'y': t['y'], 'w': t['w'], 'h': t['h']},
'mobile': {'x': m['x'], 'y': m['y'], 'w': m['w'], 'h': m['h']},
})
# Floor section height at 1 so the section stays visible even
# when homarr_apps is empty.
return {
'apps': enriched,
'section_height': {
'desktop': max(h_desktop, 1),
'tablet': max(h_tablet, 1),
'mobile': max(h_mobile, 1),
},
}
class FilterModule(object):
"""Ansible filter plugin entry point."""
def filters(self):
return {
'homarr_compute_layouts': homarr_compute_layouts,
}

View file

@ -15,7 +15,11 @@ 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__), '..'))
sys.path.insert(
0,
os.path.join(os.path.dirname(__file__), '..', '..', '..', '..',
'plugins', 'filter')
)
import pytest # noqa: E402

View file

@ -136,7 +136,7 @@
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}"
- name: Show computed app layouts
ansible.builtin.debug: