From eb51b6a05458c1e1d22cc24792c084a33b016fe0 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] 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