Commit Graph
14682 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 4.7 60f7d1a211 fix(web-svc-xmpp): declare HTTP 404 on / as healthy
ejabberd's HTTP listener binds only the explicit request_handlers
declared in configuration.yml.j2 (/admin, /api, /.well-known/host-meta,
/bosh, /ws). Bare `/` is intentionally unmapped — ejabberd responds
with HTTP 404 by design, while the actual XMPP traffic flows over
TCP 5222/5269 and is not HTTP-testable.

sys-ctl-hlth-webserver applies its DEFAULT_OK = [200, 302, 301] when
an app does not override `server.status_codes`, so the role's HTTPS
front fails the post-deploy health gate with `Got 404` and
sys-service-terminator aborts the deploy (job 77602110559 on run
26362676803).

Override `status_codes.default` to [404] — consistent with the role's
own Playwright baseline at roles/web-svc-xmpp/files/playwright/
playwright.spec.js, which asserts `/admin/` returns < 500 (not /),
and with the pattern already used by web-svc-html (403/200),
web-app-prometheus (200/302/405), web-app-jenkins (200/302/403) etc.

Failure modes remain detectable: ejabberd down still surfaces as 502
from nginx, which the [404]-only expectation rejects.

Static check only (per operator request).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:31:02 +02:00
kevinveenbirkenbachandClaude Opus 4.7 507d8aeea9 fix(tls/resolve_term): treat www.X as implicit alias of X
`generate_all_domains(include_www=True)` synthesizes `www.X` for every
canonical domain X and pushes them into CURRENT_PLAY_DOMAINS_ALL, but
they are never registered in the global canonical_domains_map or in
applications[*].server.domains. The strict `tls`/`cert` lookups
therefore raised `domain 'www.X' not found (domains/applications)`
when web-opt-rdr-www iterated REDIRECT_WWW_DOMAINS and called
`lookup('tls', 'www.X', 'enabled')` in the redirect-domain.yml TLS
gate — failing job 77602110568 on run 26362676803 (and cascading
through sys-front-tls/vars/main.yml, sys-front-tls/tasks/02_http.yml
and sys-front-tls-selfsigned/tasks/01_core.yml, all of which call
`lookup('tls', application_id, …)` with the same leaked www.X).

Add a single fallback branch to `resolve_term`: if the strict lookup
fails AND the term starts with `www.`, retry both resolver paths
(legacy reverse + applications) against the bare domain. The fix is
strictly additive — every previously successful term and every non-www
miss preserves its current behavior.

Coverage:
* test_www_prefix_falls_back_to_bare_domain
* test_www_prefix_with_unknown_bare_still_raises
* test_non_www_unregistered_still_raises (regression guard)

Verified statically via the in-container `resolve_term`:
  www.infinito.example          -> (svc-prx-openresty, infinito.example)
  www.rdr-www.infinito.example  -> (web-opt-rdr-www, rdr-www.infinito.example)
The global self-signed cert SAN list (cert.py:193) is built from
CURRENT_PLAY_DOMAINS_ALL, so it already covers the www.X variants —
nginx serves the redirect vhost with that cert and the TLS handshake
on the hlth-webserver check matches.

Static check only (per operator request).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:22:15 +02:00
kevinveenbirkenbachandClaude Opus 4.7 cd7d0e29f2 fix(web-svc-html,web-svc-file): rebind {{ domain }} from front_proxy_domain
In variant 1, sys-stk-front-base loads web-opt-rdr-domains for the
redirect set; its `include_tasks` loop sets `domain` from each
`item.source`. Ansible's include_tasks vars precedence (22) outranks the
task-level `vars:` block (17) on the subsequent "Deploy NGINX config"
task in sys-stk-front-proxy, so role-specific proxy.conf.j2 templates
that reference `{{ domain }}` directly render with the leaked redirect
source instead of the deployed app's domain. nginx then has no server
block for the intended SNI → sys-ctl-hlth-webserver fails with
TLSV1_UNRECOGNIZED_NAME and the deploy aborts at sys-service-terminator.

The generic vhost template `sys-svc-proxy/templates/vhost/basic.conf.j2`
already carries the canonical workaround (lines 1-2):

    {% set application_id = front_proxy_application_id | default(application_id) %}
    {% set domain = front_proxy_domain | default(domain) %}

Apply the same rebind to the role-specific proxy.conf.j2 of web-svc-html
(both rebinds missing) and web-svc-file (application_id rebind already
present, domain rebind missing). `front_proxy_domain` is set_fact-frozen
at the start of sys-stk-front-proxy from the outer load_app context and
verified correct in the run log (line 390447, html job in variant 1).

Unblocks 4/5 cluster-2 jobs on run 26362676803:
  * web-svc-html (77602110571) — direct
  * web-svc-file (77602110558) — direct
  * web-svc-asset (77602110556) — variant-1 bundle [asset, file]
  * web-svc-legal (77602110552) — variant-1 bundle [legal, html]

web-svc-xmpp (77602110559) has a separate failure mode (HTTP 404, not
SNI mismatch) and is not addressed here.

Static check only (per operator request).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:22:14 +02:00
kevinveenbirkenbachandClaude Opus 4.7 e47d0782e8 fix(sys-front-inj-all,sys-stk-front-proxy): scope required_by to web-app + web-svc
Both roles declared `required_by: categories: [web]`, which the
called-services verifier matches against `categories_of(role_id)` =
{top-level, top+subcat} — so `web-opt-rdr-domains` (top: web, sub:
web-opt) triggered the contract even though its 301-redirect vhost
template is self-contained and intentionally bypasses both the front
proxy stack and the global injection layer.

Narrow the contract to the two subcategories that actually render
proxy.conf.j2 and depend on the inj-all server/location snippets:
web-app and web-svc. Verified against the live `required_role_ids()`:
web-app-bluesky and web-svc-asset still mark both roles required;
web-opt-rdr-domains no longer does.

Unblocks job 77602110578 on run 26362676803. Static check only (per
operator request).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:31:30 +02:00
kevinveenbirkenbachandClaude Opus 4.7 82d49fccf2 fix(web-app-opentalk): make coturn optional per variant matrix
meta/variants.yml already deploys variants 1 and 2 without web-svc-coturn,
but controller.toml.j2 rendered [turn]/[stun] unconditionally and
vars/main.yml evaluated `lookup('config', 'web-svc-coturn', 'credentials.
auth_secret')` for those variants too. Since coturn is out of scope there,
its schema-declared `credentials.*` are unset and the lookup raises in
"Render OpenTalk controller config" (job 77602110542 on run 26362676803).

Gate the two TOML sections on a new `OPENTALK_TURN_ENABLED` flag that
reads opentalk's own `services.coturn.enabled`. The cross-role
`OPENTALK_TURN_HOST/PORT/SECRET` lookups remain lazy in vars/main.yml —
controller.toml.j2 is their only consumer and now skips them when
TURN_ENABLED is false.

Static check only (per operator request).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:24:53 +02:00
kevinveenbirkenbachandClaude Opus 4.7 47f7fb745f fix(web-svc-libretranslate/csp): allow inline script-src-elem
LibreTranslate's landing page inlines a `<script>` at line 90 to wire up
the language picker and translate form. Without `'unsafe-inline'` in
script-src-elem, sys-ctl-hlth-csp reports
`[CSP DOM] Blocked URI: inline` on libretranslate.infinito.example and
aborts the deploy in variants that disable the css/cdn injectors
(variant 1+). Same class as 27e2574d4 (bluesky) and 66745645e
(fusiondirectory).

Static check only (per operator request); pattern parity with the two
prior CSP fixes that were verified in-container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:24:52 +02:00
kevinveenbirkenbachandClaude Opus 4.7 c2d068d09e feat(playwright_summary): sort table rows ascending by time
Flatten records from all summaries and sort by ISO-Z timestamp so the
step-summary table is chronological across apps/variants/passes instead
of grouped per file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:00:44 +02:00
kevinveenbirkenbach 66745645e3 fix(web-app-fusiondirectory/csp): allow inline script-src-elem
FusionDirectory's breezy theme inlines a `<script>` block on the login
page to drive form validation. Without `'unsafe-inline'` in
script-src-elem, sys-ctl-hlth-csp reports
`[CSP DOM] Blocked URI: inline` on fd.infinito.example and aborts the
deploy in variants that disable the css/cdn injectors (variant 1+).

Verified in-container by hand-patching the rendered nginx vhost,
reloading nginx, and re-running hlth-csp: fd.infinito.example reports
`No CSP or network blocks detected`.
2026-05-25 13:42:54 +02:00
kevinveenbirkenbach a90b3f15a6 fix(web-app-gitlab): pin 19.0.0-ee.0 and patch redis autoload race via custom Dockerfile
GitLab 19.0.0-ee.0 puma crash-loops with
`NameError: uninitialized constant Gitlab::Redis::ALL_CLASSES` whenever
omniauth is not configured. The instrumentation/redis.rb autoloads
before lib/gitlab/redis.rb has the chance to define the constant; with
OIDC enabled the omniauth gem eager-loads gitlab/redis first and
silences the race. Without OIDC (variant 1/2) the race is deterministic
and the deploy aborts at hlth-webserver because lab.git returns 502.

- New plain `files/Dockerfile` prepends `require_relative "../redis"`
  to `lib/gitlab/instrumentation/redis.rb`. Image/version arrive via
  `ARG GITLAB_IMAGE` / `ARG GITLAB_VERSION` forwarded from compose
  `build.args`, so `meta/services.yml` stays the single source of truth.
- `templates/compose.yml.j2` now builds `GITLAB_CUSTOM_IMAGE` locally
  instead of pulling, with `pull_policy: never` and `build.args`.
- `vars/main.yml` adds `entity_name` and `GITLAB_CUSTOM_IMAGE`.
- `meta/services.yml` pins GitLab to `19.0.0-ee.0` (current latest
  stable on Docker Hub) to make the patch reproducible.

Verified in-container: hand-applying the same require_relative makes
puma boot listening; `INFINITO_VARIANT=1 make compose-deploy
mode=update apps=web-app-gitlab` builds the patched image and Playwright
passes 2 / 4 skipped for gitlab variant 1.
2026-05-25 13:42:54 +02:00
kevinveenbirkenbach 27e2574d42 fix(web-app-bluesky/csp): allow inline script-src-elem and *.workers.bsky.app
The bluesky social-app bootstraps via an inline script tag and pulls
runtime config from `app-config.workers.bsky.app` and live events from
`live-events.workers.bsky.app`. Without these CSP relaxations the page
loads under variants that disable the css/cdn injectors (variant 1+),
sys-ctl-hlth-csp reports `[CSP DOM] Blocked URI: inline` on
script-src-elem and `Blocked URI: https://*.workers.bsky.app/config` on
connect-src, and the final-run service aborts the deploy.

Verified in-container by hand-patching the rendered nginx vhost,
reloading nginx, and re-running hlth-csp: web.bluesky.infinito.example
reports `No CSP or network blocks detected`.
2026-05-25 13:42:53 +02:00
kevinveenbirkenbach 64a82ccdc7 fix(web-app-fusiondirectory/playwright): assert FD login form post-OIDC, not body visibility
FD has no native OIDC support; after the oauth2-proxy + Keycloak chain
settles, the user lands on FD's own breezy-themed login form. The
previous `body.toBeVisible()` assertion was flagged as hidden by the
theme's FOUC-prevent CSS, masking the otherwise-successful redirect
chain. Target a stable FD-rendered marker (the form's "Sign in" button)
so the OIDC variant-0 test proves what it intends to.
2026-05-24 15:33:17 +02:00
kevinveenbirkenbach 7891bb4784 Merge branch 'feature/web-app-kix' of https://github.com/kevinveenbirkenbach/infinito-nexus-core into feature/web-app-kix 2026-05-24 13:13:54 +02:00
kevinveenbirkenbachandClaude Opus 4.7 c26c514529 fix(web-app-fusiondirectory/env): match upstream LDAP01_* naming
The nfrastack/fusiondirectory image (1.5-3.0.0) initializes LDAP
profiles in `/container/functions/40-fusiondirectory` via
`create_profile(i)` with `i=$(printf "%02d" $i)`, so profile 1 is
expected as `LDAP01_*`. The env.j2 template was rendering `LDAP1_*`
(single digit), which the init script never consumed:

  $ docker logs fusiondirectory
  ** [ERROR] ** [fusiondirectory] No 'Server Name' Entered\! - Set '$LDAP01_NAME'
  ** ERROR - Some initialization scripts haven't completed:
            40-fusiondirectory

The container then ran without an LDAP binding, so the application
itself returned `FATAL: Error when connecting to LDAP server
"ldap://openldap:"` (with a dangling colon because `LDAP01_PORT`
was also missing).

In variant 0 the failure stayed invisible because oauth2-proxy
fronts the fd vhost and intercepts every unauthenticated request
with a redirect to Keycloak — the reachability probe got `200 OK`
from the proxy even though the upstream was dead. Variant 1
disables oauth2, the probe hits fd directly, and the matrix-deploy
aborts with `502 Bad Gateway`.

Two changes:

- rename `LDAP1_*` to `LDAP01_*` for every LDAP env-var the role
  exposes, matching the image's zero-padded convention.
- add the missing `LDAP01_PORT` from `svc-db-openldap`'s
  `services.openldap.ports.local.ldap` so the rendered URI carries
  a port (`ldap://openldap:389` instead of `ldap://openldap:`).

Verified in-container: after patching the live `.env` with the new
names + port and `docker compose up -d --force-recreate
fusiondirectory`, the container reports `healthy` and
`wget https://fd.infinito.example/` returns `200 OK` with
`<title>FusionDirectory - Sign in</title>`. Before the fix the
same probe was `502` (variant 1) or a Keycloak login page hiding
the broken upstream (variant 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:13:54 +02:00
kevinveenbirkenbachandClaude Opus 4.7 29377e1bd3 fix(cache/applications): stop variant-0 service leak via base-config defaults
`_build_application_defaults` returned variant 0 of every role with a
meta/variants.yml, which then deep-merged with the variant-N override
that build_dev_inventory bakes into the inventory. Any service flag
that variant N did not explicitly disable carried through from
variant 0 into the runtime applications map. The most visible casualty
was svc-bkp-container-2-local variant 1 (postgres + mariadb only),
where gitea + keycloak leaked in via variant 0, sys-service-loader
pulled in svc-db-openldap via keycloak.services.ldap, and the deploy
crashed in sys-svc-compose/04_files.yml on openldap's env.j2 with
"credentials is defined in schema at roles/svc-db-openldap/meta/schema.yml
but not set in application 'svc-db-openldap'".

Defaults now come from each role's variant-free base config
(meta/<topic>.yml plus schema-defaults, no variants overlay). Variants
stay accessible via get_variants for any caller that explicitly needs
a specific variant. Inventory-level applications.<app> overrides win
exactly as before.

Supporting changes that keep the variant matrix consistent under the
new defaults model:

- New lint guard tests/integration/roles/meta/variants/
  test_non_baseline_explicit_disables.py: for every dynamic
  service-key declared in a role's meta/services.yml that variant 0
  pins to literal true, every non-baseline variant MUST explicitly
  pin the same key to either literal true or to literal
  enabled: false + shared: false. Statically-enabled keys are exempt
  (same rule as test_auth_isolation.py).
- roles/web-app-{fusiondirectory,lam,nextcloud,phpldapadmin}/meta/
  variants.yml: explicit `false` pins for dashboard / email / logout /
  matomo / prometheus / talk in restrictive variants so deep-merge no
  longer hands them the deleted variant-0 inheritance.
- roles/web-app-moodle/templates/playwright.env.j2: gate the LAM
  block on `lookup('config', application_id, 'services.lam.enabled',
  false)`. LAM is a transitive helper, not a moodle-owned service, so
  the default-bearing lookup tolerates the missing path when the
  variant does not deploy LAM.
- tests/unit/utils/cache/test_variants.py: rename the legacy
  "always variant zero" coverage to TestMergedApplicationsUsesBaseConfig
  and add test_get_application_defaults_omits_variant_zero_only_keys
  as the regression guard for the leak this commit fixes.
- docs/contributing/artefact/files/role/variants.md +
  docs/contributing/design/variants.md: variant 0 now manually
  enumerates every dynamic services.yml key with
  enabled: true + shared: true; `{}` is only permitted when the role
  has no dynamic-enabled service-key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:04:21 +02:00
kevinveenbirkenbachandClaude Opus 4.7 ee01f331a8 docs(variants): document variant-aware credentials generation
design/variants.md gains a "Credentials Generation" section that
explains both flows the credential machinery can take:

- CI/CD path: planner picks the round's variants -> build_dev_inventory
  emits --app-variants -> provision CLI forwards --variant -> credentials
  CLI hands the index to InventoryManager, which resolves the root
  role's variants.yml overlay so resolve_schema_includes_recursive
  sees the variant-merged services map and discovers every shared
  provider that the variant enables.
- Standalone path: any caller that omits --app-variants / --variant
  gets variant=None, InventoryManager skips the overlay, and shared-
  provider discovery walks the base meta config only.

Also adds four reference-table rows for the involved files and fixes
two unrelated wording issues in the surrounding text:

- "every dep round R needs MUST be redeployed" -> grammatical fix.
- "historical payload" / "rename the file back" -> drop historical
  framing per documentation.md's current-state-only rule.

artefact/files/role/variants.md gains a short "Credentials
Interaction" section that anchors back to the design doc so a role
author setting services.<key>.enabled+shared in a variant knows that
the credential generator will pick the provider up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:04:20 +02:00
kevinveenbirkenbachandClaude Opus 4.7 a10555cd08 feat(inventory): variant-aware credentials generation
InventoryManager.resolve_schema_includes_recursive previously read the
root role's base meta/services.yml when discovering shared providers,
regardless of which matrix variant the planner had pinned for the
round. For a role whose variants.yml enables additional services per
variant (e.g. svc-bkp-container-2-local variant 2 enabling
services.ldap), the base config had no `enabled+shared` flag for those
services, so the recursive resolver never visited the variant's
shared providers — their credentials were never generated, and the
deploy crashed in the provider's env.j2 with "credentials is defined
in schema at roles/<provider>/meta/schema.yml but not set in
application '<provider>'".

Wires the active variant through the existing inventory pipeline:

- utils/manager/inventory.py: InventoryManager(variant=None|int). When
  set, load_role_config_by_path overlays variants.yml[N] onto the root
  role's meta config (only for the root — transitive providers keep
  their own base lookup). variant=None preserves the pre-refactor base
  behavior for every standalone caller.
- cli/administration/inventory/credentials: new --variant N flag,
  forwarded to InventoryManager.
- cli/administration/inventory/provision: new --app-variants <JSON>
  flag ({app_id: variant_index}), forwarded per-app to the credentials
  subprocess.
- cli/administration/deploy/development/inventory/builder.py: emits
  --app-variants from spec.variant_selectors() when the round pins any
  variant. Apps without variants.yml collapse to a single empty
  overlay in get_variants so this is a no-op for them.

10 unit tests cover: variant overlay for root role, variant=None
fallback to base, out-of-range fallback, no overlay on non-root
paths, --variant CLI forwarding (set + default), --app-variants
generator forwarding (set + omit), builder --app-variants emission
(set + omit).

Caveat: bkp-c2l CI variant 1 still loads svc-db-openldap transitively
at runtime (outside the planner's variant closure) — a separate
sys-service-loader / role-meta bug, not addressed here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:04:20 +02:00
kevinveenbirkenbachandClaude Opus 4.7 5e39f4958f fix(test-urls): accept HTTP 415 as reachable
keepass.info responds with 415 Unsupported Media Type to the probe's
GET; the previous 25a77bc7e widening landed 418/429/451 but missed 415.
Treat 415 like 401/403/405 (server alive, request format rejected).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:04:19 +02:00
kevinveenbirkenbachandClaude Opus 4.7 183f41845b refactor(env): SPOT pkgmgr base image via handler, drop slim variant
default.env decomposed into INFINITO_PARENT_IMAGE_{OWNER,TAG,VARIANT}
atoms. New utils/env/handlers/infinito_parent_image.py owns the URL
composition `ghcr.io/{owner}/pkgmgr-{distro}:{tag}` as the single
definition site; scripts/image/{push,build}.sh consume the handler-
emitted INFINITO_PARENT_IMAGE from .env instead of inlining their own
format string.

Side effect: `compose up --build` now picks the parent image that
matches INFINITO_DISTRO. Previously default.env's hardcoded
pkgmgr-arch:stable won regardless, so debian-named containers were
silently built FROM the arch base. scripts/image/build.sh already
overrode per-distro, so its behavior is unchanged.

Other cleanups landed in the same pass:
- Slim variant removed end-to-end (Dockerfile stage, --variant CLI
  flag, slim suffix logic). Zero in-repo callers used it.
- compose.yml build-args INFINITO_PARENT_IMAGE / INFINITO_SRC_DIR now
  carry the same `:?Run 'make dotenv'…` guard as the runtime entries,
  so a missing .env fails fast with a clear message instead of with
  buildkit's `base name should not be blank`.

Note: fork-CI (pull_request_target trusted-helpers path) will stay red
until main's scripts/image/push.sh and scripts/meta/wait/images.sh
adopt the INFINITO_* names too — that is the deliberate cost of
keeping a single SPOT instead of carrying a bridge alias.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:44:50 +02:00
kevinveenbirkenbachandClaude Opus 4.7 ce3894ad7f fix(hlth-csp): re-bump csp-checker to 2.1.2
274e4fd6f added required_by on the csp lifecycle entry and inadvertently
reset csp-checker.version back to 2.1.1, undoing the bump from 378fe140f.
Restore 2.1.2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 09:40:08 +02:00
kevinveenbirkenbachandClaude Opus 4.7 df87bbe649 fix(variants): drop DB-disable overrides in auth-isolated variants
Commit f63118f34 ("feat(lint): enforce auth-isolation in
variants.yml") added `enabled: false, shared: false` overrides for
mariadb / postgres to every auth-isolated LDAP-only variant. That
disables the role's hard-required DB at deploy time, so e.g. the
moodle baseline blows up with `Required env var MOODLE_DB_HOST is
not set` as soon as round 2 of the matrix hits variant 1.

The auth-isolation rule's premise — "auth-isolated rounds must trim
non-auth services from the include closure" — only buys closure
savings for services whose `meta/services.yml` base declares
`enabled` as a dynamic Jinja expression. DB-engine entries are
already a static `enabled: true`, so the resolver pulls them in
regardless of the variant pin; the variant `false` only breaks the
deploy without trimming anything.

Two changes:

- `test_auth_isolation`: exempt service-keys whose `services.yml`
  base has a static literal `enabled: true` from the per-variant
  pin obligation. Such keys should not be declared in auth-isolated
  variants at all; the inherited base value is correct.
- 10 web-app roles in the active deploy scope (akaunting, baserow,
  bookwyrm, espocrm, friendica, gitea, moodle, nextcloud, odoo,
  openproject): drop the entire mariadb/postgres stanza from the
  LDAP-only variant — both `enabled` and `shared` inherit from
  `meta/services.yml`. Matches the pre-f63118f34 state.
- 6 more roles out of the current iteration scope (funkwhale,
  opentalk, shopware, snipe-it, suitecrm, taiga) carry the same
  broken `enabled: false` line: removed it, kept the pre-existing
  `shared: false` to preserve the variant's own-DB-container
  topology intent.

`make test` is green; `variants-auth-isolation` and
`variants-static-override` both pass with no per-variant nochecks
required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:31:39 +02:00
kevinveenbirkenbachandClaude Opus 4.7 3ba030054b fix(web-app-moodle/playwright): repair LAM write-through verifier
Five interacting bugs kept `test-lam-writethrough.js` red across
all retries on the live stack:

- URLs hit `/templates/...` but LAM is mounted at the Apache alias
  `/lam/`, so the post-Keycloak goto landed on a 404.
- Password fill used `LAM_PASSWORD` (LAM master), but LAM with
  `loginMethod=list` expects the selected admin's LDAP bind cred.
  Adds `LDAP_ADMIN_PASSWORD` to `playwright.env.j2` (gated behind the
  existing LAM service-enabled `{% if %}`) and prefers it over the
  master password.
- Login-form input selector also accepts `name='passwd'` (LAM's
  actual HTML), not only `name='password'` / `id='passwd'`.
- User-list filter selector restricted to `input[type='text']` so it
  no longer matches the `apply_filter` submit button.
- biber-row locator targets the LAM Edit anchor (the username cell
  is a plain `<td>`, not a link), then the assertion checks the
  `input[name='initials']` value via `toHaveValue` instead of the
  body's textContent (the probe lives in the input value).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:31:38 +02:00
kevinveenbirkenbachandClaude Opus 4.7 5cd2800662 feat(web-app-moodle/playwright): drive LAM verifier through OAuth2 when fronted
Replace the LAM_OAUTH2_FRONTED skip predicate with a branched login
flow: when LAM is fronted by oauth2-proxy/Keycloak, click through the
Keycloak login form with the administrator credentials (already in
LAM's allowed_groups via meta/services.yml), then fall back to LAM's
own admin form only if it still appears post-SSO. Native path
(LAM_OAUTH2_FRONTED=false, lam-public profile with LDAP-bind auth) is
unchanged.

Move adminUsername/adminPassword from _shared.js module scope into
shared.env so the LAM verifier can read them (previously single-
consumer to beforeEach, now multi-consumer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:29:51 +02:00
kevinveenbirkenbachandClaude Opus 4.7 2cec31ec5c feat(web-app-lam): register as service provider; opt moodle v0/v1 in
Add enabled/shared/provides: lam to web-app-lam's primary service block
so consumer roles can declare services.lam.{enabled,shared} and have
the variant-aware deploy planner resolve to web-app-lam. Align tasks/
main.yml with the run_once_<slug> guard pattern (split entry into
tasks/01_core.yml that opens with the utils/once/flag.yml include).

In web-app-moodle, opt variants 0 (full stack) and 1 (LDAP-only) into
the new service via meta/variants.yml — both variants where ldap is
enabled. Per request keep lam out of moodle's meta/services.yml; the
variant overrides therefore carry the only declaration and need
# nocheck: variants-services-match markers. playwright.env.j2's LAM
guard and LAM_SERVICE_ENABLED switch to the same lookup-based config
flag as the other service gates (matomo, email, ldap, …) for uniform
plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:29:50 +02:00
kevinveenbirkenbachandClaude Opus 4.7 bbc35562f3 refactor(web-app-moodle/playwright): split spec into per-scenario modules
Mirror the nextcloud playwright layout: orchestrator + _shared.js for
multi-consumer logic, one test-*.js per scenario (baseline, ldap-variant,
profile-readonly, keycloak-scope, lam-writethrough, administrator-persona,
guest-persona). Extract the duplicated Account REST middleName-write
into setMiddleNameViaAccountRest in _shared.js, parametrized by
withRestore so keycloak-scope drives the round-trip+restore path and
lam-writethrough drives the write-only path.

Add LAM_OAUTH2_FRONTED to playwright.env.j2 and gate the LAM UI verifier
on it. With web-app-keycloak deployed, LAM is fronted by oauth2-proxy,
its native /templates/login.php is replaced by the Keycloak login page,
and the LDAP-bind admin password is unusable. The Keycloak-side write
is still verified end-to-end by keycloak-scope's main test.

Net diff -20 lines vs. the original monolith despite the new helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:59:47 +02:00
kevinveenbirkenbachandClaude Opus 4.7 79650fcb11 fix(svc-db-openldap): persist /etc/ldap/slapd.d so custom schemas survive recreate
Mount the (already-declared) config volume into the slapd dynamic
config directory. Without this, container recreate wipes cn=config
and the schemas added by tasks/03_schemas.yml (nextcloud, openssh-lpk,
moodle) vanish — but user entries in /var/lib/ldap still reference
their objectClasses, so every subsequent LDAP modify fails with
"unrecognized objectClass" until the schemas are re-injected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:59:47 +02:00
kevinveenbirkenbachandClaude Opus 4.7 5cce5aeeef fix(container_depends_on): pass extra depends_on entries via lookup kwarg
The compose templates appended additional depends_on entries (e.g. taiga
rabbitmq services, openproject cache/seeder, mailu resolver/front) on
column 6 directly after the lookup call. When the lookup returned ""
(no DB and no redis), those entries dangled without a depends_on header
and broke YAML parsing.

Move every such trailing block into the lookup's `extra` kwarg so the
header is always emitted as soon as any entry exists. Adds a regression
test for the extras-only case.

Affected: taiga, mailu, magento, pixelfed, openproject, espocrm.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:55:39 +02:00
kevinveenbirkenbachandClaude Opus 4.7 fcd4797893 fix(hlth-webserver): accept by-design status codes for prometheus, jenkins
- prometheus: declares 405 in status_codes; HEAD is not implemented by
  prometheus, the 405 is a stable liveness signal.
- jenkins: declares 403 in status_codes; root requires auth (no anonymous
  Overall/Read), so 403 is the documented response for an anonymous HEAD.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:43:19 +02:00
kevinveenbirkenbachandClaude Opus 4.7 c54895dba2 refactor(depends_on): replace dmbs_*/dbms_base templates with container_depends_on lookup
Eliminate include-style depends_on assembly. The lookup resolves DB/redis
inductively from applications config and returns indented YAML (or "" when
no entries), removing the empty `depends_on:` block bug in libretranslate.

Migrates 43 compose templates from `{% include … dmbs_*.yml.j2 %}` to
`{{ lookup('container_depends_on', application_id) }}`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:43:19 +02:00
kevinveenbirkenbachandClaude Opus 4.7 5ab4f45232 fix(sys-svc-container/depends_on): guard dmbs_incl when no local DB or redis
dmbs_incl unconditionally emitted `depends_on:` and then included
dbms_base, which the earlier `local`-flag fix had made conditional. If
a role using dmbs_incl ends up in a variant that disables both the
local DB and redis (e.g. taiga variant 2), dbms_base produces no
entries and the bare `depends_on:` left an empty block-mapping —
`compose -f compose.yml` rejected it with "did not find expected key".

Mirror the dmbs_excl guard so the `depends_on:` header is only emitted
when at least one entry will follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:43:18 +02:00
kevinveenbirkenbachandClaude Opus 4.7 5566cc5b48 chore(web-app-odoo/csp): mark google fonts CDN URLs as nocheck
The base URLs (https://fonts.googleapis.com, https://fonts.gstatic.com)
return 404 on direct GET — they only serve content under specific paths.
The URL reachability test treats them as failures; suppress with the
existing `# nocheck: url` marker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:43 +02:00
kevinveenbirkenbachandClaude Opus 4.7 fc2ac7597a refactor(lookup): rename status -> deployment, add runtime-aware deployed
`status` was too vague for what the lookup returns (apps in current
play). Rename to `deployment` and add two new fields:

* `runtime` — RUNTIME variable value
* `deployed` — runtime-aware authoritative set: equals `running`
  (whitelist or groups) for ephemeral CI runtimes (act, github),
  falls back to `groups` for persistent runtimes (host, dev). This
  matches reality: in CI the container is fresh so only whitelisted
  apps exist; in production a partial deploy still leaves prior
  apps live, so groups is the right set.

Switch the two webserver/csp healthchecks to `lookup('deployment').deployed`
so they only probe domains for apps that actually exist in the current
deploy. In production behaviour is unchanged.

Update remaining consumers (cli/build/include, web-app-keycloak,
test-e2e-playwright) for the rename. Existing `status` semantics
(`whitelist`, `running`, `groups`, `all`) preserved under the new
name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:43 +02:00
kevinveenbirkenbachandClaude Opus 4.7 333dcfb491 fix(hlth-webserver): accept additional valid redirect codes for bluesky, joomla
* bluesky web subdomain: accept 302 alongside 405. When the oauth2-proxy
  is in front (variant 0), unauthenticated requests get a 302 to keycloak
  instead of the direct 405 method-not-allowed.
* joomla: include 303 in defaults. Joomla's installer/homepage uses
  303 See Other (PRG redirect), same 3xx family as the already-accepted
  301/302.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:43 +02:00
kevinveenbirkenbachandClaude Opus 4.7 32564ef8df style(roles/web-app-funkwhale/variants): single-space before # nocheck
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:43 +02:00
kevinveenbirkenbachandClaude Opus 4.7 51842d9a85 fix(csp): widen CSP for odoo, opencloud, libretranslate
The hlth-csp checker reports the apps' frontends violate the policy:
* odoo: loads Google Fonts (https://fonts.googleapis.com/css?family=Inter)
* opencloud: Vue.js PortalTarget uses eval()
* libretranslate: vue@2 runtime uses eval()

Add the corresponding whitelist entry (odoo) and unsafe-eval flag
(opencloud, libretranslate). unsafe-eval matches the pattern already
applied to other Vue-based roles (mailu, mobilizon, matrix, taiga).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:43 +02:00
kevinveenbirkenbachandClaude Opus 4.7 3c4a73e907 fix(opentalk,xmpp): name docker volumes for image-declared mounts
The hlth-volumes healthcheck flags anonymous docker volumes (64-hex
hash names). RabbitMQ and ejabberd declare VOLUMEs in their images
without corresponding named mounts in our compose templates, so docker
auto-created anonymous ones and the check failed.

Add named volumes:
* web-app-opentalk: rabbit -> /var/lib/rabbitmq
* web-svc-xmpp: conf, upload, logs under /home/ejabberd/

Side effect: data now actually persists across container recreates
for these mounts. Existing deploys with anonymous volumes need manual
migration; fresh CI deploys are unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:43 +02:00
kevinveenbirkenbachandClaude Opus 4.7 1ee33ce4b7 fix(sys-stk-full): drop overinclusive required_by: web declaration
sys-stk-full is a convenience composition (sys-stk-front-proxy +
sys-stk-backend). It is not required for every web role — static-asset
servers (web-svc-cdn, web-svc-css, web-svc-asset, web-svc-file,
web-svc-html) only load sys-stk-front-proxy directly. The old
declaration made the deploy-verifier reject those deploys with
"required role(s) did not execute: sys-stk-full". The roles that
actually need the full stack include it explicitly via include_role,
so removing the declaration loses no correctness.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:43 +02:00
kevinveenbirkenbachandClaude Opus 4.7 591db6a391 style(roles/web-app-{akaunting,odoo}/variants): single-space before # nocheck
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:43 +02:00
kevinveenbirkenbachandClaude Opus 4.7 6c0e9ca4eb fix(deploy/universal): unbreak required_by verifier for 2 apps
Cluster 2b — two distinct root causes for "required role(s) did not
execute" in universal-pass:

* svc-bkp-remote-2-local: block-level `when` re-evaluated between
  tasks; `flag.yml` set `run_once_*` before the dependency loop, so
  the loop skipped — including sys-ctl-cln-bkps. Split into
  main.yml/01_core.yml mirroring svc-bkp-container-2-local.

* web-opt-rdr-domains: misclassified as universal because the server
  rule only matched `web-app-`/`web-svc-`. Universal constructor does
  not load web-opt-roles.yml, so the role never executed. Extend the
  server include regex to also match `web-opt-`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:42 +02:00
kevinveenbirkenbachandClaude Opus 4.7 c6c2ab3855 fix(sys-ctl-hlth-msmtp): resolve recipient via lookup plugin, fix universal-pass
Cluster 2a (hlth-msmtp failed=65 in universal single-app deploys):
the health-check script sent "To: administrator" but the administrator
Unix user is only provisioned in server-pass, so postfix rejected the
recipient. Replace the inline Jinja conditional with a role-local
lookup plugin that picks administrator.email (external), "administrator"
(local + user exists) or "root" (local + missing).

Side change: SIM105 cleanup in utils/install/lint/__main__.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:42 +02:00
kevinveenbirkenbachandClaude Opus 4.7 dd57167615 fix(install): chmod build/ to 0777 so userns-remapped container shares it
build/ is bind-mounted; under docker userns-remap the container root
maps to host uid 65534 (nobody), so files written from inside the
container are nobody-owned on the host. Whichever side created build/
first locked the other out: a stale nobody-owned build/ blocked the
host touch of build/install.stamp, and a host-owned 0755 build/
blocked the container from writing build/install-lint-<hash>.stamp.

Stamp writers now chmod the build dir to 0777 after creating it. The
first creator wins (only owner can chmod); both writers then place
their own stamps without colliding on directory ownership.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:42 +02:00
kevinveenbirkenbachandClaude Opus 4.7 d2dd66a30d feat(lookup): add 'local' flag (enabled AND NOT shared) on database/service
Fixes Cluster 3a ca-inject YAML parse: when a role variant disables its
DB (enabled=false), the depends_on template emitted `      :` because
the existing `not shared` guard fired for a non-existing DB host.

The new derived flag mirrors `service.required` (= enabled AND shared)
and is consumed in:
 - depends_on/dbms_base.yml.j2, dmbs_excl.yml.j2 (the actual fix)
 - sys-svc-rdbms postgres/mariadb service templates
 - web-app-discourse, web-app-snipe-it (latent same-pattern defensives)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:42 +02:00
kevinveenbirkenbachandClaude Opus 4.7 bc3bdf3dc4 chore(scripts/meta/resolve/apps): drop web-opt-rdr-www from global exclude
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:42 +02:00
kevinveenbirkenbachandClaude Opus 4.7 39ef28ea9e fix(scripts/tests/env/common): drop single-quoted printf that breaks compose-exec wrap
Inner `'==> %s\n'` collided with Make's `cmd='$(cmd)'` recipe wrap, mis-tokenizing the line and exiting 2 in every test-development variant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:42 +02:00
kevinveenbirkenbachandClaude Opus 4.7 7f221f91eb fix(cli/build/include): tolerate cross-UID overwrites of generated playbook entries
`cli build include` writes tasks/groups/*-roles.yml. Both
test-integration (in-container, nobody UID) and lint-ansible: setup
(host UID) run setup in parallel via scripts/make/parallel.sh and
race on the same file; `open(..., 'w')` then trips PermissionError
on the second writer. Unlink the file first when it exists but is
not writable — directory-level delete permission is host-owned in
our layout, so both UIDs can safely recreate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:42 +02:00
kevinveenbirkenbachandClaude Opus 4.7 ff84378bfa feat(make): add clean-container-owned target
Adds a Makefile target plus helper script that removes container-owned
generated artefacts (build/install.stamp, tasks/groups/*-roles.yml)
which the host user cannot rm directly. The helper auto-starts a
stopped infinito compose container before deletion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:52:41 +02:00
kevinveenbirkenbachandClaude Opus 4.7 24d4ab8e4a refactor(sso)\!: collapse oauth2 + oidc into unified services.sso block (req 21)
Implements docs/requirements/021-sso-flavor-migration.md atomically:

* Schema: every role's meta/services.yml replaces the parallel
  `oauth2:` / `oidc:` top-level blocks with a single `sso:` block
  carrying `enabled`, `shared`, and a `flavor` discriminator
  (`oidc` | `oauth2` | `saml`; default `oidc`). Flavor-specific
  sub-keys live under `sso.<flavor>.*` (`sso.oauth2.{origin,acl,
  allowed_groups}`, `sso.oidc.plugin`).
* Provider role: roles/web-app-oauth2-proxy/ is deleted in its
  entirety; its 5 sidecar templates move under
  roles/web-app-keycloak/templates/sso_proxy/, and the per-consumer
  rendering task lives at roles/web-app-keycloak/tasks/sso_proxy.yml.
  web-app-keycloak now declares `provides: sso`.
* Runtime: new resolver utils/roles/applications/services/sso.py
  centralises the SSO predicates (`is_enabled`, `is_proxy_gated`,
  `is_oidc_native`) and the oauth2 sub-values
  (`oauth2_origin_{host,port}`, `oauth2_acl`, `oauth2_allowed_groups`).
  Three lookup plugins consume the resolver: `sso` (single-app
  predicate access), `sso_proxy_consumers` (enumerates oauth2-flavored
  consumers), `sso_oidc_plugin` (renamed from `oidc_flavor`, Nextcloud
  plugin selector). sys-stk-backend, sys-svc-compose, sys-svc-proxy
  and plugins/filter/compose_volumes.py all gate on
  `lookup('sso', ..., 'is_proxy_gated')` instead of manually combining
  `enabled` and `flavor`.
* Identifier renames repo-wide: `services.<entity>.ports.local.oauth2`
  → `ports.local.sso`; `PORT_BANDS.local.oauth2` → `PORT_BANDS.local.sso`;
  `OAUTH2_PROXY_*` env/Jinja vars → `SSO_PROXY_*`;
  `oauth2_proxy_cookie_secret` → `sso_proxy_cookie_secret`;
  `OAUTH2_SERVICE_ENABLED` + `OIDC_SERVICE_ENABLED` collapse into
  `SSO_SERVICE_ENABLED`; Playwright gating helpers
  (isServiceEnabled/skipUnlessServiceEnabled/requireService/safeIsEnabled/
  safeSkipUnlessEnabled) move from `oidc`/`oauth2` to `sso`.
* Dual-block resolution: per req's Background-Table, web-app-bookwyrm
  is `flavor: oauth2`; web-app-gitea and web-app-friendica keep
  `flavor: oauth2` with their existing `oauth2.acl.blacklist`
  (/user/login) and `oauth2.acl.whitelist` (federation + discovery
  endpoints) — preserving the operative oauth2-proxy gating.
* Tests: the two mutual-exclusion guards
  (tests/integration/iam/oauth2_oidc/test_mutual_exclusive.py and
  test_acl_mutual_exclusive.py) are deleted per Decision #10;
  test_oauth2_contract.py is renamed and rewritten to
  test_sso_contract.py, asserting that `flavor: oauth2` roles carry
  `oauth2.origin.{host,port}` plus `ports.local.sso`; test_sso.py
  (web-app integration) now requires the unified `sso:` block with an
  explicit flavor or a `# nocheck: sso` opt-out; new
  tests/lint/repository/no_legacy_sso_paths/ guards the source tree
  against re-introducing the legacy strings.
* Docs: req 21 itself has every AC checked off, and reqs 006, 008,
  009, 013, 015, 016, 019, 020 plus 10+ role READMEs and
  docs/contributing/actions/testing/suppression.md are updated to the
  unified SSO naming.

Empirically validated end-to-end against the validation app set
(web-app-keycloak, web-app-nextcloud, web-app-bookwyrm, web-app-gitea,
web-app-friendica, web-app-dashboard) in matrix variants 0 and 1; v2
also passes the 4 target roles' Playwright after a documented
pre-existing KC_HOSTNAME-drift workaround (separate concern, not
caused by this refactor).

BREAKING CHANGE: every consumer of `services.oauth2.*`,
`services.oidc.*`, `OAUTH2_PROXY_*`, `OAUTH2_SERVICE_ENABLED`,
`OIDC_SERVICE_ENABLED`, `oauth2_proxy_cookie_secret`,
`oauth2_proxy_application_id`, `ports.local.oauth2`, the
`web-app-oauth2-proxy` role, or the legacy gating helper service names
(`oidc`/`oauth2`) must migrate to the unified `sso` shape documented
in docs/requirements/021-sso-flavor-migration.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:59:59 +02:00
kevinveenbirkenbach 9ed04c22ab Revert "feat(test): add live registry-reachability probe for image pins"
This reverts commit 63c6912fae.
2026-05-23 07:00:51 +02:00
kevinveenbirkenbachandClaude Opus 4.7 70b764dbf9 fix(sys-service-loader): mark main.yml as nocheck:run-once
The lint `test_all_roles_have_run_once_tag` flags every `include_role:
name: sys-service-loader` (used with `tasks_from: list_or_shoot.yml`)
because main.yml lacks a `run_once_sys_service_loader` gate.

The loader is the orchestrator entry point and intentionally
re-runnable; per-service idempotency is enforced inside
`load_service.yml` via each loaded role's own `run_once_<role>` flag.
Mark main.yml with `# nocheck: run-once` to opt out of the lint with
the reason inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:00:51 +02:00
kevinveenbirkenbachandClaude Opus 4.7 bfe1d6fd79 refactor(sys-service-loader): rename + post-load queue mechanism
Renames the shared-services loader role from sys-utils-service-loader to
sys-service-loader (shorter, no orphaned "utils" prefix; entity name in
meta/services.yml realigns to service-loader per get_entity_name).

Introduces an explicit two-phase contract in the loader: after the
ordered preload pass, flush a `_service_loader_post_run` queue that
service roles register into during preload via the new helper
`list_or_shoot.yml` (list during preload, shoot directly outside it).

Replaces the `when: not _service_loader_preloading` guards in
sys-svc-compose and sys-svc-webserver-core with helper calls. Closes
the gap where non-web deploys would never load these post-load
dependencies; in normal web deploys behavior stays the same thanks to
each sub-role's `run_once_<role>` gate.

Docs, comments, test docstring, and constructor-stage invocation
follow the rename. Historical changelogs (CHANGELOG.md, packaging/*)
intentionally left at the original name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 06:48:14 +02:00
kevinveenbirkenbachandClaude Opus 4.7 db576071f9 style(roles/web-app-*/variants): single-space before # nocheck comment
Autoformat byproduct — normalize the double space before the trailing
`# nocheck: variants-static-override` marker across four more role
variants (continuation of the earlier whitespace pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 06:48:14 +02:00