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:
kevinveenbirkenbachandClaude Opus 4.7 committed 2026-06-14 19:08:46 +02:00
1 parent 478d0679dd
commit c61c68e903
13 files changed
+727 -449

No files matched your search

+2 -23
View File
@@ -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('') }}
+42 -42
View File
@@ -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"
+44 -44
View File
@@ -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: >
+190 -200
View File
@@ -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
+4 -90
View File
@@ -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('') }}
+3
View File
@@ -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()