Inline-script-size lint regex previously matched only bare keys
(shell:/command:/script:/raw:/run:). Extended to also catch FQCN
forms (ansible.builtin.*, ansible.legacy.*, ansible.windows.win_shell)
which are interchangeable in Ansible and must be linted identically.
Four pre-existing violations exposed and fixed by extracting the
shell bodies to files/ and calling them via ansible.builtin.script:
- sys-cleanup/main.yml (21 logical lines)
-> files/cleanup_caches.sh
- svc-db-openldap/06_migrate_005.yml (30 logical lines)
-> files/migrate_005_purge_stale.sh (4 args, no_log preserved)
- dev-nix/02_non_arch_installer.yml (24 logical lines)
-> files/aggressive_prune_nix.sh (1 arg)
- web-app-espocrm/01_patch_config.yml (13 logical lines)
-> files/set_siteurl.sh (URL via env, fixes quote injection
risk in original Jinja-into-PHP-single-quoted body)
Removed cargo-culted args.executable: /bin/bash from the four
script: tasks; the script: module ignores it (shebang controls
interpreter).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The schema-privileges block in svc-db-postgres/tasks/02_init.yml mixed
GRANT/ALTER DEFAULT PRIVILEGES SQL with Jinja interpolation in a 4-line
inline `query: |` scalar. Move the statements into
`roles/svc-db-postgres/templates/sql/grant_schema_privileges.sql.j2` and
load via `lookup('template', ...)` so the SQL stays reviewable as plain
SQL (Jinja stays only for identifier interpolation, since Postgres
prepared-statement bindings cannot substitute identifiers).
Also adds tests/lint/ansible/test_no_inline_multiline_sql.py that fails
when more than 3 SQL-leader lines (INSERT/UPDATE/SELECT/GRANT/ALTER/…)
appear in a single inline YAML scalar under roles/, tasks/, or
playbooks/. The error message points at the extraction-via-lookup
pattern so future blocks land in files/<name>.sql or
templates/sql/<name>.sql.j2 by default.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new lint tests under tests/lint/ansible/roles/web-app/integration/
mirroring the existing email-integration pattern:
* test_logout_dashboard.py — every web-app-* role MUST declare
services.logout AND services.dashboard activated (truthy via
literal `true` or the dynamic `"{{ '<role>' in group_names }}"`
form). Per-service opt-out via `# noqa: logout` /
`# noqa: dashboard` directly above the key, paired with both
flags `false`. Provider role web-app-dashboard self-exempt.
* test_sso.py — every web-app-* role MUST front its login flow
with services.oidc OR services.oauth2 activated. If oidc is
intentionally opted out (`# noqa: oidc` above the key + both
flags false), then oauth2 must take over. Roles with no login
flow at all opt out of BOTH (e.g. static-content sites). Provider
roles web-app-keycloak / web-app-oauth2-proxy self-exempt.
Co-located reorganisation: the existing email lint moves with them
into the new web-app/integration/ subfolder, dropping the redundant
`web_app_` prefix and `_integration` suffix per the new naming
scheme. test_email.py renamed via git mv so blame is preserved.
Suppression catalog (docs/contributing/actions/testing/suppression.md)
gains four new rule keys (`logout`, `dashboard`, `oidc`, `oauth2`),
all with the "line above" placement semantic identical to `email`.
services.yml changes to satisfy the new guards:
* logout opt-out (`# noqa: logout` above + both flags false) added
to: web-app-keycloak (the IdP itself), and the minimal/no-login
apps web-app-bridgy-fed, -chess, -hugo, -littlejs, -mig, -mini-qr,
-navigator, -postmarks, -roulette-wheel, -socialhome, -sphinx.
* dashboard opt-out added to: web-app-bigbluebutton, -espocrm,
-matomo, -oauth2-proxy, -phpmyadmin (with explicit shared: false
added where missing), and full block created for -fusiondirectory.
* SSO opt-out (oidc + oauth2 both `# noqa`'d, both flags false)
added to roles without a login flow (the same 11 minimal apps)
and to roles where Keycloak SSO isn't wired up: -confluence, -jira,
-magento, -matomo, -shopware. matomo's existing TODO comment about
enabling oauth2 is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace literal `enabled: true / shared: true` flags across every
roles/*/meta/services.yml with a Jinja `"{{ '<role>' in group_names }}"`
form so the per-role services map becomes the single source of truth
for shared-service co-deployment, on a per-host basis. Inventory build
already runs through the templar before reaching the static `is True`
check, so runtime behaviour is unchanged; the new form only opens up
static-analysis and CLI tooling to the group-aware semantics.
Mechanism + lint:
- New integration test tests/integration/roles/meta/test_services_
dynamic_flags.py enforces the dynamic shape per top-level service
entry. Registered service keys must reference the registry-resolved
provider role; covered keys (e.g. oauth2 -> web-app-keycloak via
the new `covers:` block) reference the covering role.
- Two suppression placements documented in docs/contributing/actions/
testing/suppression.md: per-flag inline `# nocheck: dynamic-flag`
for databases (`enabled: true` stays literal, `shared` resolves
dynamically) and block-level above the key for entries whose both
flags legitimately stay literal (`css`, `javascript`, role-local
`redis`, `recaptcha`, `hcaptcha`, role-local containers with
`shared: false`).
Registry + helpers:
- utils/service_registry.py: new `is_explicit_truth(value)` helper
accepts `True` literal OR a Jinja string carrying `in group_names`.
Adopted by `resolve_service_dependency_roles_from_config`,
`ServicesResolver._is_enabled_shared`, `test_web_app_email_
integration`, `test_prometheus`, `test_universal_logout`, and the
run_after-explicit guard so static analysis keeps resolving deps.
- New `covers:` field on a primary service entity declares
transitive equivalents (web-app-keycloak.covers: [oauth2]).
Exposed via `build_role_to_covered_keys` and
`build_covered_key_to_role`, replacing the previously hard-coded
`_TRANSITIVE_SERVICE_EQUIVALENTS` / `_SPECIAL_REFERENCE_ROLE` maps
in the run_after and dynamic-flag tests.
Bulk migration:
- All 80 roles/*/meta/services.yml files migrated to the dynamic
form. DB consumer keys (mariadb, postgres, ldap) keep
`enabled: true # nocheck: dynamic-flag` literal with a dynamic
`shared`. Block-nocheck applied to css/javascript/recaptcha/
hcaptcha and role-local containers (e.g. mailu's admin/imap/smtp/
antispam/antivirus/oletools/webdav/fetchmail/webmail/resolver/
front, nextcloud's talk).
- web-app-prometheus's primary entity now declares
`provides: prometheus` + dynamic flags so the registry recognises
it as a service provider. Consequence: tasks/01_core.yml split out
with `- include_tasks: utils/once/flag.yml` as the first task and
tasks/main.yml reduced to the standard run_once-guarded loader.
- Seven minimal no-login apps (web-app-hugo, -bridgy-fed, -chess,
-littlejs, -navigator, -mini-qr, -mig) gained an explicit
`logout: { enabled: false, shared: false }` opt-out (with
`# nocheck: dynamic-flag` block-marker) to satisfy the universal-
logout schema check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Roles, playbooks, templates and scripts MUST consume the central
`NETWORK_PUBLIC_DNS_RESOLVERS` list from `group_vars/all/08_networks.yml`
instead of pasting `1.1.1.1` / `8.8.8.8` / `1.0.0.1` literally, so a
single edit propagates everywhere those resolvers are used.
- tests/lint/repository/test_no_hardcoded_dns_resolvers.py: scans the
repo for the literal IPs declared in NETWORK_PUBLIC_DNS_RESOLVERS,
fails on any occurrence not covered by `# noqa: hardcoded-dns-resolver`.
- roles/sys-svc-container/vars/main.yml: `SYS_SVC_DOCKER_AUTOMTU_TARGETS`
no longer hard-codes `1.1.1.1`; the list is now built from
`['registry-1.docker.io', 'github.com'] + NETWORK_PUBLIC_DNS_RESOLVERS`.
- roles/user/templates/bashrc.j2: `ip route get 8.8.8.8` ->
`ip route get {{ NETWORK_PUBLIC_DNS_RESOLVERS[0] }}`.
- group_vars/all/08_networks.yml: drop literal IP examples from the
resolver-block doc comment.
- compose/coredns/Corefile.tmpl, scripts/system/network/dns/setup/wsl.sh,
roles/sys-ctl-mtn-cert-deploy/SETUP.md: tag the legitimate literal
occurrences with `# noqa: hardcoded-dns-resolver`. These are
substitution points that cannot consume the variable (CoreDNS
forward directive, host-bootstrap shell fallback, Markdown example).
- docs/contributing/actions/testing/suppression.md: catalog the new
`hardcoded-dns-resolver` rule with position semantics and example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two failures from make test on feature/web-app-fediwall:
- test_compose_resource_limits flagged web-app-moodle nginx and cron
services missing cpus / mem_reservation / mem_limit / pids_limit /
min_storage. Added conservative defaults (1GB / 0.2 cpu / 128m / 256m /
256 pids) matching other lightweight sidecar services.
- test_no_raw_project_walk flagged
tests/lint/ansible/test_no_lookup_config_jinja_default.py:40 using
base.rglob("*"). Route through utils.cache.files.iter_project_files
(filters by SCAN_DIRS prefix) to share the cached single-pass walk.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The single `local.database` PORT_BAND covered both 3306 (MariaDB)
and 5432 (PostgreSQL), which let any port in 3306-5432 pass band
membership without distinguishing the engine. Replace with two
explicit single-port bands so the (scope, category) tuple
identifies the engine unambiguously:
- group_vars/all/08_networks.yml: `local.database` ->
`local.mariadb` (3306-3306) and `local.postgres` (5432-5432).
- roles/svc-db-mariadb/meta/services.yml: ports.local.database
-> ports.local.mariadb.
- roles/svc-db-postgres/meta/services.yml: ports.local.database
-> ports.local.postgres.
- roles/svc-db-{mariadb,postgres}/vars/main.yml: lookups follow
the new path.
Also add `TestPortBandsDisjoint.test_no_overlapping_bands_in_same_scope`
in tests/lint/repository/test_role_meta_layout.py: rejects any
two PORT_BANDS entries within the same scope whose ranges
intersect, with a precise overlap range in the failure message.
Cross-scope overlaps remain intentional (different bind contexts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When `test_role_meta_layout.TestHostBoundPortCollisions`,
`TestPortBandMembership`, or
`test_unique_valid_and_mapped.TestNetworksUniqueValidAndMapped`
fails, surface the canonical resolver command in the failure
message:
- ports: `infinito meta ports suggest --scope <local|public> --category <http|oauth2|...> --count 1`
- subnets: `infinito meta networks suggest --clients <N> --count 1`
Plus a hint that an exhausted `PORT_BANDS.<scope>.<category>` band
can be widened in `group_vars/all/08_networks.yml`. Reduces the
diagnose-to-fix loop for contributors hitting these lints.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds tests/lint/repository/test_literal_protocol_with_lookup.py which
fails when a file hand-writes the protocol next to a lookup-derived
domain. The protocol must come from the same source as the domain so
TLS-on / TLS-off / self-signed / public-CA stays consistent across the
stack; the `tls` lookup plugin emits both at once via the
`url.base` / `protocols.web` / `protocols.websocket` keys.
Mechanics
- Three regex patterns cover the realistic shapes:
1. `https?://{{ ... lookup( ... }}` (Jinja interpolation)
2. `"https?://" ~ lookup(...)` (Jinja concat with lookup)
3. `"https?://" ~ ... canonical.<key>` (concat with canonical access)
- Internal docker-network upstreams that are genuinely plaintext
(`http://<container>:<port>`) are not detected because the regex
requires a `lookup(` or `canonical.` token; they would only need
the noqa marker if the upstream resolution itself uses one of those.
- Per-line opt-out: `# noqa: literal-protocol-lookup` (or `nocheck`).
- File reads route through `utils.cache.files.read_text` and the
per-file scan result is cached process-wide via `lru_cache`.
Repo cleanup
- web-app-discourse and web-app-littlejs CSP entries now use
`lookup('tls', '<role>', 'url.base')` instead of pinning `http://` /
`https://` next to `lookup('domain', ...)`.
- web-app-matrix and web-svc-cdn Playwright env templates compose the
base URL through the `tls` lookup so the test runner uses the same
protocol the live stack serves.
- web-app-oauth2-proxy carries a single `# noqa: literal-protocol-lookup`
on the upstream line; that one is intentionally plaintext because
oauth2-proxy talks to its sibling upstream over the internal docker
network with no TLS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds tests/lint/repository/test_domain_primary_spot.py which fails when a
file constructs a hostname via a literal `<sub>.{{ DOMAIN_PRIMARY }}` outside
the role's `meta/server.yml` (or `meta/variants.yml`) `domains.canonical` /
`domains.aliases` blocks. Every other reference MUST flow through the
project's lookup plugins so the canonical/aliases entries stay the SPOT.
Mechanics
- Regex matches `<word>.{{ DOMAIN_PRIMARY }}` patterns; wildcard fragments
(`.{{ DOMAIN_PRIMARY }}`, `*.{{ DOMAIN_PRIMARY }}`) and bare
`{{ DOMAIN_PRIMARY }}` references are intentionally NOT flagged
- meta/server.yml and meta/variants.yml: a state-machine line scanner
exempts list items and dict entries inside `canonical:` / `aliases:`
blocks (handles compact, expanded, list, and dict YAML styles)
- README.md is exempt; docs/ and files/ subtrees inside roles are skipped
- Per-line opt-out via `# noqa: domain-spot` (or `# nocheck: domain-spot`)
- File reads route through utils.cache.files.read_text and the helpers
themselves are wrapped in lru_cache so repeated path lookups during
the traversal pay the parse cost once
Repo cleanup
- 11 role meta/server.yml files now reference Keycloak, Collabora,
OnlyOffice, Nextcloud, and their own canonical hostnames through
`lookup('domain', '<role>')` (and `lookup('config', '<role>',
'server.domains.canonical.<key>')` for the dict-canonical roles
Matrix and Bluesky) instead of duplicating the SPOT
- `roles/web-app-mattermost/tasks/main.yml` keeps a single
`# noqa: domain-spot` on the legacy domain cleanup task; the
hardcoded historical hostname must persist there to remove stale
on-disk nginx configs from the rename
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add tests/lint/repository/test_no_raw_project_walk.py: AST-walks every
.py file under tests/ and flags call expressions matching the forbidden
shapes — `<x>.rglob(...)`, `os.walk(...)`, `glob.glob(...)` — including
their aliased imports. The lint blocks any NEW raw walk from sneaking
in; pre-existing offenders are migrated below.
Background
==========
Pytest runs every test in one process. A raw walk over the repo tree
re-iterates the same paths and re-stats the same inodes for every test
that needs them, so the cost scales linearly with the number of lint /
integration tests doing project scans. Routing every walk through
`utils.cache.files.iter_project_files(...)` (or
`iter_project_files_with_content(...)`, `iter_non_ignored_files(...)`,
`read_text(...)`) shares one filesystem walk + one read per file
across the whole pytest session. See docs/agents/action/testing.md.
Migration
=========
Refactored to the cached helpers (broad project-tree walks):
Lint
tests/lint/ansible/test_no_literal_no_log.py
tests/lint/ansible/test_no_sh_lc_pipefail.py
tests/lint/ansible/test_run_once_tags.py
tests/lint/ansible/test_service_shared_consistency.py
tests/lint/filesystem/test_python_shebangs.py
tests/lint/repository/test_folder_item_limit.py
tests/lint/repository/test_markdown_links.py
tests/lint/repository/test_role_meta_layout.py
tests/lint/repository/test_todo_transparency.py
Integration / external / unit
tests/integration/compose/test_no_raw_docker.py
tests/integration/config/test_non_empty.py
tests/integration/roles/includes/test_existence.py
tests/integration/vars/test_facts_not_overridden.py
tests/integration/workflows/test_dependabot_coverage.py
tests/external/repository/test_urls_reachable.py
tests/unit/cli/deploy/development/test_inventory_vars_file_spot.py
Tagged `# noqa: project-walk` (legacy; future commits migrate them
incrementally — the lint pinpoints each offender and the fail message
points at the cached helpers and explains when noqa is appropriate):
~30 further test files (small per-role globs, scripts/utils subtree
scans, dynamic Jinja-driven globs that need richer refactors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add tests/lint/repository/test_inline_script_block_size.py: scans
roles/, tasks/, playbooks/ and .github/ YAML for literal block scalars
(`|` / `|-` / `|+`) following the keys `shell`, `command`, `script`,
`raw` (Ansible) and `run` (GitHub Actions). Counts logical lines —
trailing-`\` continuations collapse to one line, blank lines are
ignored — and fails when a block exceeds 12.
FOLDED scalars (`>` / `>-` / `>+`) are exempt: they are one shell
statement after YAML folding, even if they span many source lines for
readability.
The walk goes through `utils.cache.files.iter_project_files(...)` per
`docs/agents/action/testing.md` so the shared project-tree cache is
reused across the lint suite.
The fail message tells the author exactly how to fix: Ansible tasks
get the built-in `script:` module (collapses the task to one line);
GitHub Actions workflows extract into `scripts/github/<name>.sh` and
call it via `run: bash scripts/github/<name>.sh` (or
`_helpers/scripts/github/<name>.sh` when the workflow checks out the
repo into `_helpers/`).
Extract the pre-existing literal-block offenders so the new lint
ships green:
Ansible (script: module, file under <role>/files/)
web-app-friendica sync_addon.sh
web-app-fider create_admin_user.sh
web-app-fider configure_oidc_provider.sh
web-app-mediawiki install_or_update.sh
web-app-mediawiki composer_install_extension.sh
web-app-xwiki debug_superadmin.sh
GitHub Actions (scripts/github/)
pick_distro.sh
guard_release.sh
resolve_release_inputs.sh
disk_pressure_report.sh
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds tests/lint/repository/test_literal_protocol_with_lookup.py which
fails when a file hand-writes the protocol next to a lookup-derived
domain. The protocol must come from the same source as the domain so
TLS-on / TLS-off / self-signed / public-CA stays consistent across the
stack; the `tls` lookup plugin emits both at once via the
`url.base` / `protocols.web` / `protocols.websocket` keys.
Mechanics
- Three regex patterns cover the realistic shapes:
1. `https?://{{ ... lookup( ... }}` (Jinja interpolation)
2. `"https?://" ~ lookup(...)` (Jinja concat with lookup)
3. `"https?://" ~ ... canonical.<key>` (concat with canonical access)
- Internal docker-network upstreams that are genuinely plaintext
(`http://<container>:<port>`) are not detected because the regex
requires a `lookup(` or `canonical.` token; they would only need
the noqa marker if the upstream resolution itself uses one of those.
- Per-line opt-out: `# noqa: literal-protocol-lookup` (or `nocheck`).
- File reads route through `utils.cache.files.read_text` and the
per-file scan result is cached process-wide via `lru_cache`.
Repo cleanup
- web-app-discourse and web-app-littlejs CSP entries now use
`lookup('tls', '<role>', 'url.base')` instead of pinning `http://` /
`https://` next to `lookup('domain', ...)`.
- web-app-matrix and web-svc-cdn Playwright env templates compose the
base URL through the `tls` lookup so the test runner uses the same
protocol the live stack serves.
- web-app-oauth2-proxy carries a single `# noqa: literal-protocol-lookup`
on the upstream line; that one is intentionally plaintext because
oauth2-proxy talks to its sibling upstream over the internal docker
network with no TLS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds tests/lint/repository/test_domain_primary_spot.py which fails when a
file constructs a hostname via a literal `<sub>.{{ DOMAIN_PRIMARY }}` outside
the role's `meta/server.yml` (or `meta/variants.yml`) `domains.canonical` /
`domains.aliases` blocks. Every other reference MUST flow through the
project's lookup plugins so the canonical/aliases entries stay the SPOT.
Mechanics
- Regex matches `<word>.{{ DOMAIN_PRIMARY }}` patterns; wildcard fragments
(`.{{ DOMAIN_PRIMARY }}`, `*.{{ DOMAIN_PRIMARY }}`) and bare
`{{ DOMAIN_PRIMARY }}` references are intentionally NOT flagged
- meta/server.yml and meta/variants.yml: a state-machine line scanner
exempts list items and dict entries inside `canonical:` / `aliases:`
blocks (handles compact, expanded, list, and dict YAML styles)
- README.md is exempt; docs/ and files/ subtrees inside roles are skipped
- Per-line opt-out via `# noqa: domain-spot` (or `# nocheck: domain-spot`)
- File reads route through utils.cache.files.read_text and the helpers
themselves are wrapped in lru_cache so repeated path lookups during
the traversal pay the parse cost once
Repo cleanup
- 11 role meta/server.yml files now reference Keycloak, Collabora,
OnlyOffice, Nextcloud, and their own canonical hostnames through
`lookup('domain', '<role>')` (and `lookup('config', '<role>',
'server.domains.canonical.<key>')` for the dict-canonical roles
Matrix and Bluesky) instead of duplicating the SPOT
- `roles/web-app-mattermost/tasks/main.yml` keeps a single
`# noqa: domain-spot` on the legacy domain cleanup task; the
hardcoded historical hostname must persist there to remove stale
on-disk nginx configs from the rename
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reworks tests/lint/ansible/roles/test_compose_resource_limits.py from
warn-only-on-every-role to a strict failure scoped to actual
compose-service declarations:
1. Only roles that ship a `templates/compose.yml.j2` are scanned.
Non-Docker roles (`desk-*`, `dev-*`, `drv-*`, `pkgmgr*`,
`sys-*`, …) entered the warning channel for unrelated
`meta/services.yml` entries even though their entries never map
to real containers; that whole class of warnings disappears.
2. Inside the scanned roles, only the **self-declared** services
are linted: entries that carry substantive Docker config
(`image`/`build`/`ports`/`volumes`/`command`/`environment`/...)
OR a primary entity backed by a `files/Dockerfile` /
`templates/Dockerfile.j2`. Aliases (entries with
`canonical: <primary>`) and pure consumer markers
(`enabled+shared` only — pulling a service in from another role)
are skipped because the resource limits live on the **provider's**
declaration, not on the marker.
3. Failures are hard `self.fail` again so regressions block CI.
Concretely: `web-app-mailu/meta/services.yml.{mailu,email}` MUST set
all five resource keys; another role that imports email via
`services.email.enabled+shared: true` is a marker and is exempt.
Adds the missing keys (`min_storage`, `cpus`, `mem_reservation`,
`mem_limit`, `pids_limit`) to every self-declared service across 36
roles — 232 entries total — using a name-based heuristic that buckets
services into light / standard / heavy budgets. Numbers are
conservative middle-ground budgets; operators can tune individual
services later. Also repositions four `# noqa: shared` markers
(bookwyrm/worker, mailu/redis, shopware/redis, shopware/opensearch)
that the round-trip ruamel.yaml dump shifted into the wrong service
block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the meta/main.yml meta-dependency from web-svc-legal on
web-svc-html and replaces it with the variant-aware service-registry
mechanism: web-svc-html marks its primary entity `shared: true;
enabled: false` so the registry resolves the `html` key, and
web-svc-legal declares `services.html.enabled: true; shared: true` in
its own meta/services.yml so the deploy planner pulls web-svc-html in
on demand instead of unconditionally on every web-svc-legal deploy.
To pass the service-role schema check this also splits
web-svc-html/tasks/main.yml into a thin wrapper that loads
tasks/01_core.yml under a `run_once_web_svc_html`-guard.
Adds tests/lint/ansible/roles/test_web_role_no_web_dependency.py: a
broad guard that fails if any `web-*` role declares another `web-*`
role in `meta/main.yml.dependencies`. The fix message points the
contributor at the service-registry pattern (`shared: true` + the
`run_once` schema follow-ups, plus the consumer-side
`services.<X>.enabled: true; shared: true`). The web-svc-legal /
web-svc-html cleanup above is exactly the migration this lint walks
new contributors through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stops every direct `yaml.safe_load`/`safe_load_all`/`safe_dump`/`dump`
call across the test corpus from re-parsing files the production cache
already holds. Each migrated test loads role meta, group_vars, inventory
fixtures, etc. through the shared cache, so a single `make test` run
parses every YAML file at most once across all suites.
Cache module changes (utils/cache/yaml.py):
- Unify single- and multi-doc cache: one `_CACHE: Dict[sig, list]`
holding the `safe_load_all` payload. Single-doc helpers
(load_yaml / load_yaml_any) unwrap docs[0] and raise on >1
document, preserving the old strict semantics. A file consumed
both as single-doc and multi-doc is now parsed exactly once.
- Add load_yaml_all (path-cached) and load_yaml_all_str (string,
no cache) so multi-doc consumers no longer need a direct
`yaml.safe_load_all` plus a separate file read.
- dump_yaml now accepts any safe-dumpable structure (dict, list,
scalar) so test fixtures writing list-of-tasks YAML can use it.
Lint guard (tests/lint/repository/test_no_direct_yaml_calls.py):
- Extend the scanned tree to include `tests/` so future test code
is forced through the cache, with the existing
`# noqa: direct-yaml` per-call opt-out for genuine raw-yaml
fixture needs.
Test corpus:
- 89 test files migrated to load_yaml / load_yaml_any /
load_yaml_str / load_yaml_all / load_yaml_all_str / dump_yaml /
dump_yaml_str. No `# noqa` shortcuts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The project's `config` lookup (plugins/lookup/config.py) is strict by
default: a 2-term call `lookup('config', app, path)` raises AnsibleError
on a missing key. The Jinja `| default(...)` filter only applies to
Jinja `Undefined` values, which the lookup never produces — it either
returns a value or raises. Every `lookup('config', a, b) | default(X)`
in the tree is therefore dead code; the operator-visible behaviour is
"crash on missing key" no matter what `X` is.
Add a line-local lint that paren-walks each `lookup('config', ...)`
call to count its top-level args. When the call has exactly 2 commas
(i.e. no plugin-level default) and is followed by `| default(...)`
later on the same line, the test fails with the recommended fix:
pass the fallback as the 3rd lookup argument so the strict-mode
short-circuit is disabled and the default actually applies.
Fix the two existing occurrences:
- roles/web-app-moodle/vars/main.yml:85 (services.ldap.enabled)
- roles/web-app-matrix/templates/compose.yml.j2:86
(credentials.chatgpt_bridge_access_token)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduce a single grammar for per-line, per-block, and per-file
suppression markers across the test suite, replacing seven ad-hoc
syntaxes that had drifted apart.
Helper
utils/annotations/suppress.py exposes is_suppressed_at,
is_suppressed_in_head, is_suppressed_anywhere, line_has_rule, and
suppressed_line_numbers. Markers may use either `noqa` or `nocheck`
(case-insensitive synonyms), live inside `#`, `//`, `{# ... #}`, or
`<\!-- ... -->` comments, and combine multiple kebab-case rules
comma-separated. 17 unit tests cover the grammar in isolation.
Refactored consumers
- tests/external/repository/test_urls_reachable.py (rule: url)
- tests/external/docker/test_image_versions.py via
utils/docker/version_updater.py (rule: docker-version)
- tests/lint/repository/test_no_direct_yaml_calls.py (rule:
direct-yaml)
- tests/lint/ansible/test_service_shared_consistency.py (rule:
shared)
- tests/lint/ansible/roles/test_web_app_email_integration.py (rule:
email)
- tests/lint/repository/test_python_file_size.py (rule: file-size)
- tests/lint/ansible/test_run_once_tags.py +
tests/integration/roles/run_once/test_schema.py (rules: run-once,
run-once-suffix)
Marker migration
12 role tasks/main.yml files: `# run_once_<key>: deactivated`
-> `# nocheck: run-once`. roles/web-app-suitecrm/meta/services.yml
drops a malformed inline `noqa: intentional legacy pin` from a TODO
comment so the new parser does not pick it up as a stray rule.
Caching wins
Test files that previously loaded YAML / read files via raw
yaml.safe_load + Path.read_text now route through utils.cache.yaml
(load_yaml_any / load_yaml_str) and utils.cache.files.read_text:
- test_service_shared_consistency.py
- test_web_app_email_integration.py
- tests/integration/roles/run_once/test_schema.py
- utils/docker/version_updater.py
This both speeds up repeated reads and stays consistent with the
direct-yaml lint policy.
Documentation
New SPOT page at docs/contributing/actions/testing/suppression.md
with grammar, position semantics, the catalog of rules, examples,
and an "adding a new rule" checklist. lint.md and external.md drop
their per-rule tables and link to the new page. All touched docs
conform to docs/contributing/documentation.md (no em-dash thought
breaks, no SPOT self-labels, RFC-2119 keywords used consistently).
Settings sort tooling
scripts/lint/sort_claude_settings.sh wraps utils/claude/settings_sort.py
and is wired into scripts/lint/autoformat.sh. The tooling fixes the
ASCII-ascending requirement enforced by
tests/lint/repository/test_claude_settings_sorted.py; the test's
failure message now points operators at the script. .claude/settings.json
is committed in sorted form.
Introduce a single grammar for per-line, per-block, and per-file
suppression markers across the test suite, replacing seven ad-hoc
syntaxes that had drifted apart.
Helper
utils/annotations/suppress.py exposes is_suppressed_at,
is_suppressed_in_head, is_suppressed_anywhere, line_has_rule, and
suppressed_line_numbers. Markers may use either `noqa` or `nocheck`
(case-insensitive synonyms), live inside `#`, `//`, `{# ... #}`, or
`<\!-- ... -->` comments, and combine multiple kebab-case rules
comma-separated. 17 unit tests cover the grammar in isolation.
Refactored consumers
- tests/external/repository/test_urls_reachable.py (rule: url)
- tests/external/docker/test_image_versions.py via
utils/docker/version_updater.py (rule: docker-version)
- tests/lint/repository/test_no_direct_yaml_calls.py (rule:
direct-yaml)
- tests/lint/ansible/test_service_shared_consistency.py (rule:
shared)
- tests/lint/ansible/roles/test_web_app_email_integration.py (rule:
email)
- tests/lint/repository/test_python_file_size.py (rule: file-size)
- tests/lint/ansible/test_run_once_tags.py +
tests/integration/roles/run_once/test_schema.py (rules: run-once,
run-once-suffix)
Marker migration
12 role tasks/main.yml files: `# run_once_<key>: deactivated`
-> `# nocheck: run-once`. roles/web-app-suitecrm/meta/services.yml
drops a malformed inline `noqa: intentional legacy pin` from a TODO
comment so the new parser does not pick it up as a stray rule.
Caching wins
Test files that previously loaded YAML / read files via raw
yaml.safe_load + Path.read_text now route through utils.cache.yaml
(load_yaml_any / load_yaml_str) and utils.cache.files.read_text:
- test_service_shared_consistency.py
- test_web_app_email_integration.py
- tests/integration/roles/run_once/test_schema.py
- utils/docker/version_updater.py
This both speeds up repeated reads and stays consistent with the
direct-yaml lint policy.
Documentation
New SPOT page at docs/contributing/actions/testing/suppression.md
with grammar, position semantics, the catalog of rules, examples,
and an "adding a new rule" checklist. lint.md and external.md drop
their per-rule tables and link to the new page. All touched docs
conform to docs/contributing/documentation.md (no em-dash thought
breaks, no SPOT self-labels, RFC-2119 keywords used consistently).
Settings sort tooling
scripts/lint/sort_claude_settings.sh wraps utils/claude/settings_sort.py
and is wired into scripts/lint/autoformat.sh. The tooling fixes the
ASCII-ascending requirement enforced by
tests/lint/repository/test_claude_settings_sorted.py; the test's
failure message now points operators at the script. .claude/settings.json
is committed in sorted form.
Adds a SPOT-aware lint that walks the repo via the cached
project-tree iterator (no `git` subprocess) and fails on any tracked
non-test Python file longer than 500 lines. Files under `tests/` are
intentionally excluded — coverage breadth there reflects feature
matrix, not design weight. A per-file `# nocheck` (or `#nocheck`)
marker in the first 30 lines opts the file out; the marker is
intentionally visible so the cost of carrying long modules is not
silently buried.
Gitignore-aware filtering moves to its own SPOT
-----------------------------------------------
The fnmatch-based `.gitignore` parser/matcher previously lived inline
in `tests/lint/repository/test_test_files_location.py` (and in
near-duplicates across other tests). Pull it into
`utils.cache.gitignore` as a single source of truth:
* `load_gitignore_patterns(root)` — cached pattern loader.
* `is_path_gitignored(rel_path, patterns)` — pure matcher.
* `iter_non_ignored_files(...)` — convenience iterator in
`utils.cache.files` that composes the cached project walk with the
matcher.
`utils.cache._reset_cache_for_tests` now resets the gitignore module
too, so test fixtures that mutate the tree see consistent state.
Refactor / opt-out for existing offenders
-----------------------------------------
Three production files exceeded the cap; addressed each:
* `roles/sys-svc-compose-ca/files/compose_ca.py` — opted out via
`# nocheck: file-size`. The script is delivered to target hosts
via `ansible.builtin.copy` and runs with only the stdlib; splitting
would require also delivering a package, which the role does not
do.
* `tasks/utils/migrate_meta_layout.py` — converted to a package
(`tasks/utils/migrate_meta_layout/`) with focused submodules:
- `__main__` — entrypoint + run-orchestration.
- `yaml_io` — load/dump/empty-dir/deep-merge helpers.
- `credentials` — schema-vs-config collision detection +
runtime-to-schema conversion.
- `role_builder` — per-role meta file emission.
- `indices` — networks + ports + relays index builders.
Run via `python3 -m tasks.utils.migrate_meta_layout`. Largest
submodule is `role_builder.py` at 250 lines.
* `cli/fix/move_unnecessary_dependencies/__main__.py` — split
alongside two new sibling modules in the same package:
- `analysis` — provider/consumer static analysis.
- `apply` — meta rewrite + guarded include-block prepending.
- `yaml_io` — ruamel round-trip helpers + role-tree discovery.
Largest submodule is `analysis.py` at 174 lines.
`make test` green (162 tests, 1 skipped); the new lint passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a dedicated lifecycle reference and back it with concrete role
metadata changes:
- `docs/contributing/design/services/lifecycle.md`: enumerate the
values `meta/services.yml.<entity>.lifecycle` MAY take and the
criteria each value commits the role to. Linear axis runs from
`planned` -> `pre-alpha` -> `alpha` -> `beta` -> `rc` -> `stable`
-> `maintenance` -> `deprecated` -> `eol`, with the inner five
forming the tested envelope (matrix-deploy + Playwright gate
applies). `beta` requires single sign-on against
web-app-keycloak / svc-db-openldap whenever the upstream software
ships an OIDC or LDAP adapter; `rc` additionally requires a live
burn-in deploy on infinito.nexus.
- Introduce a parallel `unsupported` tier off the linear axis for
roles the project ships without test or maintenance commitment
(proprietary products, demo prototypes). The role's `README.md`
MUST state the unsupported status explicitly.
- `docs/contributing/design/services/layout.md`: cross-link the new
lifecycle.md as the semantic counterpart to the on-disk shape
documented under `run_after` and `lifecycle`.
- Move web-app-confluence, web-app-jira, web-app-magento from
`alpha`/`planned` to `lifecycle: unsupported`. They are commercial
proprietary products / catalogue demonstrations the project does
not commit to maintain. Their meta entries flip to `unsupported`
to match.
- `tests/lint/repository/test_role_meta_layout.py`: extend
`ALLOWED_LIFECYCLES` to cover the full set introduced in
lifecycle.md (`rc`, `maintenance`, `eol`, `unsupported`) so the
lint guard accepts the documented values without false positives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The req-008/009/010 refactor's mechanical migration left several gaps
the test suite could not catch because the test suite itself was
silently broken in spots. Audit-and-fix pass closes those.
1. Production bug: backup discovery filter
==========================================
roles/sys-ctl-bkp-docker-2-loc/filter_plugins/find_dock_val_by_bkp_entr.py
still read `app.compose.services` from the materialised application
payload. After req-008 the file root IS the services map (no
`compose.services` envelope), so the filter walked an empty dict and
silently returned `[]` for every backup discovery call. Rerouted to
the new `app.services` path. Test fixtures updated to match.
2. External test broken by source_file mismatch
===============================================
tests/external/docker/test_image_versions.py:61 filtered for
`ref.source_file == "config/main.yml"`, but
utils/docker/image/discovery emits `meta/services.yml` since req-008.
The filter rejected every candidate → `entries == []` →
`self.assertTrue(entries)` failed. Switched the filter to the new path.
3. Five lint/integration tests silently iterating empty dicts
=============================================================
The following tests still read `cfg.get("compose").get("services")`
(the legacy compose-wrapper path). After req-008 every meta/services.yml
file root IS the services map, so these tests have been iterating
empty dicts and quietly passing without checking anything:
* tests/lint/ansible/test_service_shared_consistency.py:72
* tests/integration/backups/test_enabled.py:33
* tests/integration/backups/test_no_stop_required.py:35
* tests/integration/docker/test_services_image_version_valid.py:28
* tests/integration/oauth2_oidc/test_mutual_exclusive.py:26
All five rerouted to the file-root convention. test_service_shared_consistency
immediately surfaced 24 missing `# noqa: shared` markers (see below).
4. Lost suppression markers (51 total)
======================================
The migration script (tasks/utils/migrate_meta_layout.py) used
ruamel.yaml round-trip which preserved most comments but lost
trailing-comment markers on inline list items (a known ruamel edge
case). Restored verbatim from main:
* 13 `# nocheck: url` markers on meta/server.yml CSP-whitelist entries
(web-app-bluesky, web-app-flowise, web-app-matomo, web-svc-onlyoffice).
These cover CDN root URLs that legitimately return 404 for the bare
domain (Google Fonts, Matomo CDN, BlueSky API endpoint).
* 1 `# nocheck: docker-version` on web-app-moodle/meta/services.yml
(LTS pin for OIDC compatibility).
* 20 `# noqa: shared` markers on meta/services.yml — only the markers
that are still NEEDED. Initial restoration mirrored all 24 markers
from main, but 4 of them turned out redundant against the current
shape: bigbluebutton/postgres, bookwyrm/postgres, discourse/postgres,
magento/mariadb already declare `shared:` (true or false), so the
lint rule does not fire and the marker is dead weight. Skipped them.
* 13 `# noqa: email` markers on meta/services.yml — every covered
`email:` block has `{enabled: false, shared: false}`, exactly the
pattern the email-integration lint expects to suppress.
5. Test-fixture cleanups for backwards-compat clarity
=====================================================
Several unit tests still wrapped synthetic application payloads in
`{"compose": {"services": {...}}}`. Harmless because the production
code paths under test were already migrated, but misleading for future
contributors. Reshaped to match the post-req-008 file-root
convention:
* tests/unit/plugins/filter/test_node_autosize.py
* tests/unit/plugins/filter/test_docker_service_enabled.py
* tests/unit/utils/manager/test_inventory_manager.py (5 occurrences)
* tests/unit/cli/deploy/development/test_inventory.py
* tests/unit/roles/sys-ctl-bkp-docker-2-loc/filter_plugins/test_find_dock_val_by_bkp_entr.py
Validation
==========
* Unit: 1572 passed.
* Lint: 56 passed.
* Integration: 161 passed (1 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New lint guard
==============
tests/lint/repository/test_no_direct_yaml_calls.py AST-walks the
production tree (utils/, cli/, plugins/, filter_plugins/,
lookup_plugins/, roles/, scripts/, tasks/) and flags any direct
`yaml.safe_load` / `yaml.safe_dump` / `yaml.load` / `yaml.dump` /
`yaml.safe_load_all` / `yaml.dump_all` call. Aliased imports are
tracked: `import yaml as Y` ⇒ `Y.safe_load(...)` is flagged;
`from yaml import safe_load` ⇒ bare `safe_load(...)` is flagged.
Per-line opt-out: `# noqa: direct-yaml` on the call's physical line
(or any line spanned by a multi-line call). No central exemption
list — every legitimate exception lives next to its call with a
human-readable rationale in the surrounding comment.
Cache module additions
======================
* `dump_yaml_str(data)` — symmetric to `safe_dump(data)` with no file
path. Used for stdout output and filter return values where the
cache cannot help but the API needs to stay consistent.
* `load_yaml_str(text)` — symmetric to `safe_load(text)`. Used when
YAML comes from an HTTP body, a mutated buffer, or a mock-patched
source whose content does not correspond to a stable on-disk file.
Migrated production call sites (33 → 0 direct-yaml violations)
==============================================================
* utils/{entity_name_utils, valid_deploy_id, invokable, service_registry,
domains/list, applications/{config, in_group_deps},
docker/image/discovery, meta/{port_bands, scan},
roles/{dependency_resolver, meta_lookup, resource_validation}}
* cli/{build/{graph, inventory/full/command}, create/inventory/{host_vars,
mirror_overrides}, fix/vars_main_files, meta/{applications/in_group_deps,
callorder}, mirror/resolver, validate/inventory}
* plugins/{filter/{compose_volumes, get_all_application_ids, get_role,
invokable_paths, role_path_by_app_id}, lookup/applications_current_play}
* roles/{svc-db-postgres/filter_plugins/split_postgres_connections,
web-app-bigbluebutton/filter_plugins/compose_mods,
web-app-dashboard/lookup_plugins/docker_cards}
Behaviour adjustments
=====================
* utils/roles/dependency_resolver — `safe_load_all` (multi-doc) →
single-doc `load_yaml_any` wrapped in a list. Ansible task files are
single-doc by convention; the downstream multi-doc loop stays intact
for forward compatibility.
* roles/sys-svc-compose-ca/files/compose_ca.py — KEEPS direct
yaml.safe_load / yaml.safe_dump (annotated with noqa). The script is
copied to the target host via `ansible.builtin.copy` and the target
has no `utils/` package on PYTHONPATH.
noqa-annotated direct-yaml call sites (with rationale)
======================================================
* utils/cache/yaml.py — IS the cache; would recurse.
* utils/handler/yaml.py — vault-aware Loader subclass that wraps
\!vault scalars in VaultScalar; cache-by-path collapses the wrapping.
* cli/encrypt/inventory/__main__.py — vault-aware Dumper subclass that
emits \!vault literal blocks; the cached dump_yaml writes plain YAML
which is the wrong output here.
* roles/sys-svc-compose-ca/files/compose_ca.py — runtime-deployed.
* tasks/utils/migrate_meta_layout.py — once-off migration script.
Validation
==========
* Unit tests: 1573 passed.
* Lint: 56 passed (incl. new test_no_direct_yaml_calls).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
req-011 — descriptive role-level metadata
* Move logo / homepage / video / display out of
meta/main.yml.galaxy_info into a new project-owned
roles/<role>/meta/info.yml (file-root convention, no `info:` wrapper).
* 78 role meta/main.yml files migrated via ruamel round-trip
(key-order / quoting / inline-comments preserved). Roles with none
of the four fields are left untouched (no empty info.yml created).
* web-app-dashboard/lookup_plugins/docker_cards.py reads logo +
display from meta/info.yml; description / galaxy_tags continue to
come from galaxy_info (Galaxy-spec fields).
* docker_cards.py now routes ALL its file reads through the
process-wide caches: load_yaml_any for vars/main.yml,
meta/main.yml, meta/info.yml; read_text from utils.cache.files for
README.md. The orphan `import yaml` is gone.
New utils/cache/files (renamed from tests/utils/fs)
* The process-wide filesystem cache (read_text, iter_project_files,
iter_project_files_with_content, _all_project_files) is not
test-specific; it now lives under utils/cache/files.py next to the
other process-wide caches. 34 consumer imports rewritten across
tests/ and docs/. The empty tests/utils/ package is removed.
* New unit suite tests/unit/utils/cache/test_files.py — 12 cases
covering read_text, iter_project_files (skip-dirs, extensions,
exclude_tests, exclude_dirs, walk-cache invariants), the combined
iterator, non-UTF-8 silent skip, and _reset.
utils/cache redundancy cleanup
* applications.py + users.py + cli/create/inventory/services_disabler.py
drop their direct yaml.safe_load calls and their private
_load_yaml_mapping/_load_yaml_variant_list copies; everything routes
through utils.cache.yaml.load_yaml / load_yaml_any. The dead
utils.cache.base._load_yaml_mapping is removed.
* users.py:_load_store_users (tokens.yml read) likewise migrated.
* _load_yaml_variant_list moves out of base.py to applications.py
(its only consumer) and is renamed _load_variants_overrides — also
routes through the cached loader now.
* utils.cache.yaml._reset_cache_for_tests is renamed _reset (kept as
alias) for parity with the per-domain reset helpers.
* utils.cache.files grows a _reset() that clears its two LRU caches.
* utils.cache.__init__._reset_cache_for_tests now orchestrates ALL
six caches (applications, users, domains, base, yaml, files) so
tests get clean state across cache layers — needed because
applications/users now share the yaml cache and would otherwise
serve stale parses across reset boundaries.
* utils.cache.files imports PROJECT_ROOT from base instead of
redefining it.
* tests/unit/utils/cache/test_data.py + test_base.py: obsolete
TestLoadYamlMapping / TestLoadYamlVariantList classes deleted
(covered by test_yaml.py + test_variants.py).
* README.md updated for the new module list (files, yaml) and the
six-module orchestrator.
Galaxy schema lint
* tests/lint/repository/test_role_meta_main_galaxy_schema.py: split
galaxy_info field requirements into hard-required {author,
description, license} and soft-recommended {min_ansible_version,
platforms}. Soft-recommended -> emit a GitHub Actions ::warning::
annotation via utils.annotations.message.warning (titled
"meta/main.yml galaxy_info" with file= pointing at the offender),
no test failure. Rationale: min_ansible_version + platforms are
only enforced by Galaxy when *publishing*, which Infinito.Nexus
does not do.
* Galaxy lint goes green (was 226 offenders before req-011 +
license_url/repository/documentation strip; was 62 before this
relaxation; now 0 hard offenders, 62 soft warnings).
* roles/svc-opt-keyboard-color/meta/main.yml: backfill `license`
(only role missing the hard-required field).
New lint
* tests/lint/repository/test_role_meta_info.py — enforces meta/info.yml
file-root convention, the four-field allowlist (logo, homepage,
video, display), and shape (logo.class string, homepage/video
non-empty strings, display bool). Also fails if any
meta/main.yml.galaxy_info still carries one of the four migrated
fields.
Docs
* docs/contributing/design/services/layout.md: new section
"Descriptive Role-Level Metadata — meta/info.yml" with allowed-
fields table, optionality rule, materialised path, and consumer
note. File-Layout table grows the meta/info.yml row;
meta/main.yml row gets the explicit "no logo/homepage/video/display"
note. req-011 added to header and Related Pages.
* utils/cache/README.md: files.py + yaml.py rows added; six-module
reset orchestrator documented.
Validation
* Unit: 1573 passed.
* Lint: 55 passed.
* Integration: 161 passed (1 skipped) via pre-commit hook.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Validates every roles/<role>/meta/main.yml against the Ansible Galaxy
schema:
* top-level keys ⊆ {galaxy_info, dependencies, collections,
argument_specs, allow_duplicates};
* galaxy_info sub-keys ⊆ Galaxy spec set (role_name, namespace, author,
description, company, license, min_ansible_version,
min_ansible_container_version, platforms, galaxy_tags,
issue_tracker_url, github_branch, standalone);
* required galaxy_info fields present (author, description, license,
min_ansible_version, platforms);
* platforms is a non-empty list of mappings each with a string `name`
(and a list `versions` when present);
* galaxy_tags is a list of non-empty strings;
* stringly-typed galaxy_info fields are actually strings;
* dependencies (when present) is a list of strings or mappings with a
`role`/`name` key.
The test reuses the process-wide LRU cache from tests.utils.fs.read_text
for file contents and adds a parser-level @lru_cache so each meta/main.yml
is parsed once per pytest run.
When the lint fails it lists every offending file with the exact problem
per line, so the fix-up scope is visible at a glance.
Currently 226 of ~250 roles fail the lint because of project-internal
keys (logo, license_url, repository, documentation, homepage, video)
historically buried under galaxy_info — same anti-pattern req-010 fixed
for run_after / lifecycle. Migrating these is out of scope for this
commit and tracked as a follow-up requirement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single atomic landing for three back-to-back role-meta refactor requirements
(see docs/requirements/008-role-meta-layout.md, 009-per-role-networks-and-ports.md,
010-role-meta-runafter-lifecycle-migration.md).
req-008 — role meta layout
* Move every role's metadata under meta/<topic>.yml with file-root
convention (no wrapping key matching the basename):
- schema/main.yml + config/main.yml.credentials → meta/schema.yml
- users/main.yml → meta/users.yml
- config/main.yml.server → meta/server.yml
- config/main.yml.rbac → meta/rbac.yml
- config/main.yml.compose.services + every other top-level key
of config/main.yml inlined under <entity>.<key>
→ meta/services.yml
- config/main.yml.compose.volumes → meta/volumes.yml
* Drop the legacy schema/, users/, config/ directories from every role.
* meta/schema.yml: support nested credential keys, optional `default:`
field (Jinja literal, not rendered/validated/regenerated), implicit
`algorithm: plain`.
* Materialised paths drop the `compose.` prefix:
compose.services.<e>.<…> → services.<e>.<…>
compose.volumes.<k> → volumes.<k>
<former-top-level>.<…> → services.<entity_name>.<former-top-level>.<…>
* Update every Python consumer (utils/cache, utils/manager, utils/applications,
plugins/lookup, plugins/filter, cli/*) plus every Jinja template, vars/*.yml
and task file to the new paths.
* No legacy fallback. Repository-wide grep for `compose.services.` /
`compose.volumes.` / `schema/main.yml` / `users/main.yml` / `config/main.yml`
is empty outside docs/requirements/ and tasks/utils/migrate_meta_layout.py.
* Add tests/lint/repository/test_role_meta_layout.py guarding against the
old paths returning, and an explicit lint guard that meta/variants.yml
files do not carry a `compose:` wrapper.
req-009 — per-role networks/ports
* Move group_vars/all/08_networks.yml.defaults_networks.local.<role>.{subnet,
dns_resolver} into roles/<role>/meta/server.yml.networks.local.{subnet,
dns_resolver}.
* Delete group_vars/all/09_ports.yml entirely; ports now live on the
service entity that exposes them at
roles/<role>/meta/services.yml.<entity>.ports.{inter,local,public}.
* Rename + int-cast every former top-level `services.<entity>.port`
field to `services.<entity>.ports.inter` (single int).
* Always-category-keyed maps for ports.local / ports.public; relay
spans use {start, end} integers.
* Add PORT_BANDS map to group_vars/all/08_networks.yml — single source
of truth for the per-<scope>.<category> band ranges, consumed by both
the new CLI helpers and the lint test.
* New CLI helpers (cli meta ports suggest, cli meta networks suggest)
walk the live role tree gap-first then increment-fallback, with
deterministic output and non-zero exit on capacity overflow.
* cli create role consumes both helpers when scaffolding a new role.
* Lint extensions: bare-int port shape rejection, host-bound port
collision check (single ints + relay span integers, inter excluded),
band-membership check with documented allow-list for the legacy
web-app-bigbluebutton.bigbluebutton.local.http: 48087 entry.
req-010 — run_after / lifecycle migration
* Move galaxy_info.run_after and galaxy_info.lifecycle out of
meta/main.yml into meta/services.yml.<primary_entity>.{run_after,
lifecycle}; <primary_entity> is the value returned by
get_entity_name(role_name).
* For multi-entity roles whose primary entity has no compose service
(web-app-bluesky → bluesky, web-app-matrix → matrix, desk/dev/drv/
gen roles), create a dedicated metadata-only top-level entry under
<primary_entity>.
* Introduce utils/roles/meta_lookup.py with get_role_run_after /
get_role_lifecycle that resolve <primary_entity> once and read from
meta/services.yml; degrade gracefully when meta/services.yml is
absent OR the field is absent; raise MetaServicesShapeError when the
file exists but is malformed.
* Update every consumer (utils/service_registry, utils/invokable,
cli/meta/applications/resolution/run_after/__main__, cli/meta/roles/
lifecycle_filter/__main__, cli/meta/applications/resolution/combined,
cli/build/{tree,graph,role_include}, cli/deploy/development/{common,
init,deps,deploy}, plugins/filter/canonical_domains_map, etc.) to
use the helper.
* Lint guards: no run_after/lifecycle in meta/main.yml; lifecycle
values restricted to {planned,pre-alpha,alpha,beta,stable,deprecated};
run_after/lifecycle never on a non-primary entity; empty run_after
lists rejected.
Also folded into this change set
* services_disabler now writes `services.<svc>.{enabled,shared}` into
host_vars instead of the old `compose.services.<svc>.{...}` path; the
discover/find pair walks meta/services.yml and only treats entries
carrying enabled/shared/provides as provider declarations (so
metadata-only entries from req-010 do not create spurious provider
rows).
* service_registry skips `run_after` deps whose target was filtered
out via SERVICES_DISABLED instead of failing the whole load.
* cli/mirror/resolver and cli/create/inventory/mirror_overrides emit/
consume the new applications.<role>.services.<svc>.image shape
(mirror.md updated to match).
* utils/domains/list.build_applications_from_roles now reads
meta/server.yml directly (it had been silently returning {} since
config/main.yml stopped existing).
* utils/roles/resource_validation.filter_roles_by_min_storage reads
meta/services.yml.<entity>.min_storage instead of the legacy
compose.services path.
* web-app-bluesky pre-clones the social-app build context BEFORE
sys-svc-compose so the build context is on disk by the time
sys-stk-front-proxy/tasks/main.yml flushes the docker build handler.
* 4 meta/variants.yml files (svc-ai-ollama, web-app-phpmyadmin,
web-app-wordpress, web-app-keycloak) lose their `compose: services:`
wrapper so deep-merge over meta/services.yml lands on the new
file-root shape.
Validation
* Unit tests: 1571 passed.
* Lint tests: 50 passed.
* Integration tests: 161 passed (1 skipped).
* Full-cycle fresh-purged-apps deploy succeeded for the 8-app validation
set: web-app-{nextcloud,wordpress,yourls,bigbluebutton,bluesky,gitea,
discourse,mattermost}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The retry-loop install task introduced in f7d32109e used
`argv: [sh, -lc, …]` with `set -o pipefail` in the body. On Debian/Ubuntu
CI runners `/bin/sh` is dash, which aborts with
`sh: 1: set: Illegal option -o pipefail`, so all 5 retries die on the
very first line and the Joomla deploy fails.
Switch argv[0] to `bash` so the task works on every distro and document
the constraint inline. Regression is tracked in infinito-nexus/core#194;
the retry-loop itself was the fix for #186.
Add tests/lint/ansible/test_no_sh_pipefail_in_ansible_tasks.py guarding
the three ways this class of bug can come back:
* `command` with `argv: […, sh, -lc, body_with_pipefail]`
* `args.executable` set to any non-bash shell
* removal of the global `ansible_shell_executable: /bin/bash` default
from group_vars/all/00_general.yml
Second-wave rollout of tests/utils/fs:
- 6 tests switched to the shared content cache via read_text() (replacing
raw open()/Path.read_text()): roles/includes/test_existence,
roles/dependencies/test_unnecessary, vars/test_facts_not_overridden,
services/test_sys_requires_id, lint/ansible/roles/
test_service_core_first_task_run_once, lint/ansible/roles/
test_compose_resource_limits.
- 4 unit-lookup tests moved their fixture root from <repo>/.tmp_test_* to
tempfile.TemporaryDirectory() so transient files land in /tmp (matching
the AGENTS.md rule). Manual recursive-delete teardowns removed —
TemporaryDirectory.cleanup() covers that path safely.
- filters/test_usage.py: algorithmic inversion. Old loop was
O(N_filters × M_files × 4_regex); new scan walks files once, applies two
master-alternation regexes (bare `| name` and `{% filter name %}`) plus
one combined regex over all Python callables, and records hits per filter
via the capture group. The two dropped Jinja patterns were strict
subsets of the bare pattern, so the hit set is identical — only the
complexity changes (3.6s → 0.2s, ~18x).
- docs/agents/action/testing.md: new "Filesystem Scans — Use the Shared
Cache" section codifies the rule so future agent-written tests route
through tests.utils.fs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds tests/utils/fs.py with process-level @lru_cache for both the
project-wide os.walk and per-file read_text(), then migrates 26 tests
(24 modified + 2 new) that previously re-walked/re-read the tree
independently to share one cached pass.
Headline win: tests/integration/jinja/test_variable_usage.py drops from
~26s to ~1.3s (≈19x), jinja cluster as a whole ~14x. Other migrated
tests benefit from the shared content cache when the same file is read
by multiple scans in one pytest run.
Non-caching logic changes (kept intentionally):
- test_variable_usage.py: added `if varname not in content: continue`
fast-path before the line loop (pure short-circuit, identical result).
- test_test_file_naming.py: exempt tests/utils/ from the test_*.py
prefix rule so the new shared helpers don't trip the lint.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a lint test that scans roles/, tasks/, playbooks/ for
`no_log: true` (and the YAML-truthy variants `yes`/`on`, all cases).
Hard-coded `no_log: true` blocks operators from surfacing masked
values when diagnosing a failing task — the fix is to route every
no_log through `MASK_CREDENTIALS_IN_LOGS`, which defaults to true
but can be flipped for a single play with
`-e MASK_CREDENTIALS_IN_LOGS=false`.
Failure message points at the exact file:line and shows the
required replacement form:
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace `git ls-files` with an inline `.gitignore` parser + `rglob` walk so
the lint runs inside the `make exec` container where `.git/` isn't mounted.
The matcher covers the pattern shapes used in this repo's `.gitignore`
(bare basenames, `prefix/*`, `*.ext`, path-qualified exact matches).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a repository lint that walks `git ls-files -co --exclude-standard`
and fails if any `test_*.py` lives outside the top-level `tests/` dir,
so .gitignored artefacts stay excluded. Moves the existing decidim role
test into `tests/unit/roles/web-app-decidim/` and rewrites it as
`unittest.TestCase` so `unittest discover` actually executes it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add tests/lint/ansible/roles/test_web_app_email_integration.py which:
- Emits a GHA warning annotation (via utils.annotations.message) for
each web-app-* role that does not call lookup('email', application_id)
and does not carry an explicit opt-out block.
- Hard-fails when a role DOES call the email lookup but its
compose.services.email is missing enabled: true or shared: true.
- Silencer format: `# noqa: email` on the line directly above an
`email:` block under compose.services with enabled: false + shared: false.
Opt out 13 roles that have no user-facing mail surface:
bridgy-fed, chess, dashboard, littlejs, mig, mini-qr, navigator,
oauth2-proxy, phpldapadmin, phpmyadmin, roulette-wheel, sphinx, yourls.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a warn-only lint that scans each role's primary compose service and
emits one GitHub Actions `::warning` annotation per missing key in
(min_storage, cpus, mem_reservation, mem_limit, pids_limit). The test
fails once no annotations remain, signalling that the rule should be
flipped from warn-only to strict mode.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Loosens the Open Project URL matcher to any `https://open.project.infinito.nexus/…`
and applies the same linked-item suppression to inline TODO markers that
already applied to TODO.md list items.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Under tests/integration/roles/ introduce four sub-clusters (meta/,
dependencies/, applications/, run_once/) plus nested dependencies/run_after/
and applications/id/. Fold tests/integration/application_id/ into
roles/applications/id/ and tests/integration/run_once/ into roles/run_once/.
Pull four application-related tests out of tests/integration/ into
roles/applications/, and test_run_after_references.py into the run_after/
sub-cluster.
Consolidate tests/integration/linting/ into tests/lint/{ansible,filesystem,
repository}/, so repository-wide lint checks now run under make test-lint
rather than make test-integration.
Each new cluster ships a README.md and __init__.py; cross-referencing
READMEs (categories, ports, services, roles, applications) are updated to
point at the new paths. Path depths inside moved tests and one hardcoded
ignore path in test_domain.py are adjusted accordingly. No behavior change;
all 32 lint tests and every moved cluster pass locally.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Consolidates application-config helpers under utils/applications/ with a
concise public API (config.get). Updates all plugin, role, and test
imports; removes the legacy get_app_conf filter and its tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>