test(lint): make a looped include name its loop variable

A looped `include_tasks` or `include_role` leaves its item in scope for
everything the include reaches. Left at the default name, Ansible warns as
soon as any task inside loops as well, and the sharp case is quieter than the
warning: the included body reads `item` expecting the value the include was
called with, while an inner loop has already repointed the name. Nothing
fails, so the wrong value is written and the run reports success.

web-app-prometheus was carrying exactly that shape. It hands the item to an
included file through `vars:`, which Ansible templates lazily, so
`network_role_id` was one nested loop away from resolving to something else.

The rule complements the existing loop_var shadowing lint, which checks the
other direction and cannot see this one. It parses the task files rather than
scanning their lines: a line window around each include also catches the next
task's `loop:` and reports 55 findings where there are 22.

Fourteen of those are the plain dependency include and take
`dependency_role`, the name dsk-gnome already uses. The rest take a name for
what the item is. The gitlab and mastodon wait includes pass nothing through
`vars:` and their included file reads `item` four times, so each needed that
file renamed with it; naming only the parent would have left `item`
undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-29 16:50:34 +02:00
1 parent a2e1a269e2
commit fe2156b05e
25 files changed
+219 -40

No files matched your search

@@ -3,11 +3,13 @@
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-ctl-cln-faild-bkps
- sys-ctl-cln-bkps
- sys-lock
loop_control:
loop_var: dependency_role
- name: 🛑 Fail if any backup_to_usb variable is empty
assert:
+3 -1
View File
@@ -3,12 +3,14 @@
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-lock
- sys-ctl-alm-compose
- sys-ctl-cln-bkps
- user-backup
loop_control:
loop_var: dependency_role
- include_role:
name: sys-service
@@ -3,13 +3,15 @@
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-lock
- dev-git
- sys-ctl-alm-compose
- sys-ctl-cln-bkps
- user-backup
loop_control:
loop_var: dependency_role
- name: "📁 Create Directory '{{ DOCKER_BACKUP_REMOTE_2_LOCAL_DIR }}'"
file:
@@ -3,12 +3,14 @@
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-lock
- sys-ctl-alm-compose
- sys-ctl-cln-bkps
- user-backup
loop_control:
loop_var: dependency_role
- include_role:
name: sys-service
@@ -3,13 +3,15 @@
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-svc-container
- sys-lock
- user-backup
- sys-ctl-alm-compose
- sys-ctl-cln-bkps
loop_control:
loop_var: dependency_role
- include_tasks: 01_install.yml
when: backup_docker_to_local_folder is not defined
+3 -1
View File
@@ -3,8 +3,10 @@
- name: "📂 Include dependent services for '{{ system_service_id }}'"
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop: "{{ SYSTEMCTL_ALARM_COMPOSER_SUBSERVICES }}"
loop_control:
loop_var: dependency_role
vars:
system_service_timer_enabled: false
system_service_copy_files: true
+3 -1
View File
@@ -1,10 +1,12 @@
---
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-ctl-alm-compose
- sys-lock
loop_control:
loop_var: dependency_role
- name: 📦 install lsof
package_install:
+3 -1
View File
@@ -1,10 +1,12 @@
---
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-ctl-alm-compose
- sys-daemon
loop_control:
loop_var: dependency_role
- name: 📦 Install certreap system-wide via pip
include_role:
@@ -1,10 +1,12 @@
---
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-ctl-alm-compose
- sys-lock
loop_control:
loop_var: dependency_role
- include_role:
name: sys-service
@@ -3,12 +3,14 @@
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- user-backup
- sys-daemon
- sys-ctl-alm-compose
- sys-lock
loop_control:
loop_var: dependency_role
- name: "📦 Install '{{ CLEANUP_FAILED_BACKUPS_PKG }}' via pip"
include_role:
@@ -1,11 +1,13 @@
---
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-svc-certbot
- sys-svc-webserver-core
- sys-ctl-alm-compose
loop_control:
loop_var: dependency_role
- name: 📦 install certbot
package_install:
@@ -1,11 +1,13 @@
---
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-svc-webserver-core
- sys-svc-cln-domains
- sys-svc-dns
loop_control:
loop_var: dependency_role
- meta: flush_handlers
+4 -3
View File
@@ -110,8 +110,9 @@
when: FRIENDICA_LDAP_ENABLED | bool
- name: "▶️ Run per-addon integration hooks for enabled addons"
include_tasks: "addons/{{ item.name }}.yml"
include_tasks: "addons/{{ addon.name }}.yml"
loop: "{{ friendica_addons | selectattr('enabled') | list }}"
loop_control:
label: "{{ item.name }}"
when: (role_path ~ '/tasks/addons/' ~ item.name ~ '.yml') is exists
loop_var: addon
label: "{{ addon.name }}"
when: (role_path ~ '/tasks/addons/' ~ addon.name ~ '.yml') is exists
+3 -1
View File
@@ -1,6 +1,8 @@
---
- name: "🔎 Wait until '{{ item }}' container is healthy"
- name: "🔎 Wait until '{{ wait_container }}' container is healthy"
ansible.builtin.include_tasks: 02_wait/wait_one.yml
loop_control:
loop_var: wait_container
loop:
- "{{ GITLAB_WEBSERVICE_SERVICE_NAME }}"
- "{{ GITLAB_WORKHORSE_SERVICE_NAME }}"
@@ -1,12 +1,12 @@
---
- name: "🔍 Resolve '{{ item }}' hosting node + container id"
- name: "🔍 Resolve '{{ wait_container }}' hosting node + container id"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}"
vars:
resolve_service_key: "{{ item }}"
resolve_service_key: "{{ wait_container }}"
resolve_output_prefix: GITLAB_WAIT
resolve_compose_cid: "{{ lookup('container_address', application_id, item) }}"
resolve_compose_cid: "{{ lookup('container_address', application_id, wait_container) }}"
- name: "🔎 Check health status of '{{ item }}' container"
- name: "🔎 Check health status of '{{ wait_container }}' container"
ansible.builtin.shell: >
container inspect --type container --format '{{ "{{.State.Health.Status}}" }}'
{{ GITLAB_WAIT_LOCAL_CID | quote }}
+3 -1
View File
@@ -1,6 +1,8 @@
---
- name: "🔎 Wait until '{{ item }}' container is healthy"
- name: "🔎 Wait until '{{ wait_container }}' container is healthy"
ansible.builtin.include_tasks: 03_wait/wait_one.yml
loop_control:
loop_var: wait_container
loop:
- "{{ MASTODON_SERVICE_NAME }}"
- "{{ MASTODON_STREAMING_SERVICE_NAME }}"
@@ -1,12 +1,12 @@
---
- name: "🔍 Resolve '{{ item }}' hosting node + container id"
- name: "🔍 Resolve '{{ wait_container }}' hosting node + container id"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}"
vars:
resolve_service_key: "{{ item }}"
resolve_service_key: "{{ wait_container }}"
resolve_output_prefix: MASTODON_WAIT
resolve_compose_cid: "{{ lookup('container_address', application_id, item) }}"
resolve_compose_cid: "{{ lookup('container_address', application_id, wait_container) }}"
- name: "🔎 Check health status of '{{ item }}' container"
- name: "🔎 Check health status of '{{ wait_container }}' container"
ansible.builtin.shell: >
container inspect --type container --format '{{ "{{.State.Health.Status}}" }}'
{{ MASTODON_WAIT_LOCAL_CID | quote }}
@@ -9,12 +9,13 @@
- name: 📂 include 02_create-and-seed-database.yml for multiple bridges
include_tasks: 02_create-and-seed-database.yml
vars:
database_password: "{{ item.database_password }}"
database_username: "{{ item.database_username }}"
database_name: "{{ item.database_name }}"
database_password: "{{ bridge.database_password }}"
database_username: "{{ bridge.database_username }}"
database_name: "{{ bridge.database_name }}"
loop: "{{ MATRIX_BRIDGES }}"
loop_control:
label: "{{ item.bridge_name }}"
loop_var: bridge
label: "{{ bridge.bridge_name }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🧹 cleanup project folder"
@@ -38,11 +38,12 @@
tasks_from: write.yml
vars:
sys_token_store_user_key: "{{ OPENWEBUI_MCP_GROUP_OWNER }}"
sys_token_store_app: "{{ item.key }}"
sys_token_store_token: "{{ item.value }}"
sys_token_store_app: "{{ grant_group.key }}"
sys_token_store_token: "{{ grant_group.value }}"
loop: "{{ (openwebui_mcp_grants.stdout_lines | last | from_json) | dict2items }}"
loop_control:
label: "{{ item.key }}"
loop_var: grant_group
label: "{{ grant_group.key }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "♻️ Re-render the env now that the group ids are known"
+3 -1
View File
@@ -33,8 +33,10 @@
- name: "📁 Pre-create native-metrics docker networks (idempotent)"
include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/network/create.yml') }}"
vars:
network_role_id: "{{ item }}"
network_role_id: "{{ metrics_app }}"
loop: "{{ lookup('native_metrics_apps') }}"
loop_control:
loop_var: metrics_app
- name: "🚿 flush handlers for '{{ application_id }}'"
meta: flush_handlers
+4 -2
View File
@@ -10,6 +10,8 @@
- name: "📂 Include domains redirects"
include_tasks: redirect-domain.yml
vars:
domain: "{{ item.source }}"
target: "{{ item.target }}"
domain: "{{ redirect_domain.source }}"
target: "{{ redirect_domain.target }}"
loop: "{{ redirect_domain_mappings }}"
loop_control:
loop_var: redirect_domain
@@ -2,7 +2,9 @@
- name: 📂 Include Cloudflare redirect rule to enforce www → apex
include_tasks: _02_cloudflare_redirect_rule.yml
vars:
domain: "{{ item.target }}"
www_fqdn: "{{ item.source }}"
apex_url: "{{ lookup('tls', application_id, 'protocols.web') }}://{{ item.target }}"
domain: "{{ www_redirect.target }}"
www_fqdn: "{{ www_redirect.source }}"
apex_url: "{{ lookup('tls', application_id, 'protocols.web') }}://{{ www_redirect.target }}"
loop: "{{ REDIRECT_WWW_DOMAINS }}"
loop_control:
loop_var: www_redirect
+3 -1
View File
@@ -3,7 +3,9 @@
- name: 📂 Include dependencies
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-stk-front-proxy
- dev-git
loop_control:
loop_var: dependency_role
+3 -1
View File
@@ -3,7 +3,9 @@
- name: "🧩 load front-proxy + dev-git for {{ application_id }}"
include_role:
name: "{{ item }}"
name: "{{ dependency_role }}"
loop:
- sys-stk-front-proxy
- dev-git
loop_control:
loop_var: dependency_role
@@ -0,0 +1,138 @@
"""Lint: an include that loops must name its loop item.
A looped ``include_tasks`` or ``include_role`` leaves its item in scope for
everything the include reaches. When that item keeps the default name, Ansible
warns the moment any task inside loops as well:
[WARNING]: The loop variable 'item' is already in use.
You should set the `loop_var` value in the `loop_control` option for the
task to something else to avoid variable collisions and unexpected
behavior.
The warning is the mild symptom. The sharp one is that the included body reads
``item`` expecting the value the include was called with, while an inner loop
has already repointed the name. Nothing fails, so the wrong value is written
and the run reports success.
A plain task that loops is not affected: its ``item`` reaches no other file, so
the default name is fine there and this rule ignores it.
Suppression (see ``docs/contributing/actions/testing/suppression.md``):
* ``nocheck: looped-include-loop-var`` on the include line or the one above it,
when the include provably reaches nothing that loops or reads ``item``.
"""
from __future__ import annotations
import unittest
from pathlib import Path
from typing import Any
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import iter_project_files, read_text
from utils.cache.yaml import load_yaml_any
from . import PROJECT_ROOT
_RULE = "looped-include-loop-var"
INCLUDE_KEYS = frozenset(
{
"include_tasks",
"include_role",
"ansible.builtin.include_tasks",
"ansible.builtin.include_role",
}
)
LOOP_KEYS = frozenset(
{
"loop",
"with_items",
"with_dict",
"with_list",
"with_nested",
"with_fileglob",
"with_subelements",
"with_together",
}
)
_NESTED_KEYS = ("block", "rescue", "always")
def offenders(tasks: Any) -> list[str]:
"""Return the name of every looped include that leaves its item unnamed.
Args:
tasks: a parsed task file, or any nested block of one.
"""
found: list[str] = []
if not isinstance(tasks, list):
return found
for task in tasks:
if not isinstance(task, dict):
continue
for key in _NESTED_KEYS:
found.extend(offenders(task.get(key)))
if not (INCLUDE_KEYS & task.keys() and LOOP_KEYS & task.keys()):
continue
control = task.get("loop_control")
if isinstance(control, dict) and control.get("loop_var"):
continue
found.append(str(task.get("name", "")))
return found
def _line_of(lines: list[str], name: str) -> int:
"""Return the 1-based line declaring *name*, or 1 when it is not found.
Args:
lines: the task file's lines.
name: the task name to locate.
"""
for index, line in enumerate(lines):
if name and name in line and line.lstrip().startswith(("- name:", "name:")):
return index + 1
return 1
def _task_files() -> list[str]:
return sorted(
path
for path in iter_project_files(extensions=(".yml", ".yaml"), exclude_tests=True)
if "/tasks/" in Path(path).relative_to(PROJECT_ROOT).as_posix()
or "/handlers/" in Path(path).relative_to(PROJECT_ROOT).as_posix()
)
class TestLoopedIncludeDeclaresLoopVar(unittest.TestCase):
def test_every_looped_include_names_its_loop_var(self) -> None:
findings: list[str] = []
for path in _task_files():
rel = Path(path).relative_to(PROJECT_ROOT).as_posix()
names = offenders(load_yaml_any(path))
if not names:
continue
lines = read_text(path).splitlines()
for name in names:
line = _line_of(lines, name)
if is_suppressed_at(lines, line, _RULE, mode="same-or-above"):
continue
findings.append(f"- {rel}:{line}: {name or '<unnamed>'}")
if findings:
self.fail(
"These includes loop without naming their item, so the default "
"`item` travels into the included file and any loop inside it "
"silently repoints the name:\n"
+ "\n".join(findings)
+ "\n\nFix: add `loop_control: {loop_var: <something>}` and use "
f"that name in the task, or mark it `nocheck: {_RULE}` when the "
"include provably reaches nothing that loops or reads `item`."
)
if __name__ == "__main__":
unittest.main()