Files
computer-playbook/roles/web-app-discourse/tasks/stack_host/02_credentials.yml
kevinveenbirkenbachandClaude Opus 5 e11c9517b2 fix(discourse): set runtime state at runtime, not during bootstrap
All 22 run: hooks in app.yml were rails and rake calls against the database,
yet the launcher executes them only while building the image. Two of them
carried rotating values, the OIDC client secret and the administrator
password, so every rotation forced a full rebuild with bundle install and
asset precompile: 961.82s in one pass and 13m16s in the next, on a job that
then missed its wall-clock budget by 201s.

They now run against the running instance through container exec, following
the pattern web-app-openproject already uses: real .rb files under files/ruby,
values through ENV.fetch, a readback that prints booleans rather than values,
and an assert that fails the deploy when a rotated credential did not arrive.
ensure_admin_user.rb mirrors the Y/Y path of admin:create using upstream's own
grant_admin!, change_trust_level! and activate; setting the flags by hand had
skipped auto_approve_user, the staff welcome message, the notification levels,
trust level 1 and the email confirmation.

What stays in run: is the CA trust call, which writes into the image and would
not survive a recreate. With the rest gone, the bootstrap digest f47ea0e5bb
records covers templates, expose, params, volumes, after_code and docker_args,
and nothing in that set rotates.

00_core.yml carried the same IS_STACK_HOST condition three times; it now
includes 04_stack_host.yml once, which holds the three with their own
conditions, and their implementations moved under tasks/stack_host/.

The OIDC values are read only when OIDC is enabled. Ansible resolves vars
lazily, so referencing DISCOURSE_OIDC_LOGOUT_REDIRECT_URL unconditionally
would have run its lookup against web-svc-logout on a deployment that has no
SSO, failing where the template did not.

test_oidc_setting_order.py follows the settings into apply_settings.rb. The
invariant 88218fc740 introduced is unchanged: openid_connect_enabled comes
after the discovery document, the client id and the client secret.

Four explanatory "# Exception:" comments are removed, two of them from
f47ea0e5bb. They described mechanisms rather than naming a trip-wire, which
belongs in a commit body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:07:15 +02:00

88 lines
4.1 KiB
YAML

---
- name: "⚙️ Apply the site settings to the running instance"
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- "DISCOURSE_FORCE_HTTPS={{ DISCOURSE_TLS_ENABLED | bool | string | lower }}"
- -e
- "DISCOURSE_LOCAL_LOGINS={{ DISCOURSE_LOCAL_LOGIN_ENABLED | bool | string | lower }}"
- -e
- "DISCOURSE_LDAP_SITE_SETTINGS={{ (DISCOURSE_PLUGINS['discourse-ldap-auth'].config.site_settings if DISCOURSE_LDAP_ENABLED | bool else {}) | to_json }}"
- -e
- "DISCOURSE_OIDC_ENABLED={{ DISCOURSE_OIDC_ENABLED | bool | string | lower }}"
- -e
- "DISCOURSE_OIDC_VERBOSE={{ MODE_DEBUG | bool | string | lower }}"
- -e
- "DISCOURSE_OIDC_DISCOVERY_DOCUMENT={{ OIDC.CLIENT.DISCOVERY_DOCUMENT if DISCOURSE_OIDC_ENABLED | bool else '' }}"
- -e
- "DISCOURSE_OIDC_CLIENT_ID={{ OIDC.CLIENT.ID if DISCOURSE_OIDC_ENABLED | bool else '' }}"
- -e
- "DISCOURSE_OIDC_CLIENT_SECRET={{ OIDC.CLIENT.SECRET if DISCOURSE_OIDC_ENABLED | bool else '' }}"
- -e
- "DISCOURSE_OIDC_LOGOUT_REDIRECT={{ DISCOURSE_OIDC_LOGOUT_REDIRECT_URL if DISCOURSE_OIDC_ENABLED | bool else '' }}"
- "{{ DISCOURSE_CONTAINER }}" # nocheck: container-exec-resolver the launcher names the container itself, so the bare name resolves in both modes
- rails
- r
- "{{ lookup('file', 'ruby/apply_settings.rb') }}"
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🔑 Ensure the administrator account carries the current password"
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- "DISCOURSE_ADMIN_EMAIL={{ lookup('users', 'administrator').email }}"
- -e
- "DISCOURSE_ADMIN_USERNAME={{ lookup('users', 'administrator').username }}"
- -e
- "DISCOURSE_ADMIN_PASSWORD={{ lookup('users', 'administrator').password }}"
- "{{ DISCOURSE_CONTAINER }}" # nocheck: container-exec-resolver the launcher names the container itself, so the bare name resolves in both modes
- rails
- r
- "{{ lookup('file', 'ruby/ensure_admin_user.rb') }}"
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🔎 Read the applied settings back out of the running instance"
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- "DISCOURSE_ADMIN_EMAIL={{ lookup('users', 'administrator').email }}"
- -e
- "DISCOURSE_ADMIN_PASSWORD={{ lookup('users', 'administrator').password }}"
- -e
- "DISCOURSE_OIDC_ENABLED={{ DISCOURSE_OIDC_ENABLED | bool | string | lower }}"
- -e
- "DISCOURSE_OIDC_CLIENT_SECRET={{ OIDC.CLIENT.SECRET if DISCOURSE_OIDC_ENABLED | bool else '' }}"
- "{{ DISCOURSE_CONTAINER }}" # nocheck: container-exec-resolver the launcher names the container itself, so the bare name resolves in both modes
- rails
- r
- "{{ lookup('file', 'ruby/verify_settings.rb') }}"
register: discourse_settings_readback
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🛑 Refuse an instance the rotated credentials did not reach"
ansible.builtin.assert:
that:
- "'administrator_present=true' in discourse_settings_readback.stdout_lines"
- "'administrator_admin=true' in discourse_settings_readback.stdout_lines"
- "'administrator_active=true' in discourse_settings_readback.stdout_lines"
- "'administrator_password_matches=true' in discourse_settings_readback.stdout_lines"
- "not DISCOURSE_OIDC_ENABLED | bool or 'oidc_secret_matches=true' in discourse_settings_readback.stdout_lines"
- "not DISCOURSE_OIDC_ENABLED | bool or 'oidc_enabled=true' in discourse_settings_readback.stdout_lines"
- "not DISCOURSE_OIDC_ENABLED | bool or 'oidc_host_allowed=true' in discourse_settings_readback.stdout_lines"
fail_msg: >-
The administrator account, the OIDC client secret or the internal-host allowlist
of the running Discourse does not match the inventory, so a rotated credential
or a site setting never reached it.