From 029b1a86d41e77f80dcae7a4f642b702f613162c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Thu, 15 Jan 2026 16:31:27 +0100 Subject: [PATCH 01/16] chore: add new boilerplate role for homarr --- roles/homarr/README.md | 38 ++++++++++++++++++++++++++++++++++ roles/homarr/defaults/main.yml | 3 +++ roles/homarr/handlers/main.yml | 3 +++ roles/homarr/meta/main.yml | 35 +++++++++++++++++++++++++++++++ roles/homarr/tasks/main.yml | 3 +++ roles/homarr/tests/inventory | 2 ++ roles/homarr/tests/test.yml | 6 ++++++ roles/homarr/vars/main.yml | 3 +++ 8 files changed, 93 insertions(+) create mode 100644 roles/homarr/README.md create mode 100644 roles/homarr/defaults/main.yml create mode 100644 roles/homarr/handlers/main.yml create mode 100644 roles/homarr/meta/main.yml create mode 100644 roles/homarr/tasks/main.yml create mode 100644 roles/homarr/tests/inventory create mode 100644 roles/homarr/tests/test.yml create mode 100644 roles/homarr/vars/main.yml diff --git a/roles/homarr/README.md b/roles/homarr/README.md new file mode 100644 index 0000000..da76bcd --- /dev/null +++ b/roles/homarr/README.md @@ -0,0 +1,38 @@ +Role Name +========= + +A brief description of the role goes here. + +Requirements +------------ + +Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. + +Role Variables +-------------- + +A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. + +Dependencies +------------ + +A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. + +Example Playbook +---------------- + +Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: + + - hosts: servers + roles: + - { role: username.rolename, x: 42 } + +License +------- + +BSD + +Author Information +------------------ + +An optional section for the role authors to include contact information, or a website (HTML is not allowed). \ No newline at end of file diff --git a/roles/homarr/defaults/main.yml b/roles/homarr/defaults/main.yml new file mode 100644 index 0000000..bc30dcc --- /dev/null +++ b/roles/homarr/defaults/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for homarr \ No newline at end of file diff --git a/roles/homarr/handlers/main.yml b/roles/homarr/handlers/main.yml new file mode 100644 index 0000000..56f5283 --- /dev/null +++ b/roles/homarr/handlers/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for homarr \ No newline at end of file diff --git a/roles/homarr/meta/main.yml b/roles/homarr/meta/main.yml new file mode 100644 index 0000000..faea947 --- /dev/null +++ b/roles/homarr/meta/main.yml @@ -0,0 +1,35 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: your name + description: your role description + company: your company (optional) + + # 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/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml new file mode 100644 index 0000000..c2dc205 --- /dev/null +++ b/roles/homarr/tasks/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for homarr \ No newline at end of file diff --git a/roles/homarr/tests/inventory b/roles/homarr/tests/inventory new file mode 100644 index 0000000..712db59 --- /dev/null +++ b/roles/homarr/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost diff --git a/roles/homarr/tests/test.yml b/roles/homarr/tests/test.yml new file mode 100644 index 0000000..88ecfc1 --- /dev/null +++ b/roles/homarr/tests/test.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - homarr \ No newline at end of file diff --git a/roles/homarr/vars/main.yml b/roles/homarr/vars/main.yml new file mode 100644 index 0000000..984df2b --- /dev/null +++ b/roles/homarr/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for homarr \ No newline at end of file From d3bdb1fdec3115f70f6af12859bc8e9014854730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Fri, 23 Jan 2026 15:45:59 +0100 Subject: [PATCH 02/16] chore: base config and deployment for role homarr --- roles/homarr/defaults/main.yml | 22 +++++++++++++- roles/homarr/tasks/main.yml | 18 +++++++++++- roles/homarr/templates/docker-compose.yml.j2 | 31 ++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 roles/homarr/templates/docker-compose.yml.j2 diff --git a/roles/homarr/defaults/main.yml b/roles/homarr/defaults/main.yml index bc30dcc..c5dccef 100644 --- a/roles/homarr/defaults/main.yml +++ b/roles/homarr/defaults/main.yml @@ -1,3 +1,23 @@ #SPDX-License-Identifier: MIT-0 --- -# defaults file for homarr \ No newline at end of file +# defaults file for homarr + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# homarr-specific configuration +homarr_service_name: homarr +homarr_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ homarr_service_name }}" +homarr_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ homarr_service_name }}" + +# Service configuration +homarr_domain: "homarr.local.test" +homarr_image: "ghcr.io/homarr-labs/homarr:latest" +homarr_secret_encription_key: "CHANGE_ME" +homarr_port: 7575 +homarr_use_docker: false + +# Traefik configuration +homarr_traefik_network: "proxy" +homarr_use_ssl: true \ No newline at end of file diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index c2dc205..17c3bf5 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -1,3 +1,19 @@ #SPDX-License-Identifier: MIT-0 --- -# tasks file for homarr \ No newline at end of file +# tasks file for homarr +- name: Create docker compose directory + file: + path: "{{ homarr_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create docker-compose file for homarr + template: + src: docker-compose.yml.j2 + dest: "{{ homarr_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + +- name: Start homarr containers + community.docker.docker_compose_v2: + project_src: "{{ homarr_docker_compose_dir }}" + state: present \ No newline at end of file diff --git a/roles/homarr/templates/docker-compose.yml.j2 b/roles/homarr/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..7992c7c --- /dev/null +++ b/roles/homarr/templates/docker-compose.yml.j2 @@ -0,0 +1,31 @@ +#---------------------------------------------------------------------# +# Homarr - A simple, yet powerful dashboard for your server. # +#---------------------------------------------------------------------# +services: + homarr: + container_name: {{ homarr_service_name }} + image: {{ homarr_image }} + restart: unless-stopped + volumes: +{% if homarr_use_docker %} + - /var/run/docker.sock:/var/run/docker.sock # Optional, only if you want docker integration +{% endif %} + - {{ homarr_docker_volume_dir }}/homarr/appdata:/appdata + environment: + - SECRET_ENCRYPTION_KEY={{ homarr_secret_encryption_key }} + networks: + - {{ homarr_traefik_network }} + labels: + - traefik.enable=true + - traefik.docker.network={{ homarr_traefik_network }} + - traefik.http.routers.{{ homarr_service_name }}.rule=Host(`{{ homarr_domain }}`) +{% if homarr_use_ssl %} + - traefik.http.routers.{{ homarr_service_name }}.entrypoints=websecure + - traefik.http.routers.{{ homarr_service_name }}.tls=true +{% else %} + - traefik.http.routers.{{ homarr_service_name }}.entrypoints=web +{% endif %} + - traefik.http.services.{{ homarr_service_name }}.loadbalancer.server.port={{ homarr_port }} +networks: + {{ homarr_traefik_network }}: + external: true \ No newline at end of file From 422b196831a764d34c7ccb07c93cc7a4fa4fe57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Tue, 7 Apr 2026 16:58:28 +0200 Subject: [PATCH 03/16] Chore: add admin user and seed staging added creation of the admin user, the basic homeboard and all basic setup tasks. Todo: Cleanup --- roles/homarr/defaults/main.yml | 31 +- roles/homarr/tasks/main.yml | 383 ++++++++++++++++++- roles/homarr/templates/docker-compose.yml.j2 | 18 +- 3 files changed, 424 insertions(+), 8 deletions(-) diff --git a/roles/homarr/defaults/main.yml b/roles/homarr/defaults/main.yml index c5dccef..78b32ab 100644 --- a/roles/homarr/defaults/main.yml +++ b/roles/homarr/defaults/main.yml @@ -7,17 +7,44 @@ docker_compose_base_dir: /etc/docker/compose docker_volume_base_dir: /srv/data # homarr-specific configuration +homarr_base_path: /srv/data/homarr homarr_service_name: homarr homarr_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ homarr_service_name }}" homarr_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ homarr_service_name }}" +homarr_appdata_dir: "{{ homarr_docker_volume_dir }}/{{ homarr_service_name }}/appdata" +homarr_db_dir: "{{ homarr_appdata_dir }}/db/db.sqlite" # Service configuration homarr_domain: "homarr.local.test" homarr_image: "ghcr.io/homarr-labs/homarr:latest" -homarr_secret_encription_key: "CHANGE_ME" +homarr_secret_encryption_key: "4fc2f54f54be3f4439b728da81b743fb0ee6317fd1a24f4096611f68019fa5a7" homarr_port: 7575 homarr_use_docker: false +# URL – wird für BASE_URL, NEXTAUTH_URL und die Completion-Message verwendet +homarr_base_url: "https://home.local.test" + +# OIDC Konfiguration +oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard" +oidc_client_id: "homarr-digitalboard" +oidc_client_name: "Digitalboard" +oidc_scopes: "openid profile email groups" +oidc_groups_attribute: "groups" +oidc_client_secret: "mein-test-secret-aus-keycloak" +oidc_auto_login: "false" + +# OIDC Admin-Gruppe (muss in Keycloak existieren) +oidc_admin_group: "homarr-admins" + +# Board Konfiguration +default_board_name: "Home" +default_board_public: true + # Traefik configuration homarr_traefik_network: "proxy" -homarr_use_ssl: true \ No newline at end of file +homarr_use_ssl: true + +# Lokaler Admin +homarr_admin_username: "admin" +homarr_admin_email: "admin@digitalboard.ch" +homarr_admin_password: "ChangeMe123!" \ No newline at end of file diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index 17c3bf5..f8dd3df 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -1,14 +1,47 @@ #SPDX-License-Identifier: MIT-0 --- # tasks file for homarr + +# ===================================================================== +# 1. VORBEREITUNG: Pakete und Verzeichnisse VOR Container-Start +# ===================================================================== + +- name: Ensure required packages are installed + ansible.builtin.package: + name: + - sqlite3 + - python3-docker + - python3-bcrypt + state: present + - name: Create docker compose directory - file: + ansible.builtin.file: path: "{{ homarr_docker_compose_dir }}" state: directory mode: '0755' +- name: Create Homarr data directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "1000" + group: "1000" + mode: "0755" + loop: + - "{{ homarr_appdata_dir }}" + - "{{ homarr_appdata_dir }}/db" + +- name: Check if database already exists + ansible.builtin.stat: + path: "{{ homarr_db_dir }}" + register: db_exists + +# ===================================================================== +# 2. CONTAINER STARTEN +# ===================================================================== + - name: Create docker-compose file for homarr - template: + ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ homarr_docker_compose_dir }}/docker-compose.yml" mode: '0644' @@ -16,4 +49,348 @@ - name: Start homarr containers community.docker.docker_compose_v2: project_src: "{{ homarr_docker_compose_dir }}" - state: present \ No newline at end of file + state: present + +# ===================================================================== +# 3. AUF DATENBANK WARTEN +# ===================================================================== + +- name: Wait for database to be created by Homarr + ansible.builtin.wait_for: + path: "{{ homarr_db_dir }}" + state: present + timeout: 60 + when: not db_exists.stat.exists + +- name: Wait for database schema to be initialized + ansible.builtin.shell: | + i=0 + while [ $i -lt 30 ]; do + if sqlite3 "{{ homarr_db_dir }}" "SELECT name FROM sqlite_master WHERE type='table' AND name='board';" 2>/dev/null | grep -q board; then + exit 0 + fi + sleep 2 + i=$((i + 1)) + done + exit 1 + register: schema_check + changed_when: false + when: not db_exists.stat.exists + +- name: Ensure python3-bcrypt is installed + ansible.builtin.package: + name: python3-bcrypt + state: present + +- name: Generate bcrypt hash for admin password + ansible.builtin.shell: | + python3 -c " + import bcrypt + password = '{{ homarr_admin_password }}'.encode() + salt = bcrypt.gensalt(rounds=10) + hashed = bcrypt.hashpw(password, salt) + print(salt.decode()) + print(hashed.decode()) + " + register: bcrypt_output + changed_when: false + no_log: true + +# ===================================================================== +# 4. DATENBANK SEEDEN (nur wenn Onboarding noch nicht abgeschlossen) +# ===================================================================== + +- name: Check if onboarding is already completed + ansible.builtin.shell: | + sqlite3 "{{ homarr_db_dir }}" "SELECT step FROM onboarding WHERE step='finish';" 2>/dev/null + register: onboarding_status + changed_when: false + failed_when: false + +- name: Seed Homarr database + ansible.builtin.shell: | + sqlite3 "{{ homarr_db_dir }}" << 'SEEDSQL' + -- SERVER SETTINGS + INSERT OR REPLACE INTO serverSetting (setting_key, value) + VALUES + ('analytics', '{"json": {"enableGeneral": false, "enableWidgetData": false, "enableIntegrationData": false}}'), + ('culture', '{"json": {"defaultLocale": "de"}}'), + ('crawling', '{"json": {"crawlingEnabled": false}}'), + ('board', '{"json": {"defaultBoardId": "board-default"}}'); + + -- ONBOARDING ÜBERSPRINGEN + UPDATE onboarding SET step = 'finish', previous_step = 'settings'; + + -- ===================================================================== + -- GRUPPEN (müssen VOR groupMember existieren) + -- ===================================================================== + + -- OIDC-ADMIN GRUPPE + INSERT OR IGNORE INTO "group" (id, name, owner_id, position) + VALUES ('group-oidc-admins', '{{ oidc_admin_group | default("homarr-admins") }}', NULL, 0); + + INSERT OR IGNORE INTO groupPermission (group_id, permission) + VALUES + ('group-oidc-admins', 'admin'), + ('group-oidc-admins', 'board-create'), + ('group-oidc-admins', 'board-full-access'), + ('group-oidc-admins', 'integration-create'), + ('group-oidc-admins', 'integration-full-access'); + + -- CREDENTIALS-ADMIN GRUPPE + INSERT OR IGNORE INTO "group" (id, name, owner_id, position) + VALUES ('group-credentials-admin', 'credentials-admin', NULL, 1); + + INSERT OR IGNORE INTO groupPermission (group_id, permission) + VALUES + ('group-credentials-admin', 'admin'), + ('group-credentials-admin', 'board-create'), + ('group-credentials-admin', 'board-full-access'), + ('group-credentials-admin', 'integration-create'), + ('group-credentials-admin', 'integration-full-access'); + + -- ===================================================================== + -- LOKALER ADMIN USER (Passwort wird via CLI gesetzt) + -- ===================================================================== + + INSERT OR IGNORE INTO user (id, name, email, password, salt, email_verified, provider) + VALUES ( + 'user-local-admin', + '{{ homarr_admin_username | default("admin") }}', + '{{ homarr_admin_email | default("admin@digitalboard.ch") }}', + '{{ bcrypt_output.stdout_lines[1] }}', + '{{ bcrypt_output.stdout_lines[0] }}', + 1, + 'credentials' + ); + + -- ADMIN-USER DEN GRUPPEN ZUWEISEN (Gruppen existieren jetzt) + + INSERT OR IGNORE INTO groupMember (group_id, user_id) + VALUES + ('group-credentials-admin', 'user-local-admin'), + ('group-oidc-admins', 'user-local-admin'); + + -- ===================================================================== + -- BOARD + -- ===================================================================== + + INSERT OR IGNORE INTO board ( + id, name, is_public, + primary_color, secondary_color, opacity, + background_image_attachment, background_image_repeat, background_image_size, + item_radius, disable_status + ) + VALUES ( + 'board-default', + '{{ default_board_name | default("Dashboard") }}', + {% if default_board_public | default(true) %}1{% else %}0{% endif %}, + '#fa5252', + '#fd7e14', + 100, + 'fixed', + 'no-repeat', + 'cover', + 'lg', + 0 + ); + + -- LAYOUTS + INSERT OR IGNORE INTO layout (id, name, board_id, column_count, breakpoint) + VALUES + ('layout-desktop', 'Desktop', 'board-default', 10, 0), + ('layout-tablet', 'Tablet', 'board-default', 6, 768), + ('layout-mobile', 'Mobile', 'board-default', 2, 480); + + -- HOME BOARD FÜR ADMIN SETZEN (Board existiert jetzt) + UPDATE user SET home_board_id = 'board-default', mobile_home_board_id = 'board-default' + WHERE id = 'user-local-admin'; + + -- SEKTION + DELETE FROM section_layout WHERE section_id = 'section-apps'; + DELETE FROM item_layout WHERE section_id = 'section-apps'; + DELETE FROM section WHERE id = 'section-apps'; + + INSERT INTO section (id, board_id, kind, x_offset, y_offset, name, options) + VALUES ( + 'section-apps', + 'board-default', + 'empty', + 0, + 0, + 'Anwendungen', + '{"json": {}}' + ); + + 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); + + -- BOARD-BERECHTIGUNG + INSERT OR IGNORE INTO boardGroupPermission (board_id, group_id, permission) + VALUES + ('board-default', 'group-oidc-admins', 'full-access'), + ('board-default', 'group-credentials-admin', 'full-access'); + + -- ===================================================================== + -- APPS + -- ===================================================================== + + -- Nextcloud + INSERT OR IGNORE INTO app (id, name, description, icon_url, href) + VALUES ( + 'app-nextcloud', + 'Nextcloud', + 'Cloud Storage & Collaboration', + 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nextcloud.png', + 'https://cloud.digitalboard.ch' + ); + + INSERT OR IGNORE INTO item (id, board_id, kind, options, advanced_options) + VALUES ( + 'item-nextcloud', + 'board-default', + 'app', + '{"json": {"appId": "app-nextcloud"}}', + '{"json": {}}' + ); + + INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height) + VALUES + ('item-nextcloud', 'section-apps', 'layout-desktop', 0, 0, 2, 1), + ('item-nextcloud', 'section-apps', 'layout-tablet', 0, 0, 2, 1), + ('item-nextcloud', 'section-apps', 'layout-mobile', 0, 0, 1, 1); + + -- Keycloak + INSERT OR IGNORE INTO app (id, name, description, icon_url, href) + VALUES ( + 'app-keycloak', + 'Keycloak', + 'Identity & Access Management', + 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/keycloak.png', + 'https://auth.digitalboard.ch' + ); + + INSERT OR IGNORE INTO item (id, board_id, kind, options, advanced_options) + VALUES ( + 'item-keycloak', + 'board-default', + 'app', + '{"json": {"appId": "app-keycloak"}}', + '{"json": {}}' + ); + + INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height) + VALUES + ('item-keycloak', 'section-apps', 'layout-desktop', 2, 0, 2, 1), + ('item-keycloak', 'section-apps', 'layout-tablet', 2, 0, 2, 1), + ('item-keycloak', 'section-apps', 'layout-mobile', 1, 0, 1, 1); + + -- Mailman + INSERT OR IGNORE INTO app (id, name, description, icon_url, href) + VALUES ( + 'app-mailman', + 'Mailman', + 'Mailing List Manager', + 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/mailman.png', + 'https://lists.digitalboard.ch' + ); + + INSERT OR IGNORE INTO item (id, board_id, kind, options, advanced_options) + VALUES ( + 'item-mailman', + 'board-default', + 'app', + '{"json": {"appId": "app-mailman"}}', + '{"json": {}}' + ); + + INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height) + VALUES + ('item-mailman', 'section-apps', 'layout-desktop', 4, 0, 2, 1), + ('item-mailman', 'section-apps', 'layout-tablet', 4, 0, 2, 1), + ('item-mailman', 'section-apps', 'layout-mobile', 0, 1, 1, 1); + SEEDSQL + args: + executable: /bin/bash + register: seed_result + changed_when: seed_result.rc == 0 + when: onboarding_status.stdout is not defined or 'finish' not in onboarding_status.stdout + +# ===================================================================== +# 5. RESTART, HEALTH-CHECK, DANN CLI +# ===================================================================== + +- name: Restart Homarr to apply database changes + ansible.builtin.shell: + cmd: docker compose restart + chdir: "{{ homarr_docker_compose_dir }}" + when: seed_result is changed + +- name: Wait for Homarr to be ready + ansible.builtin.shell: + cmd: docker compose exec -T {{ homarr_service_name }} wget -qO /dev/null "http://localhost:7575" 2>&1 + chdir: "{{ homarr_docker_compose_dir }}" + retries: 30 + delay: 5 + register: homarr_ready + until: homarr_ready.rc == 0 + changed_when: false + +- name: Display completion message + ansible.builtin.debug: + msg: | + ============================================================ + Homarr deployed! + URL: {{ homarr_base_url }} + Admin-User: {{ homarr_admin_username }} + Admin-Passwort: {{ homarr_admin_password }} + (Bitte sofort ändern!) + ============================================================ + when: not db_exists.stat.exists + +# ===================================================================== +# 6. ABSCHLUSS +# ===================================================================== + +- name: Display admin credentials + ansible.builtin.debug: + msg: | + ============================================ + LOKALER ADMIN PASSWORT (bitte sofort ändern!) + {{ admin_password_output.stdout }} + ============================================ + when: + - admin_password_output is defined + - admin_password_output.stdout is defined + - admin_password_output.stdout | length > 0 + +- name: Display completion message + ansible.builtin.debug: + msg: | + ============================================================ + Homarr wurde erfolgreich deployed! + ============================================================ + + URL: {{ homarr_base_url }} + + OIDC Konfiguration: + - Issuer: {{ oidc_issuer }} + - Client ID: {{ oidc_client_id }} + - Admin-Gruppe: {{ oidc_admin_group }} + + Nächste Schritte: + 1. Stelle sicher, dass in Keycloak die Gruppe "{{ oidc_admin_group }}" existiert + 2. Füge deinen User zur Gruppe "{{ oidc_admin_group }}" hinzu + 3. Öffne {{ homarr_base_url }} + 4. Du solltest automatisch via OIDC eingeloggt werden + + Das Standard-Board "{{ default_board_name }}" wurde erstellt mit: + - Nextcloud App + - Keycloak App + - Mailman App + + Weitere Apps kannst du direkt in der Homarr UI hinzufügen. + ============================================================ \ No newline at end of file diff --git a/roles/homarr/templates/docker-compose.yml.j2 b/roles/homarr/templates/docker-compose.yml.j2 index 7992c7c..b953ed6 100644 --- a/roles/homarr/templates/docker-compose.yml.j2 +++ b/roles/homarr/templates/docker-compose.yml.j2 @@ -2,17 +2,29 @@ # Homarr - A simple, yet powerful dashboard for your server. # #---------------------------------------------------------------------# services: - homarr: + {{ homarr_service_name }}: container_name: {{ homarr_service_name }} image: {{ homarr_image }} restart: unless-stopped volumes: {% if homarr_use_docker %} - - /var/run/docker.sock:/var/run/docker.sock # Optional, only if you want docker integration + - /var/run/docker.sock:/var/run/docker.sock {% endif %} - {{ homarr_docker_volume_dir }}/homarr/appdata:/appdata environment: - - SECRET_ENCRYPTION_KEY={{ homarr_secret_encryption_key }} + TZ: "Europe/Zurich" + BASE_URL: "{{ homarr_base_url }}" + NEXTAUTH_URL: "{{ homarr_base_url }}" + SECRET_ENCRYPTION_KEY: "{{ homarr_secret_encryption_key }}" + # Auth: Credentials + OIDC + AUTH_PROVIDERS: "credentials,oidc" + AUTH_OIDC_ISSUER: "{{ oidc_issuer }}" + AUTH_OIDC_CLIENT_ID: "{{ oidc_client_id }}" + AUTH_OIDC_CLIENT_SECRET: "{{ oidc_client_secret }}" + AUTH_OIDC_CLIENT_NAME: "{{ oidc_client_name | default('Keycloak') }}" + AUTH_OIDC_SCOPE_OVERWRITE: "{{ oidc_scopes | default('openid email profile groups') }}" + AUTH_OIDC_GROUPS_ATTRIBUTE: "{{ oidc_groups_attribute | default('groups') }}" + AUTH_OIDC_AUTO_LOGIN: "{{ oidc_auto_login | default('false') }}" networks: - {{ homarr_traefik_network }} labels: From 6c1c40668d0ec271a4bc89b9e72c8ab8b0c6c45c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Wed, 13 May 2026 17:23:34 +0200 Subject: [PATCH 04/16] chore: add new role for OpnForm --- roles/OpnForm/README.md | 126 ++++++++++++ roles/OpnForm/defaults/main.yml | 71 +++++++ roles/OpnForm/handlers/main.yml | 8 + roles/OpnForm/meta/main.yml | 35 ++++ roles/OpnForm/tasks/main.yml | 117 +++++++++++ roles/OpnForm/templates/docker-compose.yml.j2 | 189 ++++++++++++++++++ roles/OpnForm/templates/nginx.conf.j2 | 43 ++++ roles/OpnForm/tests/inventory | 2 + roles/OpnForm/tests/test.yml | 6 + roles/OpnForm/vars/main.yml | 3 + 10 files changed, 600 insertions(+) create mode 100644 roles/OpnForm/README.md create mode 100644 roles/OpnForm/defaults/main.yml create mode 100644 roles/OpnForm/handlers/main.yml create mode 100644 roles/OpnForm/meta/main.yml create mode 100644 roles/OpnForm/tasks/main.yml create mode 100644 roles/OpnForm/templates/docker-compose.yml.j2 create mode 100644 roles/OpnForm/templates/nginx.conf.j2 create mode 100644 roles/OpnForm/tests/inventory create mode 100644 roles/OpnForm/tests/test.yml create mode 100644 roles/OpnForm/vars/main.yml diff --git a/roles/OpnForm/README.md b/roles/OpnForm/README.md new file mode 100644 index 0000000..67e5436 --- /dev/null +++ b/roles/OpnForm/README.md @@ -0,0 +1,126 @@ +# 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-create an admin user (use the default credentials below) +- 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 + +After the role completes, OpnForm seeds a default admin user. Visit +the URL in `opnform_base_url` and log in with: + +- Email: `admin@opnform.com` +- Password: `password` + +On first login OpnForm will prompt you to change email and password. +Self-hosted instances disable public registration after this — invite +further users via the Admin UI. + +### If the login does not respond + +The DB seed may have failed. Re-run it manually: + +```bash +cd /etc/docker/compose/opnform +docker compose exec api php artisan migrate:refresh --seed +docker compose exec api php artisan app:init-project +``` + +## OIDC setup (stage 2, not yet automated) + +Manual setup via the Admin UI is currently the supported path: + +1. Settings → Identity Connections → Add Connection +2. Provider: OIDC +3. Issuer: `https://auth.digitalboard.ch/realms/Digitalboard` +4. Client ID / Secret: from your Keycloak client +5. Add Group Role Mapping: Entra/Keycloak group Object ID → OpnForm role + +Direct DB manipulation of `identity_connections` / `group_role_mappings` +is possible but fragile across OpnForm versions. A future iteration of +this role may automate it. + +## 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..35996a2 --- /dev/null +++ b/roles/OpnForm/defaults/main.yml @@ -0,0 +1,71 @@ +#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 — 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. +opnform_app_key: "base64:vsQw8EoC64nmhurLUUohXUlAeryaV6Y2Is64Tdvjlko=" +opnform_jwt_secret: "0b2e8ed326334a08ce3846bfcd6588f5a11be33999e96963cd4eaff1a3ae828b" +opnform_front_api_secret: "8f52397785a110b657f2a6beab13362877bfac936ae9002bc236c54ed1011b2d" + +# Database credentials +opnform_db_name: "opnform" +opnform_db_user: "opnform" +opnform_db_password: "xtNLUVc2ajcWictqWXWkLR" + +# 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 (Stage 1: not auto-configured, set up via UI after deploy) +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_oidc_scopes: "openid profile email groups" +opnform_oidc_admin_group: "opnform-admins" + +# 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/main.yml b/roles/OpnForm/meta/main.yml new file mode 100644 index 0000000..faea947 --- /dev/null +++ b/roles/OpnForm/meta/main.yml @@ -0,0 +1,35 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: your name + description: your role description + company: your company (optional) + + # 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/tasks/main.yml b/roles/OpnForm/tasks/main.yml new file mode 100644 index 0000000..412dc25 --- /dev/null +++ b/roles/OpnForm/tasks/main.yml @@ -0,0 +1,117 @@ +#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 + fail_msg: >- + opnform_oidc_client_secret must be set when opnform_oidc_enabled is true. + 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 + +- name: Display deployment info + ansible.builtin.debug: + msg: |- + OpnForm deployed at {{ opnform_base_url }} + + Default credentials (from API container logs on first start): + Email: admin@opnform.com + Password: password + + On first login you will be prompted to change email and password. + + If login does not respond, the DB seed may have failed. Run: + docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan migrate:refresh --seed + docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan app:init-project + + OIDC: {% if opnform_oidc_enabled %}enabled (configure via Admin UI){% else %}disabled{% 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..60bdb75 --- /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..984df2b --- /dev/null +++ b/roles/OpnForm/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for homarr \ No newline at end of file From bbbd1c8940391d9362bec24b048b0596d3da53ff Mon Sep 17 00:00:00 2001 From: Tobias-Wuest Date: Mon, 18 May 2026 10:47:06 +0000 Subject: [PATCH 05/16] fix: (Homarr) removed small mistakenly added entry in defaults --- roles/homarr/defaults/main.yml | 1 - 1 file changed, 1 deletion(-) 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 From 27ed51ee957a9021295e66b455bd328f44358df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Mon, 18 May 2026 18:02:59 +0200 Subject: [PATCH 06/16] chore: ignore python bytecode cache --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 85e7c1d..2434dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /.idea/ +__pycache__/ +*.pyc From 3f90843f97367117c388e810e709ce24aed5657d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Mon, 18 May 2026 21:00:20 +0200 Subject: [PATCH 07/16] fix: added pycache to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 85e7c1d..ef41221 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /.idea/ + +plugins/lookup/__pycache__/ From 2341815daf33d1b757305a8d5b3032cfd89e252a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Mon, 18 May 2026 22:40:19 +0200 Subject: [PATCH 08/16] feat(opnform)!: add admin and OIDC bootstrap, rename role to lowercase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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]. --- roles/OpnForm/tasks/main.yml | 117 -------- roles/{OpnForm => opnform}/README.md | 93 ++++-- roles/{OpnForm => opnform}/defaults/main.yml | 34 ++- roles/{OpnForm => opnform}/handlers/main.yml | 0 roles/{OpnForm => opnform}/meta/main.yml | 0 roles/opnform/tasks/main.yml | 265 ++++++++++++++++++ .../templates/docker-compose.yml.j2 | 0 .../templates/nginx.conf.j2 | 0 roles/{OpnForm => opnform}/tests/inventory | 0 roles/{OpnForm => opnform}/tests/test.yml | 2 +- roles/{OpnForm => opnform}/vars/main.yml | 0 11 files changed, 366 insertions(+), 145 deletions(-) delete mode 100644 roles/OpnForm/tasks/main.yml rename roles/{OpnForm => opnform}/README.md (55%) rename roles/{OpnForm => opnform}/defaults/main.yml (61%) rename roles/{OpnForm => opnform}/handlers/main.yml (100%) rename roles/{OpnForm => opnform}/meta/main.yml (100%) create mode 100644 roles/opnform/tasks/main.yml rename roles/{OpnForm => opnform}/templates/docker-compose.yml.j2 (100%) rename roles/{OpnForm => opnform}/templates/nginx.conf.j2 (100%) rename roles/{OpnForm => opnform}/tests/inventory (100%) rename roles/{OpnForm => opnform}/tests/test.yml (86%) rename roles/{OpnForm => opnform}/vars/main.yml (100%) diff --git a/roles/OpnForm/tasks/main.yml b/roles/OpnForm/tasks/main.yml deleted file mode 100644 index 412dc25..0000000 --- a/roles/OpnForm/tasks/main.yml +++ /dev/null @@ -1,117 +0,0 @@ -#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 - fail_msg: >- - opnform_oidc_client_secret must be set when opnform_oidc_enabled is true. - 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 - -- name: Display deployment info - ansible.builtin.debug: - msg: |- - OpnForm deployed at {{ opnform_base_url }} - - Default credentials (from API container logs on first start): - Email: admin@opnform.com - Password: password - - On first login you will be prompted to change email and password. - - If login does not respond, the DB seed may have failed. Run: - docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan migrate:refresh --seed - docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan app:init-project - - OIDC: {% if opnform_oidc_enabled %}enabled (configure via Admin UI){% else %}disabled{% endif %} diff --git a/roles/OpnForm/README.md b/roles/opnform/README.md similarity index 55% rename from roles/OpnForm/README.md rename to roles/opnform/README.md index 67e5436..2dfad2d 100644 --- a/roles/OpnForm/README.md +++ b/roles/opnform/README.md @@ -13,7 +13,6 @@ Docker Compose stack behind Traefik. ## What this role does NOT do (stage 1) -- Does not pre-create an admin user (use the default credentials below) - Does not pre-configure OIDC / identity_connections — set up via Admin UI ## Architecture note: why two reverse proxies? @@ -61,39 +60,83 @@ missing or malformed. ## First login -After the role completes, OpnForm seeds a default admin user. Visit -the URL in `opnform_base_url` and log in with: +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). -- Email: `admin@opnform.com` -- Password: `password` +This role supports two ways to create that first user: -On first login OpnForm will prompt you to change email and password. -Self-hosted instances disable public registration after this — invite -further users via the Admin UI. +### Option A — automated bootstrap (recommended) -### If the login does not respond +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. -The DB seed may have failed. Re-run it manually: - -```bash -cd /etc/docker/compose/opnform -docker compose exec api php artisan migrate:refresh --seed -docker compose exec api php artisan app:init-project +```yaml +opnform_admin_name: "Administrator" # default +opnform_admin_email: "admin@example.com" +opnform_admin_password: "{{ vault_opnform_admin_password }}" ``` -## OIDC setup (stage 2, not yet automated) +Password rules enforced by OpnForm: minimum 8 characters, at least one +letter, one digit, and one of `@$!%*#?&-_+=.,:;<>^()[]{}|~`. -Manual setup via the Admin UI is currently the supported path: +### Option B — manual setup page -1. Settings → Identity Connections → Add Connection -2. Provider: OIDC -3. Issuer: `https://auth.digitalboard.ch/realms/Digitalboard` -4. Client ID / Secret: from your Keycloak client -5. Add Group Role Mapping: Entra/Keycloak group Object ID → OpnForm role +Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit +`opnform_base_url` and complete the setup page in the browser. -Direct DB manipulation of `identity_connections` / `group_role_mappings` -is possible but fragile across OpnForm versions. A future iteration of -this role may automate it. +## 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 diff --git a/roles/OpnForm/defaults/main.yml b/roles/opnform/defaults/main.yml similarity index 61% rename from roles/OpnForm/defaults/main.yml rename to roles/opnform/defaults/main.yml index 35996a2..09aed4c 100644 --- a/roles/OpnForm/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -38,6 +38,17 @@ opnform_db_name: "opnform" opnform_db_user: "opnform" 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 +# 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" @@ -57,14 +68,33 @@ opnform_mail_encryption: "" opnform_mail_from_address: "noreply@digitalboard.ch" opnform_mail_from_name: "OpnForm" -# OIDC configuration (Stage 1: not auto-configured, set up via UI after deploy) +# 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_oidc_scopes: "openid profile email groups" +# 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" diff --git a/roles/OpnForm/handlers/main.yml b/roles/opnform/handlers/main.yml similarity index 100% rename from roles/OpnForm/handlers/main.yml rename to roles/opnform/handlers/main.yml diff --git a/roles/OpnForm/meta/main.yml b/roles/opnform/meta/main.yml similarity index 100% rename from roles/OpnForm/meta/main.yml rename to roles/opnform/meta/main.yml 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 similarity index 100% rename from roles/OpnForm/templates/docker-compose.yml.j2 rename to roles/opnform/templates/docker-compose.yml.j2 diff --git a/roles/OpnForm/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 similarity index 100% rename from roles/OpnForm/templates/nginx.conf.j2 rename to roles/opnform/templates/nginx.conf.j2 diff --git a/roles/OpnForm/tests/inventory b/roles/opnform/tests/inventory similarity index 100% rename from roles/OpnForm/tests/inventory rename to roles/opnform/tests/inventory diff --git a/roles/OpnForm/tests/test.yml b/roles/opnform/tests/test.yml similarity index 86% rename from roles/OpnForm/tests/test.yml rename to roles/opnform/tests/test.yml index 60bdb75..3ff9caa 100644 --- a/roles/OpnForm/tests/test.yml +++ b/roles/opnform/tests/test.yml @@ -3,4 +3,4 @@ - hosts: localhost remote_user: root roles: - - OpnForm \ No newline at end of file + - opnform \ No newline at end of file diff --git a/roles/OpnForm/vars/main.yml b/roles/opnform/vars/main.yml similarity index 100% rename from roles/OpnForm/vars/main.yml rename to roles/opnform/vars/main.yml From 61193e26f479dd21e2e1da4974dd114eae9a43d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Tue, 19 May 2026 11:19:29 +0200 Subject: [PATCH 09/16] refactor(homarr): extract layout packing to filter plugin --- .gitignore | 2 + roles/homarr/README.md | 49 ++++- roles/homarr/filter_plugins/homarr_layout.py | 140 ++++++++++++++ .../tests/test_homarr_layout.py | 178 ++++++++++++++++++ roles/homarr/tasks/main.yml | 18 +- roles/homarr/templates/homarr_seed.sql.j2 | 76 ++------ 6 files changed, 405 insertions(+), 58 deletions(-) create mode 100644 roles/homarr/filter_plugins/homarr_layout.py create mode 100644 roles/homarr/filter_plugins/tests/test_homarr_layout.py diff --git a/.gitignore b/.gitignore index 85e7c1d..ef41221 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /.idea/ + +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/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; From 6de9c031c720106106ae02192376f4db14b04082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Tue, 19 May 2026 11:26:18 +0200 Subject: [PATCH 10/16] fix: (homarr) removed mistakenly added variable --- roles/homarr/defaults/main.yml | 1 - 1 file changed, 1 deletion(-) 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 From a6f301ee546dd25f86e277f37126a4a1e65b11a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Wed, 20 May 2026 12:48:48 +0200 Subject: [PATCH 11/16] WIP on OpnForm --- roles/opnform/templates/compose.yml.j2 | 133 +++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 roles/opnform/templates/compose.yml.j2 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 From 53e80ad7be1b8653f13cbc86fb08c84edfdb365e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Wed, 13 May 2026 17:23:34 +0200 Subject: [PATCH 12/16] chore: add new role for OpnForm --- roles/OpnForm/README.md | 126 ++++++++++++ roles/OpnForm/defaults/main.yml | 71 +++++++ roles/OpnForm/handlers/main.yml | 8 + roles/OpnForm/meta/main.yml | 35 ++++ roles/OpnForm/tasks/main.yml | 117 +++++++++++ roles/OpnForm/templates/docker-compose.yml.j2 | 189 ++++++++++++++++++ roles/OpnForm/templates/nginx.conf.j2 | 43 ++++ roles/OpnForm/tests/inventory | 2 + roles/OpnForm/tests/test.yml | 6 + roles/OpnForm/vars/main.yml | 3 + 10 files changed, 600 insertions(+) create mode 100644 roles/OpnForm/README.md create mode 100644 roles/OpnForm/defaults/main.yml create mode 100644 roles/OpnForm/handlers/main.yml create mode 100644 roles/OpnForm/meta/main.yml create mode 100644 roles/OpnForm/tasks/main.yml create mode 100644 roles/OpnForm/templates/docker-compose.yml.j2 create mode 100644 roles/OpnForm/templates/nginx.conf.j2 create mode 100644 roles/OpnForm/tests/inventory create mode 100644 roles/OpnForm/tests/test.yml create mode 100644 roles/OpnForm/vars/main.yml diff --git a/roles/OpnForm/README.md b/roles/OpnForm/README.md new file mode 100644 index 0000000..67e5436 --- /dev/null +++ b/roles/OpnForm/README.md @@ -0,0 +1,126 @@ +# 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-create an admin user (use the default credentials below) +- 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 + +After the role completes, OpnForm seeds a default admin user. Visit +the URL in `opnform_base_url` and log in with: + +- Email: `admin@opnform.com` +- Password: `password` + +On first login OpnForm will prompt you to change email and password. +Self-hosted instances disable public registration after this — invite +further users via the Admin UI. + +### If the login does not respond + +The DB seed may have failed. Re-run it manually: + +```bash +cd /etc/docker/compose/opnform +docker compose exec api php artisan migrate:refresh --seed +docker compose exec api php artisan app:init-project +``` + +## OIDC setup (stage 2, not yet automated) + +Manual setup via the Admin UI is currently the supported path: + +1. Settings → Identity Connections → Add Connection +2. Provider: OIDC +3. Issuer: `https://auth.digitalboard.ch/realms/Digitalboard` +4. Client ID / Secret: from your Keycloak client +5. Add Group Role Mapping: Entra/Keycloak group Object ID → OpnForm role + +Direct DB manipulation of `identity_connections` / `group_role_mappings` +is possible but fragile across OpnForm versions. A future iteration of +this role may automate it. + +## 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..35996a2 --- /dev/null +++ b/roles/OpnForm/defaults/main.yml @@ -0,0 +1,71 @@ +#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 — 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. +opnform_app_key: "base64:vsQw8EoC64nmhurLUUohXUlAeryaV6Y2Is64Tdvjlko=" +opnform_jwt_secret: "0b2e8ed326334a08ce3846bfcd6588f5a11be33999e96963cd4eaff1a3ae828b" +opnform_front_api_secret: "8f52397785a110b657f2a6beab13362877bfac936ae9002bc236c54ed1011b2d" + +# Database credentials +opnform_db_name: "opnform" +opnform_db_user: "opnform" +opnform_db_password: "xtNLUVc2ajcWictqWXWkLR" + +# 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 (Stage 1: not auto-configured, set up via UI after deploy) +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_oidc_scopes: "openid profile email groups" +opnform_oidc_admin_group: "opnform-admins" + +# 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/main.yml b/roles/OpnForm/meta/main.yml new file mode 100644 index 0000000..faea947 --- /dev/null +++ b/roles/OpnForm/meta/main.yml @@ -0,0 +1,35 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: your name + description: your role description + company: your company (optional) + + # 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/tasks/main.yml b/roles/OpnForm/tasks/main.yml new file mode 100644 index 0000000..412dc25 --- /dev/null +++ b/roles/OpnForm/tasks/main.yml @@ -0,0 +1,117 @@ +#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 + fail_msg: >- + opnform_oidc_client_secret must be set when opnform_oidc_enabled is true. + 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 + +- name: Display deployment info + ansible.builtin.debug: + msg: |- + OpnForm deployed at {{ opnform_base_url }} + + Default credentials (from API container logs on first start): + Email: admin@opnform.com + Password: password + + On first login you will be prompted to change email and password. + + If login does not respond, the DB seed may have failed. Run: + docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan migrate:refresh --seed + docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan app:init-project + + OIDC: {% if opnform_oidc_enabled %}enabled (configure via Admin UI){% else %}disabled{% 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..60bdb75 --- /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..984df2b --- /dev/null +++ b/roles/OpnForm/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for homarr \ No newline at end of file From 03af64ca2c0933357720be382c45baa9ebe83ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Mon, 18 May 2026 22:40:19 +0200 Subject: [PATCH 13/16] feat(opnform)!: add admin and OIDC bootstrap, rename role to lowercase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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]. --- roles/OpnForm/tasks/main.yml | 117 -------- roles/{OpnForm => opnform}/README.md | 93 ++++-- roles/{OpnForm => opnform}/defaults/main.yml | 34 ++- roles/{OpnForm => opnform}/handlers/main.yml | 0 roles/{OpnForm => opnform}/meta/main.yml | 0 roles/opnform/tasks/main.yml | 265 ++++++++++++++++++ .../templates/docker-compose.yml.j2 | 0 .../templates/nginx.conf.j2 | 0 roles/{OpnForm => opnform}/tests/inventory | 0 roles/{OpnForm => opnform}/tests/test.yml | 2 +- roles/{OpnForm => opnform}/vars/main.yml | 0 11 files changed, 366 insertions(+), 145 deletions(-) delete mode 100644 roles/OpnForm/tasks/main.yml rename roles/{OpnForm => opnform}/README.md (55%) rename roles/{OpnForm => opnform}/defaults/main.yml (61%) rename roles/{OpnForm => opnform}/handlers/main.yml (100%) rename roles/{OpnForm => opnform}/meta/main.yml (100%) create mode 100644 roles/opnform/tasks/main.yml rename roles/{OpnForm => opnform}/templates/docker-compose.yml.j2 (100%) rename roles/{OpnForm => opnform}/templates/nginx.conf.j2 (100%) rename roles/{OpnForm => opnform}/tests/inventory (100%) rename roles/{OpnForm => opnform}/tests/test.yml (86%) rename roles/{OpnForm => opnform}/vars/main.yml (100%) diff --git a/roles/OpnForm/tasks/main.yml b/roles/OpnForm/tasks/main.yml deleted file mode 100644 index 412dc25..0000000 --- a/roles/OpnForm/tasks/main.yml +++ /dev/null @@ -1,117 +0,0 @@ -#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 - fail_msg: >- - opnform_oidc_client_secret must be set when opnform_oidc_enabled is true. - 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 - -- name: Display deployment info - ansible.builtin.debug: - msg: |- - OpnForm deployed at {{ opnform_base_url }} - - Default credentials (from API container logs on first start): - Email: admin@opnform.com - Password: password - - On first login you will be prompted to change email and password. - - If login does not respond, the DB seed may have failed. Run: - docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan migrate:refresh --seed - docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan app:init-project - - OIDC: {% if opnform_oidc_enabled %}enabled (configure via Admin UI){% else %}disabled{% endif %} diff --git a/roles/OpnForm/README.md b/roles/opnform/README.md similarity index 55% rename from roles/OpnForm/README.md rename to roles/opnform/README.md index 67e5436..2dfad2d 100644 --- a/roles/OpnForm/README.md +++ b/roles/opnform/README.md @@ -13,7 +13,6 @@ Docker Compose stack behind Traefik. ## What this role does NOT do (stage 1) -- Does not pre-create an admin user (use the default credentials below) - Does not pre-configure OIDC / identity_connections — set up via Admin UI ## Architecture note: why two reverse proxies? @@ -61,39 +60,83 @@ missing or malformed. ## First login -After the role completes, OpnForm seeds a default admin user. Visit -the URL in `opnform_base_url` and log in with: +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). -- Email: `admin@opnform.com` -- Password: `password` +This role supports two ways to create that first user: -On first login OpnForm will prompt you to change email and password. -Self-hosted instances disable public registration after this — invite -further users via the Admin UI. +### Option A — automated bootstrap (recommended) -### If the login does not respond +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. -The DB seed may have failed. Re-run it manually: - -```bash -cd /etc/docker/compose/opnform -docker compose exec api php artisan migrate:refresh --seed -docker compose exec api php artisan app:init-project +```yaml +opnform_admin_name: "Administrator" # default +opnform_admin_email: "admin@example.com" +opnform_admin_password: "{{ vault_opnform_admin_password }}" ``` -## OIDC setup (stage 2, not yet automated) +Password rules enforced by OpnForm: minimum 8 characters, at least one +letter, one digit, and one of `@$!%*#?&-_+=.,:;<>^()[]{}|~`. -Manual setup via the Admin UI is currently the supported path: +### Option B — manual setup page -1. Settings → Identity Connections → Add Connection -2. Provider: OIDC -3. Issuer: `https://auth.digitalboard.ch/realms/Digitalboard` -4. Client ID / Secret: from your Keycloak client -5. Add Group Role Mapping: Entra/Keycloak group Object ID → OpnForm role +Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit +`opnform_base_url` and complete the setup page in the browser. -Direct DB manipulation of `identity_connections` / `group_role_mappings` -is possible but fragile across OpnForm versions. A future iteration of -this role may automate it. +## 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 diff --git a/roles/OpnForm/defaults/main.yml b/roles/opnform/defaults/main.yml similarity index 61% rename from roles/OpnForm/defaults/main.yml rename to roles/opnform/defaults/main.yml index 35996a2..09aed4c 100644 --- a/roles/OpnForm/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -38,6 +38,17 @@ opnform_db_name: "opnform" opnform_db_user: "opnform" 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 +# 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" @@ -57,14 +68,33 @@ opnform_mail_encryption: "" opnform_mail_from_address: "noreply@digitalboard.ch" opnform_mail_from_name: "OpnForm" -# OIDC configuration (Stage 1: not auto-configured, set up via UI after deploy) +# 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_oidc_scopes: "openid profile email groups" +# 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" diff --git a/roles/OpnForm/handlers/main.yml b/roles/opnform/handlers/main.yml similarity index 100% rename from roles/OpnForm/handlers/main.yml rename to roles/opnform/handlers/main.yml diff --git a/roles/OpnForm/meta/main.yml b/roles/opnform/meta/main.yml similarity index 100% rename from roles/OpnForm/meta/main.yml rename to roles/opnform/meta/main.yml 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 similarity index 100% rename from roles/OpnForm/templates/docker-compose.yml.j2 rename to roles/opnform/templates/docker-compose.yml.j2 diff --git a/roles/OpnForm/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 similarity index 100% rename from roles/OpnForm/templates/nginx.conf.j2 rename to roles/opnform/templates/nginx.conf.j2 diff --git a/roles/OpnForm/tests/inventory b/roles/opnform/tests/inventory similarity index 100% rename from roles/OpnForm/tests/inventory rename to roles/opnform/tests/inventory diff --git a/roles/OpnForm/tests/test.yml b/roles/opnform/tests/test.yml similarity index 86% rename from roles/OpnForm/tests/test.yml rename to roles/opnform/tests/test.yml index 60bdb75..3ff9caa 100644 --- a/roles/OpnForm/tests/test.yml +++ b/roles/opnform/tests/test.yml @@ -3,4 +3,4 @@ - hosts: localhost remote_user: root roles: - - OpnForm \ No newline at end of file + - opnform \ No newline at end of file diff --git a/roles/OpnForm/vars/main.yml b/roles/opnform/vars/main.yml similarity index 100% rename from roles/OpnForm/vars/main.yml rename to roles/opnform/vars/main.yml From 48d12a1b4a19367dbe3b35503dbff5aa2e019775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:58:10 +0200 Subject: [PATCH 14/16] fix(opnform): address review feedback on vars header and meta boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * vars/main.yml: header was 'vars file for homarr' (copy-paste from the homarr role). Fixed to 'vars file for opnform'. File body is empty. * meta/main.yml: replace ansible-galaxy init boilerplate with real metadata — author, description, license (MIT-0), min_ansible_version set to '2.15' as a string (galaxy schema requires str), galaxy_tags for discovery, and an empty dependencies list. The third inline finding (dead roles/opnform/templates/compose.yml.j2) is resolved by dropping the WIP commit a6f301e during the rebase rather than removing it in a separate commit — the file no longer exists in the rebased history. --- roles/opnform/meta/main.yml | 41 ++++++++++--------------------------- roles/opnform/vars/main.yml | 2 +- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/roles/opnform/meta/main.yml b/roles/opnform/meta/main.yml index faea947..8a56a7b 100644 --- a/roles/opnform/meta/main.yml +++ b/roles/opnform/meta/main.yml @@ -1,35 +1,16 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + 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" - # 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. + galaxy_tags: + - opnform + - forms + - docker + - traefik + - oidc 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/vars/main.yml b/roles/opnform/vars/main.yml index 984df2b..94900f8 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 homarr \ No newline at end of file +# vars file for opnform \ No newline at end of file From fb81f60f9d6259d8b0584d74ee0b13c9a043fef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:58:18 +0200 Subject: [PATCH 15/16] fix(opnform): drop production-looking secrets from defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opnform_app_key, opnform_jwt_secret, opnform_front_api_secret and opnform_db_password shipped as real base64 strings in defaults — they look like production secrets that just happen to be public. Set all four to '' and rely on the existing Validate task (and the new argument_specs marking them required) to fail fast when an inventory forgets to override them. Mirror the docstring comment to show how to generate each one with openssl. --- roles/opnform/defaults/main.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml index 09aed4c..0f61c3a 100644 --- a/roles/opnform/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -25,18 +25,26 @@ opnform_redis_image: "redis:7" opnform_db_image: "postgres:16" opnform_ingress_image: "nginx:1" -# REQUIRED SECRETS — generate with: openssl rand -base64 32 -# Always prefix opnform_app_key with "base64:" +# REQUIRED SECRETS — must be overridden per-inventory. # Provide via OpenBao lookup, Ansible Vault or extra-vars. # Never commit real keys to version control. -opnform_app_key: "base64:vsQw8EoC64nmhurLUUohXUlAeryaV6Y2Is64Tdvjlko=" -opnform_jwt_secret: "0b2e8ed326334a08ce3846bfcd6588f5a11be33999e96963cd4eaff1a3ae828b" -opnform_front_api_secret: "8f52397785a110b657f2a6beab13362877bfac936ae9002bc236c54ed1011b2d" +# +# 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 +# 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: "xtNLUVc2ajcWictqWXWkLR" +opnform_db_password: "" # Admin bootstrap — when email+password are set, the role creates the # first user via OpnForm's /api/register endpoint, skipping the From 30f3c16b59de8aa2362f547730eb4a883c45895c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:58:36 +0200 Subject: [PATCH 16/16] docs(opnform): add meta/argument_specs.yml 50 typed options covering the full defaults file plus the OIDC subschema (group_role_mappings with idp_group + role choices). Required secrets (app_key, jwt_secret, front_api_secret, db_password) marked required: true so ansible refuses the play with a clear error before the validate task even runs. Loads cleanly through ansible-core's ArgumentSpecValidator. Matches the spec convention introduced for traefik, authentik, drawio, garage and nextcloud. --- roles/opnform/meta/argument_specs.yml | 220 ++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 roles/opnform/meta/argument_specs.yml 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