mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-11 21:26:44 +00:00
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:
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,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,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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()
|
||||
Reference in new issue
Block a user