Commit Graph
14682 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 e0eda30fbe style(e2e): drop four more timeout arguments isVisible discards
Same class as 642055a20, missed by the filter that selected it: files were grouped by whether they CONTAIN a waitFor state call rather than by whether the diff ADDS one, so four whose change is a pure deletion were sorted into the waiting-semantics bundle instead. Their diffs alter nothing but the discarded argument, and every added line here is a bare isVisible().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:59:15 +02:00
kevinveenbirkenbachandClaude Opus 5 642055a20a style(e2e): drop the timeout argument isVisible discards
Playwright declares the timeout option on locator.isVisible() and isHidden() as deprecated and ignored: both return immediately and never wait. Passing resolveTimeout(10_000) to one therefore changes nothing at runtime and only tells the reader a wait happened that never did. That reading is what let 7538b8a0b conclude two genuine call sites were fine, and what hid the false negative b97cf8888 had to fix.

Sixty call sites across fifty-eight role-local specs lose the argument and nothing else. Every added line in this commit is a bare isVisible(); a grep for added lines that are not one returns zero, so a behaviour change cannot have entered here. Files whose diff also alters waiting semantics are deliberately not part of this commit.

A repository lint added in b97cf8888 keeps the construct out. It matches isVisible and isHidden only, because isChecked, isDisabled, isEditable and isEnabled take a real timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:49:18 +02:00
kevinveenbirkenbachandClaude Opus 5 a77bc07d4a fix(espocrm): take the original entrypoint path from the spot
The path to the image entrypoint the build patches was written out four times in the Dockerfile, and /usr/local/bin twice more in vars. It now comes from ESPOCRM_BIN_DIR through ESPOCRM_ENTRYPOINT_SCRIPT_ORIGINAL, declared as a build ARG after FROM and passed from the compose build args. The runtime side already reads the same value from the environment, because a build ARG does not exist at runtime.

The sed expressions themselves are byte-identical. Only the file operand changed, and the variable sits outside the single quotes, so the delimiter and ampersand escaping that e272ae2d0 had to correct is untouched.

Not exercised: the four sed and grep pairs are build gates and there is no cheap target that builds a role image. A missing ARG would expand to nothing and leave sed without a file operand, which exits 4 with no input files, so the failure is loud at that layer rather than a silently broken image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:46:31 +02:00
kevinveenbirkenbachandClaude Opus 5 b97cf88881 fix(e2e): let the admin persona wait out the OIDC popup handoff
Playwright declares the timeout option on locator.isVisible() and isHidden() as deprecated and ignored - both return immediately. 7538b8a0b already fixed two call sites in this file and deliberately left four, arguing that is the password field STILL there and is a Login control STILL showing are correctly answered at once. That holds for the native login path it measured. It does not hold for the OIDC popup path.

keycloak.js polls the POPUP url and is satisfied once that lands on the callback, while the opener has not re-rendered yet - and admin.js inspects the opener. The trace of EspoCRM variant 0 shows the check starting 5.5 ms after the credential submit and taking 10.7 ms, the error-context snapshot shows the Sign in button visible but disabled, mid-submit, and the network log has oauth-callback.php and api/v1/App/user both returning 200 about 150 ms after the assertion had already failed. Biber passed the same journey in one attempt because biber.js:147 waits; administrator burned three attempts because it did not. That commit assumed EspoCRM 0 failed for an unrelated back-channel reason; it fails at admin.js:191, inside this branch.

The wait uses base 12000, not the 60000 biber carried. resolveTimeout multiplies by 5 on an onion target, so this is 60 s there. Across 13 onion traces the largest wait of any kind that ever SUCCEEDED was 790 ms and the worst navigation 1893 ms; 60 s is 76 times the former. At 60000 the same line would burn 300 s per attempt, and it burns only on the path that already fails, which is the path that has to stay diagnosable. Biber moves to the same base so the two personas cannot drift again.

The three remaining ignored arguments are deleted rather than kept. 7538b8a0b called them inert but harmless; inert is right, harmless is the part that cost it two call sites - the argument reads as a wait that never existed. A repository lint now rejects the construct for isVisible and isHidden only; isChecked, isDisabled, isEditable and isEnabled take a real timeout and are deliberately not matched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:43:28 +02:00
kevinveenbirkenbachandClaude Opus 5 2389b6e38b fix(gitlab): discover the OIDC issuer over the scheme it actually uses
GitLab could not log anyone in through Keycloak on an onion deployment. The sign-in page carried the omniauth flash: Could not authenticate you from OpenIDConnect because Ssl connect returned=1 errno=0 peeraddr=172.30.0.10:443 state=error: tlsv1 unrecognized name (ssl alert number 112), and gitlab-webservice logged OpenIDConnect::Discovery::DiscoveryFailed eleven times.

The openid_connect gem rebuilds the well-known URL and never reads the scheme of the issuer. Its config resource also drops the port when it is 80 or 443, so http://auth.onion loses port 80 and URI::HTTPS.build puts 443 back. The hidden service publishes port 80 only, so 443 reaches the SNI-less default listener, which answers ssl_reject_handshake - alert 112, exactly the string in the flash.

An initializer sets SWD.url_builder and WebFinger.url_builder to URI::HTTP when the issuer is plaintext. These are the same two lines web-app-decidim already ships for the identical bug, and this repo has one such fix per role for zammad, mastodon, decidim and peertube; gitlab was the gap. The mount copies the smtp_settings.rb initializer pattern onto webservice and sidekiq.

The scheme test lives in the ruby file rather than in the render and mount conditions, which both gate on services.sso.enabled alone. Two separately spelled conditions could drift once an inventory sets OIDC_OVERRIDE.URL, and would then either render a file that is never mounted or mount one that was never rendered.

Boot safety: omniauth_openid_connect and openid_connect are default-group Gemfile entries without require: false, so Bundler.require loads them before the initializers run, and openid_connect requires swd and webfinger itself. Both writers are public and assign an ivar. GitLab never assigns url_builder anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:37:57 +02:00
kevinveenbirkenbachandClaude Opus 5 c2018354ab fix(espocrm): set the config keys the app actually reads
Two settings never reached EspoCRM. The image entrypoint maps a double underscore to a dot and a single one to camelCase, so ESPOCRM_CONFIG_LOGGER_PATH wrote a flat loggerPath that nothing reads, while the real key is nested under logger. The level therefore stayed at the WARNING default, which is why Internal server error - Check logs for details had no logs behind it. The value php://stdout was never in effect either, and that was luck: EspoFileHandler raises RuntimeException on it and EspoCRM installs no exception handler, so making the key live with that value would have turned a working file logger into a throwing one. The path and rotation are now written explicitly at the values that were already in effect.

LdapLogin builds its search as (&(objectClass=X)(userNameAttribute=user)(loginFilter)) - the filter is ANDed on top, it does not replace anything. ldapUserNameAttribute defaults to sAMAccountName, an Active Directory attribute, while this directory is OpenLDAP with uid, so the previous filter produced a term that could never match. Setting the attribute is the knob that decides matching; web-app-semaphore sets both for the same reason.

ldapCreateEspoUser decides whether a user who authenticated against LDAP but has no EspoCRM record gets one created. Without it such a login is refused with USER_NOT_FOUND. It is exposed as services.ldap.autocreate rather than hardcoded, because it shifts who may enter from an explicit EspoCRM account to directory membership; the created user gets no team and no role.

The log directory now has one spot in vars, and the entrypoint derives both the streamed filename and the init diagnostics from the same value. It streams data/logs/<stem>-<Y-m-d>.log to stdout so the rescue collector picks it up unchanged; the date is re-evaluated because EspoRotatingFileHandler renames at midnight and a single tail would go silent there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:34:20 +02:00
kevinveenbirkenbachandClaude Opus 5 8ad639d7f8 test(nextcloud): record the Mastodon authorize handoff instead of racing it
The spec polled the live URL until it read /oauth/authorize?, a state that cannot last: Mastodon answers that endpoint with a 302 to /auth/sign_in for any session-less browser, in 5.39 ms per its own log, while expect.poll samples roughly every 100 ms. The test was chasing something it is structurally unable to observe, and reported the bridge as broken when it was working.

The listener is registered on the context rather than the page so the popup is covered too, and after every preceding navigation so no earlier request is captured. currentUrl now prefers the recorded request and otherwise behaves exactly as before. All four downstream assertions - host is not Nextcloud, host is the partner, client_id present, response_type=code - are untouched and now read a complete URL instead of one that has already gone.

Not a masked SSO defect: the partner Mastodon has OIDC_ENABLED, OMNIAUTH_ONLY and ONE_CLICK_SSO_LOGIN all true, and its log shows discovery, token, certs and userinfo each returning 200 over the onion with a successful biber login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:58:53 +02:00
kevinveenbirkenbachandClaude Opus 5 0e39f3fad5 fix(mobilizon): pin the SSO back-channel so the token exchange can connect
The server-side code-to-token call died with OAuth2.Error Connection refused, 24 times, on every /auth/keycloak/callback. The oauth2 library prints that string only for :econnrefused, so the name resolved and the connect was refused - not nxdomain, not a timeout, and not a credential or redirect_uri mismatch, which would have come back as an HTTP 400. openresty, the only ingress for the issuer vhost, logged 14 token POSTs in that window and not one from the Mobilizon address.

container_extra_hosts is the single sanctioned emitter for this pin, and its contract describes exactly this case: on an onion deployment the OIDC endpoints handed to an application are public .onion authorities, right for the browser and unroutable for its own server-side calls. f0ddb4c61 gave peertube, penpot and matrix the same line; mobilizon was missed.

The libcurl class needs a proxy instead (238b8b41f), because libcurl refuses a .onion at name resolution before it consults extra_hosts. That does not apply here: hackney resolved the name, which is what :econnrefused proves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:55:57 +02:00
kevinveenbirkenbachandClaude Opus 5 bbf4ca09cc fix(nextcloud): let occ read a credential that starts with a dash
Symfony ArgvInput reads a token beginning with a dash as an option, so passing the secret as a separate token after --value handed occ two tokens and occ reported the option as missing its value. Doorkeeper mints base64url secrets, so roughly one run in 43 produced one starting with a dash and the OpenProject provisioning died with: The "--value" option requires a value.

The equals form is unambiguous: Symfony splits at the first equals sign and takes everything after it as the value. The quote filter replaces the hand-written single quotes and stays shell-safe. Note that shlex leaves a leading-dash value unquoted, so the equals form is what carries the fix, not the quoting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:36:58 +02:00
kevinveenbirkenbachandClaude Opus 5 adc1aa3bfd style(tests): satisfy the ruff rule the addon-pinning lint tripped
make quality-high stopped at autoformat: ruff reported PERF401 in the addon
variant-pinning lint, whose third check collected findings through a loop whose
entire body was findings.append(). It hands a generator to findings.extend() now.
The two sibling checks were not flagged -- their `continue` guard makes them
something other than a pure transformation.

Rewriting a finding collector can silence a rule while every test still passes,
so the rule was broken again afterwards: a dynamic addon pinned in variant 1
still reports all eight variants, the same set as before the change.

The rest is what the formatters rewrote by themselves -- a trailing blank line in
the ganesha task file and two over-long lines wrapped in the volume spot lints.

make quality-high is green end to end after this: four test suites, ansible-lint
0 failures over 3632 files at the production profile, eleven lint targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:32 +02:00
kevinveenbirkenbachandClaude Opus 5 39e0ac821d fix(prometheus): give back the image names the path refactor swallowed
f06a3cfad replaced every spelled-out occurrence of a path declared in
meta/volumes.yml with a lookup of that volume's target. Two of those paths sit
inside image names, where they are the repository part of prom/prometheus and
prom/alertmanager rather than a mount point:

    image: prom{{ lookup('volume', 'web-app-prometheus', 'data').target }}

The rendered value stayed correct -- /prometheus is exactly what the lookup
returns -- but the image field carried Jinja from then on, and
TestDockerServicesImageVersionValid rejects that. make quality-high failed at
test-integration with both entries named.

The sibling lines prove the rewrite followed the volume declarations rather than
the field it was editing: prom/blackbox-exporter and prom/node-exporter kept
their literals, because their suffixes are not declared targets.

Both are literals again, each with a per-line opt-out naming why this path
cannot derive from the registry.

Measured: test_services_image_version_valid and tests/lint/repository/spot/ --
the lint that forced the rewrite -- pass together, and make quality-high is green
end to end afterwards: four test suites, ansible-lint 0 failures over 3632 files
at the production profile, eleven lint targets.

f06a3cfad is still unpushed, so this never reached CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:32 +02:00
kevinveenbirkenbachandClaude Opus 5 46baeeda5b feat(lint): let variant 1 decide the addons that install everywhere
An addon's enabled is either dynamic -- a Jinja expression resolving per deploy
against a service flag, an API credential, the SSO plugin choice or group_names
-- or static, a literal boolean. A dynamic addon already follows its variant:
switch the service off and the expression yields false. A statically-on addon
does not. It installs in every round, so each non-baseline variant pays install
time for plugins it never exercises, and variant 0 stops being the only
maximum-footprint shape that meta/variants.yml exists to document.

web-app-nextcloud carried 22 of them across nine variants. They are now pinned
off once in variant 1 behind a YAML anchor, and variants 2-8 alias it, so the
set has one spot -- the same shape meta/volumes.yml and the
volumes-target-single-spot lint already prescribe.

test_addons_variant_pinning.py holds the rule for every role that ships
meta/addons/ and declares more than one variant:

  1. an addon whose base enabled is literal true MUST be pinned enabled: false
     in variant 1; a literal false is out of scope, the pin would override
     nothing,
  2. an addon whose base enabled is a Jinja expression MUST NOT be pinned in
     any variant -- that replaces the expression with a constant and decouples
     it from the service it follows,
  3. every later variant MUST carry the same set, in practice by aliasing
     variant 1. Variant-specific pins beyond that set stay allowed, so a
     variant may still switch a statically-off addon on (web-app-matrix does
     exactly that for its bridges in variant 2).

Measured on the real role through utils.cache.applications.get_variants: all
nine variants keep 51 addon keys, variant 0 keeps its 22 statics on, variants
1-8 hold none, and every dynamic entry keeps its Jinja string -- xwiki still
resolves true only where services.xwiki.enabled is. The 51 keys matter: the
override merges per addon instead of replacing the map, which is what an
earlier lookup-based attempt at this got wrong.

Each rule was broken on purpose and seen to fail: calendar pinned true, xwiki
pinned (reported for variants 1-8, which also proves the alias resolves), and
variant 5's alias removed (22 findings, exactly the required set).

web-app-discourse is exempted per addon with a reason. Its plugin set is
compiled into the image from services.discourse.plugins, and meta/addons/ is a
second declaration of the same thing; an addon-level disable would still build
the plugin and only flip the gate flag, saving no time while silencing the
spec. web-app-wordpress is not exempt: vars/main.yml:16 builds the plugin list
from the merged map and 07_manager_ops.yml:45 gates the install loop on
enabled, so its variant 1 genuinely skips a wp plugin install. Its README
claimed activitypub was "always enabled"; that is corrected rather than left to
contradict the file.

Not verified: no deploy has run. The saving is derived from the install loops
being gated, not observed, and the WordPress Multisite variant losing
activitypub rests on the same reading plus its skipUnlessAddonEnabled gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:32 +02:00
kevinveenbirkenbachandClaude Opus 5 30510fe4c4 refactor(erpnext): give the bench python invocation one spot
Four tasks spelled the same container command out in full:

    bash -c 'cd {{ lookup('volume', 'web-app-erpnext', 'sites').target }} && /home/frappe/frappe-bench/env/bin/python -'

Three facts were repeated in it: the sites mount, the bench root, and the role
id -- hardcoded right next to a lookup whose whole purpose is to avoid that. A
rename of the mount in meta/volumes.yml would have had to land in four places
and could half-succeed in silence, because the tasks still parse, still deploy,
and cd into a directory that no longer holds the site.

vars/main.yml derives all of it. ERPNEXT_SITES_DIR reads meta/volumes.yml, where
the path is already the single truth, addressing the role through application_id
instead of a literal name. ERPNEXT_BENCH_PYTHON builds the command from it and
takes the bench root as its dirname, so the last hardcoded
/home/frappe/frappe-bench leaves the tasks entirely. compose.yml.j2 uses the same
var for gunicorn's --chdir rather than keeping a second copy of the lookup.

Rendered with Ansible's own filters and trim_blocks, the expression reproduces
the four replaced lines byte for byte. ansible-lint passes the role at profile
production (0 failures, 0 warnings, 29 files); the volume spot lints, the
volume-lookup lint and the comment lint pass.

Left alone deliberately: files/scripts/apply/*.py name the sites path as well,
but run inside the container with no Ansible to evaluate a lookup -- they would
need an environment variable, which is a separate change. The bench-relative
binaries (env/bin/gunicorn, apps/frappe/socketio.js) and the two
site_config.json paths appear once each, so extracting them would add
indirection without removing a duplicate.

Not verified: compose.yml.j2 drives the backend entrypoint, and that path only
proves itself on a deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:31 +02:00
kevinveenbirkenbachandClaude Opus 5 8d7fff4d2d test(svc-net-tor): let the probe demand the values its test.env already sets
roles/svc-net-tor/templates/test.env.j2 sets RETRIES=6 and SLEEP_SECONDS=10, and
test-e2e-cli/tasks/run_one.yml renders that file unconditionally at :66 before
sourcing it at :74. The ${RETRIES:-20} / ${SLEEP_SECONDS:-15} fallbacks could
therefore never be reached -- a second source of truth that additionally
documented the wrong numbers: the header promised "default 20 / default 15"
while every run used 6 and 10.

They now take the :? form the two variables above them already use, so
test.env.j2 is the only place the values live, and a missing one aborts loudly
instead of silently probing three times longer than intended. The header stops
claiming defaults.

The sibling probes in svc-db-mariadb, svc-db-openldap and svc-db-postgres keep
${RETRIES:-20}: their test.env.j2 does not set the pair, so there the fallback is
live, not dead. Converting them would mean adding the values to those templates
first, and is deliberately left out.

Measured: a missing RETRIES exits 1 with "line 24: RETRIES: pass RETRIES as env
(attempts per domain, from svc-net-tor test.env)"; a complete env leaves the
probe output unchanged. shellcheck is clean against the staged content, and the
comment, shell and cli-asset-pairing lints pass.

The message wording avoids an apostrophe on purpose: inside ${VAR:?...} bash
still treats one as a quote character, which splits the abort message across the
next source line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:31 +02:00
kevinveenbirkenbachandClaude Opus 5 64131a4c35 test(svc-net-tor): name the address a failed probe resolved to
HTTP 000 cannot tell a name that never resolved from a listener that never
answered. Both leave curl without a status and openresty without an access-log
entry, which is exactly the signature the apex infinito.example has produced in
every compose job of run 31543714113.

2a60ddb3c already stopped discarding curl's stderr, so the message now names the
layer -- (6) could not resolve, (7) failed to connect, (35) handshake. It never
names the address, and that is the half that decides between "the resolver
answered wrong" and "the resolver did not answer".

getent hosts goes through the same NSS path curl uses, so the verdict carries
what the node itself resolved:

    [FAIL] localhost                  HTTP 000 dns=::1
    [FAIL] no-such-host-xyz.invalid   HTTP 000 dns=

Empty means resolution failed. An address together with a connect or TLS error
means the resolver answered and the far side did not. An address other than the
node's own means the resolver answered wrongly. Read next to curl's line, those
three branches separate.

Only the failure path changes; [OK] keeps its format, and getent's own noise
goes to /dev/null so a probe never fails on the diagnostic.

Measured: both branches above; getent and head present in all four node images
(fedora, arch, centos, debian) via docker run; shellcheck clean against the
staged content; tests/lint/repository/test_comments_valid.py,
tests/lint/filesystem/shell/ and test_cli_test_assets_paired.py pass.

This is not the fix for the apex failure. Nine mechanisms were eliminated by
measurement today -- CoreDNS, dnsmasq and Docker's 127.0.0.11 each resolve both
the apex and its sibling to 172.30.0.10; the vhost exists in http and https;
nginx validated six times; no server-name conflict, no proxy env, no conntrack
pressure. What remains needs the one fact the log has never carried, and this
line carries it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:31 +02:00
kevinveenbirkenbachandClaude Opus 5 1ff6999ee2 fix(nfs-server): make one script own "ganesha serves", and let it judge
Penpot 0 of run 31658241728 died on the restart handler while the node was
healing itself. Its journal, in order:

    20:46:47 nfs-ganesha.service: State 'stop-sigterm' timed out. Aborting.
    20:46:58 nfs-ganesha.service: State 'stop-watchdog' timed out. Killing.
    20:46:58 Main process exited, code=killed, status=9/KILL
    20:46:58 Failed to start nfs-ganesha.service
    20:47:00 [main] nfs_start :NFS STARTUP :EVENT : NFS SERVER INITIALIZED
    20:47:01 systemd[1]: Started nfs-ganesha.service - NFS-Ganesha file server.

Ansible failed at 20:46:58, three seconds before the replacement was serving.
The rescue bundle confirms the end state independently: 17 threads, RPC workers
in svc_rqst_epoll_loop's epoll_wait, fridge threads idle -- a healthy server.

9b52a966c built exactly this recovery (ExecStartPost turns a wedge into a unit
failure, Restart=on-failure replaces the process, a wait_for on 2049 absorbs one
cycle). The defect was that the recovery sat downstream of the handler that
died on the event it exists for: `systemd: state=restarted` reports the restart
job's result, and the play never reached the gate.

A shell script now owns the whole statement -- restart or start, then wait for
2049, exit 0 only once the port answers, exit 1 with the unit's status and
journal. systemctl's own result is deliberately not the verdict, because a
recovering unit and a broken one both read 'failed' at the moment it returns;
the difference only exists two seconds later, which is what the wait measures.
The handler is that SPOT's restart entry point and the task after it is the
start one; vars carries unit, port and budget once.

That removes the tolerance instead of relocating it. Gone with it: a
`failed_when: false`, a dead `register`, a handler that set a fact instead of
restarting, three `|| true` against this repo's own no-or-true rule (unnecessary
anyway -- the script sets -u, not -e), and the now-duplicated wait_for gate.
`changed_when` on both call sites reads the script's CHANGED marker, so a start
that had to act reports changed and one that found the unit already active does
not; previously the task claimed `changed_when: false` even when it had started
the service itself.

Measured, and in the image it will run in (infinito-swarm-node, the same one the
rescue bundle came from):

  - all four combinations: start+inactive -> CHANGED, start+active -> no change,
    restart -> CHANGED either way
  - systemctl failing while the port answers -> rc 0; port never opening -> rc 1
    with systemctl's own rc and message, status and journal on stderr
  - bash 5.2.37, /usr/bin/systemctl, /usr/bin/journalctl present; /dev/tcp works
  - the script run inside that image with no systemd at all still judges
    correctly, because its verdict is the port

TimeoutStopSec stays at 10. The first attempt at this raised it to 90, which
9b52a966c rules out in writing -- the wedged process blocks SIGTERM, so a longer
budget only burns the recovery cycle the 120s gate has to hold. The merge-fix
search caught that before it was committed.

Not verified: that ansible.builtin.script's temp directory is executable on
every node (it was exercised here through docker run, not through the module),
that 120s covers a real recovery beyond the single three-second data point, and
the three sibling node images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:31 +02:00
kevinveenbirkenbachandClaude Opus 5 0c2ce12c7a feat(healthcheck): compose a probe from prefixes, and gate Moodle's mail on it
Moodle renders the same msmtprc as WordPress and mounts it into two services,
but nothing ever proved a message left the container: its healthcheck opens the
FPM port and nothing else. Moodle 0 of run 31658241728 passed green while mail
could not have worked -- the defect 2da285067 fixed for WordPress was invisible
here, and would have stayed invisible until a user reset a password.

WordPress proves delivery with the msmtp_curl flavor: the probe sends one mail
and writes a marker, and a task after convergence waits for that marker. Moodle
cannot use it, because curl cannot judge a port that answers FastCGI. Rather
than add a second msmtp_* class per liveness shape, the declaration now composes:

    healthcheck:
      flavor:
        - msmtp
        - connect
      port_key: fpm

msmtp stops being a flavor and becomes a prefix -- shell that runs on the way
past and never decides liveness. connect joins the probes as the one that only
asks whether the port accepts a TCP connection. A declaration names exactly one
probe and any number of prefixes, as a string or a list; msmtp_curl stays as an
alias for [msmtp, curl], and the lint that validates declarations now shares
resolve_flavors with the lookup instead of keeping its own idea of a flavor.

The module became five responsibilities in one file, so it is a package:
probes.py answers which probes exist, prefixes.py what runs before them,
compose.py how a declaration becomes a rendered block. The curl helpers sit with
the probes and mail_branch with the prefix -- each next to its only caller,
rather than together in a module header.

Two defects in this work were found by measuring, not reading:

  - the timing merge took the maximum across prefixes only, which cut Moodle's
    start period from 15m to 120s and would have marked a heavy PHP app dead
    while it was still installing. A prefix may now raise a timing and never
    lower one, pinned by test.
  - composing moved msmtp_curl's interval from 30s to 1m, because the composed
    class derives from Curl where MsmtpCurl derived from Probe. That is the
    interval every other curl probe uses, but it was not intended; it now has
    its own test rather than riding along unseen.

Regression measured across the whole repo rather than argued: every declared
healthcheck was rendered with the pre-refactor code and with this one. 58 render
byte-identically, 1 is Moodle's intentionally changed declaration, and 2 differ
-- friendica and wordpress, in that interval alone, with the probe command
itself unchanged.

Coverage was checked in both directions. No test was removed: the four deleted
lines are an import, the registry literal, one class name in a url test, and the
helper, which now goes through compose() instead of reaching into PROBES -- a
tightening. New code had two gaps, both closed: connect was the only probe whose
emitted command no test pinned, and compose() raised KeyError for an unknown
name unobserved.

Not verified: no deploy has run any of these probes. The composed class is built
dynamically, so what it emits is proven for all 59 services while docker
accepting it is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:31 +02:00
kevinveenbirkenbachandClaude Opus 5 2ffaa2fc5c fix(healthcheck): take the curl probes off the proxy, from one definition
Every probe in this module targets 127.0.0.1 inside the container, so a request
a proxy picks up is wrong by definition. The Wget probe has said so since it was
written -- `wget --spider --proxy=off` -- while the four curl invocations said
nothing, and a container whose environment carries http_proxy would have sent
its own liveness check through it.

That environment is not hypothetical: 2d6a5fd18 puts http_proxy and https_proxy
into EspoCRM's env_file so the OIDC back-channel can reach an onion issuer. It
also sets no_proxy=localhost,127.0.0.1, so that role's probe is covered by hand.
The next role to route a container through a proxy and forget that line would
have had a healthy web app reported dead.

The flags now come from one place. CURL_NO_PROXY plus curl_argv() build the exec
form, and curl_shell() is the same argv run through shlex.quote. The two
spellings differ in exactly one character -- the exec form needs `*` bare
because docker runs no shell, the CMD-SHELL form needs it quoted or the shell
globs it -- and that difference was previously written out by hand at three
sites plus a fourth in a different shape. Deriving one from the other is what
keeps them from drifting; the Host-header branch moved along for the same
reason, since it too existed twice in two spellings.

The http fallback keeps its wget half untouched on purpose. That flavor exists
for images where it is unknown which tool ships, and busybox wget does not
accept --proxy=off; adding it there would have made the first branch fail on an
unknown option for exactly the images the fallback is for.

Verified by execution at each step:

  - adding the flag broke five tests, which is every call site plus the hostname
    variant -- the suite pins the argv and the shell string literally, so that
    was the completeness check
  - the refactor then passed those same 31 tests with no further edit, and the
    four rendered probes were diffed against the pre-refactor output character
    for character
  - one of those tests executes the CMD-SHELL probe against stubbed msmtp and
    curl, so the quoting survives a real shell rather than just a string compare
  - the option is accepted where it will run: joomla:6.1.2-php8.3-apache, one of
    the images configured for this flavor, ships curl 8.14.1 and answers
    `curl -f --noproxy "*"` with exit 7 (refused, nothing listening) rather than
    exit 2 (unknown option)

Not verified: the other images behind this flavor. --noproxy has existed since
curl 7.19 in 2008, but only one image was measured, and no probe built this way
has yet reported healthy in a live container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:31 +02:00
kevinveenbirkenbachandClaude Opus 5 d0516716c5 fix(mail): stop the container copy of msmtprc inheriting the host's loopback
One template serves two consumers with opposite needs. sys-svc-mail-msmtp
renders it to /root/.msmtprc, which the host itself reads, and web-app-wordpress
and web-app-moodle render the same file into their config directory, from where
it is mounted at /etc/msmtprc inside a container. resolve_smtp_host returned the
loopback for both, which is right for the first and meaningless for the second:
inside a container 127.0.0.1 is that container's own loopback and nothing is
listening on it.

WordPress 0 of run 31658270172 died on it. The deploy converged and then
"⏳ Wait for the delivery marker the healthcheck writes" retried sixteen times
before `assertion: wordpress_smoke_mail.rc == 0` evaluated to false. The reason
is in the rescue bundle rather than the log, identically in all three replicas
(containers/wordpress_wordpress.{1,2,3}.*.inspect.json):

    msmtp: cannot connect to 127.0.0.1, port 587: Connection refused
    msmtp: could not send mail (account default from /etc/msmtprc)

Those containers report Status: healthy with FailingStreak: 0, which is by
design since ebdadd91b decoupled the smoke mail from liveness -- the probe ends
on curl and the delivery assertion carries detection. The assertion was right;
the address it complained about was ours.

smtp_host gains in_container=, and the template passes it from a variable each
of the three call sites sets explicitly. There is deliberately no default: a
fourth consumer must fail loudly rather than silently inherit a loopback that
does not apply to it, which is the very defect being fixed. grep across roles,
utils and plugins finds exactly those three consumers today.

The alias needs pinning to resolve, and container_extra_hosts is the only
emitter of extra_hosts. It gained host_alias=, which appends
host.docker.internal at whatever reaches the host in the effective mode by
reusing _gateway_address -- host-gateway under compose, the stack host's routable
address under swarm. Hardcoding host-gateway at the call site would repeat the
mistake 7b1839681 already paid for: that alias resolves per node and misses the
one that publishes.

Measured, not assumed:

  - all four resolver branches: host 127.0.0.1, container host.docker.internal,
    bare infra node the manager, outside the rig the configured relay unchanged
  - both branches again through a real ansible lookup call, because run() at
    first did not forward the kwarg and Python-level testing would have missed
    a silently ignored parameter
  - the pin emits `host.docker.internal:host-gateway` under compose and
    `host.docker.internal:<node address>` under swarm
  - the destination exists under swarm: sockets-tcp.txt of all three nodes in
    the same rescue bundle shows `LISTEN 0 4096 *:587 users:(("dockerd",…))`, so
    the submission port is published on every interface of the node -- which is
    why the container's own loopback missed it and the host alias will not
  - and under compose by construction: mailu publishes submission on
    MAILU_IP4_PUBLIC (templates/compose.yml.j2:12), which is
    DOCKER_PUBLIC_BIND_HOST, which is 0.0.0.0 inside the rig
    (group_vars/all/00_general.yml:46)
  - 11 existing smtp_host unit tests stay green; make test-lint stays at 328

The moodle half fixes nothing that is currently red, and that is the point:
moodle renders the same broken file into two services but its healthcheck only
opens a TCP connection to the FPM port, so Moodle 0 of run 31658241728 passed
with mail that could never leave the container. It breaks when Moodle actually
sends -- a password reset, a forum subscription -- and no job would say so.

Not verified: that the relay accepts the message once reached. The port is
proven reachable, delivery is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:30 +02:00
kevinveenbirkenbachandClaude Opus 5 f1dff50fb6 feat(nextcloud): give xwiki its own variant and loosen its bond
Variant 0 of this role no longer fits the box CI gives it. Job 94319317296 of
run 31658270172 started at 01:49:01Z and logged
"##[error]The operation was canceled." at 07:44:14Z -- 355 minutes later, to the
minute, which is the ceiling .github/workflows/call-test-deploy-swarm.yml:161
sets. It died inside its own step, at
"TASK [test-e2e-playwright : ▶️ Run Playwright container]". The drill costs about
6h40m and the box is 5h55m.

The cost is dominated by the deploy itself, not by a repeatable sweep: PASS 1
alone is 13695.90s in role-runtimes, of which sys-svc-compose is 3094.19s and
test-e2e-playwright 2832.15s. It therefore scales with how many services variant
0 switches on, which is what this commit reduces.

The file's own header states the rule: variant 0 carries every tightly-coupled
(bond>=1, same-host) service, while loosely-coupled (bond<1) partners are
excluded from the closure and distributed across later variants to keep each
host under budget. xwiki and bigbluebutton were declared bond: 1 and therefore
had to sit in variant 0. Both are bridged partners with their own role and their
own spec -- the same shape as openproject, gitlab and mattermost, which already
carry bond: 0.5 -- so the declaration was the outlier, not the placement.

Both bonds drop to 0.5. xwiki moves out of variant 0 into a new variant 8 with
sso, ldap and tor, the same shape as variant 5 (zammad) and variant 6 (gitlab).
bigbluebutton stays in variant 0 for now, by explicit request; its bond change
only makes the move possible later. Each edge is one-directional -- neither
web-app-xwiki nor web-app-bigbluebutton declares a nextcloud service -- so there
is exactly one bond per partner to change.

coturn is deliberately absent from variant 8. BigBlueButton speaks WebRTC and
the reflex is to bring a TURN server, but the only coupling in the repo is
meta/addons/spreed.yml:8, which bridges coturn for Nextcloud Talk, and talk is
off in that variant. An extra service there would be exactly the overhead this
commit removes.

Measured: the file loads to 9 variants, variant 0 falls from 16 enabled services
to 15, and variant 8 resolves to ['ldap', 'sso', 'tor', 'xwiki']. make test-lint
stays at 328 passed -- variant-limit (capped at 3, opened by the file's existing
nocheck), variants-static-override and the bond lint (which only requires a
number in 0..1) all accept it.

Not measured, and the honest cost: a ninth variant is one more deploy round, and
the limit lint's own rationale says total deploy time grows linearly with
max(variant_count) across the primary apps -- Nextcloud sets that maximum, so
this is roughly +12.5% for the whole matrix, spent to relieve the single job that
dies on its own ceiling. Whether removing one service of sixteen closes a ~45
minute overrun is not established; role-runtimes has the per-role seconds and no
run has been made with this shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:30 +02:00
kevinveenbirkenbachandClaude Opus 5 d926dd0832 fix(nextcloud): keep an onion Discourse URL on http in the settings bundle
The integration_discourse settings page rewrites what the user types, and the
rewrite is unconditional. src/components/PersonalSettings.vue:217-224 upstream:

    if (this.state.url !== '' && !this.state.url.startsWith('https://')) {
      if (this.state.url.startsWith('http://')) {
        this.state.url = this.state.url.replace('http://', 'https://')

An onion service carries no certificate and is served over plain http through
Tor, so on such a node the address is rewritten to an endpoint that does not
exist. Run 31658270172, job 94319317649 shows all three steps: the trace's
action log records the spec typing `http://forum.<onion>/`, the PUT to
/apps/integration_discourse/config carries `{"values":{"url":"https://forum.
<onion>/"}}`, and the connect navigation to `https://forum.<onion>//user-api-
key/new` fails with network status -1, which Playwright reports as
`Received string: "chrome-error://chromewebdata/"`.

The app ships compiled JS, so the fix has to reach the bundle rather than the
source. It is measured against the file the appstore actually installs --
nextcloud-releases/integration_discourse v3.3.0, whose
integration_discourse-personalSettings.mjs is the exact filename the trace
loaded from the container; the nextcloud/integration_discourse releases stop at
v2.0.1 in 2023 and still ship .js, which is why the earlier reading of that repo
was misleading.

Verified rather than argued:

  - the anchor `startsWith("https://")&&(` occurs exactly once in that file
  - after the patch the guard reads
    `…startsWith("https://")&&!this.state.url.includes(".onion")&&(…`
    and `node --check` accepts the bundle as valid ESM
  - the anchor no longer exists afterwards, so a second application is a no-op:
    idempotency is structural, not a marker check
  - the task was run through a real ansible-playbook against a real copy of the
    bundle with only the exec prefixes stubbed: first run changed=1 and patched,
    second run changed=0, so changed_when reports honestly
  - busybox sed (alpine:3, "This is not GNU sed version 4.0") produces the same
    result as GNU sed, which matters because the image is 34-fpm-alpine

`occ app:getpath` locates the app instead of hardcoding custom_apps, which
appears nowhere in the repo and which the appstore may move. The `grep -q … ||
exit 0` makes the task both idempotent and safe when the file is absent.

Clearnet is untouched: the added condition is false for every non-onion URL, so
the upgrade still happens where TLS is available.

Not verified: what `app:getpath` returns in this installation. If the path is
wrong the task no-ops silently -- the same shape as the WordPress healthcheck,
where msmtp fails and a passing curl hides it. A `test -f` with a loud failure
would close that, and the layer behind the redirect has never been reached by a
green run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:30 +02:00
kevinveenbirkenbachandClaude Opus 5 a0ef59049d fix(e2e): let biber's authenticated-surface probe actually wait
236578d3b converted this exact fallback in admin.js and left its twin in
biber.js untouched. Both personas do the same thing: when the Keycloak round
trip did not complete cleanly, fall back to looking for an authentication
marker on the page, and budget fifteen seconds for it. isVisible does not
spend that budget -- Playwright ignores the timeout option there and answers
from the current DOM.

The cost is measured, not argued. In run 31658241728 the EspoCRM 2 trace shows
the same API asked for ten seconds and returning in 0.20 s, while the network
log has the SPA finishing its boot at 0.66 s and the fallback navigations
firing at 0.91 s. A surface that renders one second late reads as
unauthenticated.

waitFor spends the budget only when the marker is absent, which is precisely
the case that is answered wrongly today; when the marker is there it returns as
soon as it appears. So a passing run pays nothing and a failing run pays the
fifteen seconds it was always meant to pay. admin.js has paid them since
236578d3b.

Ten further isVisible({timeout}) call sites in this directory were reviewed and
deliberately left alone: they ask whether something is visible right now
(passwordStillVisible, loginStillVisible, the frame scans, clickFirstVisible),
and clickFirstVisible would lose five seconds per candidate if it waited.

Not verified by a run: the mechanism is proven from the EspoCRM trace and the
parity with admin.js, not from an observed biber probe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:30 +02:00
kevinveenbirkenbachandClaude Opus 5 4f5dc2be44 fix(mastodon): skip the SeaweedFS filer check on an onion target
6f9142027 gave twelve consumer specs the onion skip that nextcloud already
carried, and mastodon is the one it missed. `git show 6f9142027 --stat` names no
mastodon file, and no commit on any ref ever added the gate to the role:
`git log --all -S'isOnionTarget' -- roles/web-app-mastodon/` is empty. Measured
before this change, the role reported 0 occurrences of isOnionTarget while
matrix, nextcloud and taiga reported 2 each; thirteen roles carried the skip,
now fourteen do.

The gap is not cosmetic, because the gate the spec did have guards the wrong
thing. skipUnlessServiceEnabled("seaweedfs") asks about the BACKEND service,
which is enabled on a Tor node; the filer's browser surface is not. The spec
therefore ran, and adminLoginFiler navigated to the clearnet filer host. On a
node whose Tor network is a private chutney instance with no clearnet exit that
host does not answer 404 -- it hangs, and Playwright reported net::ERR_TIMED_OUT.

The skip has to come before the backend gate for that reason, which is where the
thirteen siblings put it and where this change puts it. The message string is
byte-identical to theirs.

Not verified by a run: what is proven is the parity with thirteen siblings and
the domain derivation in timeouts.js:15-17, not an observed skip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:30 +02:00
kevinveenbirkenbachandClaude Opus 5 b6e3368a21 fix(funkwhale): click through Funkwhale's own log-out confirmation
Variant 2 of the swarm matrix fails the sign-out assertion for both personas,
and variant 0 fails the same assertion for a different reason. The artefacts of
run 31658241728 separate the two states. Variant 0's snapshot at failure is the
universal-logout terminal page (`- text: You have been logged out.`): the
session is gone and the test only had to return to the app, which f683d18b8
does.

Variant 2 stops one step earlier, for administrator and for biber alike:

  - heading "Are you sure you want to log out?" [level=2]
  - paragraph: You are currently logged in as administrator
  - button "Yes, log me out!"

Clicking the sidebar control routes Funkwhale to its own confirmation view, and
the session survives until that button is pressed. Returning to the app base and
asserting a sign-in offer therefore fails, and it would keep failing with
f683d18b8 alone -- that commit navigates away from the dialog without answering
it.

The new block presses the confirmation when it appears. One click covers both
personas, and the artefact lists no other failing test in the job.

Variant 0 is unaffected: its terminal page carries no button at all, so the probe
expires and the flow continues as before, at the cost of ten idle seconds.

The name regex is measured against the observed strings rather than assumed. It
matches "Yes, log me out!", "Log out", "Sign out" and "Logout", and rejects
"Log in" and "Sign in", so it cannot re-select the sign-in affordance that the
next assertion looks for.

Not verified: the click has not been run against a live instance. What is proven
is that the button is there and that the selector selects it, not that Funkwhale
ends the session afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:30 +02:00
kevinveenbirkenbachandClaude Opus 5 a30fb79a79 style(nextcloud): wrap the openproject provisioning stdin expression
The stdin: value concatenating doorkeeper_allow_onion_redirect.rb with
ensure_nextcloud_storage.rb ran past the line budget; it now wraps before the
closing braces.

Whitespace only. yaml.safe_load of the file before and after the change returns
the same document, so the Ruby that reaches the container is byte-identical.

Not authored as part of the surrounding session work; committed separately so it
does not ride along inside an unrelated bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:29 +02:00
kevinveenbirkenbachandClaude Opus 5 93bbcf7a1c fix(espocrm): route the OIDC back-channel through Tor on an onion node
EspoCRM's browser half completes and the server-side code-to-token exchange then
fails in 28.7 ms with a 401, with no matching POST to the Keycloak vhost anywhere
in openresty's log. ESPOCRM_CONFIG_OIDC_TOKEN_ENDPOINT carries the public onion
authority, which is right for the browser and unresolvable for PHP.

An extra_hosts pin does not fix that, and this is measured rather than reasoned:
the image ships libcurl 8.14.1, and with --add-host set to exactly what
extra_hosts produces, a request to an .onion name returns
`errno=6 Not resolving .onion address (RFC 7686)`. libcurl refuses the name
before /etc/hosts is ever consulted. The same request with
`http_proxy=socks5h://...` returns `errno=7 Failed to connect` instead -- the
name is handed to the proxy and never resolved locally, so the refusal does not
apply. That is the whole fix.

web-app-joomla and web-app-wordpress reach the same endpoint through code we
ship, because their frameworks set CURLOPT_PROXY themselves and override the
environment. EspoCRM does not: Login.php sets URL, timeout, method, headers and
protocols, and neither it nor anything else under
application/Espo/Core/Authentication/Oidc/ mentions CURLOPT_PROXY. libcurl
therefore falls back to http_proxy/https_proxy, so an env var is enough and no
patch to upstream code is needed.

The container_extra_hosts include returns, for the reason it was actually needed:
lookup('tor_socks_proxy') resolves to socks5h://host.docker.internal:<port> under
compose, and the container has to be able to resolve that name to reach the proxy
at all. Under swarm it is the tor overlay alias, which the role already bonds
(meta/services.yml:93-95).

Clearnet is untouched -- ESPOCRM_OIDC_SOCKS_PROXY is empty unless the Keycloak
vhost ends in .onion, and the block does not render.

Blast radius checked rather than assumed: the health probe is flavor curl against
http://127.0.0.1:<port>/, which no_proxy covers; nothing in the role's shipped
scripts makes an outbound HTTP call, so there is no boot-time dependency on Tor;
and daemon and websocket inherit the same env_file, so their scheduled outbound
HTTP also takes Tor, which on an onion node is the egress anyway.

Not verified: no token exchange has been run through a live Tor proxy. What is
proven is that libcurl forwards the name instead of refusing it, not that Tor
resolves it and the local proxy answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:29 +02:00
kevinveenbirkenbachandClaude Opus 5 29015311b6 feat(lint): derive container paths from meta/volumes.yml everywhere
meta/volumes.yml says where a volume lands inside its container. Wherever a
second file spelled the same path out, the two drifted independently: the mount
moves, the consumer keeps pointing at the old place, and nothing fails until the
process looks for content that is not there. e031b7f61 fixed exactly this for
web-app-moodle; this generalises it and makes the rule hold.

The lookup gains what a caller needs. `targets` maps each mounting service to
its container path, and `target` is the one path an entry mounts at -- present
only when every mount agrees. An entry that lands at different paths per service
omits `target` rather than picking one, and the caller names the service:

    "{{ lookup('volume', application_id, 'prometheus_config').target }}"
    "{{ lookup('volume', 'web-app-prometheus', 'host_sys').targets['node-exporter'] }}"

66 literals across 48 role files now derive. Replacement only happened where the
path stood alone -- /srv/app inside /srv/app/data is a different path -- and
every rewritten file was re-parsed, Jinja and YAML alike. Three sites the tool
refused rather than guess were done by hand: zammad's path sat in a Jinja list
literal, seaweedfs' inside a quoted string feeding a filter, and openresty's
/var/run/docker.sock is not a mount target at all but the socket on the host,
which only happens to spell the same string. That one carries the single
nocheck.

tests/lint/repository/spot/test_volumes_path_single_spot.py keeps it that way.
roles/*/files/ is out of scope by construction: it is copied verbatim into an
image and executed there, so a Dockerfile or entrypoint has no Ansible to
evaluate a lookup with.

One defect this found, and it was in the tooling rather than the roles: deciding
ambiguity AFTER filtering short paths made web-app-prometheus' host_sys look
single-valued -- it mounts /sys for cadvisor and /host/sys for node-exporter,
and /sys is below the length filter. The template would have rendered an
undefined `.target`. Both the converter and the rule now decide ambiguity over
every target and filter afterwards, and that call reads targets['node-exporter'].

Verified by resolving rather than by reading: all 70 accesses in the diff were
run against the real registry and every one yields a non-empty path equal to the
literal it replaced. make test-lint: 328 passed.

Not verified: no Ansible render, so a call site whose surrounding context cannot
build the applications registry would still fail at deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:29 +02:00
kevinveenbirkenbachandClaude Opus 5 750476b26a feat(lint): give every mount target one spot in meta/volumes.yml
A volume that reaches several services repeated its container path once per
mount. Written out each time it has no single point of truth: a rename that
misses one line still parses, still deploys, and mounts one service somewhere
nobody intended. Nothing fails until that service looks for content which is
not there.

YAML already solves this, and 62722a6ab used it here first -- web-app-openproject
anchors await_migrations_target and aliases it for the second service. Nineteen
roles now follow, and tests/lint/repository/spot/test_volumes_target_single_spot.py
fails on any target spelled out twice. An alias carries no path, so the rule
cannot see it, which is the point.

The conversion was mechanical and is provable rather than reviewed: every file
was rewritten and then reloaded, and any file whose yaml.safe_load result
differed from the original was discarded instead of kept. Nineteen written,
none discarded. Anchors and aliases are a serialisation form; the loader
resolves them before any consumer sees a document.

validate_volumes_meta over all 81 meta/volumes.yml reports no violation.
make test-lint: 328 passed.

web-app-prometheus carries two unrelated deletions: comments that predate this
change and fell under the comment lint once the file was touched. They explained
a 0444 mode and a compose-only bind; both facts are visible in the entries
themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:29 +02:00
kevinveenbirkenbachandClaude Opus 5 6e93862dd5 refactor(gitlab): ship the config trees as swarm configs, not host binds
0bf1e8ef8 replaced Omnibus with the Cloud Native composition and brought nine
bind-mounted config trees with it. Their source directories are created by
tasks/01_config.yml, undelegated, so only on the play host; the peer-replication
branches in sys-svc-compose/tasks/utils/_render_one_template.yml:36-56 are gated
on `not IS_STACK_HOST` and skip entirely when the play targets the manager
alone. Under swarm any task scheduled on a worker therefore found no bind
source, was Rejected permanently, and stack_ready.sh reported "stuck:
gitlab_shell". The nextcloud-6 job dies there, before its own role runs.

Pinning the role to managers would fix the symptom and give up the distribution
the CNG split exists for. Swarm already solves this: it hands a config or secret
to every node that runs a task, so nothing has to pre-exist anywhere. The nine
trees become 26 per-file entries, and gitaly, shell, workhorse, webservice,
sidekiq and migrations are schedulable again with no placement constraint.

One mount moves on purpose. The gitaly image sets CONFIG_TEMPLATE_DIRECTORY=
/etc/gitaly and ships config.toml.tpl there, and entrypoint.sh calls
`set-config "$CONFIG_TEMPLATE_DIRECTORY" "${CONFIG_DIRECTORY:=$CONFIG_TEMPLATE_DIRECTORY}"`
under `set -eo pipefail` -- so a read-only config at /etc/gitaly/config.toml
would be overwritten and crash-loop the container. Pointing the variable at
/etc/gitaly/templates leaves no .tpl for set-config to expand (nullglob) and
gitaly still finds the file, because process-wrapper derives
GITALY_CONFIG_FILE="${GITALY_CONFIG_FILE:-$CONFIG_TEMPLATE_DIRECTORY/config.toml}".
Verified against registry.gitlab.com/gitlab-org/build/cng/gitaly:v19.2.1.

Every other mount lands where it did before: of the 32 config/secret mounts, 5
match an old target exactly and 27 sit inside the directory an old bind
provided. No mount moved unintentionally.

Repeated targets are anchored once and aliased after, so a rename is one edit.
gitaly-config.toml.j2 and shell-config.yml.j2 read their paths from the registry
through the volume lookup instead of repeating them.

Costs, both deliberate: swarm materialises configs and secrets as root:root and
neither emitter supports uid/gid, while GitLab CNG runs as uid 1000 -- so
secrets.yml, both .gitlab_*_secret files and the SSH host keys are 0444 and
readable by any process inside the container, not just GitLab. And a config
carries a content hash, so a one-character edit to gitlab.yml now rolls three
services instead of rewriting a file in place.

Checked without a deploy: validate_volumes_meta reports no violation; both
emitters produce identical configs: blocks under compose and swarm; a file
mounted inside a named-volume mountpoint survives (measured in a container); the
host keys are exactly the static pair 01_config.yml generates; no script or
binary in gitlab-shell:v19.2.1 refuses a permissive host key, and SSH_DAEMON is
gitlab-sshd rather than OpenSSH. make test-lint: 327 passed.

Not verified: nothing here has been deployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:29 +02:00
kevinveenbirkenbachandClaude Opus 5 c7e5b0a274 fix(funkwhale): assert the sign-in offer on Funkwhale, not on the logout page
The spec signed out and then asserted, on whatever page it had landed on, that
Funkwhale offers sign-in again. That page is never Funkwhale.

Both exits leave the app by design. The fallback navigates to
${appBaseUrl}/logout, but on every app vhost that exact path belongs to the
universal-logout service: roles/web-svc-logout/templates/logout-proxy.conf.j2
defines `location = /logout` with a proxy_pass, included through
sys-front-inj-all into the shared vhost. The in-app control is no better --
initLogoutPatch rewrites it to Keycloak's end-session URL with redirect_uri set
to the primary domain.

Measured in the run's trace rather than inferred:

  https://audio.infinito.example/logout   200, content-length 25
  body                                    "You have been logged out."
  clear-site-data                         "cache","cookies","storage"
  set-cookie                              sessionid=, csrftoken=, _oauth2_proxy=,
                                          _pk_* -- all Expires 1970, across the
                                          bare, app and parent domain scopes

So the sign-out itself works; the session really is gone. Only the assertion sat
on the wrong page, where no sign-in affordance can appear at any timeout.

Returning to appBaseUrl before the expectation puts the claim where it can be
true or false: the app, freshly anonymous. The locator is untouched -- 8aaac027d
and bfe7741c6 already settled how signInAffordance matches Funkwhale's bare-div
control, and this change does not revisit it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:29 +02:00
kevinveenbirkenbachandClaude Opus 5 a0cc3dbe40 feat(lint): make default.env the enforced source of the primary domain
default.env declares INFINITO_DOMAIN and everything is meant to derive from it.
Nothing checked that, so copies accumulated. A copy is a second source nobody
updates, and it fails in the least useful way: the code keeps running and
quietly talks about the wrong host. integration_mastodon is the worked example
-- it asserted the provisioned partner equalled a clearnet literal, so the spec
passed on a clearnet node and failed on an onion one while the product was
right in both.

tests/lint/repository/spot/test_no_hardcoded_primary_domain.py now fails on the
literal anywhere in tracked source. It reads the value it hunts for FROM
default.env, so the rule follows the SPOT instead of repeating it and would
otherwise be its own first violation.

Scope, and why each exclusion is not a loophole: default.env itself is the
source; .md is documentation; tests/ and scripts/tests/ hold fixtures that must
assert against a named host; inventories/ is where writing the real values is
the entire point; gitignored output carries the value because it correctly
derived it. Everything else is in.

Two real hardcodes are repaired rather than suppressed.
utils/domains/default_primary.py mirrors utils/roles/lifecycle.py -- one
function reading one key through parse_static_env -- and replaces the module
constant in utils/cleanup/nginx_vhosts.py and the argparse default in
cli/meta/domains/__main__.py. Both resolve to the same value as before.

Seven documentation sites become <primary-domain> placeholders instead of
carrying a nocheck: a Python docstring cannot host one without polluting the
docstring, and a placeholder does not rot when the default changes. The single
nocheck is scripts/system/tls/trust/wsl2.sh:61, where the marker is matched
verbatim against what an earlier run wrote into the Windows hosts file --
deriving it would orphan that block instead of replacing it.

Collateral, not intent: touching roles/sys-ctl-hlth-csp/files/script.py brought
it under the comment lint, which found five pre-existing narration comments.
Three deleted, `fn[:-5]  # strip ".conf"` replaced by fn.removesuffix(".conf"),
and the root-user comment rewritten in the sanctioned Exception form. Its 33
tests stay green.

tests/lint/repository/test_no_hardcoded_container_name.py (05f425ea5) is the
same family for INFINITO_CONTAINER and could move into spot/ later; this change
leaves it where it is.

make test-lint: 326 passed. The rule found 18 sites before the cleanup and none
after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:28 +02:00
kevinveenbirkenbachandClaude Opus 5 c14b1d13d6 fix(nextcloud): compare the Mastodon partner host against the deployed one
integration_mastodon asserted the provisioned oauth_instance_url resolved to
the literal "microblog.infinito.example". That value is not fixed: the addon
hook writes lookup('tls','web-app-mastodon','url.base') into it
(meta/addons/integration_mastodon.yml:12), so on an onion node the plugin
correctly received http://microblog.<onion>/ and the spec failed for being
right. The product was never wrong; the assertion was pinned to a clearnet
name.

The partner URL now reaches the spec the way every sibling bridge spec already
gets it, from the same lookup the hook uses:

  playwright.env.j2   MASTODON_BASE_URL, gated on services.mastodon.enabled
  _shared.js          read and exported as mastodonBaseUrl
  the spec            compares host against host, not against a literal

web-app-fediwall already wires MASTODON_BASE_URL from this exact lookup
(templates/playwright.env.j2:12), so the name and the source are the
established ones; this adds the gate, so the variable renders empty instead of
naming an undeployed partner.

The guard in front of the comparison is not decoration: without it an empty
MASTODON_BASE_URL would reach new URL("") and abort the spec with a TypeError
instead of an assertion anyone can read.

The evidence that this is the whole difference: integration_peertube runs the
identical shape against shared.env.peertubeBaseUrl and PASSED in the same red
run, on the same onion node where integration_mastodon failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:28 +02:00
kevinveenbirkenbachandClaude Opus 5 7538b8a0bb fix(e2e): let the persona probe actually wait for a slow SPA
Playwright's isVisible() is an immediate, non-retrying check; the timeout option
it accepts is ignored. Two call sites in the shared admin persona passed a
substantial timeout and behaved as if it applied:

  :75  gates the whole native-login path, called with probeTimeout=10_000 (:101)
  :141 the fallback that decides whether an authenticated surface was reached,
       called with resolveTimeout(15_000)

The trace of EspoCRM variant 1 shows the option never reaching the call. Every
navigation carries its own budget -- {"url": ..., "timeout": 60000} -- while the
probes record only {"selector": "input[type='password']:visible >> nth=0",
"strict": true}, with no timeout field at all. The first probe answers false
220 ms after it starts, against the 10 s it was given; the three fallback probes
that follow take 36, 32 and 29 ms. The whole native-login attempt, four
navigations and four probes, is over 1.15 s after it began.

That is not enough for an SPA. The DOM snapshot at the moment of the first probe
is ["BODY", {"data-id": "espocrm"}, ["DIV", {"class": "container content"}]] --
an empty shell, with espo.js and espo-main.js still in flight. The login form the
probe waits for renders later, so the persona falls through to /login, /admin/
and /admin, collects three Apache 404s, and asserts that the administrator never
reached an authenticated surface.

Both sites move to waitFor({state: "visible", timeout}), which is the waiting
form and is already the idiom elsewhere in this directory.

The four remaining isVisible({timeout}) calls in the file stay as they are: they
ask whether something is visible right now -- is the password field STILL there
after submitting, is a Login control STILL showing -- where an immediate answer
is the correct semantics and the timeout argument is inert but harmless.

Fixes the EspoCRM 1 and 2 failures and applies to every SPA the shared persona
drives; it does not touch EspoCRM 0, whose OIDC back-channel fails for an
unrelated reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:28 +02:00
kevinveenbirkenbachandClaude Opus 5 8c7593f43f refactor(container): make container_extra_hosts the only emitter of extra_hosts
extra_hosts is the one compose key a service may not carry twice. The onion SSO
back-channel pin was emitted by a Jinja partial that six roles included, while
six other services wrote the key by hand; nothing stopped a service from
receiving both. YAML then either rejects the document or keeps one list and
drops the other, silently.

The partial becomes plugins/lookup/container_extra_hosts.py, and every pin now
goes through it. Callers add their own entries with extra_hosts=[...], the way
container_volumes already takes extra_volumes=, and the lookup merges them after
the automatic pin and deduplicates in first-seen order. With nothing to emit it
returns an empty string.

The swarm address reasoning from 7b1839681 is preserved and now written down in
the plugin rather than only in that commit: the pin must NOT be host-gateway in
swarm, because that alias resolves per node while openresty publishes :80 with
mode: host under node.role == manager. It carries the stack host's address,
which reaches every task because the compose file is rendered once with the
manager as inventory host. The mode is read as compose_mode_force or
DEPLOYMENT_MODE, not the bare cluster mode, so a role with an override does not
lose its pin and its overlay at once.

Two roles kept a pin that now collides with the automatic one, and both are
resolved the same way -- the hand-written entry stays for clearnet and yields
the .onion case to the lookup, which picks the address per mode. In
web-app-mailu the three conditional entries move into a role-local
mailu_extra_hosts lookup exposed as MAILU_EXTRA_HOSTS, because an eight-line
conditional expression in the template argument is worse than the block it
replaced. It returns its entries as separate results, so vars/main.yml uses
query(); plain lookup() would join them into one comma-separated string.

tests/lint/ansible/jinja/test_compose_extra_hosts_use_lookup.py fails on any
literal extra_hosts: key in a role template, so the invariant survives the next
author who needs a pin. It follows test_compose_ports_use_container_ports_lookup:
the raw construct is forbidden, the lookup is not mandated per service -- a
service with no pins is correct without it, and the swarm branch raises when
facts are absent, which would turn a no-op into a failure surface for containers
that never speak OIDC.

Equivalence checked by rendering rather than by reading: under the trim_blocks +
lstrip_blocks the stack-host action forces for compose.yml.j2, both former call
site shapes (indented, and matrix's column 0) emit byte-identical text to the
new form when the block applies. The no-op case differs -- a whitespace-only
line where the include emitted none -- and yaml.safe_load returns the same
document for both.

31 unit tests; make test-lint 325 passed. Does not touch web-app-espocrm, whose
missing pin is a separate claim and gets its own commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:28 +02:00
kevinveenbirkenbachandClaude Opus 5 ccb2c82b8b test(svc-net-tor): let the probe report why a domain failed
The apex infinito.example has failed six compose jobs with HTTP 000 and no
explanation, because the probe throws curl's stderr away:

    curl ... "${scheme}://${domain}/" 2>/dev/null || true

--show-error was already set, so curl had the answer the whole time and the
redirect discarded it. Command substitution captures stdout only, so
%{http_code} and the accept regex at :61 are untouched, and || true still
swallows the exit code. test-e2e-cli/tasks/run_one.yml:85-92 prints stdout and
stderr when rc != 0, so the text reaches the job log.

This is NOT the fix for that failure. Three rounds of reasoning about it rested
on the premise that the apex answers 301 and curl follows it off-instance;
openresty.log:126 refutes it. That line logs the successful clearnet probe
"GET / HTTP/2.0" 200 with "Scheme: https" and User-Agent curl/8.18.0, so https
requests do appear in that log -- and the apex left no entry at all. curl never
completed hop 1, and there never was a 301 to follow. Whether the cause is
NXDOMAIN, a refused connect, or the 443 default_server's ssl_reject_handshake
(sys-svc-webserver-core/templates/default_server.conf.j2:17-20, which produces
exactly this signature: rejected handshake, 000 at curl, no access-log entry)
is what one curl error line will now say.

Costs up to six extra stderr lines per failing domain, RETRIES=6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:28 +02:00
kevinveenbirkenbachandClaude Opus 5 0c56709be7 fix(mobilizon): spell the TLS option the way its adapter reads it
6b941c1e5 gave the Keycloak strategy the internal CA and chose the key
deliberately, reasoning that Mobilizon configures Tesla.Adapter.Hackney, which
reads maps:get(ssl_options, Opts, []). The runtime says otherwise. That commit
was active in run 31658241728 and the container logged, for the same request_id:

  09:02:27.908 [notice] Invalid option {ssl_options,[{verify,verify_peer},
                        {cacertfile,<<"/tmp/infinito/ca/root-ca.crt">>},...]} ignored
  09:02:27.917 [notice] TLS :client: ... CLIENT ALERT: Fatal - Unknown CA
  09:02:27.923 [error]  ** (OAuth2.Error) Connection refused
                        oauth2/client.ex:312 -> ueberauth_keycloak .../oauth.ex:80
                        -> mobilizon lib/web/controllers/auth_controller.ex:89

hackney would have consumed ssl_options silently and correctly. "Invalid option
... ignored" is Erlang :httpc discarding a key it does not know, nine
milliseconds before the handshake failed on the CA it was meant to carry.
Mobilizon's global Tesla adapter is not the one in play: oauth2 resolves its own
via Application.get_env(:oauth2, :adapter, Tesla.Adapter.Httpc), and nothing
sets that. So the option list IS the httpc adapter's, and its key is ssl.

The visible failure was three steps downstream and looked unrelated: the
Keycloak callback returned 500, no session was created, and the Playwright
universal-logout assertion failed 63 seconds later on an anonymous page.

The empty branch is untouched -- without SSL_CERT_FILE the value stays [].

Not verifiable the way the predecessor was: it rendered the template and
evaluated it under Config.Reader on elixir:1.17-alpine, which proves the config
parses, not that the option reaches its consumer. That check would have passed
for ssl_options too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:28 +02:00
kevinveenbirkenbachandClaude Opus 5 bffe4ee04c fix(peertube): derive the webserver authority from the TLS lookup
The role set no webserver scheme or port, so PeerTube kept its upstream default
of https on 443 even on an onion node, where the vhost is plaintext and Tor
publishes port 80. redirect_uris.py meanwhile resolves the same deployment
through resolve_enabled and registers http://video.<onion>/* with Keycloak, so
the plugin asked for an https callback against an http registration and Keycloak
answered invalid_redirect_uri four times in its event log.

Both values now come from the tls lookup, which decides scheme and port together
from one resolve_enabled call -- the same predicate redirect_uris.py uses for the
registration and nginx_ssl_header.j2 uses for its listen directive. There is no
second expression left that could drift.

The port is not cosmetic. WEBSERVER.URL is built as scheme://hostname:port and
sanitizeUrl drops the port only for (https,443) and (http,80), so flipping the
scheme alone would have produced http://video.<onion>:443 -- still unmatched
against the portless registration, and against a port Tor never publishes.

095197a65 removed these two variables in 2022 because they were hardcoded to
https=false and port=9000, which is the internal listener and was wrong for every
proxied deployment. That fix stands: the upstream default is correct for clearnet
behind the proxy. It is only wrong where TLS is off, which is what this restores,
conditionally.

Evidence: run 31658270172 job 94319317216, artifact 9171666491 keycloak event log
(error="invalid_redirect_uri", redirect_uri="https://video.<onion>/plugins/
auth-openid-connect/router/code-cb"), and the peertube service log label
[video.<onion>:443] proving PORT was 443 at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:28 +02:00
kevinveenbirkenbachandClaude Opus 5 f7bb608927 refactor(nextcloud): sort files by language, and let Doorkeeper accept onion
files/ now mirrors the role's own structure: php, ruby and shell, each split
into addons for the per-integration scripts that tasks/addons/ drives and utils
for what more than one caller shares. set_encrypted_appconfig.php serves gitlab,
mattermost and zammad; doorkeeper_allow_onion_redirect.rb serves zammad and
openproject; init-gate.sh belongs to the role itself. The partner directories
survive because two scripts are both named provision_oauth_app.rb.

NEXTCLOUD_INIT_GATE_FILE had to split. It was the source path under files/, the
name Dockerfile.j2 COPYs into /usr/local/bin, and the ENTRYPOINT, all at once;
moving the script would have produced /usr/local/bin/shell/utils/init-gate.sh.
The basename keeps that name and NEXTCLOUD_INIT_GATE_SRC carries the path.

The same commit closes two of the three faces of one defect. resolve_enabled
reports TLS off for an .onion domain, correctly -- the vhost is plaintext and
Tor carries the transport crypto -- so every redirect URI built from
lookup('tls', ...) is http, and Doorkeeper rejects it. Zammad raises out of
app.save! and fails the task at rc 1; OpenProject discards result.errors, keeps
the unsaved model whose plaintext_secret already exists, reports
oauth_application_created:true, and only dies three tasks later on a null
client_id. The prelude relaxes force_ssl_in_redirect_uri for .onion hosts and
delegates every other host to the previous setting, so clearnet is unchanged at
both partners; the OpenProject script now raises with the real validation
errors.

The third face is PeerTube, whose Keycloak callback is registered http and
requested https, and it stays open: fixing it means changing either PeerTube's
scheme, which is its federation identity, or the registration side.

Evidence: run 31658270172, jobs 94319317307 ("Redirect URI must be an HTTPS/SSL
URI.") and 94319317278 (changed:true, then length on NoneType).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:27 +02:00
kevinveenbirkenbachandClaude Opus 5 4311acfdfd fix(ci): gate the image consumers on the fork image wait
test-dns depended on build-ci-images, which skips its push step for a fork PR
and reports success anyway, so the dependency was satisfied by a job that built
nothing. The images it pulls come from the sibling pull_request_target run, and
nothing made it wait for them: arch finished at 00:10:40 while the pull ran at
00:09:28. fedora was no safer -- it completed four seconds after the job even
started, and only survived because its pull fell later in the probe order. The
race was thin for four of five distros, so no retry count would have settled it.

The gate for exactly this already existed and only done consumed it.
wait-fork-prereq-run polls docker manifest inspect through
scripts/meta/wait/images.sh until every ci-<sha> distro ref is present, and
fails early once the privileged run ends unsuccessfully; fork-prereqs-ready
enforces its result. The three jobs that pull those images directly now depend
on it.

test-deploy-swarm-priority and test-instructions were exposed the same way and
are wired too -- gating only the job that happened to fail would have left both
to lose the next close race. The remaining test-deploy-* jobs inherit the gate
transitively, which was computed over the needs graph rather than assumed.

Same-repo PRs are unaffected: wait-fork-prereq-run carries no job-level if,
reports "implicitly ready" outside fork PRs, and the gate script accepts both
success and skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:27 +02:00
kevinveenbirkenbachandClaude Opus 5 28e917721e fix(healthcheck): stop deriving the HTTP Host from a kernel hostname
container_hostname is computed for sethostname(2) and clamped to 63 bytes, so a
v3 onion domain degrades it to the bare entity name. container_healthcheck
forwarded that value into the curl probe's Host header, and curl is the only
flavor that asserts a vhost identity on a loopback probe -- tcp and http_status
hardcode Host: localhost, wget and http send none. An app that validates Host
therefore rejected its own healthcheck: bookwyrm's Django answered 400 to
Host: bookwyrm and the container never turned healthy while serving fine over
its onion.

This was diagnosed once already. e9817f825 measured it A/B on pretix
(Host: pretix -> 400 x4 unhealthy, Host: localhost -> healthy), named reusing a
kernel hostname as an HTTP Host the underlying design bug, and deferred the
general fix because it would move six roles' probes at once. 9fb08b4ed then
pinned bookwyrm the same way. This is that deferred change, so there is no
third round of per-role pinning.

The header now comes from meta/services.yml and nowhere else. Of the eight
roles defining container_hostname, bookwyrm and pretix were already pinned and
bridgy-fed and postmarks use the tcp flavor, which never reads it. Of the
remaining four, chess never passed anything but its entity name and xwiki was
measured healthy while answering to Host: xwiki, a name in no vhost config; so
both are safe without a header. confluence and jira are pinned here rather than
argued about -- their Host is today the real domain on a clearnet deploy, and
Tomcat's ATL_PROXY_NAME being a proxyName and not an allow-list is reasoning,
not a measurement.

The templating contract moves from the play var to healthcheck.hostname, and a
new test fails if the fallback ever returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:27 +02:00
kevinveenbirkenbachandClaude Opus 5 5bc1653ea8 fix(ci): stop asserting the merge in the trusted context
For a fork PR both clauses of the gate were true, so assert-main-merged ran
once per event. checkout@v7.0.0 now refuses the fork-head checkout under
pull_request_target outright, and the trusted copy fails on every push while
the untrusted one passes. Dropping the second clause removes a checkout of
fork code held against the base repo token rather than authorising one with
allow-unsafe-pr-checkout.

Nothing is lost. The assertion reads git history and needs no token, the
pull_request run already performs it, and no job declares needs on it. Same-repo
PRs never matched the second clause anyway.

The three jobs that genuinely need the trusted context keep it untouched:
images-mirror-missing-fork pins source_repository to github.repository, so it
never sees fork code despite holding packages: write; build-ci-images-fork
switches to the fork head only behind the "Trusted" label; prepare-fork-merge-ref
checks out the base with no ref at all. Those were wired through
prepare-fork-merge-ref in 158d57342. assert-main-merged never was, which is why
the guard caught it alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:27 +02:00
kevinveenbirkenbachandClaude Opus 5 c541e7c22f docs(credentials): name the path the code actually reads
Eight prose references still pointed at the pre-rename root, and one of them
did more than mislead a reader: the addons lint told an author to reach a
secret through lookup('config', application_id, 'credentials.<name>') at the
exact moment it rejected their literal, so the guidance produced the next
defect. The check itself only tests whether the value is templated, so no
enforcement changes here.

variants.md gains the reason rather than a corrected list. A variant entry is
baked into host_vars raw, which makes its top-level key the inventory path, so
a key no meta topic produces is silently discarded. The previous list drifted
precisely because it stated the set without stating why it was the set.

design/variants.md was wrong twice over: the old root, and an argument order in
lookup('config', 'credentials', '<provider>') that could never have resolved.
The erpnext key was checked against svc-db-mariadb/vars/main.yml rather than
prefixed on faith.

Left alone on purpose: test_credentials_roundtrip and the layout lint name
meta/schema.yml and schema/main.yml as history and as the legacy directory the
lint forbids. Both are correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:27 +02:00
kevinveenbirkenbachandClaude Opus 5 cf0c7f2a40 test(variants): whitelist the top-level keys instead of one blacklist
The only shape check on meta/variants.yml rejected a single key, `compose`.
Its docstring stated the actual rule -- top keys must be payload roots -- and
never enforced it, and had itself gone stale: it still named `credentials`,
the root f713ba835 retired. That is how the previous commit's four blocks
passed every gate while writing where nothing reads.

The legal roots are now derived from the meta files instead of restated: every
roles/*/meta/*.yml stem, plus addons when that directory carries specs, minus
main and variants, which are mechanism rather than payload. A role adding a new
topic makes its root legal without touching this test, so the list cannot drift
from the loader again.

compose needs no special case anymore -- it is not a meta file, so it fails
like any other unknown key, as does a typo such as `servicse`. Verified by
running the pre-fix keycloak and lam variants through the new whitelist: all
four `credentials` blocks are rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:26 +02:00
kevinveenbirkenbachandClaude Opus 5 8a812c20ec fix(variants): pin the dev password where the role reads it
A variant entry is baked into host_vars raw, not deep-merged, so its top-level
key IS the inventory path. keycloak and lam still pinned their Playwright
administrator_password under credentials, while f713ba835 moved both roles'
vars/main.yml to secrets.credentials. The write and the read had been pointing
at different places ever since.

Nothing failed, which is the problem: the read resolves, because the generator
had produced a random password at the new path, so the deploy came up with a
secret nobody knows and the admin sign-in the pin exists for broke with no
trace. The inventory validator could not catch it either -- it widens the legal
keys with the variant overlays themselves, so the stale root was legal exactly
because the variant declared it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:30:23 +02:00
kevinveenbirkenbachandClaude Opus 5 8d45159233 style(lint): let ansible-lint say why it failed
The runner sent both streams to /dev/null and then classified by exit code, so
a failure produced one line -- "FAILED ansible-lint" -- with no code and no
message. A lint finding, a crash and a lock conflict were indistinguishable,
which is exactly the state a quality run left me in while ansible-lint was
clean on the host at the same commit.

Output now goes to a temp file, is discarded on success because ansible-lint is
verbose when it passes, and is written to stderr together with the exit code
when it fails. The accepted codes are untouched, so a passing run behaves
exactly as before. run_ruff already reports this way, which is why the TID252
error it hit was fixable on sight.

This changes no outcome. The failure that motivated it has not recurred, so the
new path is still unexercised against a real fault.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:20:01 +02:00
kevinveenbirkenbachandClaude Opus 5 d86913225b fix(inventory): exempt credentials at their renamed root
compare_application_keys skips generated credentials because no default exists
for a secret, but it recognised them by the pre-rename prefix. Once the
generator started writing them again, every credential arrived as
secrets.credentials.<key> and the exemption stopped matching, so validation
rejected three keys per credential -- the leaf and both intermediate nodes,
which recursive_keys emits separately -- and aborted the deploy before the
playbook ran. The condition now admits the bare secrets node and the
secrets.credentials subtree, leaving a sibling such as secrets.other flagged.

The check in the inventory manager stays on the bare prefix on purpose: it
walks meta/secrets.yml, whose own root key is credentials.

test_credentials_keys_are_skipped fed the validator the old shape, so it kept
passing while real inventories were being rejected.

Evidence: run 31655559799, job 94311023723,
"Missing default for web-app-dashboard: secrets.credentials.sso_proxy_cookie_secret".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:13:26 +02:00
kevinveenbirkenbachandClaude Opus 5 32d9755763 style(credentials): import the emit helpers absolutely
provision and credentials are sibling packages, so reaching the emit helpers
with a parent-relative import tripped TID252. Same-package imports stay
relative, as everywhere else in the package. The blank line before the
role_resolver import keeps the first-party and relative groups apart, which
I001 requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:45:56 +02:00
kevinveenbirkenbachandClaude Opus 5 0fd99b38ee style(inventory): drop narration the assertions already carry
The four comment lines above the assertNotIn block restate what those three
assertions state executably, and unlike them they cannot fail when the claim
stops holding. test_comments_valid lints only changed targets, so the block
stayed invisible until the previous commit made the file one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:44:23 +02:00
kevinveenbirkenbachandClaude Opus 5 1ba543dcc4 test(inventory): put the overrides test back in its class
test_apply_vars_overrides_from_file_deep_merge_and_overwrite sat at module
level while still taking self, so pytest treated self as a fixture request and
errored the test out at collection instead of running it. The assertions it
carries for apply_vars_overrides_from_file have therefore never executed.
Re-indenting it into HostVarsTestCase makes it a real test again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:42:57 +02:00
kevinveenbirkenbachandClaude Opus 5 8edabb3246 fix(credentials): merge snippets at the renamed root
The host_vars merger still wrote applications.<app>.credentials and read
"credentials" out of the generated snippet. f713ba835 moved the emitter to
secrets.credentials, so the read resolved to {} on every app: nothing was ever
copied, and _ensure_ruamel_map left an empty credentials: {} shell behind as
the only trace. Every credential generated since that commit was discarded on
its way into the inventory.

Both sides now go through credentials_map() and declared_credentials(), the
accessors the emitter already uses, so the two ends cannot drift again.
_ensure_ruamel_map was a copy of emit.ensure_map and is gone.

The failure surfaced in two shapes from the one cause. Templates reading
through lookup('config', ...) raise, because get() defaults to strict:
"Key 'secrets' not found in dict at 'secrets'" for
web-app-mastodon.secrets.credentials.secret_key_base, with the dumped payload
showing 'credentials': {} and no secrets node. Templates reading through
lookup('database', ...) stay quiet, because that plugin passes strict=False
with default="": friendica's MariaDB received an empty MYSQL_ROOT_PASSWORD and
refused to initialise.

The generator's own tests pinned the pre-rename shape in both the snippet and
the assertion, which is why they stayed green through the regression.

Evidence: run 31649221474, job 94303863545.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:40:39 +02:00