diff --git a/.gitignore b/.gitignore index a84afb0..ef41221 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ /.idea/ -__pycache__/ -*.pyc plugins/lookup/__pycache__/ diff --git a/roles/homarr/README.md b/roles/homarr/README.md index 1e92cba..db0ed4d 100644 --- a/roles/homarr/README.md +++ b/roles/homarr/README.md @@ -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: diff --git a/roles/homarr/filter_plugins/homarr_layout.py b/roles/homarr/filter_plugins/homarr_layout.py deleted file mode 100644 index 6650709..0000000 --- a/roles/homarr/filter_plugins/homarr_layout.py +++ /dev/null @@ -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, - } diff --git a/roles/homarr/filter_plugins/tests/test_homarr_layout.py b/roles/homarr/filter_plugins/tests/test_homarr_layout.py deleted file mode 100644 index 3a49f2b..0000000 --- a/roles/homarr/filter_plugins/tests/test_homarr_layout.py +++ /dev/null @@ -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 diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index 9d00cde..06488f4 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -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 diff --git a/roles/homarr/templates/homarr_seed.sql.j2 b/roles/homarr/templates/homarr_seed.sql.j2 index 1d2526b..fdb1a2f 100644 --- a/roles/homarr/templates/homarr_seed.sql.j2 +++ b/roles/homarr/templates/homarr_seed.sql.j2 @@ -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 %} -COMMIT; +; + +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; \ No newline at end of file diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml index 0f61c3a..09aed4c 100644 --- a/roles/opnform/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -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 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 index 8a56a7b..faea947 100644 --- a/roles/opnform/meta/main.yml +++ b/roles/opnform/meta/main.yml @@ -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. \ No newline at end of file diff --git a/roles/opnform/templates/compose.yml.j2 b/roles/opnform/templates/compose.yml.j2 new file mode 100644 index 0000000..d2886fe --- /dev/null +++ b/roles/opnform/templates/compose.yml.j2 @@ -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: \ No newline at end of file diff --git a/roles/opnform/vars/main.yml b/roles/opnform/vars/main.yml index 94900f8..984df2b 100644 --- a/roles/opnform/vars/main.yml +++ b/roles/opnform/vars/main.yml @@ -1,3 +1,3 @@ #SPDX-License-Identifier: MIT-0 --- -# vars file for opnform \ No newline at end of file +# vars file for homarr \ No newline at end of file