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 committed by Simon Bärlocher
parent 611964f7d6
commit 4fe9d6b177
No known key found for this signature in database
GPG key ID: 63DE20495932047A
16 changed files with 664 additions and 2 deletions

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: