01_bootstrap.ymlΒΆ

---
# n8n owner setup: probe whether the owner account exists by attempting a
# single login with the alphanumeric break-glass owner_password. This
# replaces the deprecated /api/v1/owner/pre-setup probe (removed in n8n 1.x,
# always returns 404) and avoids two n8n class-validator false-positives:
# 1. @NoXss() on password: escapeHtml turns special chars (e.g. single quotes)
#    to HTML entities, so the system admin password fails the check. Fix: use a
#    dedicated alphanumeric owner_password (meta/secrets.yml).
# 2. @NoUrl() on firstName/lastName: regex /(\.[\p{L}\d-]+)/iu flags ANY dot
#    followed by letters β€” including org names like "Infinito.Nexus". Fix:
#    replace dots with spaces before sending (see firstName/lastName below).
# Normal users authenticate via SSO/Keycloak; this password is break-glass only.
#
# A single probe avoids triggering n8n's rate limiter. If a retry is still
# needed (e.g. n8n not fully up yet), keep well under n8n's default login
# rate limit of 5 attempts per 5-minute window: delay=30s/retries=4 caps this
# task at 5 attempts total, spanning 120s, so it never crosses the window.
- name: "πŸ”Ž Probe n8n owner account via single login attempt"
  uri_retry:
    url: "{{ N8N_INTERNAL_API_URL }}/rest/login"
    method: POST
    body_format: json
    body:
      emailOrLdapLoginId: "{{ lookup('users', 'platform_administrator').email }}"
      password: "{{ N8N_OWNER_PASSWORD }}"
    status_code: [200, 401, 403]
    return_content: false
  register: n8n_owner_probe
  until: "'json' in (n8n_owner_probe.content_type | default(''))"
  retries: 4
  delay: 30
  no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"

- name: "πŸ”§ Initialise n8n owner account (first-time only)"
  uri_retry:
    url: "{{ N8N_INTERNAL_API_URL }}/rest/owner/setup"
    method: POST
    body_format: json
    body:
      email: "{{ lookup('users', 'platform_administrator').email }}"
      firstName: "{{ lookup('users', 'platform_administrator').firstname | replace('.', ' ') | trim }}"
      lastName: "{{ lookup('users', 'platform_administrator').lastname | replace('.', ' ') | trim }}"
      password: "{{ N8N_OWNER_PASSWORD }}"
    status_code: [200, 201, 400]
    return_content: true
  register: n8n_owner_setup
  when: n8n_owner_probe.status != 200
  no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"

- name: "βœ… Assert n8n owner setup succeeded or was already done"
  ansible.builtin.assert:
    that:
      - >-
        (n8n_owner_setup is skipped)
        or (n8n_owner_setup.status in [200, 201])
        or ('already setup' in (n8n_owner_setup.content | default('')))
    fail_msg: "n8n owner setup returned {{ n8n_owner_setup.status | default('n/a') }}: {{ n8n_owner_setup.content | default('') }}"

- name: "πŸ” Resolve {{ application_id }} hosting node + container id"
  ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}"
  vars:
    resolve_service_key: "{{ entity_name }}"
    resolve_output_prefix: N8N
    resolve_compose_cid: "{{ N8N_CONTAINER }}"

- name: "πŸ” Converge a rotated owner password n8n still rejects"
  when:
    - n8n_owner_probe.status in [401, 403]
    - "'already setup' in (n8n_owner_setup.content | default(''))"
  block:
    - name: "πŸ” Write the deployed owner password into n8n's user row"
      ansible.builtin.command:
        argv:
          - container
          - exec
          - -i
          - -e
          - "NODE_PATH={{ N8N_NODE_MODULES }}"
          - -e
          - "N8N_OWNER_EMAIL={{ N8N_OWNER_EMAIL }}"
          - -e
          - N8N_OWNER_PASSWORD
          - "{{ N8N_LOCAL_CID }}" # nocheck: container-exec-resolver
          - node
          - "-"
        stdin: "{{ lookup('file', 'javascript/reconcile/owner_password.js') }}"
      environment:
        N8N_OWNER_PASSWORD: "{{ N8N_OWNER_PASSWORD }}"
      register: n8n_owner_password
      retries: 30
      delay: 10
      until: n8n_owner_password.rc == 0
      changed_when: n8n_owner_password.stdout.startswith('CHANGED')
      delegate_to: "{{ N8N_HOST_NODE }}"
      no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"

    - name: "πŸ”Ž Confirm the owner signs in with the deployed password"
      uri_retry:
        url: "{{ N8N_INTERNAL_API_URL }}/rest/login"
        method: POST
        body_format: json
        body:
          emailOrLdapLoginId: "{{ lookup('users', 'platform_administrator').email }}"
          password: "{{ N8N_OWNER_PASSWORD }}"
        status_code: [200]
        return_content: false
      retries: 0
      no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"