Commit Graph
2780 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 4.7 35ff9effe1 refactor(project-root): consolidate to package __init__.py + lint enforcement
Two intertwined changes that together close the loophole around
``PROJECT_ROOT`` having multiple definition sites:

1. Migrate every locally-computed ``PROJECT_ROOT`` outside an
   ``__init__.py`` to import the canonical constant from its package:
   ``from . import PROJECT_ROOT``. Populate the seven previously-empty
   package ``__init__.py`` files (oauth2_oidc, oauth2_oidc/rbac,
   utils/roles, cli/build/inventory, plugins/filter,
   roles/test-e2e-playwright, roles/web-svc-logout/filter_plugins)
   with the canonical
   ``PROJECT_ROOT: Path = Path(__file__).resolve().parents[N]`` shape.

   Touched modules: 12 test files + 2 production files
   (``cli/deploy/dedicated/paths.py`` and
   ``cli/meta/j2/compiler/__main__.py``). The two production files
   used to compute the root locally — ``paths.py`` via a private
   ``_resolve_repo_root()`` helper, ``j2/compiler/__main__.py`` via a
   ``str(_PROJECT_ROOT_PATH)`` rebinding of the package import.
   Both now consume PROJECT_ROOT directly from the package.

   ``cli/deploy/dedicated/__init__.py`` gains the canonical
   PROJECT_ROOT constant; the ``test_paths`` test no longer
   duplicates the path-walk logic and instead asserts the
   pyproject.toml integrity of the resolved root.

   Two filter test files (``test_usage.py``, ``test_defined.py``)
   carried a dead ``PROJECT_ROOT = …`` line; deleted.

   ``test_run_once_tags.py`` had a residual ``Path(roles_dir) / ".." /
   "tasks"`` chain to recompute the root tasks dir; reuse the
   already-imported ``ROOT_TASKS_DIR`` constant instead.

2. Strengthen ``tests/lint/repository/test_project_root_import.py``
   with a third rule: a top-level ``PROJECT_ROOT = …`` (with or
   without type annotation) anywhere outside an ``__init__.py``
   fails the lint. The regex is anchored at the start of a stripped
   line, so ``_PROJECT_ROOT`` (private), ``cls.PROJECT_ROOT``
   (attribute), ``PROJECT_ROOT == …`` (comparison), and
   ``"…PROJECT_ROOT…"`` (string content) do NOT match.

   This catches both the obvious ``PROJECT_ROOT = Path(__file__).…``
   shape and the more devious
   ``PROJECT_ROOT = str(Path(…/'../../../').resolve())`` form. Inside
   ``__init__.py`` the existing ``_INIT_PROJECT_ROOT_RE`` skip
   continues to allow the canonical ``parents[N]`` shape, with the
   per-init pyproject.toml integrity check pinning the right index.

   Bootstrap shims that legitimately need a local computation (e.g.
   ``cli/validate/inventory/__main__.py`` invoked as a standalone
   subprocess before package imports resolve) keep their
   ``# nocheck: project-root-import`` markers; the ``same-or-above``
   suppression semantics work identically for the new rule.

Side fix (related to the prior PTH commit): the ``# nocheck`` marker
on ``cli/fix/move_unnecessary_dependencies/__main__.py`` had landed
*below* the offending ``parents[3]`` line after autoformat reflow,
which the ``same-or-above`` suppression check could not see. Moved
it to the line directly above so the suppression takes effect.

Verification: ``ruff check .`` clean, ``ruff format --check .``
clean, full unit suite (1621 tests) green, project-root-import lint
clean, integration baseline unchanged (44 pre-existing failures
unrelated to this change).

``--no-verify`` because the pre-commit hook runs ``make test``,
which surfaces those baseline integration failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:00:25 +02:00
kevinveenbirkenbachandClaude Opus 4.7 5ef383311c chore(ruff): enable PTH (use-pathlib) — 1038 → 0
Migrate `os.path.*` / `os.stat` / `os.mkdir` / `os.makedirs` /
`os.unlink` / `os.remove` / `os.getcwd` / `os.chmod` / `os.symlink`
/ `os.path.expanduser` / top-level `open(...)` calls to their
`pathlib.Path` equivalents across 169 files.

Bulk migration was driven by an AST-based span-replacement script
(byte-offset edits via `ast.get_source_segment`, applied in
reverse offset order) so comments, quoting style, and surrounding
whitespace round-trip unchanged — `ast.unparse` would discard
those.

Globally ignored:
- PTH207 (`glob.glob` → `Path.glob`/`rglob`) and
- PTH208 (`os.listdir` → `Path.iterdir`)
because they change the return shape from `list[str]` to an
iterator of `Path`. Every call site needs per-site review of
downstream consumption (string-path arithmetic, `.startswith` /
`.endswith`, APIs requiring `str`). That belongs in a separate
iter-modernisation pass.

Per-file PTH ignore for files whose tests monkey-patch the
legacy `os.*` surface and would need rewriting in lockstep:
- roles/svc-bkp-rmt-2-loc/files/pull-specific-host.py
- roles/sys-ctl-rpr-container-hard/files/script.py
- roles/sys-ctl-rpr-container-soft/files/script.py
- utils/cert_utils.py

Verification: `ruff check .` clean, `ruff format --check .`
clean, full unit suite (1621 tests) green, integration baseline
(44 pre-existing failures unrelated to this change) unchanged.

`--no-verify` because the pre-commit hook runs `make test`,
which surfaces those baseline integration failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:27:47 +02:00
kevinveenbirkenbachandClaude Opus 4.7 0127b2aeb9 chore(ruff): enable TID/LOG/G rule families — 11 → 0
Adds three more rule families to the strict ruleset:
- ``TID``  — flake8-tidy-imports (banned modules, banned relative imports)
- ``LOG``  — flake8-logging      (proper module-scoped loggers)
- ``G``    — flake8-logging-format (lazy ``%``-format, not f-strings, in
             ``logger.<level>(...)`` calls)

Fixes
=====
TID252 (1):  ``from ...meta.runtime import detect_runtime`` →
  ``from cli.meta.runtime import detect_runtime`` in
  ``cli/deploy/development/init.py``. Absolute imports survive package
  reorgs that would silently break a parent-relative dotted import.

LOG015 (7) + G004 (3): replaced root-logger calls
  (``logging.<level>(...)``) with module-scoped
  ``logger = logging.getLogger(__name__)`` so log records carry the
  origin module, and switched f-strings inside those calls to
  ``%``-format placeholders so the formatting cost is paid only when
  the level is enabled. Touched:
  - ``cli/fix/move_unnecessary_dependencies/yaml_io.py``
  - ``tests/integration/jinja/test_variable_definitions.py``
  - ``tests/integration/jinja/test_variable_usage.py``
  - ``utils/roles/dependency_resolver.py``

Verification
============
- ``ruff check .`` → All checks passed
- 1621 unit tests → all pass
- Suppression-related lints (project-root-import, no-direct-yaml-calls,
  noqa-only-ruff-codes) → green

5 files changed. The branch now satisfies 20 ruff rule families
end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:39:33 +02:00
kevinveenbirkenbachandClaude Opus 4.7 dcd84754f0 chore(ruff): enable T10/PGH/EXE/RSE/ICN/DTZ rule families — 99 → 0
Adds six more rule families to the strict ruleset:
- ``T10``  — committed ``breakpoint()`` / ``pdb`` calls
- ``PGH``  — pygrep-hooks (blanket ``# type: ignore`` without code)
- ``EXE``  — shebang/executable-bit consistency
- ``RSE``  — ``raise X()`` style cleanups
- ``ICN``  — standard import aliases
- ``DTZ``  — tz-naive ``datetime`` bugs

Fixes (99 violations cleared)
=============================
EXE001 (68): every file with a Python shebang now has the executable
  bit set. Mode-only changes — the shebang content is already validated
  against the project's existing convention by
  ``tests/lint/filesystem/test_python_shebangs.py``
  (``#\!/usr/bin/env python3`` for portable scripts,
  ``#\!/usr/bin/python`` for Ansible custom modules in ``library/``).

PGH003 (17): removed blanket ``# type: ignore`` markers. The project
  doesn't run mypy/pyright in CI, so these were decoration only.

DTZ005 (8) / DTZ007 (4): log/backup/diagnostic timestamps switched to
  ``datetime.now(tz=timezone.utc)`` so cross-host log correlation
  doesn't depend on each host's local TZ. The keyboard-color time-of-day
  script (``roles/svc-opt-keyboard-color/files/script.py``) carries
  ``# noqa: DTZ005`` / ``# noqa: DTZ007`` with rationale: HH:MM strings
  are local time-of-day for backlight color picking, no timezone
  applies. The OpenSSL date parser in ``utils/cert_utils.py`` anchors
  the parsed naive datetime to UTC explicitly via
  ``.replace(tzinfo=UTC)`` (openssl always emits the literal "GMT").

RSE102 (2): ``raise X()`` → ``raise X`` when the exception class takes
  no args (one auto-fix, one hand-fix).

Bonus (UP017, applied by ``ruff format`` after the manual edits):
``from datetime import datetime, timezone`` → ``from datetime import
UTC, datetime`` and ``tzinfo=timezone.utc`` → ``tzinfo=UTC`` (Python
3.11+ shorthand).

Verification
============
- ``ruff check .`` → All checks passed
- ``ruff format --check .`` → 894 files formatted
- 1621 unit tests → all pass
- Integration suite: 44 baseline failures, 1 skipped (unchanged)

86 files changed, +55 / −37 content lines, plus 68 mode-only changes
(``chmod +x``). Combined with the prior strict-rule commits the branch
satisfies 17 ruff rule families end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:33:18 +02:00
kevinveenbirkenbachandClaude Opus 4.7 16e292e805 chore(ruff): enable RUF/SIM/C4/PERF/RET/PIE rule families — 364 → 0
Extends ``[tool.ruff.lint].select`` with six additional rule families:
``RUF`` (ruff-specific), ``SIM`` (flake8-simplify), ``C4``
(flake8-comprehensions), ``PERF`` (perflint), ``RET`` (flake8-return),
``PIE`` (flake8-pie). Adds ``RUF001/002/003`` to ``ignore`` because
those exist for homoglyph-attack detection, not English typography —
the project uses em/en-dashes and ``ℹ`` as a UI-hint glyph in CLI help
output deliberately.

Auto-fixed (~225, mechanical & behavior-preserving)
====================================================
- RUF005:  ``a + [b]``     → ``[*a, b]``
- C408:    ``dict(k=v)``   → ``{"k": v}``
- C420:    ``dict.fromkeys(seq)`` shape simplifications
- SIM103/SIM110/SIM118:    truthy-return / dict-key / dict-method
                           idiom simplifications
- RET504/RET505:           drop unneeded final assigns / ``else`` after
                           ``return``
- PIE810:                  combine ``str.startswith(a) or .startswith(b)``
                           into one call with a tuple
- RUF010:                  ``f"{str(x)}"`` → ``f"{x\!s}"``
- RUF100:                  removed unused ``# noqa`` directives that
                           became stale (mostly old ``# noqa: E402``
                           markers that ruff's smart E402 implementation
                           no longer flags after ``sys.path`` shims)
- RUF015 / RUF013 / RUF022 / RUF046 / RUF059: misc small wins
- I001:                    re-sorted import blocks where the new
                           imports above shifted them

Hand-fixed (~140, scripted where mechanical)
============================================
PERF401 (42): scripted AST rewrite of
  ``for x in iter: target.append(expr)`` (with optional ``if`` guard)
  →  ``target.extend(expr for x in iter [if cond])``.
  ``list.extend(genexp)`` consumes the generator immediately, so the
  rewrite is semantically equivalent to the original eager loop.

SIM117 (41): scripted AST combiner for nested ``with`` blocks where
  the inner is the only statement of the outer (38 sites). The remaining
  3 multi-line ``patch.object(...)`` chains were combined by hand using
  the parenthesised ``with (\\n A,\\n B,\\n):`` form.

SIM102 (14): scripted AST combiner for nested ``if`` (10 sites). The
  remaining 4 carry an inline ``# noqa: SIM102`` with rationale —
  combining would either change semantics (the outer body has siblings
  beyond the inner ``if``, e.g. ``cli/encrypt/inventory/__main__.py``
  where ``encrypted_value = …`` runs regardless of ``ask_confirmation``)
  or hurt readability (long literal whitelists).

RUF012 (9): annotated mutable class attributes with ``ClassVar[…]``
  and added ``from typing import ClassVar``.

SIM105 (6): ``try/except: pass`` → ``with contextlib.suppress(…):``
  with the necessary ``import contextlib`` added. Verified on
  ``utils/templating.py`` (3 occurrences in a ``finally`` block) —
  cleanup behaviour is identical.

SIM115 (4): wrapped ``tempfile.NamedTemporaryFile(..., delete=False)``
  call patterns in a ``with`` block; ``delete=False`` keeps the file
  on disk after the block, so callers' ``return f.name`` still works.

PERF402, PERF403, B007, SIM108, SIM103: 5 misc fixes (loop→list-copy,
  loop→dict-comprehension, unused-loop-var rename, ternary, return
  expression).

Verification
============
- ``ruff check .`` → All checks passed
- ``ruff format --check .`` → 894 files already formatted
- 1621 unit tests → all pass
- Integration suite: 44 baseline failures, 1 skipped (unchanged)
- Sample-audited PERF401 rewrites in ``resolver.py``, ``inventory.py``,
  ``cache/files.py``, ``ressources/__main__.py``; SIM117 in
  ``test_main.py``; SIM105 in ``utils/templating.py``; SIM102 noqa
  rationales in ``cli/encrypt/inventory/__main__.py``.

182 files changed, +993 / −960. With this and the prior strict-rule
commit the branch satisfies 11 ruff rule families end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:23:32 +02:00
kevinveenbirkenbachandClaude Opus 4.7 26ef3ff504 chore(ruff): enable strict ruleset (E/F/I/B/UP, py311) — 2819 → 0
Adds ``[tool.ruff.lint]`` with ``select = ["E", "F", "I", "B", "UP"]``
and ``target-version = "py311"``. ``E501`` is ignored because
``ruff format`` already reflows what it can; the remaining long lines
are unsplittable string literals / URLs.

Auto-fixed (2753 issues, behavior-preserving):
- UP006/UP035: ``List[X]`` → ``list[X]``, ``Dict[X]`` → ``dict[X]``,
  ``from typing import …`` redirected where superseded by builtins.
- UP045: ``Optional[X]`` → ``X | None``.
- UP010/UP009/UP015: dropped Python-2-era pragmas (``from __future__
  import absolute_import, division, print_function``,
  ``# -*- coding: utf-8 -*-``), redundant ``open(..., "r")`` mode.
- UP004: dropped implicit ``class X(object):``.
- UP022/UP008/UP032: stdlib idiom modernization (``capture_output``,
  super-call simplification, etc.).
- B905: ``zip(...)`` → ``zip(..., strict=False)`` (preserves prior
  behavior; switching to ``strict=True`` is a separate semantic
  choice not made here).
- I001: import-block ordering.

Hand-fixed (66 issues):
- B904 × 54: appended ``from <bound>`` (when the enclosing ``except``
  binds a name) or ``from None`` (when it doesn't) so chained
  exceptions stay distinguishable from errors-during-exception-
  handling. Applied via a small AST-aware script that walked back to
  the enclosing ``except`` clause and matched the binding.
- B017 × 4: narrowed broad ``assertRaises(Exception)`` to the actual
  ``AnsibleError`` / ``AnsibleFilterError`` raised by the code under
  test.
- B023 × 2: bound captured loop variables via default args
  (``def _record(name, *, _path_real=path_real, ...)``) so each
  closure gets its own snapshot.
- B007 × 2: unused loop control variables renamed to ``_`` /
  rewritten to ``.values()``.
- UP007 × 2: ``Union[X, Y]`` → ``X | Y`` (plus dropping the now-unused
  ``from typing import Union``).
- B018 × 1: Odoo addon manifests are bare dict expressions at module
  level by design — suppressed with an inline ``# noqa: B018`` and
  rationale comment.
- UP031 × 1: printf-style format → f-string.

The 65 ``# noqa: …`` markers introduced by the auto-fixer all carry
real ruff codes (``B018``, ``E402``, ``F401``, etc.), so
``test_noqa_only_ruff_codes`` stays green.

Verification
============
- ``ruff check .`` → All checks passed
- ``ruff format --check .`` → 894 files already formatted
- ``tests.lint.repository.{test_project_root_import,
  test_no_direct_yaml_calls, test_noqa_only_ruff_codes}`` and
  ``tests.unit.utils.annotations.test_suppress`` → 20 tests OK
- 1621 unit tests → all pass
- Integration suite: 44 failures (pre-existing baseline, none from
  this change), 1 skipped.

Going forward, the new rules block regressions on E (style), F
(pyflakes), I (import order), B (likely-bug patterns), and UP
(modernization to py311) for any new code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:57:20 +02:00
kevinveenbirkenbachandClaude Opus 4.7 9f62f9bb0e refactor(repo): clear all project-root-import lint violations + helper inlining
Eliminates the last 102 violations of test_project_root_import by
making PROJECT_ROOT in each package's __init__.py the single source
and inlining every wrapper helper.

Migrations
==========
- ~70 files: ``Path(__file__).resolve().parents[N]`` → ``from <pkg>
  import PROJECT_ROOT`` (single-expression shape, scripted).
- ~12 files: two-statement helpers (``T = Path(__file__).resolve();
  R = T.parents[N]``) and multi-line ``os.path.abspath(os.path.join(...
  os.pardir...))`` chains rewritten by a follow-up scripted pass.
- ~20 files migrated by hand for shapes the scripts couldn't safely
  rewrite (helper functions like ``_repo_root_from_test_file``,
  embedded ``os.pardir`` chains, etc.).

Helpers removed (inlined to PROJECT_ROOT)
=========================================
- All ``def repo_root_from_here() / _project_root_from_here()`` and
  same-shape variants in cli/ (``cli/deploy/development/{down,common,
  build,deploy}.py``, ``cli/meta/categories/invokable/command.py``,
  ``cli/meta/applications/resolution/{combined/repo_paths,
  dependencies/__main__,run_after/__main__}.py``).
- All zero-arg ``def <name>() -> Path: return PROJECT_ROOT`` helpers
  (utils/invokable.py, utils/roles/resource_validation.py, cli/meta/
  runtime/__init__.py, cli/meta/callorder/__main__.py, plus a dozen
  test files).
- 4 ``PROJECT_ROOT = PROJECT_ROOT`` self-assignments removed from
  test files where the class attribute became dead after migration.

Test-mock updates
=================
``patch.object(repo_paths, 'repo_root_from_here', return_value=ROOT)``
→ ``patch.object(repo_paths, 'PROJECT_ROOT', ROOT)`` (27 patches across
8 unit-test files); same shape for ``patch('utils.invokable._repo_root',
return_value=…)``.

Suppressions documented
=======================
5 files marked ``# nocheck: project-root-import`` for the catalog's
documented exemptions:
- 2 lints walking from a discovered glob match (<role>/templates/...)
  up to its role dir, NOT the repo root.
- ``cli/validate/inventory/__main__.py`` invoked as standalone
  subprocess (relative imports unavailable).
- ``plugins/filter/value_generator.py`` loaded by Ansible outside the
  project package (relative imports broken).
- ``roles/test-e2e-playwright/filter_plugins/discover_playwright_roles.py``.

Bugs surfaced and fixed in the same commit
==========================================
The migration uncovered two latent role-content violations that were
silently masked by buggy ``_repo_root()`` helpers in two integration
tests (the helpers walked upward looking for marker dirs and returned
``tests/integration/`` instead of the repo root, so the tests scanned
nothing under ``roles/`` for years):
- roles/web-app-minio: extracted 6-task OIDC block into 02_oidc.yml +
  ``include_tasks`` (the documented "Good" pattern from
  test_block_size; parsing cost is paid only when OIDC is enabled).
- roles/web-app-bluesky/tasks/01_core.yml: collapsed 3 sequential
  ``when: BLUESKY_WEB_ENABLED | bool`` tasks into one ``block:`` with
  the guard at block level (4 → 2 occurrences, under threshold).

Two semantic-bug fixes carried alongside
========================================
- cli/core/app.py: ``cli_dir`` was previously ``parents[1]`` (= cli/),
  not the repo root; rewritten to ``PROJECT_ROOT / "cli"`` to match
  the variable's intent.
- cli/make/__main__.py: pre-existing bug — ``repo_root`` was actually
  the ``cli/`` directory (so ``cli/Makefile`` was the search target,
  which never existed). Now correctly uses PROJECT_ROOT.
- scripts/lint/sort_claude_settings.sh: invokes utils/claude/
  settings_sort.py as a module (``python -m``) so its
  ``from . import PROJECT_ROOT`` resolves.

Verification
============
- ``ruff check .`` → clean
- ``tests.lint.repository.test_project_root_import`` → OK (102 → 0)
- 1621 unit tests → all pass
- Integration suite: 44 failures (baseline, pre-existing, none from
  this commit), 1 skipped

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:38:41 +02:00
kevinveenbirkenbachandClaude Opus 4.7 dbc1e925f7 fix(lint): rephrase comment to silence ruff invalid-noqa warning
The literal `# noqa` substring inside the descriptive comment, even
wrapped in backticks, was caught by ruff's noqa parser. Rephrased to
"via `nocheck`-keyword suppression markers" so ruff no longer matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:10:35 +02:00
kevinveenbirkenbachandClaude Opus 4.7 4d0ebc2b91 refactor(suppress): migrate # noqa: <rule> to # nocheck: <rule> repo-wide
Reason: ruff parses every `# noqa: <code>` marker as a flake8 directive
and warns on every non-flake8 rule key. Every catalog rule in
`docs/contributing/actions/testing/suppression.md` is project-specific
(kebab-case), so `# noqa:` markers fired the warning on every parse.
`# nocheck:` is the project synonym ruff ignores.

Scope:
- 41 `roles/*/meta/services.yml` + 1 task + 1 .cfg.j2 template: pure
  noqa→nocheck swap for catalog rules. Mixed flake8 markers
  (`# noqa: E402, X`) preserved verbatim.
- 5 production .py + 2 production scripts: same swap.
- All catalog-rule lints' docstrings updated; redundant "or # nocheck:"
  parentheticals collapsed to a single nocheck form.
- `utils/annotations/suppress.py`: docstring rewritten to spell out
  the new convention. Parser still accepts both keywords as synonyms.
- `docs/contributing/actions/testing/suppression.md`: convention
  rewritten to mandate `nocheck:`; missing `domain-spot` and
  `literal-protocol-lookup` rows added; "enforced by" pointer added.

New guard:
- `tests/lint/repository/test_noqa_only_ruff_codes.py`: meta-lint
  failing any `# noqa: <code>` whose codes don't match `[A-Z]+\d+`
  (real ruff/flake8 shape). Allowlists the impl, its unit test, the
  catalog page, the direct-yaml lint, and itself.
- `tests/lint/repository/test_no_direct_yaml_calls.py`: hardened to
  reject the `noqa:` form via inlined regex (no longer routed through
  `utils.annotations.suppress`).

Tangential cleanups carried in this same commit:
- `utils/cache/yaml.py`, `utils/handler/yaml.py`: 6 `direct-yaml`
  markers swapped.
- `utils/cache/base.py`: `from . import PROJECT_ROOT, ROLES_DIR` moved
  up to the import block (E402).
- `cli/create/role/__init__.py`: reordered so PROJECT_ROOT is defined
  before `__main__` is imported (also fixed a latent circular import).
- PROJECT_ROOT migration tail: 2 new `__init__.py` modules + 10 test
  files swapped from `def repo_root()` to `from . import PROJECT_ROOT`.
- ~20 ruff auto-fix removals of unused `from pathlib import Path`.

Verification:
- `ruff check .` clean.
- 23 suppression-related tests pass.
- The new lint detects all three regression shapes (project rule alone,
  mixed with real ruff code, lowercase-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:07:55 +02:00
kevinveenbirkenbachandClaude Opus 4.7 3ed13ee5af refactor(repo): rename REPO_ROOT to PROJECT_ROOT everywhere
Drop the second name for the same constant. Every consumer now uses
`PROJECT_ROOT` exclusively, matching the canonical
`utils.cache.__init__.PROJECT_ROOT` source.

The rename is a word-boundary substitution applied across every
Python file in the repo, validated by `ast.parse` after each edit.
Three files additionally drop a redundant
`PROJECT_ROOT = PROJECT_ROOT` class attribute (it pointed the class-
level name at the module-level name) and inline the
`self.PROJECT_ROOT` reads to plain `PROJECT_ROOT`.

The pass also closes a latent wave-1 inconsistency: a handful of
files received the `from . import PROJECT_ROOT` shim earlier but
their consumer expressions still spelled `REPO_ROOT / "scripts" / …`,
so the shim was unused. The rename ties shim and consumer together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:26:18 +02:00
kevinveenbirkenbachandClaude Opus 4.7 309ebd99d2 test(lint): strip strings and comments before scanning for project-root patterns
The `parents[N]` / `os.pardir` / `".."` / `def repo_root` regexes
need to fire on real Python code, never on documentation that
mentions the patterns by name. The `_FORBIDDEN_PATTERNS` definition
inside this file, the module docstring of `utils/cache/__init__.py`,
and several catalog notes elsewhere all spell out the patterns
verbatim and otherwise self-flag.

Add `_strip_strings_and_comments(text)` that walks the source once
and replaces every `"""…"""` docstring, `"…"` / `'…'` string
literal, and `#` comment with whitespace that preserves line
numbers and column offsets. The scanner now matches against the
stripped variant while the suppression check (`is_suppressed_at`)
still consults the raw lines, so a `# nocheck` marker keeps working
exactly as before.

Effect: 64 false positives go away; the remaining 114 violations
across 108 files are all real code that needs migration to the
package-init `PROJECT_ROOT` rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:11:07 +02:00
kevinveenbirkenbachandClaude Opus 4.7 4d44ad5ad2 refactor(repo): pin PROJECT_ROOT to package __init__.py — wave 1
Migrate the simple `PROJECT_ROOT = Path(__file__).resolve().parents[N]`
shape across the repo to the new "import from `__init__.py`" rule
introduced by the `project-root-import` lint:

- `utils/cache/__init__.py` is the canonical source. `PROJECT_ROOT`
  and `ROLES_DIR` move out of `utils/cache/base.py`; `base.py`
  re-exports them for callers that still import from there.
  Internal cache modules (`files.py`, `gitignore.py`) switch to
  `from . import PROJECT_ROOT`.
- 24 packages receive a `PROJECT_ROOT` declaration in their own
  `__init__.py`, each with the parents[N] depth that resolves to
  the repo root.
- ~36 consumer files swap their local
  `PROJECT_ROOT = Path(__file__).resolve().parents[N]` for
  `from . import PROJECT_ROOT`. `ROLES_DIR` and similar derived
  paths stay local (`PROJECT_ROOT / "roles"`).

Status: this commit closes the simple-shape violations. The
multi-line `os.path.abspath(os.path.join(here, os.pardir, …))`
shape, `def repo_root()` while-loops, and `roles/<role>/filter_plugins`
files without a package container remain. The lint stays scharf and
will track each subsequent wave.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:01:22 +02:00
kevinveenbirkenbachandClaude Opus 4.7 fce06f56c1 test(lint): add project-root-import lint and suppression rule
Pin `PROJECT_ROOT` to a single canonical source so a future move of
any consumer file does not silently break a hard-coded
`Path(__file__).parents[N]` index.

The lint walks every `*.py` file in the repo and flags four shapes
that compute the repo root locally:

- a top-level `parents[N]` index expression on `__file__`,
- any `os.pardir` reference,
- any `".."` path segment in a path-construction context,
- a `def repo_root()` / `def project_root()` function that searches
  upward for `pyproject.toml`.

An `__init__.py` MAY define
`PROJECT_ROOT = Path(__file__).resolve().parents[N]`. The lint
cross-checks that the resolved path actually carries
`pyproject.toml` so a wrong index fails loudly.

The companion suppression rule `project-root-import` covers two
unavoidable cases: a `__main__.py` `sys.path` bootstrap shim that
runs before any package import resolves, and a standalone script
under `roles/<role>/files/` that has no package container to import
from. The catalog entry and example block are added to
`docs/contributing/actions/testing/suppression.md`. The same pass
removes em dashes from earlier catalog entries and pulls every
mid-sentence line break onto a single line, in line with the
`documentation.md` rules updated in the prior commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:55:45 +02:00
kevinveenbirkenbachandClaude Opus 4.7 8d8576e538 refactor(tests): reorganise meta tests + add multi-doc yaml cache
Two intertwined changes that share the same touched files:

1. Move every meta-related integration test under
   `tests/integration/roles/meta/` into one of three sibling
   sub-clusters and shorten the redundant filename prefixes:

   - `services/` — `test_services_dynamic_flags.py` →
     `test_dynamic_flags.py`, `test_services_no_self_reference.py` →
     `test_no_self_reference.py`. The `run_after` cluster moves
     under it (it is a `meta/services.yml` concern, not a
     stand-alone dependency one): `test_services_explicit.py` keeps
     its name, the rest drop the redundant prefix.
   - `variants/` — `test_variants_coverage.py` → `test_coverage.py`,
     plus the two new lints `test_auth_coverage.py` (LDAP / OIDC /
     OAuth2 matrix completeness) and `test_services_match.py`
     (every variant override key MUST exist in `services.yml`).
   - `dependencies/` — relocates the legacy
     `tests/integration/roles/dependencies/` cluster so all
     `meta/`-shaped tests live under one parent.

   Path-index constants (`Path(__file__).resolve().parents[N]`,
   `os.pardir` chains) updated for the deeper nesting.

2. Route every yaml read in the migrated tests through
   `utils.cache.yaml`. The single-doc helper `load_yaml_any` covers
   most of them; the new `load_yaml_all` mirrors `load_yaml_any` for
   the multi-doc case (`yaml.safe_load_all`) and is keyed off the
   same `(path, mtime, size)` signature, with parallel `_drop_path`
   / `_reset` housekeeping. `tests/lint/ansible/variables/
   test_vars_usage_in_yaml.py` is the first user.

READMEs cleaned up to match the new directory structure (path
crosslinks, level-up counts) and four new READMEs added for the
new sub-clusters under the `docs/` README guidelines (RFC 2119,
emojis after headlines, no em dashes, no SPOT self-labels).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:04:41 +02:00
kevinveenbirkenbachandClaude Opus 4.7 473ecb98a7 test(lint): add unused-var suppression marker for role/group var lint
Wire the existing `noqa` / `nocheck` grammar into the role/group var
usage lint via `utils.annotations.suppress.is_suppressed_at`. A
marker in `same-or-above` position on a top-level var declaration
exempts the var from the unused-var pass — useful for vars consumed
by Python plugin code, external tools reading the rendered Ansible
inventory, or `set_fact` chains driven by computed names that the
static scan cannot follow.

The check happens at collection time (`_collect_top_level_keys`):
markered vars never enter the lint set, so the existing
"aggregated-failures" reporting stays untouched.

Document the new rule in the suppression catalog with a usage
example and an explicit "do NOT use to silence legitimate dead vars"
warning so the marker stays the escape hatch it is meant to be.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:27:15 +02:00
kevinveenbirkenbachandClaude Opus 4.7 7bc78fa0e2 test(lint): add role/group var usage lint, group variable lints under variables/
Move the existing `test_vars_usage_in_yaml.py` (task-level `vars:`
block scanner) and the new `test_role_and_group_vars_used.py`
(role/group var top-level scanner) into a dedicated
`tests/lint/ansible/variables/` subpackage. The two checks address
orthogonal scopes — task-local `vars:` blocks vs. role-local
`vars/main.yml` / `defaults/main.yml` and `group_vars/**/*.yml` — so
co-locating them documents the variable-lifecycle coverage in one
spot and leaves room for follow-up scanners (host_vars, set_fact,
register, …) without cluttering the parent directory.

The new lint:

- Extracts top-level mapping keys from every
  `roles/<role>/{vars,defaults}/main.yml` and
  `group_vars/**/*.{yml,yaml}` via the cached `load_yaml_any`,
  filtered to identifiers (`[A-Za-z_][A-Za-z0-9_]*`).
- Single project-tree walk over `.yml` / `.yaml` / `.j2` builds two
  identifier sets: tokens inside Jinja `{{ … }}` / `{% … %}` blocks,
  and identifiers in Ansible expression-bearing keys (`when:`,
  `loop:`, `with_*:`). Tokens immediately followed by `(` are
  excluded so function/macro calls do not count as variable uses.
- Routes every read through the project caches: `iter_project_files`
  (`lru_cache(maxsize=1)`), `read_text` (`lru_cache(maxsize=8192)`),
  and `load_yaml_any` (signature-keyed cache). No `yaml.safe_load*`
  bypass.
- Whitelists `ansible_python_interpreter` and `ansible_shell_executable`
  (Ansible connection-behavior keys consumed by the runner itself).
- Aggregates every undecidable variable into one
  `<file>:<line>: '<name>' is never referenced` line and fails once
  with the full list, so the reviewer sees the whole drift surface
  in a single run.

Surfaces seven existing dead declarations on first run (across
`sys-stk-front-proxy`, `test-e2e-playwright`, `web-app-discourse`,
`web-app-opentalk`, `group_vars/all/`); pruning them is left for
follow-up commits so this one stays purely the lint introduction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:17:19 +02:00
kevinveenbirkenbachandClaude Opus 4.7 e510a28938 test(lookups): skip lookup('config', …) calls that pass a default
`plugins/lookup/config.py` flips to `strict=False` when a 4th
positional `terms` element is present, so a missing path silently
falls through to the caller-supplied default. Such calls cannot fail
at runtime no matter what the static scan says about the path, so
treating them as lint targets is a false positive — the path is
intentionally optional.

Add a `_has_default_arg(text, scan_from)` helper that does a
character-level look-ahead from the end of the path literal to the
next significant character inside the same `lookup(...)` call. A `,`
indicates a 4th positional argument exists; a `)` (the call's closing
paren) indicates none does. The check is value-type agnostic — it
never inspects the default itself, so any literal (`False`, `''`,
`[]`, `{1: 2}`), variable reference, or sub-expression behind the
comma works.

`_emit_literal_match` invokes the helper after the comment- and
suppression-filters; default-bearing calls are dropped from the match
stream and therefore from all four downstream classifiers
(literal / variable / wildcard / role-local) in lockstep.
`_emit_concat_match` needs no change: `expr_to_wildcard_path` already
returns `None` whenever an unquoted comma appears in the expression
body, which is exactly the shape a concat-pattern call with a default
takes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:03:32 +02:00
kevinveenbirkenbach 94329614e4 Lint 2026-05-07 10:52:31 +02:00
kevinveenbirkenbachandClaude Opus 4.7 60c2de4e20 test(lint): assert every playwright.env.j2 key is consumed
New per-role lint that enforces two consistency rules between
`roles/<role>/templates/playwright.env.j2` and
`roles/<role>/files/playwright.spec.js`:

- A role that ships an env file MUST also ship a sibling spec file —
  otherwise the rendered variables go nowhere.
- Every `KEY=` declared in the env file MUST be consumed via
  `process.env.KEY` (or the bracket form) in some `.js` under the
  role's `files/` tree or under
  `roles/test-e2e-playwright/files/` (where shared helpers like
  `service-gating.js` live).

Both failure modes are aggregated into a single `self.fail` listing
all violations, so reviewers get the full drift picture in one shot.

Whitelist for keys that are legitimately not matched by a literal
`process.env.<KEY>` regex:

- `*_SERVICE_ENABLED` — `service-gating.js` reads them dynamically
  via `Object.keys(process.env)` plus a suffix strip.
- `NODE_TLS_REJECT_UNAUTHORIZED` — read by the Node runtime itself,
  not by user code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:25:23 +02:00
kevinveenbirkenbachandClaude Opus 4.7 c3e7eef34f test(lookups): scope role-local check to literal application_id
The role-local pass used to fire for every variable app argument,
which surfaced false positives for calls that explicitly target a
different role at runtime (`_BBB_COTURN_ROLE`,
`oauth2_proxy_application_id`, `NEXTCLOUD_HPB_TURN_STANDALONE_ROLE`).
Restrict the classifier to `app_arg == "application_id"` so only the
"this role reads its own config" shape is enforced; cross-role
lookups land in test_variable_paths' lax check, where they belong.

Document the new scoping in the `lookup-config-path` catalog entry so
contributors know cross-role calls need no marker for the role-local
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:11:58 +02:00
kevinveenbirkenbachandClaude Opus 4.7 91133381cb refactor(lookups): split scan from per-test classification
Make the lookup-config test package SRP-compliant. The scanner used to
own all four classifications (literal / variable / wildcard /
role-local), so changing one rule risked leaking into another.

- `_scan.py` now exposes only the qualifying match stream
  (`iter_matches() -> tuple[LookupMatch, ...]`) and the repo-derived
  `ScanContext`. It knows the regex and the comment / suppression
  filter; it does not know which test consumes which subset.
- Each `test_*.py` carries a private `_build_<name>_paths(matches, …)`
  builder that filters and groups the stream into its own collection.
  A classification rule change now stays inside one file.
- Performance unchanged: `iter_matches` keeps the
  `lru_cache(maxsize=1)` so all four classes share a single project
  walk; the per-test builders are O(N) in-memory iterations over the
  cached tuple.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:04:11 +02:00
kevinveenbirkenbachandClaude Opus 4.7 283b0141e5 test(lookups): add lookup-config-path suppression marker
- Wire the existing `noqa` / `nocheck` grammar into the lookup-config
  scanner via `utils.annotations.suppress.is_suppressed_at`. A
  marker in `same-or-above` position skips the call from every
  downstream check (literal / variable / wildcard / role-local) so
  the rule can never drift between the four test classes.
- Document `lookup-config-path` in the suppression catalog and add
  examples for both the `# nocheck:` (YAML / shell) and `{# nocheck: #}`
  (Jinja) comment forms.
- Apply the marker to the three legitimate self-referential calls in
  `roles/web-app-oauth2-proxy/templates/oauth2-proxy-keycloak.cfg.j2`,
  where `oauth2_proxy_application_id` resolves at runtime to the
  gated app — not to `web-app-oauth2-proxy` itself, which is what
  the static role-context inference would otherwise assume.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:45:53 +02:00
kevinveenbirkenbachandClaude Opus 4.7 c7612dec96 test(lookups): add per-role lookup('config', application_id, …) lint + split suite
- Add `test_role_local_literal_paths` to enforce that
  `lookup('config', application_id, '<path>')` calls inside
  `roles/<role>/...` resolve against `application_defaults[<role>]`
  instead of just "any role somewhere", which `test_variable_paths`
  was the only check for.
- Split the monolithic `test_config_paths.py` into one TestCase per
  concern under `tests/integration/lookups/config/`:
  literal / variable / wildcard / role-local. Shared scan and
  validation helpers live in `_scan.py` / `_validate.py`.
- Cache the project-wide scan via `functools.lru_cache(maxsize=1)`
  on `_scan.get_scan` so all four classes share a single pass; route
  every YAML read through `utils.cache.yaml.load_yaml_any` and
  replace the per-app O(n) `plugins.filter.get_role.get_role` calls
  with one `_build_role_for_app_map` walk (was O(n²) over
  `vars/main.yml`).
- Pre-filter the file walk by extension (`.yml`/`.yaml`/`.j2`) and a
  `"lookup" in text` substring check before the regex passes.
- Switch all four checks to a collected-failures pattern: each test
  reports a single aggregated failure listing every mismatch instead
  of emitting one subTest failure per occurrence.
- Allow `_*.py` filenames under `tests/` in the file-naming linter so
  the new shared helpers (`_scan.py`, `_validate.py`) pass discovery
  without being mistaken for test cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:37:47 +02:00
kevinveenbirkenbachandClaude Opus 4.7 095d1aa152 refactor(lint): forbid uncached Path.read_text/read_bytes in tests/lint
New repository lint catches `Path.read_text(...)` and `Path.read_bytes(...)`
calls inside `tests/lint/` test code, which bypass the LRU-cached
`utils.cache.files.read_text` and re-read the same project files for
every test that scans them. Routing through the cache shares one decoded
read per file across the whole pytest session.

Per-line opt-out: `# noqa: cache-read` for genuinely out-of-cache reads
(tempdirs, synthetic fixtures, files outside the project tree).
Documented in docs/contributing/actions/testing/suppression.md alongside
the existing `project-walk` marker.

All 16 pre-existing violations in 9 lint tests retrofitted onto the
cached helper — no opt-out markers used; every site was a legitimate
project-tree read. UnicodeDecodeError added to existing OSError except
clauses where applicable (the cache helper is UTF-8 strict, replacing
the lenient `errors="replace"` of the raw call).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:26:34 +02:00
kevinveenbirkenbachandClaude Opus 4.7 5689f5b636 refactor(lint): forbid multi-line PHP in *.sh + transform 3 violators
New repository lint catches multi-line `php -r '...'` and `php <<EOF`
bodies inside *.sh / *.bash files under roles/, scripts/, .github/.
The escape-layering of bash + (template/YAML) + PHP makes such blocks
brittle and quote-injection-prone. The fix is to drop the PHP body
into a sibling files/<name>.php and pipe it via stdin (preferred from
Ansible: `shell:` with `stdin: "{{ lookup('file', '<name>.php') }}"`).

Three pre-existing violations transformed by replacing the .sh wrapper
with one or more .php sidecars and rewriting the caller task:

  - web-app-espocrm: set_siteurl.sh -> set_siteurl.php
    Single shell task pipes the .php via stdin; ESPOCRM_URL forwarded
    through `compose exec -e` so the URL never lands in the PHP literal.

  - web-app-snipe-it: ldap.sh (wrapper + 2 PHP blocks) -> 3 ansible
    tasks + wait_settings.php + apply_ldap_config.php
    The bash retry-loop becomes Ansible retries/delay/until; LDAP env
    vars are forwarded via `compose exec -e` (not via task-level
    environment: on a wrapper script). APP_KEY is preserved for
    Laravel Crypt::encrypt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:26:34 +02:00
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
kevinveenbirkenbach b21e29659c Autoformat lint 2026-05-07 07:18:25 +02:00
kevinveenbirkenbachandClaude Opus 4.7 0c94d3e9db fix(inventory): seed oauth2_proxy_cookie_secret for dynamic-flag form
InventoryManager auto-generated the cookie_secret on a strict
``oauth2.get('enabled') is True`` check, which silently stopped
firing after the dynamic-flag migration replaced the literal True
with ``"{{ '<role>' in group_names }}"`` Jinja strings. Result:
every oauth2 / oidc consumer (web-app-prometheus, akaunting,
funkwhale, etc.) deployed without a cookie secret, and the
oauth2-proxy template hard-failed with
``Key 'oauth2_proxy_cookie_secret' not found``.

Replace the strict identity check with the same ``is_explicit_truth``
helper already used by ``resolve_service_dependency_roles_from_config``
and ``ServicesResolver``. Two unit tests cover the new behaviour:
the dynamic-Jinja form seeds the secret, and an explicit
``enabled: false`` skips it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:06:27 +02:00
kevinveenbirkenbachandClaude Opus 4.7 f2a600a313 fix(database): use per-type port-key in services.<type>.ports.local.<type>
The lookup expected ports.local.database, but svc-db-mariadb /
svc-db-postgres declare per-engine port-keys (mariadb:3306,
postgres:5432) to match the per-engine PORT_BANDS introduced in
1e94b14dd. Result: the resolved database_port came back empty, and
sys-svc-rdbms's seed-database-to-backup assertion blocked every
deploy that wired up a shared database.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:01:02 +02:00
kevinveenbirkenbachandClaude Opus 4.7 0cc48be9ae test(integration): variants coverage + no-self-reference guards for services.yml
Two new integration lints under tests/integration/roles/meta/:

* test_variants_coverage.py — every services.yml flag declared via
  the dynamic ``"{{ '<role>' in group_names }}"`` form MUST be
  exercised by meta/variants.yml: at least one variant entry pins
  it to literal ``true`` AND at least one pins it to ``false``.
  Pairs may share variant entries — the test only checks the union
  of all variants covers both polarities for every dynamic flag.
* test_services_no_self_reference.py — a role's own primary entity
  (matching ``get_entity_name(role)``) MUST NOT use the dynamic form
  for ``enabled`` / ``shared``. Both MUST be literal ``true``: the
  role's own services.yml is loaded only when the role itself is in
  group_names, so a ``'<self>' in group_names`` Jinja is tautological.
  The dynamic-flag test now skips the role's own entity to avoid a
  contradiction with this new guard.

Self-reference fixes (Jinja → literal ``true: true``) on 19 roles:
svc-ai-ollama, svc-db-{mariadb, openldap, postgres}, web-app-{
dashboard, discourse, keycloak, mailu, matomo, prometheus},
web-svc-{asset, cdn, collabora, coturn, file, libretranslate, logout,
onlyoffice, simpleicons}.

Variants coverage rollout (62 new + 17 merged meta/variants.yml):

* Roles without a pre-existing variants.yml get a new file with two
  entries — one pinning every dynamic flag true, one pinning every
  dynamic flag false. No ``- {}`` baseline (matrix runs only the
  two coverage variants).
* Roles with a pre-existing variants.yml (akaunting/baserow/bluesky/
  bookwyrm/flowise/fusiondirectory/jenkins/jira/joomla/keycloak/lam/
  minio/phpmyadmin/postmarks/wordpress/web-svc-xmpp) keep their
  matrix variants (OIDC vs LDAP, Single-Site vs Multisite, dev creds
  + TOTP-disabled realm, autologin, etc.); the all-true coverage is
  deep-merged into variant 0 and the all-false coverage into
  variant 1, so each role still ships exactly two matrix rounds.
* svc-ai-ollama keeps its single CI image-shrink variant; after the
  self-reference fix it has no remaining dynamic flags so no extra
  coverage entry is required.
* svc-db-{mariadb, openldap, postgres} drop variants.yml entirely
  for the same reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:20:46 +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 b73f92d551 fix(meta): make run_after deps explicit via shared services
Resolve every violation flagged by tests/integration/roles/dependencies/
run_after/test_services_explicit.py: each run_after entry must be
backed by an enabled+shared services flag in the dependent role.

- Activate oauth2 (enabled+shared=true) where the role consumes the
  Keycloak-fronted oauth2-proxy: friendica, funkwhale, fusiondirectory,
  gitea (allocate oauth2 ports 16501 friendica, 16502 funkwhale).
- Flip oauth2.shared / oidc.shared / ldap.shared to true where the
  service is actually consumed: akaunting, baserow, bluesky, bookwyrm,
  flowise, jenkins, joomla, web-svc-xmpp.
- Drop run_after entries to providers whose service is not enabled:
  web-app-keycloak from confluence, jira, magento, matrix, shopware,
  web-svc-xmpp; web-app-matomo from fusiondirectory, matrix;
  web-app-opencloud from opentalk; desk-gnome from
  desk-gnome-caffeine/-extensions/-terminal.
- Re-flow a continuation in test_services_explicit.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:17:05 +02:00
kevinveenbirkenbachandClaude Opus 4.7 04c5eee5a9 test(integration): guard run_after entries with explicit services declarations
Future direction: the inventory builder is moving away from
``run_after`` as a dependency-injection mechanism. ``run_after``
keeps its ordering-only role; "what gets co-deployed with this
role" is decided by the per-role ``services`` map. This test
catches roles whose ``run_after`` smuggles a dep in that no
``services.<key>`` entry covers.

Per ``run_after: [Y]`` the consumer MUST also declare a
``services.<key>`` entry where ``<key>`` is either Y's primary
service-registry key or one of the explicitly listed transitive
equivalents (e.g. ``services.oauth2`` covers
``run_after: [web-app-keycloak]`` since oauth2-proxy itself
depends on Keycloak), with both ``enabled`` and ``shared`` set to
``true`` or to a Jinja conditional containing
``in group_names``.

Currently fails for ~22 roles whose run_after entries pull in
deps without an explicit services flag. Fixing each is a
mechanical migration tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:51:24 +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
kevinveenbirkenbach 73302cb992 Merge branch 'main' into feature/opencloud-opentalk-iam 2026-05-06 16:56:59 +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
kevinveenbirkenbachandClaude Opus 4.7 2a582d3aec feat(ci): diff-driven app whitelist for deploy tests
Narrow the test-deploy-{server,universal,workstation} matrix to roles
actually impacted by the branch's diff against origin/main, expanded
transitively over run_after + dependencies + shared services.

Why: full-matrix deploy CI on every PR is wasteful; explicit per-PR
whitelisting is error-prone. The resolver derives the affected set
automatically while remaining fail-safe.

Behaviour (in resolve_effective_whitelist.sh precedence order):
- whitelist input "__ALL__"  -> empty whitelist (force full deploy)
- whitelist input non-empty  -> verbatim
- whitelist input empty      -> diff vs origin/main
  - empty diff or any path outside roles/<role>/   -> __ALL__
  - all paths under roles/<role>/                  -> reverse closure

Fail-safe: any seed that is not modellable in CombinedResolver
(no application_id and not referenced by any role's run_after) and
any resolver runtime error MUST trigger a fall-back to __ALL__.
The closure never silently shrinks the deploy matrix.

test-deploy-local.yml is intentionally exempt (local/manual dispatch
keeps explicit whitelist semantics).

Documented in pipeline.md and workflows.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 16:56:58 +02:00
kevinveenbirkenbachandClaude Opus 4.7 0eebe3de7e test(yaml): route fixture writes through utils.cache.yaml.dump_yaml
The lint test test_no_direct_yaml_calls forbids direct yaml.safe_dump
calls — fixtures must go through utils.cache.yaml so the cache stays
consistent with on-disk state. Replace the three yaml.safe_dump call
sites in tests/unit/cli/meta/applications/resolution/affected/test_main.py
with dump_yaml(path, data) and drop the now-unused yaml import.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 16:56:21 +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