Compare commits

..

5 commits

Author SHA1 Message Date
a6f301ee54 WIP on OpnForm 2026-05-20 12:48:48 +02:00
6de9c031c7
fix: (homarr) removed mistakenly added variable 2026-05-19 11:26:18 +02:00
2341815daf feat(opnform)!: add admin and OIDC bootstrap, rename role to lowercase
Rename roles/OpnForm → roles/opnform so the role resolves as
  digitalboard.core.opnform (Ansible collection convention is
  lowercase). Update tests/test.yml reference accordingly.

  Add automated admin user creation via POST /api/register, gated on
  opnform_admin_email + opnform_admin_password. Idempotent through a
  prior login probe. Without these vars the manual setup page flow is
  preserved.

  Add automated OIDC IdentityConnection setup via the per-workspace
  /api/open/workspaces/{id}/oidc-connections endpoint, gated on
  opnform_oidc_enabled. Hard-coupled to the admin bootstrap (the API
  requires an authenticated admin token); validation block fails fast
  if OIDC is enabled without admin credentials. Supports both an
  explicit opnform_oidc_group_role_mappings list and a fallback
  opnform_oidc_admin_group convenience var.

  Convert opnform_oidc_scopes from space-separated string to YAML list
  to match OpnForm's API expectation. Rewrite README "First login" and
  "OIDC setup" sections to reflect that self-hosted OpnForm does not
  ship a pre-seeded admin and to document the new bootstrap paths.
  BREAKING CHANGE: opnform_oidc_scopes changed from space-separated
  string to YAML list. Inventories that override it must update from
  "openid profile email" to [openid, profile, email].
2026-05-18 22:40:19 +02:00
3f90843f97 fix: added pycache to gitignore 2026-05-18 21:00:20 +02:00
6c1c40668d
chore: add new role for OpnForm 2026-05-13 17:23:34 +02:00
11 changed files with 230 additions and 653 deletions

2
.gitignore vendored
View file

@ -1,5 +1,3 @@
/.idea/
__pycache__/
*.pyc
plugins/lookup/__pycache__/

View file

@ -13,7 +13,7 @@ and customizable application tiles.
- a local admin user with bcrypt-hashed password
- OIDC and credentials admin groups with full permissions
- application tiles defined in `homarr_apps`, auto-laid-out across all
three screen sizes via the bundled `homarr_compute_layouts` filter
three screen sizes
- Skips the onboarding wizard so the instance is usable right after deploy
- Restarts the container via handler when the seed or compose file changes
@ -100,53 +100,6 @@ homarr_apps:
width: 2
```
## Layout filter plugin
The grid-packing algorithm that places tiles on the desktop, tablet
and mobile layouts lives in `filter_plugins/homarr_layout.py` rather
than inside the Jinja seed template. This keeps the SQL template
readable and lets the algorithm be unit-tested in isolation.
The filter is invoked once from `tasks/main.yml`:
```yaml
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
```
This produces a `homarr_layout` fact with two keys, both consumed by
`templates/homarr_seed.sql.j2`:
| Key | Shape | Purpose |
|---|---|---|
| `apps` | list, same order as `homarr_apps` | each entry enriched with `desktop`, `tablet`, `mobile` sub-dicts of `{x, y, w, h}` |
| `section_height` | dict with `desktop`, `tablet`, `mobile` | minimum height of the parent section so all tiles fit |
The filter signature accepts custom column counts if Homarr ever
changes the breakpoint widths:
```jinja
{{ homarr_apps | homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }}
```
To debug a layout without running the full deploy, run the play with
`-vv` — the `Show computed app layouts` task dumps the full
`homarr_layout` fact.
### Running the filter tests
The filter is covered by unit tests in
`filter_plugins/tests/test_homarr_layout.py`:
```bash
pip install pytest ansible-core
pytest filter_plugins/tests/
```
15 tests cover packing, width clamping, height/section-height,
input validation and custom grid sizes.
## First login
After the role completes, log in at `{{ homarr_base_url }}` with:

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

@ -1,178 +0,0 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT-0
"""Unit tests for the homarr_layout filter plugin.
Run from the role root:
pytest filter_plugins/tests/
Requires `pytest` and `ansible-core` in the environment.
"""
import os
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__), '..'))
import pytest # noqa: E402
from ansible.errors import AnsibleFilterError # noqa: E402
from homarr_layout import homarr_compute_layouts # noqa: E402
def _app(app_id, width, height=1):
"""Build a minimal app dict for tests."""
return {
'id': app_id,
'name': app_id.title(),
'icon': 'https://example.com/{0}.png'.format(app_id),
'href': 'https://{0}.example.com'.format(app_id),
'width': width,
'height': height,
}
# ---------------------------------------------------------------------
# Happy-path packing
# ---------------------------------------------------------------------
def test_empty_apps_returns_empty_list_and_min_height():
result = homarr_compute_layouts([])
assert result['apps'] == []
# Even an empty grid keeps section_height >= 1 so the section
# renders in the UI.
assert result['section_height'] == {
'desktop': 1, 'tablet': 1, 'mobile': 1,
}
def test_single_app_positioned_at_origin_in_all_grids():
result = homarr_compute_layouts([_app('a', width=2)])
a = result['apps'][0]
assert a['desktop'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
assert a['tablet'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
assert a['mobile'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
def test_original_app_keys_are_preserved():
apps = [_app('nextcloud', width=2)]
apps[0]['description'] = 'Cloud Storage'
result = homarr_compute_layouts(apps)
a = result['apps'][0]
# Original fields survive the layout enrichment.
assert a['name'] == 'Nextcloud'
assert a['description'] == 'Cloud Storage'
assert a['icon'] == 'https://example.com/nextcloud.png'
def test_desktop_wraps_after_filling_row():
# 5 apps of width 2 fill the 10-col desktop row exactly; 6th wraps.
apps = [_app('a{0}'.format(i), width=2) for i in range(6)]
result = homarr_compute_layouts(apps)
assert result['apps'][4]['desktop'] == {'x': 8, 'y': 0, 'w': 2, 'h': 1}
assert result['apps'][5]['desktop'] == {'x': 0, 'y': 1, 'w': 2, 'h': 1}
def test_mobile_wraps_after_every_app():
# Mobile is only 2 cols wide → every app of width 2 starts a new row.
apps = [_app('a{0}'.format(i), width=2) for i in range(3)]
result = homarr_compute_layouts(apps)
assert [a['mobile']['y'] for a in result['apps']] == [0, 1, 2]
# ---------------------------------------------------------------------
# Width clamping
# ---------------------------------------------------------------------
def test_width_clamped_per_grid():
result = homarr_compute_layouts([_app('big', width=8)])
# Desktop has room (8 <= 10), tablet clamps to 6, mobile clamps to 2.
a = result['apps'][0]
assert a['desktop']['w'] == 8
assert a['tablet']['w'] == 6
assert a['mobile']['w'] == 2
def test_width_larger_than_desktop_still_clamps():
# A pathological width=20 still works — it just becomes a full-width
# tile on every grid.
result = homarr_compute_layouts([_app('huge', width=20)])
a = result['apps'][0]
assert a['desktop']['w'] == 10
assert a['tablet']['w'] == 6
assert a['mobile']['w'] == 2
# ---------------------------------------------------------------------
# Height handling
# ---------------------------------------------------------------------
def test_section_height_grows_with_rows():
# 6 apps of width 2 on desktop → 5 in row 1, 1 in row 2.
apps = [_app('a{0}'.format(i), width=2) for i in range(6)]
result = homarr_compute_layouts(apps)
assert result['section_height']['desktop'] == 2
# On mobile every app is on its own row.
assert result['section_height']['mobile'] == 6
def test_tall_app_extends_row_height():
apps = [
_app('tall', width=2, height=3),
_app('short', width=2, height=1),
]
result = homarr_compute_layouts(apps)
# Both fit in row 0 horizontally, but the section must be 3 tall.
assert result['section_height']['desktop'] == 3
def test_tall_app_pushes_subsequent_row_down():
# tall (h=3) fills full desktop width → next app wraps to y=3.
result = homarr_compute_layouts([
_app('tall', width=10, height=3),
_app('next', width=2, height=1),
])
assert result['apps'][1]['desktop'] == {'x': 0, 'y': 3, 'w': 2, 'h': 1}
# ---------------------------------------------------------------------
# Input validation
# ---------------------------------------------------------------------
def test_rejects_non_list_input():
with pytest.raises(AnsibleFilterError, match='expected a list'):
homarr_compute_layouts('not a list')
def test_rejects_non_dict_entry():
with pytest.raises(AnsibleFilterError, match='not a dict'):
homarr_compute_layouts(['just a string'])
def test_rejects_app_without_id():
with pytest.raises(AnsibleFilterError, match="missing required key 'id'"):
homarr_compute_layouts([{'name': 'no id', 'width': 2}])
def test_rejects_app_without_width():
with pytest.raises(AnsibleFilterError,
match="missing required key 'width'"):
homarr_compute_layouts([{'id': 'no-width', 'name': 'x'}])
# ---------------------------------------------------------------------
# Configurable grid sizes
# ---------------------------------------------------------------------
def test_custom_grid_sizes():
# If Homarr ever switches to 12-col desktop, the filter still works.
result = homarr_compute_layouts(
[_app('a', width=4), _app('b', width=4), _app('c', width=4)],
desktop_cols=12, tablet_cols=8, mobile_cols=4,
)
# All three fit in desktop row 0 (4+4+4 = 12).
assert [a['desktop']['x'] for a in result['apps']] == [0, 4, 8]
assert result['section_height']['desktop'] == 1

View file

@ -128,23 +128,7 @@
no_log: true
# =====================================================================
# 5. COMPUTE APP LAYOUTS
# =====================================================================
# Packing is done by the homarr_compute_layouts filter plugin (Python)
# rather than inline Jinja, so the seed template stays readable and the
# packing algorithm can be unit-tested in isolation.
- name: Compute Homarr app layouts
ansible.builtin.set_fact:
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
- name: Show computed app layouts
ansible.builtin.debug:
var: homarr_layout
verbosity: 1
# =====================================================================
# 6. SEED DATABASE (only if local admin user does not exist yet)
# 5. SEED DATABASE (only if local admin user does not exist yet)
# =====================================================================
- name: Check if local admin user exists

View file

@ -1,13 +1,35 @@
{#-
Homarr database seed.
Auto-layout packing macro.
The packing algorithm previously lived in this template as a Jinja
`pack()` macro with from_json/to_json round-trips. It has been
extracted to the `homarr_compute_layouts` filter plugin (see
filter_plugins/homarr_layout.py) and the result is provided as the
`homarr_layout` fact set in tasks/main.yml. This template therefore
only renders SQL — no logic.
Greedy left-to-right packing of apps into a grid with `cols` columns.
Returns the list of apps with computed x/y/w/h fields.
Width is clamped to cols (so an app wider than the grid is downsized
rather than overflowing). Height is taken as-is.
-#}
{%- macro pack(apps, cols) -%}
{%- set ns = namespace(x=0, y=0, row_h=0, out=[]) -%}
{%- for app in apps -%}
{%- set w = [app.width, cols] | min -%}
{%- set h = app.height | default(1) -%}
{%- if ns.x + w > cols -%}
{%- set ns.x = 0 -%}
{%- set ns.y = ns.y + ns.row_h -%}
{%- set ns.row_h = 0 -%}
{%- endif -%}
{%- set _ = ns.out.append({'id': app.id, 'x': ns.x, 'y': ns.y, 'w': w, 'h': h}) -%}
{%- set ns.x = ns.x + w -%}
{%- if h > ns.row_h -%}
{%- set ns.row_h = h -%}
{%- endif -%}
{%- endfor -%}
{{- ns.out | to_json -}}
{%- endmacro -%}
{%- set desktop_layout = pack(homarr_apps, 10) | from_json -%}
{%- set tablet_layout = pack(homarr_apps, 6) | from_json -%}
{%- set mobile_layout = pack(homarr_apps, 2) | from_json -%}
BEGIN TRANSACTION;
-- =====================================================================
@ -126,14 +148,11 @@ VALUES (
'{"json": {}}'
);
-- Section height is sized to fit the computed layout (see
-- homarr_compute_layouts filter). It grows automatically when more
-- apps or taller tiles are added.
INSERT OR REPLACE INTO section_layout (section_id, layout_id, parent_section_id, x_offset, y_offset, width, height)
VALUES
('section-apps', 'layout-desktop', NULL, 0, 0, 10, {{ homarr_layout.section_height.desktop }}),
('section-apps', 'layout-tablet', NULL, 0, 0, 6, {{ homarr_layout.section_height.tablet }}),
('section-apps', 'layout-mobile', NULL, 0, 0, 2, {{ homarr_layout.section_height.mobile }});
('section-apps', 'layout-desktop', NULL, 0, 0, 10, 3),
('section-apps', 'layout-tablet', NULL, 0, 0, 6, 4),
('section-apps', 'layout-mobile', NULL, 0, 0, 2, 6);
-- Board permissions
INSERT OR IGNORE INTO boardGroupPermission (board_id, group_id, permission)
@ -142,11 +161,11 @@ VALUES
('board-default', 'group-credentials-admin', 'full-access');
-- =====================================================================
-- APPS (positions pre-computed by homarr_compute_layouts filter)
-- APPS (auto-generated from homarr_apps variable)
-- =====================================================================
{% for app in homarr_layout.apps %}
-- {{ app.name }}
{% if homarr_apps | length > 0 %}
{% for app in homarr_apps %}
INSERT OR IGNORE INTO app (id, name, description, icon_url, href)
VALUES (
'app-{{ app.id }}',
@ -165,11 +184,28 @@ VALUES (
'{"json": {}}'
);
{% endfor %}
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
VALUES
('item-{{ app.id }}', 'section-apps', 'layout-desktop', {{ app.desktop.x }}, {{ app.desktop.y }}, {{ app.desktop.w }}, {{ app.desktop.h }}),
('item-{{ app.id }}', 'section-apps', 'layout-tablet', {{ app.tablet.x }}, {{ app.tablet.y }}, {{ app.tablet.w }}, {{ app.tablet.h }}),
('item-{{ app.id }}', 'section-apps', 'layout-mobile', {{ app.mobile.x }}, {{ app.mobile.y }}, {{ app.mobile.w }}, {{ app.mobile.h }});
{% for entry in desktop_layout %}
('item-{{ entry.id }}', 'section-apps', 'layout-desktop', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %}
{% endfor %}
;
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
VALUES
{% for entry in tablet_layout %}
('item-{{ entry.id }}', 'section-apps', 'layout-tablet', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %}
{% endfor %}
;
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
VALUES
{% for entry in mobile_layout %}
('item-{{ entry.id }}', 'section-apps', 'layout-mobile', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %}
{% endfor %}
;
{% endif %}
COMMIT;

View file

@ -25,26 +25,18 @@ opnform_redis_image: "redis:7"
opnform_db_image: "postgres:16"
opnform_ingress_image: "nginx:1"
# REQUIRED SECRETS — must be overridden per-inventory.
# REQUIRED SECRETS — generate with: openssl rand -base64 32
# Always prefix opnform_app_key with "base64:"
# 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: ""
opnform_app_key: "base64:vsQw8EoC64nmhurLUUohXUlAeryaV6Y2Is64Tdvjlko="
opnform_jwt_secret: "0b2e8ed326334a08ce3846bfcd6588f5a11be33999e96963cd4eaff1a3ae828b"
opnform_front_api_secret: "8f52397785a110b657f2a6beab13362877bfac936ae9002bc236c54ed1011b2d"
# Database credentials. opnform_db_password must be overridden; the
# validate task fails fast on an empty value.
# Database credentials
opnform_db_name: "opnform"
opnform_db_user: "opnform"
opnform_db_password: ""
opnform_db_password: "xtNLUVc2ajcWictqWXWkLR"
# Admin bootstrap — when email+password are set, the role creates the
# first user via OpnForm's /api/register endpoint, skipping the

View file

@ -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

View file

@ -1,16 +1,35 @@
#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"
author: your name
description: your role description
company: your company (optional)
galaxy_tags:
- opnform
- forms
- docker
- traefik
- oidc
# If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
# Choose a valid license ID from https://spdx.org - some suggested licenses:
# - BSD-3-Clause (default)
# - MIT
# - GPL-2.0-or-later
# - GPL-3.0-only
# - Apache-2.0
# - CC-BY-4.0
license: license (GPL-2.0-or-later, MIT, etc)
min_ansible_version: 2.2
# If this a Container Enabled role, provide the minimum Ansible Container version.
# min_ansible_container_version:
galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -0,0 +1,133 @@
---
services:
api: &api-environment
image: jhumanj/opnform-api:latest
container_name: opnform-api
volumes: &api-environment-volumes
- opnform_storage:/usr/share/nginx/html/storage:rw
environment: &api-env
APP_ENV: production
# Database settings
DB_HOST: db
REDIS_HOST: redis
DB_DATABASE: ${DB_DATABASE:-forge}
DB_USERNAME: ${DB_USERNAME:-forge}
DB_PASSWORD: ${DB_PASSWORD:-forge}
DB_CONNECTION: ${DB_CONNECTION:-pgsql}
# PHP Configuration
PHP_MEMORY_LIMIT: "1G"
PHP_MAX_EXECUTION_TIME: "600"
PHP_UPLOAD_MAX_FILESIZE: "64M"
PHP_POST_MAX_SIZE: "64M"
env_file:
- ./api/.env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy # Depend on redis being healthy too
healthcheck:
test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"]
interval: 30s
timeout: 15s
retries: 3
start_period: 60s
api-worker:
<<: *api-environment
container_name: opnform-api-worker
command: ["php", "artisan", "queue:work"]
environment:
<<: *api-env
APP_ENV: production
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-environment
container_name: opnform-api-scheduler
command: ["php", "artisan", "schedule:work"]
environment:
<<: *api-env
APP_ENV: production
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 # Allow time for first scheduled run and cache write
ui:
image: jhumanj/opnform-client:latest
container_name: opnform-client
env_file:
- ./client/.env
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
redis:
image: redis:7
container_name: opnform-redis
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 30s
timeout: 5s
db:
image: postgres:16
container_name: opnform-db
environment:
POSTGRES_DB: ${DB_DATABASE:-forge}
POSTGRES_USER: ${DB_USERNAME:-forge}
POSTGRES_PASSWORD: ${DB_PASSWORD:-forge}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-forge}"]
interval: 30s
timeout: 5s
volumes:
- postgres-data:/var/lib/postgresql/data
ingress:
image: nginx:1
container_name: opnform-ingress
volumes:
- ./docker/nginx.conf:/etc/nginx/templates/default.conf.template
ports:
- 80:80
environment:
- NGINX_MAX_BODY_SIZE=64m
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
volumes:
postgres-data:
opnform_storage:
redis-data:

View file

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