Commit Graph
14682 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 57f90fb731 test(lint): an upper bound must scale even when the literal hides behind a name
The rule read the character after `timeout:` and objected to a digit, so a
duration bypassed the helper the moment it was given a name. That was already
reachable before, and it became easy to reach now that a poll interval is
allowed to stay unscaled: the two look identical at the call site and only one
of them is correct.

They are opposites, and the rule now says so. An upper bound must scale, because
the answer over a Tor circuit arrives later and a fixed cap expires before it
does. A poll interval must not, because scaling one makes a slow target notice
its element later rather than sooner, which is the reverse of the tolerance the
scaling exists to buy. So `timeout:` rejects a CONSTANT_CASE name as well as a
digit, while `waitForTimeout` and `setTimeout` keep matching on a leading digit
only and accept a named interval.

Across the 1307 `timeout:` sites in scope the sharpened rule finds exactly one,
and it is a true positive that wanted saying out loud. The n8n LiteLLM spec
passes a completion timeout into an `n8n-nodes-base.httpRequest` node: not a
Playwright wait at all, but a parameter for a request n8n makes to LiteLLM
inside the deployment, which never crosses a circuit. Leaving it unscaled is
right and was invisible; it now carries the sanctioned marker and its reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 00:32:36 +02:00
kevinveenbirkenbachandClaude Opus 5 7f59f48ef1 perf(test-e2e-playwright): wait for the logout control instead of sleeping for it
Three waits in the logout flow were blind sleeps scaled by the onion multiplier.
A timeout is an upper bound and shrinks to nothing when the answer arrives early,
but page.waitForTimeout always burns its full duration, so multiplying it by five
spends the time whether the target is slow or not. The settle before the logout
sweep cost three seconds on clearnet and fifteen on an onion, every persona, every
role; the dropdown settle cost that again per menu trigger tried; and the poll
interval of waitForAnyLogoutCandidate was multiplied too, which made detection
five times coarser exactly where the target is slowest.

So the sleeps become waitForLogoutControl, a bounded poll that keeps the scaled
deadline and drops the scaling from the interval. It returns the moment a
logout-shaped element is visible, which is the condition the click that follows
needs anyway. Worst case is unchanged: with nothing to find it runs the deadline
out, exactly what the sleep did unconditionally. The first of the two
waitForAnyLogoutCandidate calls around the settle went with it; its result was
discarded, and the second still carries the thirty second deadline that covers a
navbar arriving late.

Four unit tests pin the contract, since the argument for the change is entirely
about when it stops waiting: it returns without sleeping when the control is
already there, returns as soon as one appears, costs at most the deadline when
none ever does, and polls on a fixed 250ms interval no multiplier touches.

Measured context, from run 34348821577: the same dashboard logout test takes
161.0 to 164.0s over Tor and 34.1 to 34.8s on clearnet across five jobs spanning
two distros, both deploy modes and three filesystems. A 1.9% spread across five
runner VMs is not network latency, it is a constant being multiplied, and the
ratio 4.73 is the multiplier itself. Solving the pair puts 32.0s of that test
inside scaled waiting against 2.3s of work. This commit removes the part of that
which is a sleep; the larger part is timeout caps on waits that expire and are
swallowed by a catch, which is a separate defect and stays for now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 00:31:02 +02:00
kevinveenbirkenbachandClaude Opus 5 28dd5462d7 fix(svc-net-tor): ask the running Tor for circuits instead of re-reading its config
The healthcheck ran `tor --verify-config`, which parses the torrc and never
speaks to the daemon. Ansible renders that file from a template, so it is valid
by construction and the probe could not fail once it existed: bootstrap
progress, circuit establishment and descriptor publication were all invisible,
and `retries` was a dead parameter. Swarm therefore reported the service healthy
while Tor was still coming up, and the readiness that actually gates deploys had
to be rebuilt beside it, in onion_self_reach.py and playwright's
wait_onion_reachable.sh.

The torrc already opens a cookie-authenticated control port, so the probe now
authenticates there and asks GETINFO status/circuit-established. That is the
question the container is supposed to answer. It needs no package the image
lacks: bash carries /dev/tcp and od hex-encodes the cookie, both present in
debian:trixie-slim.

Measured against real Tor, the public flavor reports healthy 6.1s after start
and chutney 54.6s, each confirmed by "Bootstrapped 100% (done)" in the container
log. Chutney is the slower of the two because its entrypoint brings up a whole
private network and waits for consensus before the node tor starts; its
localhost advantage only pays off afterwards, at publish and rendezvous. Both
fit ten minutes with margin to spare, so the flavor-dependent start window goes
and every timing is now the platform default. Three nocheck markers go with it,
including an interval of 60s whose own reason said the probe was "worth running
rarely" while setting twice the default frequency.

Two paths and one condition that had grown copies are pinned in vars/main.yml.
The Dockerfile takes the script locations as build args rather than repeating
them, reaching the entrypoint through a fixed symlink because ENTRYPOINT in exec
form would not expand the arg and would silently keep pointing at the old path.

One behaviour change worth stating plainly: a Tor that never establishes a
circuit is now reported unhealthy after the start window, where before it was
reported healthy forever. That is the point of the change, and it moves such a
failure from the later Playwright run to the service itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 23:21:03 +02:00
kevinveenbirkenbachandClaude Opus 5 72c17ffaf7 refactor(meta): read the video-embed exemption from info.yml too
The info-media marker moved to meta/info.yml one commit ago, and this rule is
where the reason it could not move earlier was written down: a comment-only
info.yml parses to an empty document, which the schema lint used to reject. That
obstacle is gone, so the docstring now states a justification that no longer
holds, and the two video-related exemptions sit in different files for no reason
a reader can reconstruct.

So the marker is read from info.yml, the failure message names info.yml, and the
docstring says why the marker lives there rather than why it cannot. No role
opts out of this rule today, so nothing under roles/ changes and the move cannot
break in either direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 22:28:43 +02:00
kevinveenbirkenbachandClaude Opus 5 4ac07edaea refactor(meta): move the info-media exemption to the file it speaks about
The marker names the reason a role has no upstream homepage or demo video, and
it sat in meta/main.yml because a comment-only info.yml parses to an empty
document, which the schema lint rejected outright. So the exemption lived one
file away from the two fields it explains, and a reader looking for the video
found neither the field nor the reason it was missing.

The schema lint now accepts an empty document exactly when it carries the
marker, and that is what allows the move. The media lint reads the marker from
info.yml rather than main.yml, and its failure message names that same file, so
the fix it suggests lands where the absent field would have gone.

A hundred and two roles follow. Every occurrence was a standalone comment on
the second line of main.yml, directly under the document start, so the move is
a line delete and an insert at the same offset. Seventy-three roles already had
an info.yml and gain the line; twenty-nine get one whose whole content is the
document start and the marker. All hundred and two reasons carry over verbatim,
including the thirty-five bespoke ones.

Nothing downstream sees a change. Of the four readers of meta/info.yml, three
coerce a null parse through `or {}` behind an isinstance guard, and
utils/cache/applications.py skips a falsy parse before assignment, so
applications.<role>.info stays absent for the twenty-nine exactly as it was
while the file did not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 22:26:38 +02:00
kevinveenbirkenbachandClaude Opus 5 96408f23a0 perf(healthcheck): let the start window outlast a real boot
start_interval is the cadence while start_period runs, and only while it runs.
Once the window closes the probe falls back to interval, so a service that
becomes ready just after it pays a whole tick anyway and the cadence never
reached it. Six of the seven flavors were shaped that way, Curl worst: a thirty
second window against a one minute interval.

Measured across 151 converge waits in four job logs, 63 sit exactly on a tick
boundary, 36 in the thirty second step and 27 in the sixty second one.
Attributed by application, web-app-seaweedfs is the largest at 1501s over
sixteen waits with a median of 57.7s: it probes with curl and fell straight into
the gap. web-app-zammad probes with connect, whose window is fifteen minutes,
and its 154s median is real boot time with nothing to recover.

So the window moves to ten minutes and the steady state interval to two, and the
flavor overrides that sat below them go: Curl, Wget and Connect lose interval
1m, Http its twenty second window, Nc its ten. Connect keeps fifteen minutes.

Neither number costs anything while a container works. start_period delays
nothing, it only stops failures counting, and interval no longer touches
convergence at all since that hangs on the window alone; it is steady state
load, which drops from 120 probes an hour per service to 30.

Forty six role files follow. Twenty seven declarations go that repeat the new
default or undercut it, an undercut window being the defect itself. Eight
windows longer than ten minutes keep their value and now say why. The literal
blocks and the three Dockerfiles this repository builds spell every timing out,
because outside the lookup an omission takes docker's value and not the
platform's.

A fourth lint rule keeps the shape: start_period must outlast one interval,
checked against the effective values, so raising interval without the window
cannot quietly bring the cliff back.

Two faults in the lint surfaced while building it and are fixed here. It
compared timings as strings, so 600s and 10m read as different values, and it
matched the healthcheck key exactly, so a line carrying a trailing nocheck was
skipped whole, which is how the two swarm-init partials went unexamined.

One regression, bounded. A container that starts and never turns healthy is
neither rejected nor errored, so stack_ready.sh only calls it fatal after three
killed tasks, and each cycle now spans the longer window. That reaches twenty
minutes rather than six, capped there by swarm_stack_converge_retries at 600
with a two second delay, and it lands only on runs that were failing anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:54:26 +02:00
kevinveenbirkenbachandClaude Opus 5 e2cca6b120 test(lint): a healthcheck timing matches its default or says why
Three checks over the two places a timing can be written.

In meta/services.yml the lookup supplies the defaults, so a declaration is
either noise or a decision. A value equal to what the service would have
inherited is deleted rather than justified: it changes nothing and hides the day
the default moves. A value that differs is kept and carries its reason.

That reason is required for interval, timeout and start_interval only. Those
three cost something: steady state load for the life of the deployment, the
threshold at which a slow probe counts as failed, and the only timing that moves
the moment a stack reports healthy. start_period and retries buy patience with a
container that is already broken and cost nothing while it works. Demanding a
defence for them would have produced sixty one sentences nobody had evidence
for.

Outside the lookup, in a literal healthcheck block and in a role's own
Dockerfile, an omission is a finding too. Nothing there inherits from the SPOT,
and docker has no daemon-wide default to fall back on, so a missing key lands on
docker's value rather than the platform's. They disagree on start_period, zero
against thirty seconds, which is exactly what left start_interval without a
window in every hand-written block.

Comparison is against the effective default, resolved through the same compose
the lookup uses. Curl probes every minute, Connect grants a fifteen minute start
window, and a prefix raises timeout, retries and start_period further; measuring
against Probe alone would have called out every service that simply agrees with
its flavor.

Negative control for each check. Before the three commits that precede this one
the rule named 47 restatements, 20 unexplained deviations and 34 definitions
outside the lookup. It names none now.

Two limits worth knowing. The line number for a meta timing is found by
indentation and service name rather than by a parser, so an unusually formatted
file makes the rule ask for a reason it cannot anchor; it errs towards asking,
never towards silence. And the Dockerfile reader follows a backslash
continuation but not a comment inside one, which no Dockerfile in the tree uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:16:56 +02:00
Alejandro Roman 7e62b544ea Merge remote-tracking branch 'upstream/main' into feature/role-stalwart-mail-swarm 2026-09-09 23:16:07 +04:00
kevinveenbirkenbachandClaude Opus 5 16be22007b fix(healthcheck): spell the timings out where the SPOT cannot reach
A healthcheck written outside the container_healthcheck lookup inherits nothing
from utils/docker/healthcheck, and docker offers nowhere to put a replacement.
daemon.json carries storage, logging, DNS, registries and runtimes, and no
health key at all, so an omitted timing lands on docker's own value rather than
the platform's. The two disagree where it matters most: docker starts a
container with no start period, which leaves start_interval no window to act in.
That is why the hand-written blocks never gained the cadence.

The three images this repository builds itself now carry it. openldap and
postgres also drop to the platform interval, and bluesky gains the start window
it never had:

  svc-db-openldap/files/Dockerfile         --interval=30s ... --start-interval=5s
  svc-db-postgres/templates/Dockerfile.j2  --interval=30s ... --start-interval=5s
  web-app-bluesky/.../Dockerfile           --start-period=30s --start-interval=5s

Six literal blocks move to the platform interval as well: magento, redis,
elasticsearch, matrix and both sys-svc-rdbms partials. Since start_interval
exists, interval no longer touches convergence and is steady state load alone.

Sixteen values that stay where they are now name the mechanism that keeps them
there. Two are worth reading. web-app-kix overrides start_period and nothing
else on purpose, letting the image's own HEALTHCHECK supply test, interval,
timeout and retries; filling those in would have overwritten what upstream tuned.
The memcached and redis swarm-init partials probe every second and deliberately
run with no start window, which is why start_interval is absent rather than
forgotten.

The rest name their probe: occ boots the whole Nextcloud stack, tor re-parses
its torrc instead of asking a running service, opensearch is still forming a
cluster, matrix waits on a file another step writes to a share.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:15:49 +02:00
kevinveenbirkenbachandClaude Opus 5 bcc467a435 refactor(healthcheck): name the timing defaults instead of inlining them
Probe carried the five platform timings as literals on its class body. They are
the answer to a question asked outside the class as well, by anything that has
to decide whether a declared value adds something or merely repeats what the
service would have inherited.

Name them, and let Probe read the names. TIMING_DEFAULTS collects the five so a
caller can walk them without restating the list and drifting from it.

No behaviour changes: every value is the one that stood there, and the class
attributes still resolve to the same strings and the same integer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:13:09 +02:00
kevinveenbirkenbachandClaude Opus 5 19f4ac3390 refactor(healthcheck): drop timings that repeat their flavor default
The lookup writes overrides.get(key, effective_default), so a service that
repeats what it would have inherited changes nothing and only hides the day the
default moves. Forty seven such lines stood in the tree.

Almost all of them came from the migration that moved the literal blocks into
meta/services.yml. The values were carried over verbatim so the move could be
proven lossless, which was right for the proof and left the no-ops behind.

The defaults they are measured against are the effective ones, not the base
class. Curl probes every minute, Connect grants a fifteen minute start window,
and a prefix raises timeout, retries and start_period further. Comparing against
Probe alone would have called out every service that simply agrees with its
flavor.

Eight services lose interval: 10s as well and fall back to their flavor's
cadence, thirty seconds, or a minute for decidim's curl. Since start_interval
exists, interval no longer touches convergence at all: it is steady state load
for the life of the deployment. That takes the five databases, matomo, snipe-it
and decidim from 360 probes an hour each to 120. It buys that with slower
failure detection, 180s to 540s for the databases, which is acceptable because
an unhealthy container fails the deploy whichever number stands there.

Twelve deviations that do cost something now carry their reason. Where the
mechanism is known it is named: ejabberdctl and rabbitmq-diagnostics start an
Erlang CLI node, mmctl spawns a binary and talks to the local socket, php artisan
boots Laravel on every probe, keycloak's management endpoint answers from the
JVM.

Two say instead that the value was carried over and never measured. './fider
ping' and pgrep both fit the five second default and no reason for ten is
visible, so the comment records that there is no evidence rather than inventing
one. They mark where a measurement belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:02:02 +02:00
Alejandro Roman 4287dee96b fix(swarm-stalwart): fix new issue on CI and allow videos on logic playwright 2026-09-09 22:45:08 +04:00
kevinveenbirkenbachandClaude Opus 5 b7ce27d40d test(lint): a variable-free healthcheck belongs in meta/services.yml
utils/docker/healthcheck owns the probe timings, and among them start_interval,
the cadence docker uses while start_period is still running. A block written out
in a template bypasses that owner and never gains the key. Nothing stopped it,
which is how forty six blocks ended up without the cadence and a stack converged
no faster than its coarsest interval.

The rule walks every .j2 under roles/, takes each healthcheck: key with the
lines nested under it, and reports the block when its body contains neither {{
nor {%. Such a block can move verbatim, because the lookup writes
overrides.get(key, default) for every timing key and a declaration repeating the
literal values renders the same block plus the cadence.

A block that interpolates is exempt rather than tolerated. Its value resolves in
the template's own scope, which meta/services.yml does not share, so moving it
would be a rewrite and the check would be asking for a different probe.

The opt-out is a nocheck on the healthcheck line or the one above it, resolved
through is_suppressed_at in same-or-above mode, the idiom both sibling rules
already use. It is meant for a block that interpolates nothing yet still cannot
move: a partial rendered for another role's application_id, or timings the
flavor defaults would make worse.

With the two existing rules this closes the triangle. One requires every call to
find a declaration, the second requires every declaration to reach a call, and
this one requires a declaration to exist wherever one is possible.

Negative control: run before the four nocheck markers were added, it named
exactly those four blocks and nothing else; run after, it is green.

One known limit. The body ends at the first blank line, so a block a future
author splits with one would be read only as far as the gap. No block in the
tree is written that way today, checked across all twenty one that remain
literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 20:33:14 +02:00
kevinveenbirkenbachandClaude Opus 5 d0a8608bfa perf(healthcheck): give every probe the start cadence 42ebd9ea71 added
42ebd9ea71 gave the healthcheck SPOT start_interval, the cadence docker uses
while start_period is still running. A block a template writes out itself
bypasses the SPOT and never gained it. Measured across the repository: 46 such
blocks, 40 of them already carrying start_period, not one carrying
start_interval.

Without it a probe fires only every interval, so a service ready after two
seconds is reported healthy at the next tick and a stack converges no faster
than its coarsest quantisation. That lands in sys-svc-compose's swarm converge
wait, 1551.7s per CI job over 38 stack deploys.

Twenty five of the blocks move into meta/services.yml and render through
container_healthcheck. Each keeps an explicit test rather than picking a flavor:
a flavor would replace the command instead of carrying it across.

The move is lossless by construction. probes.py block() writes
overrides.get(key, default) for interval, timeout, retries, start_period and
start_interval, so a declaration repeating the literal values renders the same
block plus the missing cadence. Checked per block against the old values: the
only difference is start_interval 5s, nothing changed, nothing lost.

Twenty one stay literal. Seventeen interpolate a template variable, whose value
resolves in a scope meta/services.yml does not share, so moving them would be a
rewrite rather than a transcription. Four interpolate nothing and still cannot
move: svc-db-memcached and svc-db-redis probe every second in their swarm
service partial, where a 5s start cadence would report healthy later than the
block already does, and the sys-svc-rdbms and keycloak sso_proxy partials render
for the consuming role's application_id, so their declaration has no single
meta/services.yml to live in. Each carries a nocheck naming its reason.

All twenty one gain start_interval. Three also gain start_period, without which
the key does nothing at all, since it only applies while the start period runs.
That widens their failure tolerance rather than narrowing it: bluesky from 90 to
120 seconds, magento from 50 to 80, the mariadb partial from 180 to 210.

pixelfed spelled its probe as a bare string and now spells it CMD-SHELL. Docker
reads both as a shell command; only the YAML shape changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 20:31:07 +02:00
kevinveenbirkenbachandClaude Opus 5 cce3d48e65 fix(mattermost): give the server plugin tree its own directory per node
The volume mounted at /mattermost/plugins was an NFS export shared by every
replica, so a redeploy had one task remove the tree while the outgoing task
still held the plugin binary open. Measured on task .1.ixph4yrvwfbo7mgghcbdsyreh:

  13:41:15.658  [outgoing]  plugin exited  mattermost-ai
  13:41:17.280  [incoming]  Removing local installation of managed plugin before sync
  13:41:17.304  [incoming]  Failed to remove ... unlinkat plugins/mattermost-ai/server/dist: directory not empty
  13:41:24.227  [incoming]  Failed to install prepackaged plugin ... destination already exists
  14:28:23.936  [incoming]  Access to route for non-existent plugin  mattermost-ai

NFS replaces a still-open file with a .nfsXXXX entry on unlink, so unlinkat on
the parent returns ENOTEMPTY. The removal fails, the directory survives, the
reinstall cannot move into it, and that node answers 404 on the plugin routes
for the rest of its life while the service VIP keeps sending it traffic. On a
local filesystem unlinking an open file is legal, so the failure cannot arise.
Four of six replicas in the same run had no overlapping predecessor and came up
clean.

becd8f1577 fixed the sibling volume and deliberately left this one shared, on
the grounds that uploaded plugin archives live in it. That reason does not
hold. Upstream server/channels/app/plugin.go lists bundles from
fileStorePluginFolder, reads them with fileReader and calls installPluginLocally
after removePluginLocally, which is the code path that emits the log line above;
the local directory is the copy and the file store is the original. In this
deployment the file store holds no plugin at all: every replica logs "No plugins
to sync from the file store" and the bundle comes from
/mattermost/prepackaged_plugins inside the image.

So plugins and client-plugins are the two halves of one per-node extraction. The
activation error names bundle_path plugins/mattermost-ai while failing to copy
into client/plugins, and splitting their lifetimes is what left one half node
local and the other shared. They now share a lifetime.

Two consequences worth stating. The flag only takes effect on a volume that does
not exist yet, because docker does not recreate a named volume when driver_opts
change and stack deploy --prune prunes services; a cluster already running the
NFS stack needs the volume removed per node. And the worker copies leave the
export's backup path for svc-bkp-volume-2-local, which runs manager-only, so
they are covered by nothing. The content is extracted per node from the image
on every boot, which is what the nfs-false-data-loss exemption argues.

The README promised persistence through both plugin volumes. That has been
untrue for client-plugins since becd8f1577, so it now says what the volumes are
and where an uploaded bundle actually persists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 20:03:14 +02:00
kevinveenbirkenbachandClaude Opus 5 5324eadd8b fix(ai): resolve the gateway backend from the inventory, not from one host
LITELLM_BACKEND_ROLES read lookup('deployment').groups, which deployment.py
fills from group_names. That variable is host-scoped: it lists only the groups
the host currently being processed belongs to.

On a swarm round the manager carries svc-ai-ollama, svc-ai-lmstudio and
svc-ai-litellm while the workers carry three groups and none of them is an AI
backend. Both branches of LITELLM_CHAT_MODEL therefore missed on a worker and,
with OPENROUTER_API_KEY empty, the model resolved to the empty string.

Only one task made that visible. Every stateful task in the gateway include
carries delegate_to STACK_HOST plus run_once, so it templates in the manager's
scope and wrote the correct model into the database. The assert carries
neither, so it re-templated in each worker's own scope and failed there while
the manager passed.

The manager-only membership is not a placement decision. The swarm test
inventory is provisioned with a single --host MGR, so every role of the round's
closure gets that one host, and extend_inventory then widens only
svc-swarm-node, svc-storage-nfs-client, svc-storage-nfs-server and the round's
own application. The three AI roles declare no placement at all; they are
manager-only because nothing widened them.

Add an inventory selector next to the host-scoped one, built from the play-wide
groups magic var, skipping groups without hosts and Ansible's implicit all and
ungrouped, sorted so the cache key is stable. The key takes it too, or two
hosts with equal group_names but different inventories would share an entry.

The blast radius is two readers, both in 16_ai.yml, and both ask only whether
svc-ai-ollama or svc-ai-lmstudio is present. On the manager the new value gains
exactly svc-storage-nfs-server and svc-bkp-nfs-2-local, neither of which either
reader tests, so the manager's answer does not move and only the workers change,
to match it.

This is the third step on the same line. 46a16127fc set .running; b149ad0d22
corrected that to .groups because a targeted apps= round leaves only the
command-line roles in the whitelist. .groups solves the targeted round and not
the multi-host one, because group_names stays host-scoped either way.

Measured in a two-host play with a real group_vars file and the real lookup:
the worker resolves ollama false through group_names and true through the new
selector, the manager is true both ways, and the assert that failed in CI
passes on both hosts.

Requirement 036 named the host-scoped backend selection as the thing that has
to change and deferred it. That residual is closed, so the paragraph now
describes the selector in use and why the two rejected ones are wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 19:28:44 +02:00
kevinveenbirkenbachandClaude Opus 5 1d54eb7367 fix(front-proxy): stop redeploying the proxy when a backend answers 5xx
The drift check that runs on an unchanged vhost escalated on any status the
app does not accept. That escalation notifies restart-openresty, whose handler
runs container service update --force at a measured 40.32s mean over 186 fires,
and openresty sets service_update_order stop-first, so the update stops the
serving task before the replacement starts.

A 5xx cannot justify that. OpenResty emits one only after a server block
matched and proxied, so the vhost is loaded and the proxy is not at fault.
Restarting it takes the front end away from every other vhost and leaves the
backend exactly where it was.

Require the status to be a stale-config signature as well, the same [-1, 404]
the sibling escalation has used since 2c192ab7e6. Ansible's uri returns -1 when
the TLS handshake is rejected, which is what default_server.conf.j2 does when no
server block matches, and 404 is what that same default server returns on port
80. Both mean the vhost is not loaded, which is the only case a task redeploy
repairs.

The sibling's reload arm is deliberately not copied. It reads openresty_reload,
which reload.yml registers only when a reload ran. This branch notifies nothing,
so the variable would carry the previous domain's result.

default(-1) keeps the test total. The probe has no when of its own and shares
the block guard with the escalation, so site_check is always registered and a
missing status can only mean a probe that got no response.

Across 98 job logs the block executed 6 times, answering 200 four times and 405
twice, and all six keep their outcome. The other 488 of 494 evaluations skipped
the block outright, because a fresh CI host renders every vhost as changed. A
repeat deploy of the same inventory is what reaches this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 19:03:07 +02:00
kevinveenbirkenbachandClaude Opus 5 fa7083ba07 perf(keycloak): resolve the RBAC root group once, not once per app
The per-app mapper loop included three tasks that resolve the groups.path root
group id: a container exec, an assert and a set_fact. Their environment is
KC_CONTAINER, KC_REALM and KC_RBAC_ROOT_PATH, none of which mentions kc_app_id,
so every iteration re-fetched the same id.

Move them above the loop, right after the purge that clears orphan groups under
the root. The purge works below the root and leaves it standing, so the id
resolves against the same state the first iteration sees today.

per_app_root_id was read only by the assert and the set_fact, and both moved
with it. What the loop still reads is kc_root_group_id, now a host fact set
four lines before the include, and it feeds the same ROOT_GROUP_ID env as
before. One include site, one reader, both checked across the repo.

The assert stays and now runs once. It checks a loop-invariant value, so
repeating it per item proved nothing the single evaluation does not.

Worth 168 seconds per CI job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 18:17:41 +02:00
kevinveenbirkenbachandClaude Opus 5 bb5a8e9ad1 perf(volumes): answer one role from its own file, not from a whole-tree build
get_canonical_volumes fell back to _build_application_defaults whenever the
registry was empty, which walks all 265 application roles and parses roughly
6600 meta files to answer for one. That fallback is not rare: the applications
carrier exists so the defaults build never runs inside a worker, so every
forked worker starts with an empty map and the first call pays the whole tree.

Read the role's own meta/volumes.yml instead, through the same
_load_yaml_any_cached loader and the same ROLE_FILE_META_VOLUMES path the
builder uses, and memoise per application_id. Old and new agree for all 266
roles, 0 mismatches, 86 non-empty either way, and a repeat call returns the
same object so the read-only contract callers rely on is unchanged.

The empty result is stored under its id as well. Today the builder only writes
non-empty dicts, so .get() cannot tell absent from empty, and a role without
the file would re-stat it on every call.

687f1243cd added a forced defaults build in plugins/lookup/volume.py for the
registry-miss case. That stays and its two guard tests still pass; the miss it
covers can no longer happen for a role that ships a volumes file, because the
first call now reads that file.

Measured in the nfs_prep cluster, where the tasks render the lookup in their
loop and when expressions and otherwise do nothing: 294 to 371 seconds of
excess over the p5 floor per job across four CI logs, of which 186 to 232
seconds sit in the eight to ten cold-page-cache executions this removes
outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 18:16:20 +02:00
kevinveenbirkenbachandClaude Opus 5 2c192ab7e6 perf(front-proxy): escalate to a redeploy only when the vhost is not loaded
The post-reload probe escalated on any status the app does not accept, and a
forced OpenResty task redeploy costs a measured 40.32s. In four CI job logs it
fired 11 times per job on 502 and 500 only, never on 404 and never on a
connection failure.

Those statuses refute the escalation rather than justify it. OpenResty emits
5xx after it has matched a server block and proxied, so they prove the vhost is
loaded and the reload took effect. The default server cannot emit them at all:
it returns 404 on port 80 and rejects the handshake on 443, and it carries no
proxy_pass. In eight of eight round-one escalations the upstream app had not
been deployed yet when its vhost was probed, the deploy trigger following the
502 by up to 52 seconds.

The log also holds a control. A full 40s redeploy completes three to six
seconds before every one of the 28 post-reload probes, notified by another
pending restart in the same flush. Seventeen then pass and eleven do not, same
proxy, same recency, so the differentiator is the backend. The escalation
answers that by running the same redeploy again, and nothing re-probes after it.

Narrow the trigger to the two signatures that do prove a stale configuration,
plus the case where the reload never landed because the container was
mid-recreate, which reload.yml tolerates. Worth 438 to 470 seconds per job.

The status test also gains the connection-failure case. Today the
`status is defined` clause excludes it, so the one symptom a genuinely stale
config produces could never fire the escalation at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 18:11:38 +02:00
kevinveenbirkenbachandClaude Opus 5 b62cb14004 perf(openresty): read the healthcheck from the SPOT that already has a start cadence
The proxy hand-wrote its healthcheck block, so 42ebd9ea71 never reached it.
Without a start_period docker waits a full interval before the first probe,
and the block declared interval: 30s. Measured on docker 29.7.2, time to
healthy: 30.13s for that block, 5.10s once the SPOT adds start_period 30s
and start_interval 5s.

That cadence is what the swarm redeploy handler waits on. Force OpenResty
service redeploy measures 40.32s mean over 186 fires across four CI job logs,
with a spread of only 40 to 42 seconds. A fixed timer, not work: 40.32 minus
the measured 30.13 leaves 10.19s of scheduling. At 47 fires per job that is
roughly 1176s.

Moving the probe into meta/services.yml keeps the three declared values
identical, since the Probe defaults already matched what was written by hand.
Only start_period and start_interval are added.

The earlier verdict is not premature: the image answers HTTP on port 80 after
0.43s and the config test passes at 0.39s, both far inside the 5s first probe.
docker stack config confirms the swarm loader preserves both keys, and
svc-db-postgres already ships them through the same SPOT on every distro axis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:17:27 +02:00
kevinveenbirkenbachandClaude Opus 5 8a1118a30d perf(mailu): loop over the users Mailu provisions, not the whole catalogue
The account loop walked every user any role declares, 291 of them in a CI
round, and let each task discard the rest through its own when:. That still
costs a task execution per user per task: 1706 of 1746 executions reported
skipping, at 0.146-0.157s each, for 259.5s of a single job.

A role-local mailu_users lookup returns the subset instead, in the dict
shape lookup('users') yields so the call site keeps dict2items. The
predicate is the union of both gates the tasks apply: a mailbox account, or
a bot role, whose API token Mailu issues. Filtering on the mailbox alone
would quietly stop issuing tokens to a bot without one.

The per-task when: conditions stay. The returned set is a superset of what
each of them selects, so it decides how often they are evaluated, never
their outcome.

A non-list accounts declaration raises rather than being filtered away, the
way reserved_users.py already treats it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 16:44:50 +02:00
kevinveenbirkenbachandClaude Opus 5 6db3efdd87 perf(test-e2e-playwright): drop the fixtures copy the tree copy already made
Staging copied every role fixture twice. 3b60b36fb2 added the flat
fixtures fileglob when no recursive stager existed; 8e92f15f6a then
introduced the tree copy with a trailing-slash source, which subsumed it,
and the older pair stayed behind.

Both wrote to the same destination from the same source, so the second
copy reported ok rather than changed on every run. The mkdir preceding it
created an empty directory for 23 of 25 roles: only web-app-dashboard and
web-app-peertube ship a fixtures tree, and their specs reach into it
relatively from inside the copied tree.

Costs 21.17s per CI job before the retry-pass multiplier, which stages
web-app-nextcloud three times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:16:02 +02:00
kevinveenbirkenbachandClaude Opus 5 401de9bfba test(iam): let the password-quote rule be suppressed where it misfires
PASSWORD_TOKEN_RE matches the token anywhere inside a Jinja expression, so a
path or a filename carrying it demands "| quote" as loudly as a credential
does. It fired on Moodle upstream CLI script:

    roles/web-app-moodle/tasks/01_manager_ops.yml:55: In shell tasks, password
    expressions must include quote: {{ [MOODLE_CLI_DIR, reset_password.php] | path_join }}

The credential on the next line already carries the filter. Listmonk hit the
same shape earlier and was resolved by renaming the file, which is not open
here: the name belongs to Moodle.

The rule had no suppression at all - it never called is_suppressed_at - so a
marker in the role would have been silently ignored. Teach it the marker, then
use it.

Suppression is honoured at the finding line OR at the shell: key that opens the
block, and the second placement is the one that matters. A marker beside the
finding necessarily sits inside the scalar, and this block is folded: lines
join with spaces, so a hash would comment out the rest of the command and
swallow --username, --password and --ignore-password-policy. On the shell: key
it is a YAML comment instead. The failure message now says so, so the next
false positive explains itself rather than repeating the discovery.

Verified the command is untouched by loading the task and reading the string
Ansible would hand the shell: no hash, no marker, byte-identical.

Note the cost: the marker grants the whole block, not one expression, because
a per-expression marker cannot be placed. This block loses the guard against a
future unquoted addition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 14:01:38 +02:00
kevinveenbirkenbachandClaude Opus 5 aa4d3d13a7 style: apply make autoformat
Reflows the cat argument list in the CA trust-bundle task. The scalar is
folded, so the wrapped closing braces fold back to a space and the shell sees
the same string.

It landed unformatted because the cluster work ran targeted checks only -
ansible-lint on the touched task files, ruff, shellcheck, the unit and lint
suites - and never a full autoformat, which is a writer rather than a checker.
The first complete gate pass afterwards collected it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 14:00:16 +02:00
evangelostsak dc6aeee471 fix(sys-svc-compose): let docker stack deploy adopt pre-created overlays
A network pre-created by the util carried only the compose labels, so
`docker stack deploy` could not recognise it as belonging to the stack:
`getStackNetworks` filters on `com.docker.stack.namespace`, finds nothing,
and the deploy retries the create and aborts with "network with name
<entity> already exists".

This is reachable whenever a network is pre-created for a role whose stack
has not deployed yet. web-app-prometheus does exactly that for every
native-metrics app, which broke the swarm deploy of web-app-openbao.

Stamping the namespace label makes the owning stack adopt the existing
network instead of recreating it. Verified against docker 29.1.3 on a
throwaway swarm: without the label the deploy fails as above, with it the
stack adopts the same network id, and an external consumer stack deploying
with --prune leaves the network intact.
2026-09-09 14:00:05 +02:00
kevinveenbirkenbachandClaude Opus 5 42ebd9ea71 perf(healthcheck): probe on a start cadence instead of the full interval
The emitted block carried test, interval, timeout, retries and start_period,
but never start_interval. Docker therefore fires the probe only every interval
for the whole start_period, so a service that answers after three seconds is
still reported healthy at the next tick: 30s for the base flavor, a full minute
for the curl ones (probes.py:119,131,215). The swarm converge wait sits on that
signal, so every stack pays the quantisation of its slowest service.

Emit start_interval, defaulting to 5s and overridable per service like every
other timing. This changes when the same probe runs, never what it tests:
test, timeout, retries and start_period are untouched, so a service that never
becomes healthy still fails after exactly the same grace and the same attempts.

Supported by the engine that runs it: the failing job reports Docker 28.0.4 and
Compose v2.38.2 (runtime.txt), and start_interval has existed since Docker 25.0.

The saving is reasoned, not measured. Expected padding is about half an
interval per service, so roughly 500-1000s across 17 stacks and two passes,
but only for services that are genuinely ready well before their interval. How
many those are is what a run has to answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 13:49:51 +02:00
kevinveenbirkenbachandClaude Opus 5 c66781ab19 fix(playwright): size the worker count from free memory, not total
_ram_gb read mem_total_mb while mem_available_mb sat next to it, added by
3e9deab6f7 for the drill abort and never carried over here. The ceiling exists
to stop the sidecar from competing with what already runs, so measuring the
machine instead of what is free made the term inert: on a 16 GB runner it
computed five workers while 5.5 GB was actually available, and the CI cap was
the only thing that ever bound.

Reading MemAvailable makes the guard real and self-limiting, which matters now
that the onion case raises the count. Against the memory the run itself reports:

    PASS 1, 9113M free    clearnet 1    onion 2
    PASS 2, 5550M free    clearnet 1    onion 1

Also withdraws a CI CPU divisor I had added on a false premise. It cited the
matrix telemetry stall60 as evidence that reserving three cores of four buys
nothing on a CI runner - but mem_stall_pct reads /proc/pressure/memory
(utils/env/runtime.py:84), so that figure proves memory headroom and says
nothing about CPU. Raising the clearnet count needs CPU evidence this run does
not carry, and a contention flake would cost coverage, so the divisor stays at
four until something measures the CPU side.

The onion lift keeps its own justification, which never rested on that number:
a browser waiting on Tor circuits does not consume CPU, measured at 4817s over
Tor against 911s on clearnet for the same specs on the same cores.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 13:48:25 +02:00
kevinveenbirkenbachandClaude Opus 5 a112364e17 perf(playwright): let the worker count answer to the transport
The sidecar is the dominant cost of an onion round and almost free without it.
Measured on one Nextcloud variant at equal mode and filesystem, differing only
in the onion axis, sync pass:

    test-e2e-playwright   4817s over Tor    911s on clearnet   +5.3x
    sys-svc-compose       2294s             2174s              +120s
    everything else                                            < +25s

The whole Tor surcharge sits in one role. Tor burns no browser CPU, so a 5.3x
wall clock against identical specs on identical cores is circuit latency: the
phase waits rather than computes. The CPU divisor that sizes the clearnet count
does not describe it, and on a 4-core runner it yields exactly one worker,
which leaves the CI cap of 2 unreachable.

compute_workers takes an onion flag and, when set, multiplies the count and
caps it separately. The CI ceiling is overridden rather than respected: it
describes CPU contention, which Tor does not create. ram_workers keeps its
veto, because circuits are cheap and browsers are not.

The lookup reads the transport itself, from the same source the role derives
its own flag from, lookup(tls, application_id, url.base) tested for .onion,
following the _swarm_prefix idiom in plugins/lookup/image.py: read from
variables, render through the templar, fall back to the safe answer. One truth
about the onion state, and a caller asking for a worker count cannot forget to
pass it.

Factor 2 and cap 4 rather than more: each worker holds its own circuits and the
Tor daemon is the next bound, which no measurement here reaches. Expect the
sidecar near 2.6ks per pass instead of 4.8ks, roughly 4.4ks per onion job
against a 20.4ks budget. It does not help a clearnet round, where the sidecar
costs 911s to begin with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 13:11:32 +02:00
kevinveenbirkenbachandClaude Opus 5 403a35ed95 perf(front-tls): reload the proxy for a redirect config, do not redeploy it
Writing the per-domain HTTP->HTTPS redirect notified restart-openresty, which
runs container service update --force and waits for a swarm rolling update. It
fired once per domain. Measured across both passes of a green Nextcloud round:
the forced redeploy ran 47 times at a mean of 40s, and 34 of those
notifications came from this task; the remaining 11 came from the genuine
fallback in sys-stk-front-proxy. The role accounted for 1178s of a 9694s sync
pass and 1043s of the async pass, so roughly 2.2ks of an 18.8ks job against a
20.4ks budget.

The redeploy is not needed here. plugins/lookup/nginx.py resolves
files.domain to conf.d/servers/<protocol>/<domain>.conf, which sits inside the
conf.d DIRECTORY bind, and handlers/reload.yml states the rule for exactly this
case: a directory keeps its inode across an atomic file write, so the running
container already sees the new file and SIGHUP is enough. Only nginx.conf is
bound as a file whose inode the task pins at create time, and nothing here
writes it.

The choice was never made deliberately: a46348b6cf only renamed
"restart openresty" to "restart-openresty" in this file, carrying the topic
forward unchanged.

Correctness does not rest on the reload being sufficient. sys-stk-front-proxy
probes each domain after the reload and notifies restart-openresty when it is
not served, a guard that demonstrably fires today. If SIGHUP falls short the
redeploy still happens; the worst case is no saving rather than a broken vhost.

Expected saving is 1.5 to 1.75ks per job, about 8% of the distro budget. That
makes the comfortable jobs comfortable and leaves the heaviest Nextcloud
variants marginal; it does not on its own end the budget kills.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:56:47 +02:00
kevinveenbirkenbachandClaude Opus 5 6683d89470 fix(onlyoffice): validate the JWT secret against the generator that makes it
algorithm: random_hex emits secrets.token_hex(64), which is 128 lowercase hex
characters. The declared pattern demanded 64 characters from a set that
includes uppercase, so it could never match a value its own generator produced.
web-app-gitlab and web-app-bigbluebutton already spell the same algorithm as
^[a-f0-9]{128}$.

Nothing enforces the validation field today - utils/cache/schema_credentials.py
treats it as a leaf marker only - so this corrects a declaration that lied
rather than a live failure. It matters the moment anyone wires the field up.

Found while triaging the ONLYOFFICE connector in run 34294297836; the same
unsatisfiable shape sat in web-svc-coturn, where the credential has since been
replaced by a declared user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:35:35 +02:00
kevinveenbirkenbachandClaude Opus 5 b5fcd535de fix(ca): extend the container trust store instead of replacing it
SSL_CERT_FILE, CURL_CA_BUNDLE and REQUESTS_CA_BUNDLE replace the trust store;
only NODE_EXTRA_CA_CERTS extends it. compose_ca.py pointed all four at the
deployment own root CA, so every OpenSSL and rustls client in every container
was blind to every public certificate. Measured here, one variable, same URL:

    unset                          curl https://registry.npmjs.org/ -> 200
    a single unrelated CA          -> 000 unable to get local issuer certificate
    that CA + the system bundle    -> 200

PeerTube was the one process that hit it: its plugin install fetches from
registry.npmjs.org at runtime through a client that honours SSL_CERT_FILE, and
it failed identically in compose and in swarm with

    invalid peer certificate: UnknownIssuer

Everything else reaches its artefacts at image build time, or through Node,
whose NODE_EXTRA_CA_CERTS is additive and therefore kept working.

The replacement was never a posture. e7e8cf8c03 calls these variables an
"env-based CA trust fallback", f1ea036bd2 adds them "instead of falling back to
a plain docker run without CA trust", e79183f96a wires the extra CA in "so npm
traffic through the cache validates". Every commit framed them as adding trust.

sys-ca-selfsigned now builds ca-bundle.crt beside the root CA on every node,
from that node own files: the stack host mints the CA and workers fetch it, so
there is no cross-node dependency. The bundle is the root CA concatenated with
the first present CA_BUNDLE_CANDIDATES entry, the SPOT fc0881ff33 introduced.
The cat argument list is assembled in Jinja, so a host with no public bundle
emits the root CA alone, which is exactly the previous behaviour, rather than an
empty argument that would fail. bundle_host joins the existing stray-directory
sweep and the build runs after it.

compose_ca.py uses the bundle only when the file exists and is non-empty, so a
host that has not built one keeps the previous behaviour instead of binding a
missing path that Docker would materialise as a directory. The php ini, msmtprc
and the Matrix flavour may name the container path with no fallback because the
bundle is written at task 99 and the php ini at task 123: no host can carry the
ini without the bundle.

CA_TRUST_CERT and NODE_EXTRA_CA_CERTS stay on the single certificate. keytool,
occ security:certificates:import and update-ca-certificates each take one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:32:44 +02:00
kevinveenbirkenbachandClaude Opus 5 a8cea953f8 fix(sys-svc-rdbms): find the engine by running, not by healthy
The compose flush recreates a dedicated postgres moments before this probe
runs, and its healthcheck sits on a 10s interval, so health=healthy cannot have
reported yet. The probe returned empty, the realign task below is gated on that
output being non-empty, and it skipped silently. The rotated POSTGRES_PASSWORD
therefore never reached the surviving data volume, and ./launcher rebuild
discourse died minutes later:

    PG::ConnectionBad: connection to server at "192.168.9.2", port 5432 failed:
    FATAL:  password authentication failed for user "discourse"

Measured in the failing job: the engine started 01:29:44.757, postgres logged
"database system is ready to accept connections" at 01:29:46.737, and the probe
ran at 01:29:48.419 - 1.68s after the socket was already live, and 3.66s after
a start whose first healthcheck could not fire before ~10s.

Running is the predicate the operation needs, not a loosening.
set_postgres_superuser_password.sh reaches the cluster over the container local
socket by peer auth, which is exactly what lets a rotated credential be written
into an engine nobody can log into any more, and it carries its own readiness
loops: 30 x 30 x 2s around SHOW hba_file and 15 x 2s of TCP auth retry. Against
that 60s budget the observed start-to-ready times were 1.98s, 4.05s and 5.51s
across three engines, an 11x margin.

The other call site, sys-svc-compose post_deploy, already waits for healthy
before reaching this file, so running matches there too and its behaviour is
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:24:28 +02:00
kevinveenbirkenbachandClaude Opus 5 f66e76da08 refactor(coturn): give the TURN relay account a declared system user
COTURN_USER_NAME came from a bare config string, services.coturn.username, and
COTURN_USER_PASSWORD from a role credential named user_password. Neither says
what the account is. compose.yml.j2 shows it: with user_auth_enabled the
turnserver runs --lt-cred-mech --user="name:password", a Long-Term Credential
that WebRTC clients present to be allowed to relay media. That is a machine
account, so declare it as one.

meta/users.yml now holds coturnconsumer with accounts: [], the shape
roles/user/meta/users.yml already uses for root, daemon, bin and sys, and the
same shape the reservation logic auto-creates for role names. No mailbox, no
identity, no host login - an account that exists to carry credentials.

The secret moves with it. users_generator.py pins a vaulted password into the
inventory at users.<name>.password for every user of the resolved role set that
has none, and get_merged_users overlays those inventory values over the role
declaration, so lookup(users, coturnconsumer).password is generated once and
stable across runs. A user may declare password: as a policy dict
(algorithm, validation) when the application demands a shape; a TURN credential
does not, so none is declared. secrets.credentials.user_password is therefore
unread and leaves meta/secrets.yml, and services.coturn.username leaves
meta/services.yml.

auth_secret is untouched. It drives the other, mutually exclusive branch
(--use-auth-secret), which is the one this deployment actually uses:
user_auth_enabled is false in the only place it is set and true nowhere, and
web-app-matrix and web-app-socialhome consume auth_secret alone.

Existing inventories keep an orphaned secrets.credentials.user_password. The
credentials generator is purely additive and never prunes, so it stays and is
read by nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:20:47 +02:00
kevinveenbirkenbachandClaude Opus 5 8114a7a2b1 fix(moodle): give the site administrator the administrator password
install_database.php was the only place that ever wrote the Moodle site
administrator password, and its failed_when tolerates "Database tables already
present". That is not laxity: upstream aborts in install_database.php before
install_cli_database() writes the password once tables exist, so the role
swallowed exactly the path in which the write is unreachable. After the
credential rotation gate Moodle therefore kept the install-time secret while
templates/playwright.env.j2 rendered the rotated one, and the login answered
"Invalid login, please try again". The same job proves it: variant-2/sync
reports failures="0" with the spec passing in 5.6s, variant-2/async
failures="1", and only the rotation gate lies between them.

The deeper problem was the credential itself. secrets.credentials.user_password
described itself as "Initial password for the Moodle admin user" - a role-local
secret for an account that is not role-local. Moodle hosts the platform
administrator, so read that user instead, the way web-app-baserow,
web-app-gitlab, web-app-penpot, web-app-matrix, web-app-bluesky and
web-app-pihole already do. lookup(users, administrator).password is stable
because roles/user-administrator/meta/users.yml pins it to
ansible_become_password; only a user with no declared password re-renders
{{ 42 | strong_password }} on every evaluation.

vars/main.yml now holds username, password and email as one constant each, so
install, realign and the Playwright env cannot drift apart again, and the
now-unread credential leaves meta/secrets.yml.

Realigning uses admin/cli/reset_password.php. Verified against the pinned
release v4.5.11: --username and --password are non-interactive (lines 35-37,
prompts only when empty at 76 and 88), --ignore-password-policy is required
because the platform password is not chosen to satisfy Moodle policy (line 96),
and the "when" gate is required because line 84 resolves only auth=manual rows
while tasks/04_oidc.yml rewrites every row to auth=oidc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:10:54 +02:00
kevinveenbirkenbachandClaude Opus 5 2d993141d2 fix(mattermost): let the OAuth token exchange reach a self-hosted issuer
Mattermost 11.11.0 routes its OAuth token and userinfo requests through the
same outbound filter as webhooks and link previews. Upstream commit 232d968f435e
"Filter OAuth endpoint requests that hit reserved IPs" changed two lines in
server/channels/app/oauth.go:

    1102 -> 1117  a.HTTPService().MakeClient(true)  ->  MakeClient(false)
    1144 -> 1159  a.HTTPService().MakeClient(true)  ->  MakeClient(false)

MakeTransport(trustURLs=true) returns NewTransport(insecure, nil, nil), so
before the bump neither a host nor an IP filter applied to that path at all.
Keycloak resolves into a reserved range in this topology, so every sign-in now
ends at /error?message=Token+request+failed:

    AuthorizeOAuthUser: Token request failed., Post
    "https://auth.<domain>/realms/<realm>/protocol/openid-connect/token":
    address forbidden ... IP 172.30.0.10 is in a reserved range and not in
    AllowedUntrustedInternalConnections

Name the issuer host, which is the smallest grant Mattermost offers. A verbatim
hostname short-circuits the dial before DNS and before the IP check
(shared/httpservice/client.go:136), so no CIDR is needed; a CIDR would open the
whole stack to webhook and link-preview SSRF instead. Both guarded legs are
listed because an inventory may override USER_INFO_URL to a different host, and
they are comma-joined because splitFields splits on whitespace and commas while
a space would need quoting in a dotenv value.

Not CI-only: IsOwnIP treats any address bound on a local interface as reserved,
so a single-host production deployment hits the same block. The cost is that
every MakeClient(false) consumer may now dial that one hostname on any port,
since Mattermost offers no port or path granularity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 11:56:47 +02:00
kevinveenbirkenbachandClaude Opus 5 dca9db9283 fix(test): resolve the PHP vendor tree against a declared floor
ebb835d541 raised phpunit/phpunit from ^11.0 to ^13.3. PHPUnit 13 requires
PHP >=8.4.1 and ubuntu-latest ships 8.3.6, so composer install could not
resolve and test-unit-php failed on every CI run:

    - phpunit/phpunit[13.3.0, ..., 13.3.2] require php >=8.4.1
      -> your php version (8.3.6) does not satisfy that requirement.

Nothing in this repository installs a particular PHP. ensure_php_toolchain
takes whatever the distro php package is, so the vendor tree was resolved
against an ambient interpreter that no file named. A dependency floor above
that interpreter is then only discovered by the resolver, on the machine that
happens to have the oldest PHP.

Declare the floor and resolve against it. config.platform.php binds composer
everywhere, including Dependabot; require states the same for readers; the
phpunit constraint follows it. Packagist floors: 11 -> >=8.2, 12 -> >=8.3,
13 -> >=8.4.1.

Verified in the dev container: the pinned resolution downgrades phpunit
13.3.2 -> 12.5.34 and the suite passes unchanged, 26 tests, 56 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 02:15:56 +02:00
kevinveenbirkenbach 465cd60d48 Merge branch 'main' into feature/ai-agent-employees 2026-09-09 01:53:25 +02:00
kevinveenbirkenbachandClaude Opus 5 a2b32b5cb9 test(mcp): let a provider extend test.env beyond the shared template
test_every_role_renders_the_shared_template demanded that a provider render
exactly the shared key set. That equality held only because no MCP provider
had extended its own test.env yet; other roles do so freely (svc-runner adds
17 lines, svc-db-postgres 8), so it was an accident of the current set rather
than a rule anyone stated.

The guarantee the docstring names, that every key of the shared template
resolves, is a superset check. Assert that instead, and name the provider in
the failure.

Unblocks the four role-local keys the Zammad AI gateway probe (b42f217efa)
needs to reach the gateway from inside the rails container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:53:17 +02:00
kevinveenbirkenbach 4a5992f2a4 Merge pull request #602 from infinito-nexus/update/docker-image-versions-web-app-erpnext-20260908-85a24e8
update(images): update Docker image versions (web-app-erpnext)
2026-09-09 01:52:13 +02:00
kevinveenbirkenbach 193fa086f6 Merge pull request #603 from infinito-nexus/update/docker-image-versions-web-app-espocrm-20260908-031b4db
update(images): update Docker image versions (web-app-espocrm)
2026-09-09 01:51:05 +02:00
kevinveenbirkenbach 7a9f862f77 Merge pull request #604 from infinito-nexus/update/docker-image-versions-web-app-mattermost-20260908-1e14d72
update(images): update Docker image versions (web-app-mattermost)
2026-09-09 01:50:44 +02:00
kevinveenbirkenbachandClaude Opus 5 f5413db03c fix(listmonk): keep the password lint off a filename
test_quote_in_shell matches any token containing 'password' anywhere in a shell task and requires it to carry '| quote'. In the realign task that token was the filename in the stdin lookup, not a credential: the password itself already goes through '| quote' on the line above. The check was right to look and wrong about what it found, because the name invited it.

Renaming removes the cause. A nocheck would have blinded the check at that spot for good, including for a genuinely unquoted password added there later, and 'credential' describes the file just as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:49:17 +02:00
kevinveenbirkenbachandClaude Opus 5 d611131423 fix(lint): enumerate docs/ without asking git
The check listed docs/ through git ls-files so untracked build artefacts stayed out of scope. The exclusion was right and the instrument was not: the gate runs the lints in the debian container, which owns the checkout under a different uid, and git refuses such a repository outright. git ls-files docs exited 128 there while the same test passed 483 times on the host, so the failure was visible only in the environment the gate uses.

iter_non_ignored_files walks the tree and applies .gitignore, which excludes the same artefacts without a git invocation. test_urls_reachable already falls back to it for this reason; using it directly drops the fallback branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:48:00 +02:00
kevinveenbirkenbachandClaude Opus 5 32698fc7ab test(playwright): fail a helper no spec and no sibling reaches
playwright.config.js collects **/*.@(spec|test).js and nothing else, so a .js file that no sibling requires never runs while still reading as part of the suite. It survives refactors, gets maintained by mistake, and a merge can resurrect one long after the spec that used it was renamed away: web-app-openwebui/files/playwright/test-ollama-chat.js came back exactly that way.

A file now earns its place by matching the collector or by being required through a relative path from somewhere else under the same role's files/playwright/. Resolution follows Node, including the bare and /index.js forms, and stays inside one role because that is the unit Playwright is staged in. .test.js counts alongside .spec.js so the lint cannot drift from the collector.

The repository already satisfies this: 477 files, 185 collected, 292 helpers, none orphaned. That zero is also the evidence the resolver works, since a broken one would flag hundreds. Checked in both directions besides: a planted orphan is caught, the nocheck marker clears it, and renaming it to .spec.js clears it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:47:19 +02:00
kevinveenbirkenbachandClaude Opus 5 1d52090d17 test(roles): require one embeddable video per invokable role
The root-README overview renders a Video link per role and a reader following it expects a demo, but 26 of the 62 entries addressed a channel, a playlist or a product page: youtube.com/@zammadhq, /channel/UC..., framatube.org/c/mobilizon/videos, pgadmin.org/videos. None of those can be embedded and none shows the product without a second click.

Each replacement was resolved through YouTube's oEmbed endpoint, which returns the real title and channel, so a candidate could be checked rather than assumed. That caught a supposed LibreOffice intro published by an unrelated channel, a Discourse overview belonging to a hosting vendor, and a Zoom video answering 403 with embedding disabled. Fourteen of the new entries come from the vendor itself and two are PeerTube.

The reachability half needed the same endpoint for a different reason: YouTube answers a removed video with HTTP 200 and a Video-unavailable page, so a plain fetch proves only that YouTube is up, while oEmbed answers 400. Measured both ways before relying on it.

The check is external because reachability needs live HTTP, and like its siblings it reports an unanswered probe as unverified rather than failing, so a firewalled runner cannot redden a good URL. Only the shape is asserted, because that is a repository fact.

README.md follows rather than leads: overview.py renders the Video column from this field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:46:36 +02:00
kevinveenbirkenbachandClaude Opus 5 5e6884b16d feat(update): keep pinned pip requirements from ageing unnoticed
The version cron updates docker image tags and repository refs, and tests/external/update covers docker, addons and repository. Neither sees a version inside an Ansible task variable, and dependabot reads pyproject.toml rather than roles/. A pin set once therefore froze and nothing thawed it: backup-docker-to-local has not moved since it was written, and the automtu pin one commit back would have gone the same way.

utils/update/pip mirrors the docker module: collect the pins, ask PyPI, rewrite in place. Only exact == pins are collected, because a range or a VCS reference states an intent an updater must not overrule. The external check warns rather than fails, like its docker sibling, so a slow PyPI cannot redden a run and a release the repository has deliberately not taken is not a regression.

The cron job is the docker job with one script swapped, so a changed pin arrives as its own PR under update/pip-pins.

Executed: the collector finds both pins in the repository; a synthetic file rewrites from 1.0.0 to 1.1.1 with the indent, quoting and neighbouring line untouched; automtu==1.0.0 would be reported while a bare name and a git+ reference are passed over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:44:51 +02:00
kevinveenbirkenbachandClaude Opus 5 3cf057cdc6 fix(sys-svc-container): pin the automtu the deploy installs
sys-pip-install passes package_name straight to ansible.builtin.pip and defaults package_state to latest, so a bare name pulled whatever PyPI served that day and nothing in the repository recorded which version a host ended up with. svc-bkp-volume-2-local already pins this way.

A version inside the name is compatible with state=latest: the pip module rejects only the separate version parameter, its guard reads 'state == latest and version is not None', so this resolves as pip install -U automtu==1.1.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:42:54 +02:00
kevinveenbirkenbach f6f6a89148 Merge pull request #605 from infinito-nexus/update/docker-image-versions-web-app-seaweedfs-20260908-1013abd
update(images): update Docker image versions (web-app-seaweedfs)
2026-09-09 01:33:43 +02:00
kevinveenbirkenbach 8de889d7ca Merge pull request #606 from infinito-nexus/update/docker-image-versions-web-svc-coturn-20260908-8565c55
update(images): update Docker image versions (web-svc-coturn)
2026-09-09 01:33:18 +02:00