diff --git a/.gitignore b/.gitignore index 85e7c1d..a84afb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ /.idea/ +__pycache__/ +*.pyc + +plugins/lookup/__pycache__/ diff --git a/roles/homarr/README.md b/roles/homarr/README.md index db0ed4d..1e92cba 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 + three screen sizes via the bundled `homarr_compute_layouts` filter - 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,6 +100,53 @@ 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/defaults/main.yml b/roles/homarr/defaults/main.yml index 728e3b3..f6ef75e 100644 --- a/roles/homarr/defaults/main.yml +++ b/roles/homarr/defaults/main.yml @@ -1,4 +1,3 @@ -homarr_apps: [ ] #SPDX-License-Identifier: MIT-0 --- # defaults file for homarr diff --git a/roles/homarr/filter_plugins/homarr_layout.py b/roles/homarr/filter_plugins/homarr_layout.py new file mode 100644 index 0000000..6650709 --- /dev/null +++ b/roles/homarr/filter_plugins/homarr_layout.py @@ -0,0 +1,140 @@ +# -*- 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 new file mode 100644 index 0000000..3a49f2b --- /dev/null +++ b/roles/homarr/filter_plugins/tests/test_homarr_layout.py @@ -0,0 +1,178 @@ +# -*- 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 06488f4..9d00cde 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -128,7 +128,23 @@ no_log: true # ===================================================================== -# 5. SEED DATABASE (only if local admin user does not exist yet) +# 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) # ===================================================================== - 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 fdb1a2f..1d2526b 100644 --- a/roles/homarr/templates/homarr_seed.sql.j2 +++ b/roles/homarr/templates/homarr_seed.sql.j2 @@ -1,35 +1,13 @@ {#- - Auto-layout packing macro. + Homarr database seed. - 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. + 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. -#} -{%- 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; -- ===================================================================== @@ -148,11 +126,14 @@ 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, 3), - ('section-apps', 'layout-tablet', NULL, 0, 0, 6, 4), - ('section-apps', 'layout-mobile', NULL, 0, 0, 2, 6); + ('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 }}); -- Board permissions INSERT OR IGNORE INTO boardGroupPermission (board_id, group_id, permission) @@ -161,11 +142,11 @@ VALUES ('board-default', 'group-credentials-admin', 'full-access'); -- ===================================================================== --- APPS (auto-generated from homarr_apps variable) +-- APPS (positions pre-computed by homarr_compute_layouts filter) -- ===================================================================== -{% if homarr_apps | length > 0 %} -{% for app in homarr_apps %} +{% for app in homarr_layout.apps %} +-- {{ app.name }} INSERT OR IGNORE INTO app (id, name, description, icon_url, href) VALUES ( 'app-{{ app.id }}', @@ -184,28 +165,11 @@ VALUES ( '{"json": {}}' ); -{% endfor %} - INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height) VALUES -{% 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 %} -; + ('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 }}); -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 +COMMIT; diff --git a/roles/opnform/README.md b/roles/opnform/README.md new file mode 100644 index 0000000..2dfad2d --- /dev/null +++ b/roles/opnform/README.md @@ -0,0 +1,169 @@ +# opnform + +Deploy [OpnForm](https://github.com/OpnForm/OpnForm) as a self-contained +Docker Compose stack behind Traefik. + +## What this role does + +- Deploys the full official OpnForm stack: `api`, `api-worker`, `api-scheduler`, + `ui`, `db` (Postgres), `redis`, and `ingress` (nginx) +- Configures all environment variables for self-hosted production use +- Integrates the ingress container with an existing Traefik proxy network +- Waits for the API container to become healthy before returning + +## What this role does NOT do (stage 1) + +- Does not pre-configure OIDC / identity_connections — set up via Admin UI + +## Architecture note: why two reverse proxies? + +``` +Browser → Traefik (TLS, host routing) → ingress-nginx → api (PHP-FPM) / ui (Nuxt) +``` + +The `ingress` container looks like a redundant proxy next to Traefik but +does a different job. OpnForm's `api` image is **PHP-FPM only** — it +speaks the FastCGI protocol on port 9000, not HTTP. Traefik cannot +translate FastCGI, so the ingress nginx is required to: + +- Translate HTTP `/api/*` requests into FastCGI calls to `api:9000` +- Rewrite request URIs via the `$api_uri` map +- Set Laravel-specific FastCGI params (`SCRIPT_FILENAME`, `REQUEST_URI`) +- Reverse-proxy `/` to the Nuxt UI container on port 3000 + +Both containers run on the same Docker network on the same host, so the +performance overhead of the extra hop is negligible (in-kernel memory +copy, not a real network round-trip). Removing the ingress would require +a custom OpnForm image with a built-in HTTP server, which is out of +scope for this role. + +## Required variables + +Provide via OpenBao, Ansible Vault, or extra-vars. **Never commit real +secrets to version control.** + +| Variable | Format | Generate with | +|---|---|---| +| `opnform_app_key` | `base64:<32 bytes base64>` | `echo "base64:$(openssl rand -base64 32)"` | +| `opnform_jwt_secret` | 32 bytes base64 | `openssl rand -base64 32` | +| `opnform_front_api_secret` | 32 bytes base64 | `openssl rand -base64 32` | +| `opnform_db_password` | strong password | `openssl rand -base64 24` | + +When `opnform_oidc_enabled` is `true`: + +| Variable | Source | +|---|---| +| `opnform_oidc_client_secret` | from your Keycloak/Authentik client | + +The `assert` task at the top of the role will fail fast if any secret is +missing or malformed. + +## First login + +OpnForm in self-hosted mode does **not** ship a pre-seeded admin user. +The first user to register becomes the owner of the default workspace, +and further public registration is disabled afterwards (additional +users must be invited via the Admin UI). + +This role supports two ways to create that first user: + +### Option A — automated bootstrap (recommended) + +Set `opnform_admin_email` and `opnform_admin_password` (ideally from +Vault / OpenBao). The role then POSTs to `/api/register` after the +API container is healthy, skipping the setup page entirely. The task +is idempotent: it does a login check first and only registers if the +user does not already exist. + +```yaml +opnform_admin_name: "Administrator" # default +opnform_admin_email: "admin@example.com" +opnform_admin_password: "{{ vault_opnform_admin_password }}" +``` + +Password rules enforced by OpnForm: minimum 8 characters, at least one +letter, one digit, and one of `@$!%*#?&-_+=.,:;<>^()[]{}|~`. + +### Option B — manual setup page + +Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit +`opnform_base_url` and complete the setup page in the browser. + +## OIDC setup + +Set `opnform_oidc_enabled: true` and the role creates an +IdentityConnection on the admin's default workspace via +`POST /api/open/workspaces/{id}/oidc-connections`. OpnForm enforces a +single OIDC connection per workspace, so the task is idempotent (GETs +existing connections first and skips if any exist). + +**Prerequisite**: the admin bootstrap must be configured +(`opnform_admin_email` + `opnform_admin_password`). The OIDC API +requires an authenticated admin token; the role logs in with those +credentials to make the call. The validation block fails fast if OIDC +is enabled without admin credentials. + +### Required when `opnform_oidc_enabled: true` + +| Variable | Notes | +|---|---| +| `opnform_oidc_client_secret` | from your IdP, never commit | +| `opnform_oidc_domain` | email domain that triggers OIDC (e.g. `example.com`) | + +### Tunables (defaults shown) + +```yaml +opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard" +opnform_oidc_client_id: "opnform-digitalboard" +opnform_oidc_client_name: "Digitalboard" # display name in UI +opnform_oidc_slug: "oidc" # used in /auth/{slug}/callback +opnform_oidc_scopes: [openid, profile, email, groups] +``` + +### Group → role mapping + +Two ways, the list takes precedence: + +```yaml +# Option 1: full list (any number of mappings) +opnform_oidc_group_role_mappings: + - idp_group: "opnform-admins" + role: admin + - idp_group: "opnform-editors" + role: editor + +# Option 2: convenience — single admin group +opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin +``` + +Valid roles: `owner`, `admin`, `editor`, `member`. + +## Example playbook + +```yaml +- name: Deploy OpnForm service + hosts: opnform_servers + become: true + roles: + - digitalboard.core.opnform +``` + +With inventory variables: + +```yaml +# group_vars/opnform_servers.yml +opnform_domain: forms.digitalboard.ch +opnform_base_url: "https://forms.digitalboard.ch" +opnform_app_key: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.app_key }}" +opnform_jwt_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.jwt_secret }}" +opnform_front_api_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.front_api_secret }}" +opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.db_password }}" +``` diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml new file mode 100644 index 0000000..0f61c3a --- /dev/null +++ b/roles/opnform/defaults/main.yml @@ -0,0 +1,109 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for opnform + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# opnform-specific configuration +opnform_service_name: opnform +opnform_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ opnform_service_name }}" +opnform_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ opnform_service_name }}" +opnform_storage_dir: "{{ opnform_docker_volume_dir }}/storage" +opnform_db_data_dir: "{{ opnform_docker_volume_dir }}/db" +opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis" + +# Service configuration +opnform_domain: "forms.local.test" +opnform_base_url: "https://forms.local.test" + +# Images +opnform_api_image: "jhumanj/opnform-api:latest" +opnform_client_image: "jhumanj/opnform-client:latest" +opnform_redis_image: "redis:7" +opnform_db_image: "postgres:16" +opnform_ingress_image: "nginx:1" + +# REQUIRED SECRETS — must be overridden per-inventory. +# Provide via OpenBao lookup, Ansible Vault or extra-vars. +# Never commit real keys to version control. +# +# Generate with: +# opnform_app_key: echo "base64:$(openssl rand -base64 32)" +# opnform_jwt_secret: openssl rand -hex 32 +# opnform_front_api_secret: openssl rand -hex 32 +# +# opnform_app_key MUST start with the prefix "base64:" — the validate +# task at the top of tasks/main.yml enforces this. +opnform_app_key: "" +opnform_jwt_secret: "" +opnform_front_api_secret: "" + +# Database credentials. opnform_db_password must be overridden; the +# validate task fails fast on an empty value. +opnform_db_name: "opnform" +opnform_db_user: "opnform" +opnform_db_password: "" + +# Admin bootstrap — when email+password are set, the role creates the +# first user via OpnForm's /api/register endpoint, skipping the +# self-hosted setup page. Leave both empty to keep the manual setup flow. +# Password must satisfy OpnForm's rules: min 8 chars, contain a letter, +# a digit and one of @$!%*#?&-_+=.,:;<>^()[]{}|~ +# Provide via OpenBao, Ansible Vault or extra-vars. +opnform_admin_name: "Administrator" +opnform_admin_email: "" +opnform_admin_password: "" +opnform_admin_hear_about_us: "ansible" + +# PHP configuration +opnform_php_memory_limit: "1G" +opnform_php_max_execution_time: "600" +opnform_php_upload_max_filesize: "64M" +opnform_php_post_max_size: "64M" + +# Nginx ingress +opnform_nginx_max_body_size: "64m" + +# Mail configuration (optional — defaults to log driver) +opnform_mail_mailer: "log" +opnform_mail_host: "" +opnform_mail_port: "" +opnform_mail_username: "" +opnform_mail_password: "" +opnform_mail_encryption: "" +opnform_mail_from_address: "noreply@digitalboard.ch" +opnform_mail_from_name: "OpnForm" + +# OIDC configuration — when enabled, the role auto-creates an +# IdentityConnection in the first workspace via OpnForm's API after the +# admin bootstrap. Requires opnform_admin_email/_password to be set +# (the API call needs an authenticated admin token). +opnform_oidc_enabled: false +opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard" +opnform_oidc_client_id: "opnform-digitalboard" +opnform_oidc_client_secret: "" +opnform_oidc_client_name: "Digitalboard" +# OpnForm-side identifier used in /auth/{slug}/callback. Lowercase +# alphanumeric + hyphens, unique across all identity_connections. +opnform_oidc_slug: "oidc" +# Email domain that triggers OIDC login for matching users (e.g. users +# with @example.com emails are redirected to the IdP). Required when +# opnform_oidc_enabled is true. +opnform_oidc_domain: "" +opnform_oidc_scopes: + - openid + - profile + - email + - groups +# Convenience: maps a single IdP group to the OpnForm "admin" role. +# Ignored when opnform_oidc_group_role_mappings is non-empty. +opnform_oidc_admin_group: "opnform-admins" +# Full group-to-role mapping list. Takes precedence over the convenience +# var. Each item: {idp_group: "", role: "owner|admin|editor|member"} +opnform_oidc_group_role_mappings: [] + +# Traefik configuration +opnform_traefik_network: "proxy" +opnform_use_ssl: true diff --git a/roles/opnform/handlers/main.yml b/roles/opnform/handlers/main.yml new file mode 100644 index 0000000..1c0b422 --- /dev/null +++ b/roles/opnform/handlers/main.yml @@ -0,0 +1,8 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for opnform + +- name: restart opnform + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: restarted diff --git a/roles/opnform/meta/argument_specs.yml b/roles/opnform/meta/argument_specs.yml new file mode 100644 index 0000000..9fbfc7a --- /dev/null +++ b/roles/opnform/meta/argument_specs.yml @@ -0,0 +1,220 @@ +--- +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 new file mode 100644 index 0000000..8a56a7b --- /dev/null +++ b/roles/opnform/meta/main.yml @@ -0,0 +1,16 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: Tobias Wüst + description: Deploy OpnForm self-hosted form builder via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 + min_ansible_version: "2.15" + + galaxy_tags: + - opnform + - forms + - docker + - traefik + - oidc + +dependencies: [] diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml new file mode 100644 index 0000000..68e093b --- /dev/null +++ b/roles/opnform/tasks/main.yml @@ -0,0 +1,265 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for opnform + +# ===================================================================== +# 0. VALIDATION +# ===================================================================== + +- name: Validate required secrets + ansible.builtin.assert: + that: + - opnform_app_key | length > 0 + - opnform_app_key is match('^base64:[A-Za-z0-9+/=]+$') + - opnform_jwt_secret | length > 0 + - opnform_front_api_secret | length > 0 + - opnform_db_password | length > 0 + fail_msg: >- + OpnForm requires opnform_app_key (prefix 'base64:'), opnform_jwt_secret, + opnform_front_api_secret and opnform_db_password. + Generate with: openssl rand -base64 32 + The app_key MUST be prefixed with "base64:" + Provide via OpenBao, Ansible Vault or extra-vars. + success_msg: Secrets validation passed + +- name: Validate OIDC configuration when enabled + ansible.builtin.assert: + that: + - opnform_oidc_client_secret | length > 0 + - opnform_oidc_domain | length > 0 + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + fail_msg: >- + When opnform_oidc_enabled is true, you must set: + - opnform_oidc_client_secret + - opnform_oidc_domain (email domain that triggers OIDC) + - opnform_admin_email / opnform_admin_password + (the OIDC API requires an authenticated admin; the role logs in + with these credentials to POST the connection) + when: opnform_oidc_enabled | bool + +# ===================================================================== +# 1. PREPARATION +# ===================================================================== + +- name: Ensure required packages are installed + ansible.builtin.package: + name: + - python3-docker + state: present + +- name: Create docker compose directory + ansible.builtin.file: + path: "{{ opnform_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create OpnForm data directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ opnform_docker_volume_dir }}" + - "{{ opnform_storage_dir }}" + - "{{ opnform_db_data_dir }}" + - "{{ opnform_redis_data_dir }}" + +# ===================================================================== +# 2. CONFIGURATION FILES +# ===================================================================== + +- name: Deploy nginx ingress configuration + ansible.builtin.template: + src: nginx.conf.j2 + dest: "{{ opnform_docker_compose_dir }}/nginx.conf" + mode: '0644' + notify: restart opnform + +- name: Deploy docker-compose file + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + notify: restart opnform + +# ===================================================================== +# 3. CONTAINER STARTUP +# ===================================================================== + +- name: Start opnform containers + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: present + wait: true + wait_timeout: 180 + +# ===================================================================== +# 4. WAIT FOR API READINESS +# ===================================================================== + +- name: Wait for API container to be healthy + ansible.builtin.command: + cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api + register: api_health + until: api_health.stdout == "healthy" + retries: 30 + delay: 10 + changed_when: false + +# ===================================================================== +# 5. ADMIN BOOTSTRAP (optional) +# ===================================================================== +# Skips the self-hosted setup page by registering the first user via +# OpnForm's /api/register endpoint. Idempotent: a successful login +# attempt with the same credentials means the user already exists. + +- name: Check if OpnForm admin user already exists + ansible.builtin.uri: + url: "https://127.0.0.1/api/login" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + status_code: [200, 401, 422] + validate_certs: false + register: opnform_admin_login + when: + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + +- name: Create OpnForm admin user via /api/register + ansible.builtin.uri: + url: "https://127.0.0.1/api/register" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + name: "{{ opnform_admin_name }}" + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + password_confirmation: "{{ opnform_admin_password }}" + hear_about_us: "{{ opnform_admin_hear_about_us }}" + status_code: [200, 201] + validate_certs: false + no_log: true + when: + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + - opnform_admin_login.status != 200 + +# ===================================================================== +# 6. OIDC IDENTITY CONNECTION (optional) +# ===================================================================== +# Creates a single OIDC connection on the admin's default workspace. +# OpnForm enforces one OIDC connection per workspace, so this block is +# idempotent: we GET existing connections first and skip if any exists. + +- name: Log in as admin to obtain OIDC API token + ansible.builtin.uri: + url: "https://127.0.0.1/api/login" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + status_code: 200 + validate_certs: false + register: opnform_oidc_token + no_log: true + when: opnform_oidc_enabled | bool + +- name: Fetch admin's workspaces + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces" + method: GET + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + status_code: 200 + validate_certs: false + register: opnform_workspaces + no_log: true + when: opnform_oidc_enabled | bool + +- name: Fetch existing OIDC connections for the default workspace + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" + method: GET + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + status_code: 200 + validate_certs: false + register: opnform_existing_oidc + no_log: true + when: opnform_oidc_enabled | bool + +- name: Resolve OIDC group-role mappings + ansible.builtin.set_fact: + _opnform_oidc_group_role_mappings: >- + {{ + opnform_oidc_group_role_mappings + if (opnform_oidc_group_role_mappings | length > 0) + else + ([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}] + if (opnform_oidc_admin_group | length > 0) else []) + }} + when: opnform_oidc_enabled | bool + +- name: Create OIDC identity connection + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" + method: POST + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + body_format: json + body: + name: "{{ opnform_oidc_client_name }}" + slug: "{{ opnform_oidc_slug }}" + domain: "{{ opnform_oidc_domain }}" + issuer: "{{ opnform_oidc_issuer }}" + client_id: "{{ opnform_oidc_client_id }}" + client_secret: "{{ opnform_oidc_client_secret }}" + scopes: "{{ opnform_oidc_scopes }}" + enabled: true + options: + require_state: true + group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}" + status_code: [201] + validate_certs: false + no_log: true + when: + - opnform_oidc_enabled | bool + - opnform_existing_oidc.json | length == 0 + +- name: Display deployment info + ansible.builtin.debug: + msg: |- + OpnForm deployed at {{ opnform_base_url }} + + {% if opnform_admin_email | length > 0 %} + Admin user bootstrapped: + Email: {{ opnform_admin_email }} + Password: (from opnform_admin_password) + {% else %} + No admin bootstrap configured — visit {{ opnform_base_url }} and + complete the self-hosted setup page to create the first user. + Set opnform_admin_email + opnform_admin_password to automate this. + {% endif %} + + {% if opnform_oidc_enabled %} + OIDC: connection "{{ opnform_oidc_client_name }}" bootstrapped + (slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }}) + Users with @{{ opnform_oidc_domain }} addresses will be + redirected to {{ opnform_oidc_issuer }} on login. + {% else %} + OIDC: disabled (set opnform_oidc_enabled=true to auto-configure) + {% endif %} diff --git a/roles/opnform/templates/docker-compose.yml.j2 b/roles/opnform/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..de88a33 --- /dev/null +++ b/roles/opnform/templates/docker-compose.yml.j2 @@ -0,0 +1,189 @@ +#---------------------------------------------------------------------# +# OpnForm — Beautiful open-source form builder # +#---------------------------------------------------------------------# +services: + api: &api-service + image: {{ opnform_api_image }} + container_name: opnform-api + restart: unless-stopped + volumes: + - {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw + environment: &api-env + APP_ENV: production + APP_KEY: "{{ opnform_app_key }}" + APP_URL: "{{ opnform_base_url }}" + APP_DEBUG: "false" + SELF_HOSTED: "true" + + LOG_CHANNEL: errorlog + LOG_LEVEL: info + + DB_CONNECTION: pgsql + DB_HOST: db + DB_PORT: "5432" + DB_DATABASE: "{{ opnform_db_name }}" + DB_USERNAME: "{{ opnform_db_user }}" + DB_PASSWORD: "{{ opnform_db_password }}" + + REDIS_HOST: redis + REDIS_PORT: "6379" + + CACHE_STORE: redis + CACHE_DRIVER: redis + QUEUE_CONNECTION: redis + SESSION_DRIVER: redis + SESSION_LIFETIME: "120" + BROADCAST_CONNECTION: log + + FILESYSTEM_DISK: local + FILESYSTEM_DRIVER: local + LOCAL_FILESYSTEM_VISIBILITY: public + + MAIL_MAILER: "{{ opnform_mail_mailer }}" + MAIL_HOST: "{{ opnform_mail_host }}" + MAIL_PORT: "{{ opnform_mail_port }}" + MAIL_USERNAME: "{{ opnform_mail_username }}" + MAIL_PASSWORD: "{{ opnform_mail_password }}" + MAIL_ENCRYPTION: "{{ opnform_mail_encryption }}" + MAIL_FROM_ADDRESS: "{{ opnform_mail_from_address }}" + MAIL_FROM_NAME: "{{ opnform_mail_from_name }}" + + JWT_TTL: "1440" + JWT_SECRET: "{{ opnform_jwt_secret }}" + + PHP_MEMORY_LIMIT: "{{ opnform_php_memory_limit }}" + PHP_MAX_EXECUTION_TIME: "{{ opnform_php_max_execution_time }}" + PHP_UPLOAD_MAX_FILESIZE: "{{ opnform_php_upload_max_filesize }}" + PHP_POST_MAX_SIZE: "{{ opnform_php_post_max_size }}" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"] + interval: 30s + timeout: 15s + retries: 3 + start_period: 60s + networks: + - opnform-internal + + api-worker: + <<: *api-service + container_name: opnform-api-worker + command: ["php", "artisan", "queue:work"] + environment: + <<: *api-env + IS_API_WORKER: "true" + healthcheck: + test: ["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + api-scheduler: + <<: *api-service + container_name: opnform-api-scheduler + command: ["php", "artisan", "schedule:work"] + healthcheck: + test: + - "CMD-SHELL" + - "php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1" + interval: 60s + timeout: 30s + retries: 3 + start_period: 70s + + ui: + image: {{ opnform_client_image }} + container_name: opnform-ui + restart: unless-stopped + environment: + NUXT_PUBLIC_APP_URL: "{{ opnform_base_url }}" + NUXT_PUBLIC_API_BASE: "/api" + NUXT_PRIVATE_API_BASE: "http://ingress/api" + NUXT_PUBLIC_ENV: production + FRONT_API_SECRET: "{{ opnform_front_api_secret }}" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 45s + networks: + - opnform-internal + + redis: + image: {{ opnform_redis_image }} + container_name: opnform-redis + restart: unless-stopped + volumes: + - {{ opnform_redis_data_dir }}:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 30s + timeout: 5s + networks: + - opnform-internal + + db: + image: {{ opnform_db_image }} + container_name: opnform-db + restart: unless-stopped + environment: + POSTGRES_DB: "{{ opnform_db_name }}" + POSTGRES_USER: "{{ opnform_db_user }}" + POSTGRES_PASSWORD: "{{ opnform_db_password }}" + volumes: + - {{ opnform_db_data_dir }}:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {{ opnform_db_user }}"] + interval: 30s + timeout: 5s + networks: + - opnform-internal + + ingress: + image: {{ opnform_ingress_image }} + container_name: opnform-ingress + restart: unless-stopped + volumes: + - ./nginx.conf:/etc/nginx/templates/default.conf.template:ro + environment: + NGINX_MAX_BODY_SIZE: "{{ opnform_nginx_max_body_size }}" + depends_on: + api: + condition: service_started + ui: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - opnform-internal + - {{ opnform_traefik_network }} + labels: + - traefik.enable=true + - traefik.docker.network={{ opnform_traefik_network }} + - traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`) +{% if opnform_use_ssl %} + - traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure + - traefik.http.routers.{{ opnform_service_name }}.tls=true +{% else %} + - traefik.http.routers.{{ opnform_service_name }}.entrypoints=web +{% endif %} + - traefik.http.services.{{ opnform_service_name }}.loadbalancer.server.port=80 + +networks: + opnform-internal: + driver: bridge + {{ opnform_traefik_network }}: + external: true diff --git a/roles/opnform/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 new file mode 100644 index 0000000..fa3193b --- /dev/null +++ b/roles/opnform/templates/nginx.conf.j2 @@ -0,0 +1,43 @@ +map $original_uri $api_uri { + ~^/api(/.*$) $1; + default $original_uri; +} + +server { + listen 80; + server_name {{ opnform_domain }}; + root /app/public; + + client_max_body_size {% raw %}${NGINX_MAX_BODY_SIZE}{% endraw %}; + + access_log /dev/stdout; + error_log /dev/stderr error; + + index index.html index.htm index.php; + + location / { + proxy_http_version 1.1; + proxy_pass http://ui:3000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } + + location ~/(api|open|local\/temp|forms\/assets)/ { + set $original_uri $uri; + try_files $uri $uri/ /index.php$is_args$args; + } + + location ~ \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass api:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php; + fastcgi_param REQUEST_URI $api_uri; + } +} diff --git a/roles/opnform/tests/inventory b/roles/opnform/tests/inventory new file mode 100644 index 0000000..712db59 --- /dev/null +++ b/roles/opnform/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost diff --git a/roles/opnform/tests/test.yml b/roles/opnform/tests/test.yml new file mode 100644 index 0000000..3ff9caa --- /dev/null +++ b/roles/opnform/tests/test.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - opnform \ No newline at end of file diff --git a/roles/opnform/vars/main.yml b/roles/opnform/vars/main.yml new file mode 100644 index 0000000..94900f8 --- /dev/null +++ b/roles/opnform/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for opnform \ No newline at end of file