mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
test(lint): forbid >3-child blocks + ≥3-task same-when runs; refactor 5 roles
Adds two structural ansible lints under tests/lint/ansible/structure/:
- ansible-block-oversized: forbid `- block:` constructs with more than
3 direct child tasks. Big block bodies hide intent, accumulate
unrelated guards on the wrapper, and signal that the body wants to
be its own include_tasks sub-file. Extraction keeps the parent
scannable; the sub-file MAY carry the `# include-gated:
when: …` header marker so swarm-compat lints exempt it.
- ansible-repeated-when-run: forbid 3+ consecutive sibling tasks
carrying the SAME `when:` expression. Copy-pasted guards drift,
silently disable each other over time, and read worse than a single
`- name: …` + `when: <expr>` + `block:` wrapper. Per-line opt-out
for runs where wrapping would alter semantics (cross-task register
dependencies).
Both lints use the line-based scanner pattern from existing
tests/lint/ansible/jinja/* + `is_suppressed_at` from
utils.annotations.suppress for the per-line nocheck markers.
Refactors five roles into compliance:
- svc-prx-openresty/tasks/02_assert.yml: heavy rescue body extracted
to 02a_assert_rescue_debug.yml.
- test-e2e-playwright/tasks/01_setup.yml: 3 install-needed siblings
wrapped in a single when-block.
- web-app-erpnext/tasks/02_bench_bootstrap.yml: 3 site-probe-failed
siblings wrapped likewise.
- web-app-odoo/tasks/02_install_modules.yml: 3 OIDC-enabled siblings
wrapped. tasks/03_oidc.yml: dropped redundant single-task wrapper
block (the original wrapper carried name only and would have
required extraction for size — inlining the 23 tasks at file root
keeps the file purpose-narrow and removes one layer of indirection).
- web-app-xwiki/tasks/04_extensions.yml: large block body + rescue
body both extracted to 04a_extensions_rescue.yml +
04b_extensions_execute.yml.
Mixed-scope offenders (web-app-keycloak/tasks/{02_manager_ops,05a,
assert/05_ldap,update/05_ldap_shared,02a} and
web-app-matomo/tasks/{03_manager_ops,05_bootstrap,05a}) deliberately
stay unstaged; they ship with their respective swarm-aware
restructure commits where they belong by topic, not bundled with the
structural-lint cleanup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
478d0679dd
commit
c61c68e903
13 files changed
+727
-449
No files matched your search
@@ -16,26 +16,5 @@
|
||||
changed_when: false
|
||||
failed_when: openresty_test_post.rc != 0
|
||||
rescue:
|
||||
- name: Debug containers (before OpenResty restart)
|
||||
command: container ps -a --format '{{"{{.Names}} {{.Status}}"}}'
|
||||
register: docker_ps
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Show docker containers
|
||||
debug:
|
||||
var: docker_ps.stdout_lines
|
||||
|
||||
- name: Dump OpenResty container logs on failure
|
||||
command: >
|
||||
container logs --tail=200 {{ OPENRESTY_CONTAINER }}
|
||||
register: openresty_logs
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Show OpenResty logs
|
||||
debug:
|
||||
msg: |
|
||||
===== OpenResty container logs (last 200 lines) =====
|
||||
{{ openresty_logs.stdout | default('') }}
|
||||
{{ openresty_logs.stderr | default('') }}
|
||||
- name: "Load OpenResty assert rescue debug routines"
|
||||
include_tasks: 02a_assert_rescue_debug.yml
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
- name: Debug containers (before OpenResty restart)
|
||||
command: container ps -a --format '{{"{{.Names}} {{.Status}}"}}'
|
||||
register: docker_ps
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Show docker containers
|
||||
debug:
|
||||
var: docker_ps.stdout_lines
|
||||
|
||||
- name: Dump OpenResty container logs on failure
|
||||
command: >
|
||||
container logs --tail=200 {{ OPENRESTY_CONTAINER }}
|
||||
register: openresty_logs
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Show OpenResty logs
|
||||
debug:
|
||||
msg: |
|
||||
===== OpenResty container logs (last 200 lines) =====
|
||||
{{ openresty_logs.stdout | default('') }}
|
||||
{{ openresty_logs.stderr | default('') }}
|
||||
@@ -31,48 +31,48 @@
|
||||
!= TEST_E2E_PLAYWRIGHT_VERSION)
|
||||
}}
|
||||
|
||||
- name: Pull Playwright image if missing (shared install pass)
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- pull
|
||||
- "{{ TEST_E2E_PLAYWRIGHT_IMAGE }}"
|
||||
register: _playwright_shared_pull
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: _playwright_shared_pull.rc == 0
|
||||
- name: Provision the shared Playwright install (image pull + npm install + stamp)
|
||||
when: _playwright_shared_install_needed | bool
|
||||
block:
|
||||
- name: Pull Playwright image if missing (shared install pass)
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- pull
|
||||
- "{{ TEST_E2E_PLAYWRIGHT_IMAGE }}"
|
||||
register: _playwright_shared_pull
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: _playwright_shared_pull.rc == 0
|
||||
|
||||
- name: Install @playwright/test into the shared dir (once per host per version)
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- run
|
||||
- --rm
|
||||
- -v
|
||||
- "{{ TEST_E2E_PLAYWRIGHT_SHARED_DIR }}:/shared"
|
||||
- -w
|
||||
- /shared
|
||||
- "{{ TEST_E2E_PLAYWRIGHT_IMAGE }}"
|
||||
- /bin/bash
|
||||
- -lc
|
||||
- >-
|
||||
npm install --no-fund --no-audit --prefer-offline
|
||||
--fetch-retries 5
|
||||
--fetch-retry-mintimeout 10000
|
||||
--fetch-retry-maxtimeout 60000
|
||||
register: _playwright_shared_install
|
||||
changed_when: _playwright_shared_install.rc == 0
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: _playwright_shared_install.rc == 0
|
||||
when: _playwright_shared_install_needed | bool
|
||||
- name: Install @playwright/test into the shared dir (once per host per version)
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- run
|
||||
- --rm
|
||||
- -v
|
||||
- "{{ TEST_E2E_PLAYWRIGHT_SHARED_DIR }}:/shared"
|
||||
- -w
|
||||
- /shared
|
||||
- "{{ TEST_E2E_PLAYWRIGHT_IMAGE }}"
|
||||
- /bin/bash
|
||||
- -lc
|
||||
- >-
|
||||
npm install --no-fund --no-audit --prefer-offline
|
||||
--fetch-retries 5
|
||||
--fetch-retry-mintimeout 10000
|
||||
--fetch-retry-maxtimeout 60000
|
||||
register: _playwright_shared_install
|
||||
changed_when: _playwright_shared_install.rc == 0
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: _playwright_shared_install.rc == 0
|
||||
|
||||
- name: Write shared install stamp
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ TEST_E2E_PLAYWRIGHT_SHARED_STAMP }}"
|
||||
content: "{{ TEST_E2E_PLAYWRIGHT_VERSION }}"
|
||||
mode: "0644"
|
||||
when: _playwright_shared_install_needed | bool
|
||||
- name: Write shared install stamp
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ TEST_E2E_PLAYWRIGHT_SHARED_STAMP }}"
|
||||
content: "{{ TEST_E2E_PLAYWRIGHT_VERSION }}"
|
||||
mode: "0644"
|
||||
@@ -8,57 +8,57 @@
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: "bench new-site --install-app erpnext '{{ ERPNEXT_SITE_NAME }}'"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
container exec -i \
|
||||
-e DB_ROOT_USER -e DB_ROOT_PASSWORD -e ADMIN_PASSWORD \
|
||||
{{ ERPNEXT_BACKEND_CONTAINER }} bash -c '
|
||||
bench new-site \
|
||||
--no-mariadb-socket \
|
||||
--mariadb-user-host-login-scope=% \
|
||||
--db-root-username="$DB_ROOT_USER" \
|
||||
--db-root-password="$DB_ROOT_PASSWORD" \
|
||||
--admin-password="$ADMIN_PASSWORD" \
|
||||
--install-app erpnext \
|
||||
--set-default \
|
||||
{{ ERPNEXT_SITE_NAME }}
|
||||
'
|
||||
environment:
|
||||
DB_ROOT_USER: "{{ ERPNEXT_DB_ROOT_USER }}"
|
||||
DB_ROOT_PASSWORD: "{{ ERPNEXT_DB_ROOT_PASSWORD }}"
|
||||
ADMIN_PASSWORD: "{{ ERPNEXT_INITIAL_ADMIN_PASSWORD }}"
|
||||
register: erpnext_new_site
|
||||
changed_when: true
|
||||
timeout: 1800
|
||||
- name: "Bootstrap ERPNext site when missing (create + restart + wait for backend)"
|
||||
when: erpnext_site_probe.rc != 0
|
||||
block:
|
||||
- name: "bench new-site --install-app erpnext '{{ ERPNEXT_SITE_NAME }}'"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
container exec -i \
|
||||
-e DB_ROOT_USER -e DB_ROOT_PASSWORD -e ADMIN_PASSWORD \
|
||||
{{ ERPNEXT_BACKEND_CONTAINER }} bash -c '
|
||||
bench new-site \
|
||||
--no-mariadb-socket \
|
||||
--mariadb-user-host-login-scope=% \
|
||||
--db-root-username="$DB_ROOT_USER" \
|
||||
--db-root-password="$DB_ROOT_PASSWORD" \
|
||||
--admin-password="$ADMIN_PASSWORD" \
|
||||
--install-app erpnext \
|
||||
--set-default \
|
||||
{{ ERPNEXT_SITE_NAME }}
|
||||
'
|
||||
environment:
|
||||
DB_ROOT_USER: "{{ ERPNEXT_DB_ROOT_USER }}"
|
||||
DB_ROOT_PASSWORD: "{{ ERPNEXT_DB_ROOT_PASSWORD }}"
|
||||
ADMIN_PASSWORD: "{{ ERPNEXT_INITIAL_ADMIN_PASSWORD }}"
|
||||
register: erpnext_new_site
|
||||
changed_when: true
|
||||
timeout: 1800
|
||||
|
||||
# Long-lived Frappe containers cache the app set at startup; without a restart they
|
||||
# 500 on /login after install-app erpnext.
|
||||
- name: "restart Frappe containers to pick up newly-installed app"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
container restart \
|
||||
{{ ERPNEXT_BACKEND_CONTAINER }} \
|
||||
{{ ERPNEXT_FRONTEND_CONTAINER }} \
|
||||
{{ ERPNEXT_WEBSOCKET_CONTAINER }} \
|
||||
{{ ERPNEXT_SCHEDULER_CONTAINER }} \
|
||||
{{ ERPNEXT_QUEUE_SHORT_CONTAINER }} \
|
||||
{{ ERPNEXT_QUEUE_LONG_CONTAINER }}
|
||||
changed_when: false
|
||||
when: erpnext_site_probe.rc != 0
|
||||
# Long-lived Frappe containers cache the app set at startup; without a restart they
|
||||
# 500 on /login after install-app erpnext.
|
||||
- name: "restart Frappe containers to pick up newly-installed app"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
container restart \
|
||||
{{ ERPNEXT_BACKEND_CONTAINER }} \
|
||||
{{ ERPNEXT_FRONTEND_CONTAINER }} \
|
||||
{{ ERPNEXT_WEBSOCKET_CONTAINER }} \
|
||||
{{ ERPNEXT_SCHEDULER_CONTAINER }} \
|
||||
{{ ERPNEXT_QUEUE_SHORT_CONTAINER }} \
|
||||
{{ ERPNEXT_QUEUE_LONG_CONTAINER }}
|
||||
changed_when: false
|
||||
|
||||
- name: "wait for {{ ERPNEXT_BACKEND_CONTAINER }} gunicorn TCP port after restart"
|
||||
ansible.builtin.shell: |
|
||||
set -eo pipefail
|
||||
container exec {{ ERPNEXT_BACKEND_CONTAINER_ADDRESS }} \
|
||||
bash -c 'exec 3<>/dev/tcp/localhost/{{ ERPNEXT_BACKEND_PORT }} && exec 3<&- 3>&-'
|
||||
register: erpnext_backend_ready_post_restart
|
||||
changed_when: false
|
||||
retries: 60
|
||||
delay: 5
|
||||
until: erpnext_backend_ready_post_restart.rc == 0
|
||||
when: erpnext_site_probe.rc != 0
|
||||
- name: "wait for {{ ERPNEXT_BACKEND_CONTAINER }} gunicorn TCP port after restart"
|
||||
ansible.builtin.shell: |
|
||||
set -eo pipefail
|
||||
container exec {{ ERPNEXT_BACKEND_CONTAINER_ADDRESS }} \
|
||||
bash -c 'exec 3<>/dev/tcp/localhost/{{ ERPNEXT_BACKEND_PORT }} && exec 3<&- 3>&-'
|
||||
register: erpnext_backend_ready_post_restart
|
||||
changed_when: false
|
||||
retries: 60
|
||||
delay: 5
|
||||
until: erpnext_backend_ready_post_restart.rc == 0
|
||||
|
||||
- name: "ensure encryption_key on site '{{ ERPNEXT_SITE_NAME }}'"
|
||||
ansible.builtin.shell: |
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- "{{ MAGENTO_PHP_DIR }}"
|
||||
|
||||
- name: Render nginx main config (no TLS; single source of truth)
|
||||
template: # nocheck: stack-host-template (app compose dir, not project nginx tree)
|
||||
template: # nocheck: stack-host-template (app compose dir, not project nginx tree)
|
||||
src: "docker.conf.j2"
|
||||
dest: "{{ MAGENTO_NGINX_CONF_PATH }}"
|
||||
mode: "0644"
|
||||
@@ -26,7 +26,7 @@
|
||||
notify: compose up
|
||||
|
||||
- name: Render php-fpm pool override (TCP listen; clear_env=no)
|
||||
template: # nocheck: stack-host-template (app compose dir, not project nginx tree)
|
||||
template: # nocheck: stack-host-template (app compose dir, not project nginx tree)
|
||||
src: "php-fpm-zz-docker.conf.j2"
|
||||
dest: "{{ MAGENTO_PHP_ZZ_CONF_PATH }}"
|
||||
mode: "0644"
|
||||
|
||||
@@ -36,53 +36,53 @@
|
||||
changed_when: "'loading' in module_update.stdout"
|
||||
failed_when: false
|
||||
|
||||
- name: "Install auth_oauth module for OIDC support"
|
||||
shell: >
|
||||
container exec {{ ODOO_CONTAINER_ADDRESS }}
|
||||
odoo -i auth_oauth
|
||||
-d {{ ODOO_DB_NAME }}
|
||||
--db_host {{ ODOO_DB_HOST }}
|
||||
--db_port {{ ODOO_DB_PORT }}
|
||||
--db_user {{ ODOO_DB_USER }}
|
||||
--db_password {{ ODOO_DB_PASSWORD | quote }}
|
||||
--stop-after-init
|
||||
--no-http
|
||||
register: auth_oauth_install
|
||||
changed_when: "'loading' in auth_oauth_install.stdout"
|
||||
failed_when: false
|
||||
- name: "Install OIDC-related modules (auth_oauth + auth_signup + auth_oauth_https)"
|
||||
when: ODOO_OIDC_ENABLED | bool
|
||||
block:
|
||||
- name: "Install auth_oauth module for OIDC support"
|
||||
shell: >
|
||||
container exec {{ ODOO_CONTAINER_ADDRESS }}
|
||||
odoo -i auth_oauth
|
||||
-d {{ ODOO_DB_NAME }}
|
||||
--db_host {{ ODOO_DB_HOST }}
|
||||
--db_port {{ ODOO_DB_PORT }}
|
||||
--db_user {{ ODOO_DB_USER }}
|
||||
--db_password {{ ODOO_DB_PASSWORD | quote }}
|
||||
--stop-after-init
|
||||
--no-http
|
||||
register: auth_oauth_install
|
||||
changed_when: "'loading' in auth_oauth_install.stdout"
|
||||
failed_when: false
|
||||
|
||||
- name: "Install auth_signup module for OAuth user auto-creation"
|
||||
shell: >
|
||||
container exec {{ ODOO_CONTAINER_ADDRESS }}
|
||||
odoo -i auth_signup
|
||||
-d {{ ODOO_DB_NAME }}
|
||||
--db_host {{ ODOO_DB_HOST }}
|
||||
--db_port {{ ODOO_DB_PORT }}
|
||||
--db_user {{ ODOO_DB_USER }}
|
||||
--db_password {{ ODOO_DB_PASSWORD | quote }}
|
||||
--stop-after-init
|
||||
--no-http
|
||||
register: auth_signup_install
|
||||
changed_when: "'loading' in auth_signup_install.stdout"
|
||||
failed_when: false
|
||||
when: ODOO_OIDC_ENABLED | bool
|
||||
- name: "Install auth_signup module for OAuth user auto-creation"
|
||||
shell: >
|
||||
container exec {{ ODOO_CONTAINER_ADDRESS }}
|
||||
odoo -i auth_signup
|
||||
-d {{ ODOO_DB_NAME }}
|
||||
--db_host {{ ODOO_DB_HOST }}
|
||||
--db_port {{ ODOO_DB_PORT }}
|
||||
--db_user {{ ODOO_DB_USER }}
|
||||
--db_password {{ ODOO_DB_PASSWORD | quote }}
|
||||
--stop-after-init
|
||||
--no-http
|
||||
register: auth_signup_install
|
||||
changed_when: "'loading' in auth_signup_install.stdout"
|
||||
failed_when: false
|
||||
|
||||
- name: "Install auth_oauth_https module for HTTPS redirect URI fix"
|
||||
shell: >
|
||||
container exec {{ ODOO_CONTAINER_ADDRESS }}
|
||||
odoo -i auth_oauth_https
|
||||
-d {{ ODOO_DB_NAME }}
|
||||
--db_host {{ ODOO_DB_HOST }}
|
||||
--db_port {{ ODOO_DB_PORT }}
|
||||
--db_user {{ ODOO_DB_USER }}
|
||||
--db_password {{ ODOO_DB_PASSWORD | quote }}
|
||||
--stop-after-init
|
||||
--no-http
|
||||
register: auth_oauth_https_install
|
||||
changed_when: "'loading' in auth_oauth_https_install.stdout"
|
||||
failed_when: false
|
||||
when: ODOO_OIDC_ENABLED | bool
|
||||
- name: "Install auth_oauth_https module for HTTPS redirect URI fix"
|
||||
shell: >
|
||||
container exec {{ ODOO_CONTAINER_ADDRESS }}
|
||||
odoo -i auth_oauth_https
|
||||
-d {{ ODOO_DB_NAME }}
|
||||
--db_host {{ ODOO_DB_HOST }}
|
||||
--db_port {{ ODOO_DB_PORT }}
|
||||
--db_user {{ ODOO_DB_USER }}
|
||||
--db_password {{ ODOO_DB_PASSWORD | quote }}
|
||||
--stop-after-init
|
||||
--no-http
|
||||
register: auth_oauth_https_install
|
||||
changed_when: "'loading' in auth_oauth_https_install.stdout"
|
||||
failed_when: false
|
||||
|
||||
- name: "Install auth_ldap module for LDAP support"
|
||||
shell: >
|
||||
|
||||
@@ -1,223 +1,213 @@
|
||||
---
|
||||
- name: "Configure OIDC (Keycloak) for Odoo"
|
||||
block:
|
||||
- name: "Check if Keycloak OAuth provider exists"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/select-keycloak-provider-id.sql"
|
||||
expect_rows: true
|
||||
register: oauth_check
|
||||
- name: "Check if Keycloak OAuth provider exists"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/select-keycloak-provider-id.sql"
|
||||
expect_rows: true
|
||||
register: oauth_check
|
||||
|
||||
- name: "Update existing Keycloak OAuth provider"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/update-keycloak-provider.sql"
|
||||
named_args:
|
||||
auth_endpoint: "{{ ODOO_OIDC_AUTHORIZE_URL }}"
|
||||
userinfo_endpoint: "{{ ODOO_OIDC_USERINFO_URL }}"
|
||||
client_id: "{{ ODOO_OIDC_CLIENT_ID }}"
|
||||
when: oauth_check.rows | length > 0
|
||||
- name: "Update existing Keycloak OAuth provider"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/update-keycloak-provider.sql"
|
||||
named_args:
|
||||
auth_endpoint: "{{ ODOO_OIDC_AUTHORIZE_URL }}"
|
||||
userinfo_endpoint: "{{ ODOO_OIDC_USERINFO_URL }}"
|
||||
client_id: "{{ ODOO_OIDC_CLIENT_ID }}"
|
||||
when: oauth_check.rows | length > 0
|
||||
|
||||
- name: "Create new Keycloak OAuth provider"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/insert-keycloak-provider.sql"
|
||||
named_args:
|
||||
auth_endpoint: "{{ ODOO_OIDC_AUTHORIZE_URL }}"
|
||||
userinfo_endpoint: "{{ ODOO_OIDC_USERINFO_URL }}"
|
||||
client_id: "{{ ODOO_OIDC_CLIENT_ID }}"
|
||||
when: oauth_check.rows | length == 0
|
||||
- name: "Create new Keycloak OAuth provider"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/insert-keycloak-provider.sql"
|
||||
named_args:
|
||||
auth_endpoint: "{{ ODOO_OIDC_AUTHORIZE_URL }}"
|
||||
userinfo_endpoint: "{{ ODOO_OIDC_USERINFO_URL }}"
|
||||
client_id: "{{ ODOO_OIDC_CLIENT_ID }}"
|
||||
when: oauth_check.rows | length == 0
|
||||
|
||||
- name: "Enable OAuth authentication in system parameters"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter.sql"
|
||||
named_args:
|
||||
key: "auth_oauth.oauth_providers"
|
||||
value: "True"
|
||||
- name: "Enable OAuth authentication in system parameters"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter.sql"
|
||||
named_args:
|
||||
key: "auth_oauth.oauth_providers"
|
||||
value: "True"
|
||||
|
||||
- name: "Enable OAuth signup (allow user creation on first OAuth login)"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter.sql"
|
||||
named_args:
|
||||
key: "auth_signup.invitation_scope"
|
||||
value: "b2c"
|
||||
- name: "Enable OAuth signup (allow user creation on first OAuth login)"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter.sql"
|
||||
named_args:
|
||||
key: "auth_signup.invitation_scope"
|
||||
value: "b2c"
|
||||
|
||||
- name: "Allow uninvited OAuth signup on website"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/update-website-uninvited-b2c.sql"
|
||||
- name: "Allow uninvited OAuth signup on website"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/update-website-uninvited-b2c.sql"
|
||||
|
||||
# Create an internal user template for OAuth signup. New users
|
||||
# inherit this user's groups via base.template_portal_user_id /
|
||||
# auth_signup.template_user_id.
|
||||
- name: "Check if OAuth template user exists"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/select-oauth-template-user.sql"
|
||||
expect_rows: true
|
||||
register: oauth_template_check
|
||||
# Create an internal user template for OAuth signup. New users
|
||||
# inherit this user's groups via base.template_portal_user_id /
|
||||
# auth_signup.template_user_id.
|
||||
- name: "Check if OAuth template user exists"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/select-oauth-template-user.sql"
|
||||
expect_rows: true
|
||||
register: oauth_template_check
|
||||
|
||||
- name: "Create OAuth template partner"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/insert-oauth-template-partner.sql"
|
||||
expect_rows: true
|
||||
register: oauth_template_partner
|
||||
when: oauth_template_check.rows | length == 0
|
||||
- name: "Create OAuth template partner"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/insert-oauth-template-partner.sql"
|
||||
expect_rows: true
|
||||
register: oauth_template_partner
|
||||
when: oauth_template_check.rows | length == 0
|
||||
|
||||
- name: "Create OAuth template user"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/insert-oauth-template-user.sql"
|
||||
named_args:
|
||||
partner_id: "{{ oauth_template_partner.rows[0][0] | int }}"
|
||||
register: oauth_template_user
|
||||
when:
|
||||
- oauth_template_check.rows | length == 0
|
||||
- oauth_template_partner.rows is defined
|
||||
- oauth_template_partner.rows | length > 0
|
||||
- name: "Create OAuth template user"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/insert-oauth-template-user.sql"
|
||||
named_args:
|
||||
partner_id: "{{ oauth_template_partner.rows[0][0] | int }}"
|
||||
register: oauth_template_user
|
||||
when:
|
||||
- oauth_template_check.rows | length == 0
|
||||
- oauth_template_partner.rows is defined
|
||||
- oauth_template_partner.rows | length > 0
|
||||
|
||||
- name: "Ensure OAuth template user is active"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/update-activate-oauth-template.sql"
|
||||
- name: "Ensure OAuth template user is active"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/update-activate-oauth-template.sql"
|
||||
|
||||
- name: "Grant Internal User group to OAuth template"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/grant-internal-user-to-oauth-template.sql"
|
||||
- name: "Grant Internal User group to OAuth template"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/grant-internal-user-to-oauth-template.sql"
|
||||
|
||||
- name: "Grant company access to OAuth template"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/grant-company-to-oauth-template.sql"
|
||||
- name: "Grant company access to OAuth template"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/grant-company-to-oauth-template.sql"
|
||||
|
||||
# Set the template user id for both knobs:
|
||||
# - base.template_portal_user_id is consumed by
|
||||
# _create_user_from_template() during signup.
|
||||
# - auth_signup.template_user_id is what the Odoo UI settings page
|
||||
# displays; we set both for consistency.
|
||||
- name: "Set OAuth template user for signup (base.template_portal_user_id)"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter-template-user-id.sql"
|
||||
named_args:
|
||||
key: "base.template_portal_user_id"
|
||||
# Set the template user id for both knobs:
|
||||
# - base.template_portal_user_id is consumed by
|
||||
# _create_user_from_template() during signup.
|
||||
# - auth_signup.template_user_id is what the Odoo UI settings page
|
||||
# displays; we set both for consistency.
|
||||
- name: "Set OAuth template user for signup (base.template_portal_user_id)"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter-template-user-id.sql"
|
||||
named_args:
|
||||
key: "base.template_portal_user_id"
|
||||
|
||||
- name: "Set OAuth template user for signup (auth_signup.template_user_id)"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter-template-user-id.sql"
|
||||
named_args:
|
||||
key: "auth_signup.template_user_id"
|
||||
- name: "Set OAuth template user for signup (auth_signup.template_user_id)"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter-template-user-id.sql"
|
||||
named_args:
|
||||
key: "auth_signup.template_user_id"
|
||||
|
||||
- name: "Use Authorization header for OAuth token validation"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter.sql"
|
||||
named_args:
|
||||
key: "auth_oauth.authorization_header"
|
||||
value: "True"
|
||||
- name: "Use Authorization header for OAuth token validation"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter.sql"
|
||||
named_args:
|
||||
key: "auth_oauth.authorization_header"
|
||||
value: "True"
|
||||
|
||||
- name: "Set web.base.url for HTTPS redirects"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter.sql"
|
||||
named_args:
|
||||
key: "web.base.url"
|
||||
value: "{{ ODOO_URL }}"
|
||||
- name: "Set web.base.url for HTTPS redirects"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter.sql"
|
||||
named_args:
|
||||
key: "web.base.url"
|
||||
value: "{{ ODOO_URL }}"
|
||||
|
||||
- name: "Freeze web.base.url to prevent automatic detection"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter.sql"
|
||||
named_args:
|
||||
key: "web.base.url.freeze"
|
||||
value: "True"
|
||||
- name: "Freeze web.base.url to prevent automatic detection"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/upsert-ir-config-parameter.sql"
|
||||
named_args:
|
||||
key: "web.base.url.freeze"
|
||||
value: "True"
|
||||
|
||||
# Repair fallback for users created before
|
||||
# base.template_portal_user_id was set correctly. New OAuth signups
|
||||
# inherit the group from the template, but legacy users do not.
|
||||
- name: "Grant Internal User group to OAuth-created users"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/grant-internal-user-to-oauth-users-repair.sql"
|
||||
# Repair fallback for users created before
|
||||
# base.template_portal_user_id was set correctly. New OAuth signups
|
||||
# inherit the group from the template, but legacy users do not.
|
||||
- name: "Grant Internal User group to OAuth-created users"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/grant-internal-user-to-oauth-users-repair.sql"
|
||||
|
||||
# Link existing Odoo users to Keycloak for SSO.
|
||||
- name: "Get Keycloak OAuth provider id in Odoo"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/select-keycloak-provider-id.sql"
|
||||
expect_rows: true
|
||||
register: keycloak_provider
|
||||
# Link existing Odoo users to Keycloak for SSO.
|
||||
- name: "Get Keycloak OAuth provider id in Odoo"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/select-keycloak-provider-id.sql"
|
||||
expect_rows: true
|
||||
register: keycloak_provider
|
||||
|
||||
- name: "Query Keycloak DB for administrator username"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', 'web-app-keycloak') }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/select-keycloak-realm-user.sql"
|
||||
named_args:
|
||||
realm: "{{ OIDC.CLIENT.REALM }}"
|
||||
admin: "{{ lookup('users', 'administrator').username }}"
|
||||
expect_rows: true
|
||||
register: keycloak_users
|
||||
when: keycloak_provider.rows | length > 0
|
||||
- name: "Query Keycloak DB for administrator username"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', 'web-app-keycloak') }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/select-keycloak-realm-user.sql"
|
||||
named_args:
|
||||
realm: "{{ OIDC.CLIENT.REALM }}"
|
||||
admin: "{{ lookup('users', 'administrator').username }}"
|
||||
expect_rows: true
|
||||
register: keycloak_users
|
||||
when: keycloak_provider.rows | length > 0
|
||||
|
||||
- name: "Link administrator user to Keycloak in Odoo"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/update-user-oauth-link.sql"
|
||||
named_args:
|
||||
provider_id: "{{ keycloak_provider.rows[0][0] | int }}"
|
||||
oauth_uid: "{{ (keycloak_users.rows | map('first') | select('equalto', lookup('users', 'administrator').username) | list | first) }}"
|
||||
login: "{{ lookup('users', 'administrator').username }}"
|
||||
when:
|
||||
- keycloak_provider.rows | length > 0
|
||||
- keycloak_users.rows | map('first') | select('equalto', lookup('users', 'administrator').username) | list | length > 0
|
||||
- name: "Link administrator user to Keycloak in Odoo"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/update-user-oauth-link.sql"
|
||||
named_args:
|
||||
provider_id: "{{ keycloak_provider.rows[0][0] | int }}"
|
||||
oauth_uid: "{{ (keycloak_users.rows | map('first') | select('equalto', lookup('users', 'administrator').username) | list | first) }}"
|
||||
login: "{{ lookup('users', 'administrator').username }}"
|
||||
when:
|
||||
- keycloak_provider.rows | length > 0
|
||||
- keycloak_users.rows | map('first') | select('equalto', lookup('users', 'administrator').username) | list | length > 0
|
||||
|
||||
# Auto-created OAuth users get portal access by default. Promote
|
||||
# them to internal-user + default-company so they can reach the
|
||||
# main Odoo interface.
|
||||
- name: "Grant internal user access to OAuth-created users"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/grant-internal-user-to-oauth-signup-users.sql"
|
||||
when: keycloak_provider.rows | length > 0
|
||||
# Auto-created OAuth users get portal access by default. Promote
|
||||
# them to internal-user + default-company so they can reach the
|
||||
# main Odoo interface.
|
||||
- name: "Grant internal user access to OAuth-created users"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/grant-internal-user-to-oauth-signup-users.sql"
|
||||
when: keycloak_provider.rows | length > 0
|
||||
|
||||
- name: "Grant company access to OAuth-created users"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/grant-company-to-oauth-signup-users.sql"
|
||||
when: keycloak_provider.rows | length > 0
|
||||
|
||||
# Note: Non-admin users (like biber) are auto-created via OAuth signup
|
||||
# when they first SSO login. The auth_signup.invitation_scope = 'b2c'
|
||||
# setting enables this. Users are defined in
|
||||
# inventories/development/default.yml and created in LDAP by
|
||||
# svc-db-openldap, then federated to Keycloak for SSO authentication.
|
||||
# The tasks above ensure OAuth-created users get internal access and
|
||||
# company membership.
|
||||
- name: "Grant company access to OAuth-created users"
|
||||
database_query:
|
||||
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
config: "{{ lookup('database', application_id) }}"
|
||||
query_file: "{{ role_path }}/files/sql/oidc/grant-company-to-oauth-signup-users.sql"
|
||||
when: keycloak_provider.rows | length > 0
|
||||
@@ -35,98 +35,12 @@
|
||||
register: _put_page
|
||||
|
||||
- block:
|
||||
- name: "XWIKI | Execute installer page"
|
||||
uri_retry:
|
||||
url: "{{ XWIKI_DOCKER_REACH_URL }}/bin/view/XWiki/InstallExtensions?xpage=plain"
|
||||
method: GET
|
||||
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
|
||||
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
|
||||
force_basic_auth: true
|
||||
status_code: [200]
|
||||
return_content: true
|
||||
timeout: 300
|
||||
register: _exec_page
|
||||
retries: 12
|
||||
delay: 20
|
||||
until:
|
||||
- _exec_page is succeeded
|
||||
- (_exec_page.content | default('')) is not search('WARN::')
|
||||
changed_when: false
|
||||
|
||||
- name: "XWIKI | Assert installer output has no errors"
|
||||
assert:
|
||||
that:
|
||||
- (_exec_page.content | default('')) is not search('ERROR::')
|
||||
- (_exec_page.content | default('')) is not search('INSTALLED_MISSING::')
|
||||
- (_exec_page.content | default('')) is not search('WARN::')
|
||||
fail_msg: |
|
||||
XWiki extension installer reported errors.
|
||||
Output:
|
||||
{{ _exec_page.content | default('') }}
|
||||
- name: "XWIKI | Load installer-page execution + assertion routines"
|
||||
include_tasks: 04b_extensions_execute.yml
|
||||
|
||||
rescue:
|
||||
- name: "XWIKI | DEBUG | Show installer page output"
|
||||
debug:
|
||||
var: _exec_page.content
|
||||
|
||||
- name: "XWIKI | DEBUG | Repo reachability from container (nexus + maven central)"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
container exec "{{ XWIKI_CONTAINER_ADDRESS }}" sh -lc '
|
||||
echo "== DNS ==";
|
||||
(getent hosts nexus.xwiki.org || true);
|
||||
(getent hosts repo1.maven.org || true);
|
||||
echo "== HTTP ==";
|
||||
(wget -S -O- -T 10 https://nexus.xwiki.org/ 2>&1 | head -n 30) || true;
|
||||
(wget -S -O- -T 10 https://repo1.maven.org/maven2/ 2>&1 | head -n 30) || true;
|
||||
'
|
||||
register: _dbg_net
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: "XWIKI | DEBUG | Dump mounted xwiki.properties (first 200 lines)"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
container exec "{{ XWIKI_CONTAINER_ADDRESS }}" sh -lc '
|
||||
echo "== xwiki.properties ==";
|
||||
ls -la /usr/local/tomcat/webapps/ROOT/WEB-INF/xwiki.properties || true;
|
||||
sed -n "1,200p" /usr/local/tomcat/webapps/ROOT/WEB-INF/xwiki.properties 2>/dev/null || true;
|
||||
echo "== extension.repositories ==";
|
||||
grep -n "^[[:space:]]*extension\.repositories" /usr/local/tomcat/webapps/ROOT/WEB-INF/xwiki.properties 2>/dev/null || true;
|
||||
'
|
||||
register: _dbg_props
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: "XWIKI | DEBUG | Tail container logs"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
container logs --tail 300 "{{ XWIKI_CONTAINER }}" || true
|
||||
register: _dbg_logs
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: "XWIKI | DEBUG | Print debug outputs"
|
||||
debug:
|
||||
msg: |
|
||||
===== XWIKI DEBUG NET =====
|
||||
{{ _dbg_net.stdout | default('') }}
|
||||
{{ _dbg_net.stderr | default('') }}
|
||||
|
||||
===== XWIKI DEBUG PROPS =====
|
||||
{{ _dbg_props.stdout | default('') }}
|
||||
{{ _dbg_props.stderr | default('') }}
|
||||
|
||||
===== XWIKI DEBUG LOGS =====
|
||||
{{ _dbg_logs.stdout | default('') }}
|
||||
{{ _dbg_logs.stderr | default('') }}
|
||||
|
||||
- name: "XWIKI | FAIL | Extension installation failed (see debug above)"
|
||||
fail:
|
||||
msg: |
|
||||
Extension installation failed.
|
||||
Installer output:
|
||||
{{ _exec_page.content | default('') }}
|
||||
- name: "XWIKI | Load extension-install rescue/debug routines"
|
||||
include_tasks: 04a_extensions_rescue.yml
|
||||
|
||||
always:
|
||||
- name: "XWIKI | Delete installer page (best effort)"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
- name: "XWIKI | DEBUG | Show installer page output"
|
||||
debug:
|
||||
var: _exec_page.content
|
||||
|
||||
- name: "XWIKI | DEBUG | Repo reachability from container (nexus + maven central)"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
container exec "{{ XWIKI_CONTAINER_ADDRESS }}" sh -lc '
|
||||
echo "== DNS ==";
|
||||
(getent hosts nexus.xwiki.org || true);
|
||||
(getent hosts repo1.maven.org || true);
|
||||
echo "== HTTP ==";
|
||||
(wget -S -O- -T 10 https://nexus.xwiki.org/ 2>&1 | head -n 30) || true;
|
||||
(wget -S -O- -T 10 https://repo1.maven.org/maven2/ 2>&1 | head -n 30) || true;
|
||||
'
|
||||
register: _dbg_net
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: "XWIKI | DEBUG | Dump mounted xwiki.properties (first 200 lines)"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
container exec "{{ XWIKI_CONTAINER_ADDRESS }}" sh -lc '
|
||||
echo "== xwiki.properties ==";
|
||||
ls -la /usr/local/tomcat/webapps/ROOT/WEB-INF/xwiki.properties || true;
|
||||
sed -n "1,200p" /usr/local/tomcat/webapps/ROOT/WEB-INF/xwiki.properties 2>/dev/null || true;
|
||||
echo "== extension.repositories ==";
|
||||
grep -n "^[[:space:]]*extension\.repositories" /usr/local/tomcat/webapps/ROOT/WEB-INF/xwiki.properties 2>/dev/null || true;
|
||||
'
|
||||
register: _dbg_props
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: "XWIKI | DEBUG | Tail container logs"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
container logs --tail 300 "{{ XWIKI_CONTAINER }}" || true
|
||||
register: _dbg_logs
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: "XWIKI | DEBUG | Print debug outputs"
|
||||
debug:
|
||||
msg: |
|
||||
===== XWIKI DEBUG NET =====
|
||||
{{ _dbg_net.stdout | default('') }}
|
||||
{{ _dbg_net.stderr | default('') }}
|
||||
|
||||
===== XWIKI DEBUG PROPS =====
|
||||
{{ _dbg_props.stdout | default('') }}
|
||||
{{ _dbg_props.stderr | default('') }}
|
||||
|
||||
===== XWIKI DEBUG LOGS =====
|
||||
{{ _dbg_logs.stdout | default('') }}
|
||||
{{ _dbg_logs.stderr | default('') }}
|
||||
|
||||
- name: "XWIKI | FAIL | Extension installation failed (see debug above)"
|
||||
fail:
|
||||
msg: |
|
||||
Extension installation failed.
|
||||
Installer output:
|
||||
{{ _exec_page.content | default('') }}
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
- name: "XWIKI | Execute installer page"
|
||||
uri_retry:
|
||||
url: "{{ XWIKI_DOCKER_REACH_URL }}/bin/view/XWiki/InstallExtensions?xpage=plain"
|
||||
method: GET
|
||||
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
|
||||
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
|
||||
force_basic_auth: true
|
||||
status_code: [200]
|
||||
return_content: true
|
||||
timeout: 300
|
||||
register: _exec_page
|
||||
retries: 12
|
||||
delay: 20
|
||||
until:
|
||||
- _exec_page is succeeded
|
||||
- (_exec_page.content | default('')) is not search('WARN::')
|
||||
changed_when: false
|
||||
|
||||
- name: "XWIKI | Assert installer output has no errors"
|
||||
assert:
|
||||
that:
|
||||
- (_exec_page.content | default('')) is not search('ERROR::')
|
||||
- (_exec_page.content | default('')) is not search('INSTALLED_MISSING::')
|
||||
- (_exec_page.content | default('')) is not search('WARN::')
|
||||
fail_msg: |
|
||||
XWiki extension installer reported errors.
|
||||
Output:
|
||||
{{ _exec_page.content | default('') }}
|
||||
@@ -0,0 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT: Path = Path(__file__).resolve().parents[4]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Forbid Ansible ``block:`` constructs with more than 3 direct child
|
||||
tasks.
|
||||
|
||||
Big block: bodies become hard to scan, hard to give a meaningful name,
|
||||
and tempt callers to express conditions / delegate_to / vars on the
|
||||
outer wrapper that semantically belong to a single grouped concern.
|
||||
The convention is: ≤ 3 direct children, otherwise extract the body
|
||||
into its own ``include_tasks:`` sub-file. If the include is gated by
|
||||
``when: DEPLOYMENT_MODE != "swarm"`` (or another compose-only guard),
|
||||
the sub-file MAY carry the file-header marker
|
||||
|
||||
# include-gated: when: DEPLOYMENT_MODE != "swarm"
|
||||
|
||||
so the swarm-compat lints (``compose-chdir-in-task`` /
|
||||
``compose-verb-in-task``) treat the file as exempt — see
|
||||
``utils/annotations/task_gate.py::is_file_compose_only_by_header``.
|
||||
|
||||
Per-line opt-out
|
||||
================
|
||||
Add ``# nocheck: ansible-block-oversized`` on the ``block:`` line or
|
||||
the immediately preceding non-empty line. Reserved for genuinely-
|
||||
atomic groups whose pieces cannot stand alone (rare).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from utils.annotations.suppress import is_suppressed_at
|
||||
from utils.cache.files import iter_project_files_with_content
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
_RULE = "ansible-block-oversized"
|
||||
_MAX_CHILDREN = 3
|
||||
|
||||
_BLOCK_LINE = re.compile(r"^(?P<indent>\s*)(?:-\s+)?block\s*:\s*$")
|
||||
_LIST_ITEM_LINE = re.compile(r"^(?P<indent>\s*)-\s+\S")
|
||||
_KEY_LINE = re.compile(r"^(?P<indent>\s*)[A-Za-z_][\w-]*\s*:")
|
||||
|
||||
|
||||
def _is_role_task_file(rel_path: str) -> bool:
|
||||
if not rel_path.startswith("roles/"):
|
||||
return False
|
||||
return "/tasks/" in rel_path and rel_path.endswith((".yml", ".yaml"))
|
||||
|
||||
|
||||
def _count_block_children(lines: list[str], block_idx: int) -> int:
|
||||
"""Count direct ``- ...`` list items beneath the ``block:`` keyword
|
||||
at *block_idx*, terminating when indentation drops back to or below
|
||||
the block keyword's own indentation."""
|
||||
block_match = _BLOCK_LINE.match(lines[block_idx])
|
||||
if not block_match:
|
||||
return 0
|
||||
block_indent = len(block_match.group("indent"))
|
||||
children = 0
|
||||
child_indent: int | None = None
|
||||
for j in range(block_idx + 1, len(lines)):
|
||||
raw = lines[j]
|
||||
if not raw.strip():
|
||||
continue
|
||||
stripped_indent = len(raw) - len(raw.lstrip())
|
||||
if stripped_indent <= block_indent:
|
||||
list_match = _LIST_ITEM_LINE.match(raw)
|
||||
key_match = _KEY_LINE.match(raw)
|
||||
if list_match or key_match:
|
||||
break
|
||||
list_match = _LIST_ITEM_LINE.match(raw)
|
||||
if not list_match:
|
||||
continue
|
||||
item_indent = len(list_match.group("indent"))
|
||||
if item_indent <= block_indent:
|
||||
break
|
||||
if child_indent is None:
|
||||
child_indent = item_indent
|
||||
if item_indent == child_indent:
|
||||
children += 1
|
||||
return children
|
||||
|
||||
|
||||
class TestNoOversizedAnsibleBlock(unittest.TestCase):
|
||||
def test_no_block_has_more_than_three_children(self) -> None:
|
||||
findings: list[tuple[str, int, int]] = []
|
||||
for path_str, content in iter_project_files_with_content(
|
||||
extensions=(".yml", ".yaml"),
|
||||
exclude_tests=True,
|
||||
):
|
||||
rel = Path(path_str).relative_to(PROJECT_ROOT).as_posix()
|
||||
if not _is_role_task_file(rel):
|
||||
continue
|
||||
lines = content.splitlines()
|
||||
for idx, line in enumerate(lines):
|
||||
if not _BLOCK_LINE.match(line):
|
||||
continue
|
||||
count = _count_block_children(lines, idx)
|
||||
if count <= _MAX_CHILDREN:
|
||||
continue
|
||||
if is_suppressed_at(lines, idx + 1, _RULE, mode="same-or-above"):
|
||||
continue
|
||||
findings.append((rel, idx + 1, count))
|
||||
|
||||
if findings:
|
||||
formatted = "\n".join(
|
||||
f"- {p}:{n}: block has {c} direct children (limit {_MAX_CHILDREN})"
|
||||
for p, n, c in sorted(set(findings), key=lambda i: (i[0], i[1]))
|
||||
)
|
||||
self.fail(
|
||||
f"Found ansible `block:` constructs with more than "
|
||||
f"{_MAX_CHILDREN} direct child tasks. Extract the body into "
|
||||
"its own `include_tasks:` sub-file so the parent block "
|
||||
"stays scannable.\n\n"
|
||||
"Example refactor:\n"
|
||||
" - name: '<descriptive>'\n"
|
||||
" when: DEPLOYMENT_MODE != 'swarm'\n"
|
||||
" include_tasks: <NN>_<descriptive>.yml\n\n"
|
||||
"If the sub-file is when-gated, mark it with the header\n"
|
||||
' # include-gated: when: DEPLOYMENT_MODE != "swarm"\n'
|
||||
"(see utils/annotations/task_gate.py) so the swarm-compat "
|
||||
f"lints exempt it.\n\nOffenders:\n{formatted}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Forbid runs of 3 or more consecutive sibling tasks that carry the
|
||||
SAME ``when:`` expression. The convention is: when the same condition
|
||||
guards a contiguous sequence of tasks, wrap them in a single ``- name:
|
||||
...`` + ``when: <expr>`` + ``block:`` so the condition appears once.
|
||||
|
||||
This catches the common copy-paste pattern where each step gets its
|
||||
own ``when: not (X_failed | default(false) | bool)`` line — duplicate
|
||||
guards drift over time, the eye stops parsing them after the first
|
||||
two, and adding a fourth task perpetuates the noise.
|
||||
|
||||
Per-line opt-out
|
||||
================
|
||||
Add ``# nocheck: ansible-repeated-when-run`` on the FIRST task of the
|
||||
run. Reserved for runs where wrapping in a block would alter semantics
|
||||
(e.g. each task needs an independent ``register:`` value used by the
|
||||
next task's ``when:`` — though even then, splitting the run via an
|
||||
intermediate task makes the dependency explicit).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from utils.annotations.suppress import is_suppressed_at
|
||||
from utils.cache.files import iter_project_files_with_content
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
_RULE = "ansible-repeated-when-run"
|
||||
_MIN_RUN = 3
|
||||
|
||||
_TASK_MARKER = re.compile(
|
||||
r"^(?P<indent>\s*)-\s+(?:name|include_tasks|import_tasks|block|hosts)\s*:"
|
||||
)
|
||||
_WHEN_INLINE = re.compile(r"^\s*when\s*:\s*(?P<expr>.+?)\s*$")
|
||||
|
||||
|
||||
def _is_role_task_file(rel_path: str) -> bool:
|
||||
if not rel_path.startswith("roles/"):
|
||||
return False
|
||||
return "/tasks/" in rel_path and rel_path.endswith((".yml", ".yaml"))
|
||||
|
||||
|
||||
def _task_block_when(lines: list[str], start_idx: int, indent_len: int) -> str | None:
|
||||
"""Return the textual ``when:`` expression of the task starting at
|
||||
*start_idx*, or None if the task carries no ``when:``. The body is
|
||||
everything until the next sibling marker at the same or shallower
|
||||
indent, or EOF. Only top-level ``when:`` inside this task body
|
||||
counts (not a nested block child's when).
|
||||
"""
|
||||
indent_prefix = " " * indent_len
|
||||
for j in range(start_idx + 1, len(lines)):
|
||||
raw = lines[j]
|
||||
stripped = raw.lstrip()
|
||||
if not stripped:
|
||||
continue
|
||||
cur_indent = len(raw) - len(stripped)
|
||||
if cur_indent <= indent_len and _TASK_MARKER.match(raw):
|
||||
return None
|
||||
if cur_indent != indent_len + 2:
|
||||
continue
|
||||
if not raw.startswith(indent_prefix + " when:"):
|
||||
continue
|
||||
match = _WHEN_INLINE.match(raw)
|
||||
if match:
|
||||
return match.group("expr").strip()
|
||||
return None
|
||||
|
||||
|
||||
class TestNoRepeatedWhenRuns(unittest.TestCase):
|
||||
def test_no_three_consecutive_tasks_share_a_when(self) -> None:
|
||||
findings: list[tuple[str, int, str]] = []
|
||||
for path_str, content in iter_project_files_with_content(
|
||||
extensions=(".yml", ".yaml"),
|
||||
exclude_tests=True,
|
||||
):
|
||||
rel = Path(path_str).relative_to(PROJECT_ROOT).as_posix()
|
||||
if not _is_role_task_file(rel):
|
||||
continue
|
||||
lines = content.splitlines()
|
||||
run_when: str | None = None
|
||||
run_indent: int | None = None
|
||||
run_count = 0
|
||||
run_start_line = 0
|
||||
for idx, line in enumerate(lines):
|
||||
marker = _TASK_MARKER.match(line)
|
||||
if not marker:
|
||||
continue
|
||||
cur_indent = len(marker.group("indent"))
|
||||
when_expr = _task_block_when(lines, idx, cur_indent)
|
||||
same_run = (
|
||||
when_expr is not None
|
||||
and when_expr == run_when
|
||||
and cur_indent == run_indent
|
||||
)
|
||||
if same_run:
|
||||
run_count += 1
|
||||
else:
|
||||
if (
|
||||
run_count >= _MIN_RUN
|
||||
and run_when is not None
|
||||
and not is_suppressed_at(
|
||||
lines, run_start_line, _RULE, mode="same-or-above"
|
||||
)
|
||||
):
|
||||
findings.append((rel, run_start_line, run_when))
|
||||
if when_expr is not None:
|
||||
run_when = when_expr
|
||||
run_indent = cur_indent
|
||||
run_count = 1
|
||||
run_start_line = idx + 1
|
||||
else:
|
||||
run_when = None
|
||||
run_indent = None
|
||||
run_count = 0
|
||||
run_start_line = 0
|
||||
if (
|
||||
run_count >= _MIN_RUN
|
||||
and run_when is not None
|
||||
and not is_suppressed_at(
|
||||
lines, run_start_line, _RULE, mode="same-or-above"
|
||||
)
|
||||
):
|
||||
findings.append((rel, run_start_line, run_when))
|
||||
|
||||
if findings:
|
||||
formatted = "\n".join(
|
||||
f"- {p}:{n}: {_MIN_RUN}+ siblings share `when: {w}`"
|
||||
for p, n, w in sorted(set(findings), key=lambda i: (i[0], i[1]))
|
||||
)
|
||||
self.fail(
|
||||
f"Found {_MIN_RUN}+ consecutive sibling tasks sharing the "
|
||||
"SAME `when:` expression. Wrap them in a single block so "
|
||||
"the condition appears once:\n\n"
|
||||
" - name: '<descriptive>'\n"
|
||||
" when: <expression>\n"
|
||||
" block:\n"
|
||||
" - name: ...\n"
|
||||
" - name: ...\n"
|
||||
" - name: ...\n\n"
|
||||
"If the resulting block exceeds 3 direct children, also "
|
||||
"apply `ansible-block-oversized` (extract into an "
|
||||
f"`include_tasks:` sub-file).\n\nOffenders:\n{formatted}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user