#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, opnform_jwt_secret, opnform_front_api_secret and opnform_db_password. Generate with: opnform_app_key='base64:'$(openssl rand -base64 32) (the 'base64:' prefix is required); opnform_jwt_secret and opnform_front_api_secret via openssl rand -hex 32. 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 ingress # OIDC_FORCE_LOGIN disables OpnForm's password login — including the # password-based admin/OIDC bootstrap this role performs below. The # bootstrap must therefore run with force-login OFF. To stay idempotent # on re-runs (avoid recreating api containers on every apply), we only # turn force-login OFF when the bootstrap is actually needed (first run # on a fresh host, no OIDC connection yet). Once the connection exists # we render the final force-login value straight away, so the compose # file is byte-identical across re-runs. - name: Probe whether OpnForm is already bootstrapped block: - name: Check if opnform-api container exists and is healthy ansible.builtin.command: cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api register: _opnform_api_health_probe changed_when: false failed_when: false - name: Attempt admin login (only when api is healthy) 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_probe_login no_log: true when: - _opnform_api_health_probe.rc == 0 - _opnform_api_health_probe.stdout == "healthy" - opnform_admin_email | length > 0 - opnform_admin_password | length > 0 - name: Probe for existing OIDC connection ansible.builtin.uri: url: "https://127.0.0.1/api/open/workspaces" method: GET headers: Host: "{{ opnform_domain }}" Authorization: "Bearer {{ _opnform_probe_login.json.token }}" status_code: 200 validate_certs: false register: _opnform_probe_workspaces no_log: true when: - opnform_oidc_enabled | bool - _opnform_probe_login is defined - _opnform_probe_login.status | default(0) == 200 - name: Probe OIDC connections on default workspace ansible.builtin.uri: url: "https://127.0.0.1/api/open/workspaces/{{ _opnform_probe_workspaces.json[0].id }}/oidc-connections" method: GET headers: Host: "{{ opnform_domain }}" Authorization: "Bearer {{ _opnform_probe_login.json.token }}" status_code: 200 validate_certs: false register: _opnform_probe_oidc no_log: true when: - opnform_oidc_enabled | bool - _opnform_probe_workspaces is defined - _opnform_probe_workspaces.json | default([]) | length > 0 - name: Decide whether force-login can render in its final state ansible.builtin.set_fact: # True when force-login is desired AND admin+OIDC bootstrap has # already completed (admin user exists with the configured password, # OIDC connection is present). On a fresh host both checks fail and # we fall back to false so the bootstrap below can run. _opnform_force_login_effective: >- {{ (opnform_oidc_enabled | bool) and (opnform_oidc_force_login | bool) and (_opnform_probe_login.status | default(0) == 200) and ((_opnform_probe_oidc.json | default([])) | length > 0) }} - name: Deploy docker-compose file ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" mode: '0644' register: _opnform_compose_rendered 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. # # Skipped entirely when force-login already rendered in its final state # (probe in step 2 confirmed admin + connection exist). Re-running the # /api/login probe on a force-login-enabled api would 401 and 422, so # avoid the noise — and avoid spurious "changed" status from a register # call that won't help anyway. - 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 - not (_opnform_force_login_effective | bool) - 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 - not (_opnform_force_login_effective | bool) - opnform_admin_login.status | default(0) != 200 # ===================================================================== # 6. OIDC IDENTITY CONNECTION (optional) # ===================================================================== # Provisions a single OIDC connection on the admin's default workspace. # OpnForm enforces one OIDC connection per workspace, so we GET the # existing connections first and then either POST a new one or PATCH the # existing one to the desired state. PATCHing (rather than skipping when # one exists) keeps inventory changes — e.g. a corrected issuer — applied # on re-runs instead of leaving stale values in the DB forever. # # Skipped on re-applies when force-login is already enabled — the API # password login required for these calls is disabled, and the connection # is known to exist (otherwise force-login wouldn't have rendered in its # final state in step 2). To intentionally re-provision the connection # from inventory changes on such a host: temporarily set # opnform_oidc_force_login=false, re-apply, then set it back to true. - 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 - not (_opnform_force_login_effective | 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 - not (_opnform_force_login_effective | 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 - not (_opnform_force_login_effective | 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 - not (_opnform_force_login_effective | bool) # Desired connection state shared by both the create (POST) and update # (PATCH) calls below. client_secret is always sent: OpnForm's update # endpoint only persists it when present, and on create it is required. - name: Build desired OIDC connection body ansible.builtin.set_fact: _opnform_oidc_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 }}" no_log: true when: - opnform_oidc_enabled | bool - not (_opnform_force_login_effective | 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: "{{ _opnform_oidc_body }}" status_code: [201] validate_certs: false no_log: true when: - opnform_oidc_enabled | bool - not (_opnform_force_login_effective | bool) - opnform_existing_oidc.json | length == 0 # An OIDC connection already exists: PATCH it to the desired state so # inventory changes (e.g. a corrected issuer) are applied. OpnForm allows # exactly one connection per workspace, so the first entry is ours. - name: Update existing OIDC identity connection ansible.builtin.uri: url: >- https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections/{{ opnform_existing_oidc.json[0].id }} method: PATCH headers: Host: "{{ opnform_domain }}" Authorization: "Bearer {{ opnform_oidc_token.json.token }}" body_format: json body: "{{ _opnform_oidc_body }}" status_code: [200] validate_certs: false no_log: true when: - opnform_oidc_enabled | bool - not (_opnform_force_login_effective | bool) - opnform_existing_oidc.json | length > 0 # ===================================================================== # 7. ENABLE FORCE LOGIN (first-run only) # ===================================================================== # On the very first apply, step 2 rendered the compose file with # force-login disabled (so the bootstrap above could use the password # login). Now that the OIDC connection exists, re-render the compose # file with force-login in its final state and recreate the api # containers once. # # On all subsequent applies the probe in step 2 already rendered the # final value, the compose file is byte-identical here, and this block # is a no-op (the template task reports "ok", no recreate). - name: Enable force login (first run, after OIDC bootstrap) when: - opnform_oidc_enabled | bool - opnform_oidc_force_login | bool - not (_opnform_force_login_effective | bool) block: - name: Re-render compose with force-login enabled ansible.builtin.set_fact: _opnform_force_login_effective: true - name: Deploy docker-compose file with force-login enabled ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" mode: '0644' register: _opnform_force_login_compose - name: Apply force-login by recreating the api containers community.docker.docker_compose_v2: project_src: "{{ opnform_docker_compose_dir }}" state: present wait: true wait_timeout: 180 when: _opnform_force_login_compose is changed - name: Restart ingress so nginx picks up the new api container IPs community.docker.docker_container: name: opnform-ingress state: started restart: true when: _opnform_force_login_compose is changed - 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. {% if opnform_oidc_sso_entrypoint %} Login intercept active: {{ opnform_base_url }}/login forwards directly to the IdP. Use {{ opnform_base_url }}/login?bypass=1 as a break-glass path for the email form when the IdP is down. {% endif %} {% else %} OIDC: disabled (set opnform_oidc_enabled=true to auto-configure) {% endif %}