Commit Graph
636 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 4.7 311bbfc809 refactor(lint): catch FQCN shell blocks + extract 4 violators
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>
2026-05-07 09:26:33 +02:00
kevinveenbirkenbachandClaude Opus 4.7 1aa1b084d5 refactor(svc-db-postgres): extract grant-schema SQL + lint inline multiline SQL
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>
2026-05-07 07:55:34 +02:00
kevinveenbirkenbachandClaude Opus 4.7 e729b54a3b test(lint): web-app-* logout, dashboard, oidc, oauth2 integration guards
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>
2026-05-06 23:25:58 +02:00
kevinveenbirkenbachandClaude Opus 4.7 e5de4e085a refactor(meta): wire services.<key>.{enabled,shared} dynamically via group_names
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>
2026-05-06 22:52:55 +02:00
kevinveenbirkenbachandClaude Opus 4.7 cb4cc393d0 test(lint): forbid hard-coded NETWORK_PUBLIC_DNS_RESOLVERS IPs
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>
2026-05-06 20:27:38 +02:00
kevinveenbirkenbachandClaude Opus 4.7 bec81768fd fix(lint): moodle resource limits + replace rglob with iter_project_files
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>
2026-05-06 20:08:50 +02:00
kevinveenbirkenbachandClaude Opus 4.7 1e94b14dd4 refactor(meta): split database PORT_BAND into mariadb + postgres + disjoint lint
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>
2026-05-06 19:56:36 +02:00
kevinveenbirkenbachandClaude Opus 4.7 d97b16e9f1 test(lint): add CLI suggest hint to port and subnet collision failures
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>
2026-05-06 19:38:21 +02:00
kevinveenbirkenbach d05f35f167 Merge branch 'main' into feature/web-app-fediwall 2026-05-06 19:16:51 +02:00
kevinveenbirkenbach 152a9b8d8e Merge branch 'main' into feature/moodle-self-built 2026-05-06 19:10:28 +02:00
kevinveenbirkenbach d239b2ab19 Merge branch 'main' into feature/web-app-fediwall 2026-05-06 18:33:32 +02:00
kevinveenbirkenbach cca665e0d7 Merge branch 'main' into feature/web-app-fediwall 2026-05-06 16:57:00 +02:00
kevinveenbirkenbachandClaude Opus 4.7 b3e582d70a test(lint): forbid literal http(s):// glued in front of a lookup-derived domain
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>
2026-05-06 16:56:59 +02:00
kevinveenbirkenbachandClaude Opus 4.7 2c94c0095b test(lint): enforce single-source-of-truth for domain literals
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>
2026-05-06 16:56:59 +02:00
kevinveenbirkenbach f4d33063ae Merge branch 'main' into feature/alpha-to-beta 2026-05-06 16:37:34 +02:00
kevinveenbirkenbach 8267269b5c Merge branch 'main' into feature/moodle-self-built 2026-05-06 16:09:25 +02:00
kevinveenbirkenbachandClaude Opus 4.7 60036a9249 test(lint): forbid raw project-tree walks in tests; route through utils.cache.files
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>
2026-05-05 23:56:06 +02:00
kevinveenbirkenbachandClaude Opus 4.7 f3845323f1 test(lint): cap inline literal script blocks at 12 logical lines + extract offenders
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>
2026-05-05 20:42:44 +02:00
kevinveenbirkenbachandClaude Opus 4.7 5039008337 test(lint): forbid literal http(s):// glued in front of a lookup-derived domain
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>
2026-05-05 17:49:16 +02:00
kevinveenbirkenbachandClaude Opus 4.7 031fb7568b test(lint): enforce single-source-of-truth for domain literals
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>
2026-05-05 17:34:34 +02:00
kevinveenbirkenbachandClaude Opus 4.7 dc334aa7ac refactor(lint,roles): tighten compose-resource-limits lint and fix corpus
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>
2026-05-05 12:49:21 +02:00
kevinveenbirkenbachandClaude Opus 4.7 c84e5ee4a9 refactor(web-svc-legal,web-svc-html): consume html via service registry
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>
2026-05-05 12:20:50 +02:00
kevinveenbirkenbachandClaude Opus 4.7 623035618c refactor(cache): route every yaml call in tests/ through utils.cache.yaml
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>
2026-05-05 12:16:20 +02:00
kevinveenbirkenbachandClaude Opus 4.7 9adb4ec635 test(lint): forbid lookup('config', ...) | default(X) dead-code pattern
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>
2026-05-04 19:31:07 +02:00
kevinveenbirkenbach 63ddd24aaf feat(req-015): self-built Moodle image, OIDC+LDAP hybrid + LDAP-only variants 2026-05-04 18:37:03 +02:00
kevinveenbirkenbach aa7f144455 test(suppression): unify # noqa / # nocheck marker grammar
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.
2026-04-30 12:51:50 +02:00
kevinveenbirkenbach 837d0c5335 test(suppression): unify # noqa / # nocheck marker grammar
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.
2026-04-30 11:54:46 +02:00
kevinveenbirkenbach 4e3ef819d4 Merge branch 'feature/package-cache-frontend' into feature/alpha-to-beta 2026-04-30 08:31:34 +02:00
kevinveenbirkenbachandClaude Opus 4.7 4b37e123d3 test(lint): cap production Python files at 500 lines
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>
2026-04-29 17:15:13 +02:00
kevinveenbirkenbachandClaude Opus 4.7 2c2a1e3508 docs(lifecycle)+roles: define lifecycle stages, mark jira/confluence/magento unsupported
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>
2026-04-29 00:27:00 +02:00
kevinveenbirkenbachandClaude Opus 4.7 b9353118d1 fix(meta): close req-008 sweep gaps — lost suppressions, silent test breakage, 1 prod bug
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>
2026-04-27 15:25:38 +02:00
kevinveenbirkenbachandClaude Opus 4.7 a8a117c86e refactor(yaml): route every YAML touchpoint through utils.cache.yaml + lint
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>
2026-04-27 13:10:14 +02:00
kevinveenbirkenbachandClaude Opus 4.7 b3de2a4d7c refactor(meta): land req-011 meta/info.yml + utils/cache cleanup
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>
2026-04-27 12:27:17 +02:00
kevinveenbirkenbachandClaude Opus 4.7 2475a086e2 test(lint): add Ansible Galaxy schema check for roles/*/meta/main.yml
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>
2026-04-27 10:31:46 +02:00
kevinveenbirkenbach 07247b3552 Merge branch 'main' into feature/role-meta-refactor
# Conflicts:
#	roles/web-app-bridgy-fed/config/main.yml
#	roles/web-app-decidim/config/main.yml
#	roles/web-app-joomla/config/main.yml
#	roles/web-app-magento/config/main.yml
#	roles/web-app-shopware/config/main.yml
#	roles/web-app-suitecrm/config/main.yml
2026-04-27 10:16:52 +02:00
kevinveenbirkenbachandClaude Opus 4.7 ed04dad5d5 refactor(meta): land req-008/009/010 role-meta layout/networks/ports/run_after migration
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>
2026-04-27 09:58:26 +02:00
kevinveenbirkenbach 14a1eb651a Merge branch 'main' into fix/web-app-joomla/sh-pipefail-dash/194 2026-04-25 10:18:06 +02:00
kevinveenbirkenbachandCopilot Autofix powered by AI 999a01a231 Potential fix for code scanning alert no. 278: Empty except
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-25 09:38:26 +02:00
kevinveenbirkenbachandCopilot Autofix powered by AI baddc1fec8 Potential fix for code scanning alert no. 278: Empty except
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-24 19:26:04 +02:00
kevinveenbirkenbach 3fdbe8d9e2 fix(web-app-joomla): use bash for CLI install argv (dash lacks pipefail)
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
2026-04-24 16:32:27 +02:00
kevinveenbirkenbachandClaude Opus 4.7 e6eb5115bf test(utils/fs): extend cache coverage + invert filters/test_usage scan
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>
2026-04-23 22:19:06 +02:00
kevinveenbirkenbachandClaude Opus 4.7 9c8cc1ddcb test(utils/fs): cache project file walks + contents across test run
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>
2026-04-23 20:29:54 +02:00
kevinveenbirkenbachandClaude Opus 4.7 f0475e393f test(lint): forbid literal no_log: true in Ansible YAML
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>
2026-04-23 10:46:24 +02:00
kevinveenbirkenbachandClaude Opus 4.7 1e085de616 test(lint): drop git subprocess from test-location check
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>
2026-04-22 02:12:02 +02:00
kevinveenbirkenbachandClaude Opus 4.7 b67b392210 test(lint): enforce test_*.py files live under tests/
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>
2026-04-22 00:54:36 +02:00
kevinveenbirkenbachandClaude Opus 4.7 1014bc51d3 test(email): lint warning for web-apps without email integration
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>
2026-04-22 00:10:21 +02:00
kevinveenbirkenbachandClaude Opus 4.7 a8d8cb9d8d test(lint/roles): warn on missing compose-service resource limits
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>
2026-04-21 14:01:36 +02:00
kevinveenbirkenbachandClaude Opus 4.7 13d2c0f597 test(lint/todo): suppress warnings when open.project link present
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>
2026-04-21 13:51:22 +02:00
kevinveenbirkenbachandClaude Opus 4.7 02da33b0a5 refactor(tests): reorganize into topical sub-clusters and consolidate linting
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>
2026-04-21 12:22:33 +02:00
kevinveenbirkenbachandClaude Opus 4.7 34d6121293 refactor(utils): move config_utils to utils.applications.config, rename resolve_app_config -> get
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>
2026-04-19 18:32:30 +02:00