Commit Graph
52 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 7fd495cdfc feat(ci): replay a role's README instructions on its smallest deploy row
The instructions test held a job of its own that picked one random role per
run, so a README's Production block was proven roughly once per 265 roles.
The replay now rides on a deploy row instead: no extra runner, and every role
the sweep touches gets its documented one-command deploy exercised.

The row is the role's smallest variant -- fewest services enabled -- because
that is the shape the README describes, and it is chosen among the variants
the sweep actually deploys, so a row cut as redundant coverage cannot silently
drop the role from the test. A role qualifies only when its README carries a
Production block and the guide's own mode is one the role runs and tests.

The marked entry carries the guide mode in `instructions` and a 📖 in its job
title, between the variant number and a priority ⭐. The guide mode is
independent of the mode the rotation gave the row: a swarm row still replays
in compose. The step runs last in the deploy job, after the swarm teardown,
because the replay deploys the role a second time and needs the runner's
ports.

call-test-instructions.yml, cli.meta.ci.guide_select and the `instructions`
dispatch input go with it. `redundant()` moves next to the marker so the
chunker, the plan table and the marker share one definition.

The plan table trades its 📊 Weight column for 📖 Instructions; nothing read
the weight any more, so it leaves the matrix entries too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 16:57:04 +02:00
kevinveenbirkenbachandClaude Opus 5 bfde5876ba fix(ci-trigger): resume the regular line behind the green window, not the deployed one
The offset a retrigger carries skipped every row the source run merely
started. A row that failed or was cancelled therefore retired its place in
the ranking: the regular line resumed past it, and the row came back only on
the priority line, pinned to the one combination it was recorded in. Every
other combination of that row was silently dropped from the sweep.

resume_offset now stops at the first row that is not green, so only proven
coverage is skipped. passed_selections supplies it; the three token
comprehensions that only differed in their state filter collapse into one
helper. deployed_selections and settled_selections keep feeding
--include-unrun unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 16:25:51 +02:00
kevinveenbirkenbachandClaude Opus 5 a206afaff5 fix(flowise): give the fixture nodes the keys buildChatflow iterates
The MCP registry reconcile retried thirty times and failed with Flowise's own words:

    FAILED calling qdrant_list_collections on svc-db-qdrant: 500
    {"message":"Error: predictionsServices.buildChatflow - inputParams is not iterable"}

fixture_flow_data hand-builds an Agentflow v2 definition, and both of its nodes
carried inputs, outputAnchors and outputs but no inputParams. buildChatflow iterates
that key, finds undefined and throws, so the fixture flow could be created and was
dead on its first invocation.

Empty is the right value rather than a placation: the fixture puts its arguments in
inputs literally, so there is nothing for Flowise to resolve. inputAnchors comes
along because a Flowise node carries both and the next node written here would
otherwise trip over the same edge.

The new test asserts the three keys on every node the fixture emits, not on the two
that exist today, so a third node cannot reintroduce the bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:13:04 +02:00
kevinveenbirkenbachandClaude Opus 5 7d8157a08d fix(networks): emit nothing rather than a childless networks key
Both renderers build their output as a list of lines seeded with the "networks:"
header and append one line per attachment. With no attachment to append they
still returned the bare header, and a mapping key with no children parses as
null, not as an empty mapping. Compose then rejects the whole file instead of
ignoring an empty section.

The two functions do not share their non-empty paths and disagree on where the
newline goes -- render_compose_networks terminates with one, render_container_networks
leads with one -- so the guard is written out in each rather than factored into a
helper that would have to know both shapes.

Both callers already treat an empty string as "insert nothing", so returning it
needs no change on their side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 11:05:51 +02:00
kevinveenbirkenbachandClaude Opus 5 3cc032fd1e refactor(layout): fold the persisted-credential patches into a patch/ folder
1f870b1e5 left two marks the layout rules reject. friendica's utils/ held
patch_config.yml beside patch_db_credentials.yml, which is a shared
delimiter-bounded prefix, and its numbered files ran 00, 01, 02, 04, 05 with
nothing at 03 after the config patch moved out of the sequence.

The prefix becomes the folder it was standing in for, the way 8e8dbbd08c did it
for the AI files. All four roles move, not only the one the lint could see: the
path post_deploy.yml resolves a role's declaration through has to read the same
everywhere, so a folder in one role and a prefix in the other three would be no
domain at all.

  utils/patch_db_credentials.yml -> utils/patch/db_credentials.yml   (4 roles)
  utils/patch_config.yml         -> utils/patch/config.yml           (friendica)

The numbers close up behind the file that left: 04_addons and 05_user_bootstrap
become 03 and 04.

Four references follow: the include in 01_manager_ops.yml, the lookup in
post_deploy.yml, the caller path in the unit test that reads each role's
declaration, and a requirement doc that linked the old addon file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 01:54:25 +02:00
kevinveenbirkenbachandClaude Opus 5 19a8580728 fix(volumes): render a tmpfs size as the byte count compose demands
f85996b43a turned the tmpfs options into a dict so a declaration could carry a
mode as well as a size, and coerced the mode to the int docker expects. The size
sitting next to it kept being copied through verbatim. The short form accepts
"512m" and so does a human writing the declaration; the long form this renders
takes an integer and refuses the string rather than parsing it:

  services.checkmk.volumes.1.tmpfs.size must be a integer

That is the whole of Checkmk#0 in run 33317483326, and it stops the deploy before
the role does anything.

The suffix is resolved the way the mode already was, in a helper beside it, and
a value that is neither an int nor a byte count is refused instead of passed on
-- passing it on is what let "512m" reach compose in the first place.

The existing test asserted {"size": "64m"}, so it pinned the defect: the suite
stayed green while CI failed on it. It now asserts the byte count, every unit a
declaration may write resolves, and three malformed sizes raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 01:17:54 +02:00
kevinveenbirkenbachandClaude Opus 5 6d191555c3 feat(ci-trigger): retrigger by role, and by what never got a verdict
Two switches and an alias for the case f4c5a7cc36 was not written for: a run
where most of the ranking is red and much of it never ran.

--roles-only collapses the failed selections to their role ids. f4c5a7cc36
replaced exactly that form with pinned tokens, and its reason holds: the
rotation may not draw the combination that broke. It holds for a handful of red
rows. At thirty roles the question stops being which combination broke and
becomes whether the role comes up at all, and a token per combination spends
the priority budget proving the same role red twice. The default is unchanged;
this is the trade, named, behind a flag.

--include-unrun puts the rows the source run holds no job for on the priority
line, with their axes. resume_offset covers a run that walked a contiguous
prefix; it stops at the first gap, and a run whose priority line consumed the
budget before the head of the ranking leaves a hole at row one. Then the offset
is empty, the retrigger starts over, and the rows nobody judged queue behind
the ones already settled.

With --strict the two agree on what settles a row. Only a success or a failure
does. A cancelled row, or one still running when the run was read, was never
given the chance to be either, so it comes back as itself rather than as a role
name that would let the rotation draw some other combination instead. On the
run this was written against that moves 24 rows: 123 started, 99 judged.

Membership is decided on role#variant. Mode, onion state and distro are
assigned by the sweep rotation, so comparing full tokens between a source run
and a freshly computed ranking reports almost every row as unrun -- measured,
266 of 269, against 177 once the identity is right. Each ranking row is a
distinct role#variant, which is what makes it the identity to compare on.

i8ciall carries the combination, next to i8cifail which reproduces the exact
red rows and i8cifo which takes only the hard failures.

resume_offset and the new code now build their tokens through one helper
rather than two copies of the same five lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 16:14:25 +02:00
kevinveenbirkenbachandClaude Opus 5 ab2062da2a feat(sys-svc-rdbms): give the persisted-password patch a script of its own
The body was a Jinja-interpolated shell string inside the task. As a file it
can be read, shellchecked and run, and the three parameters travel through the
environment the way the password already did.

That forces one thing into the open. The old expression carried
${PATCH_PASSWORD} and the shell expanded it while assembling the command; a
value arriving in an environment variable is not expanded a second time, so the
substitution has to be explicit. The consumers now write @PASSWORD@ and the
script replaces it, which is also the version a reader can follow without
knowing that rule.

The script refuses to run without its parameters rather than treating an unset
one as empty, because an empty volume name would look exactly like a node that
does not hold the volume: a silent success on every host.

Tests move with it, to the files/ segment every other test of a shipped script
uses. They run it against a stubbed `container` in three states per consumer -
no volume, volume without the config, both present - and assert the marker the
task reads as a change. Five mutations of the script were checked against them:
dropping the substitution, the marker, the existence test, the parameter guards
and the volume filter each turn the suite red.

Layout follows what the linters already require: files/shell/ is enforced by
role-files-layout, and 65e34746fc names ansible.builtin.script as the sanctioned
way to run one from a task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:16:20 +02:00
kevinveenbirkenbachandClaude Opus 5 d7de6e3805 fix(compose_volumes): leave the two-engine diagnostic to its owner
b067d21ef added the realignment helper as an argument to compose_volumes(),
so it now runs before that function reaches its own guarded call. A role with
both engines enabled therefore raised the bare ValueError through the lookup,
and the message that names the collision and says to pick one dbtype per role
had become unreachable. The helper catches it and defers.

No role can trigger this today: over all 265 meta/services.yml, none declares
both keys at all, and `enabled` has a single shape across the tree, the literal
True in 54 places. The guard protects the diagnostic for whoever adds the
second engine, which is exactly when a bare ValueError would be least useful.

The tests pin what the two startup failures cost to find. The SQL must stay one
ALTER USER IF EXISTS: root must not appear, because giving it a password during
the entrypoint's temporary server breaks a readiness probe that connects
without one; CREATE USER must not appear, because the entrypoint's own carries
no IF NOT EXISTS and the script runs under set -e; and the GRANT must not come
back, because rotating a credential changes passwords, not privileges.

Verified by rendering the version those tests were written against: it puts
root, CREATE USER and GRANT in the file and four statements in place of one, so
every assertion fails on it. The deferral was checked the same way, against the
module as it stands in HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:56:51 +02:00
kevinveenbirkenbachandClaude Opus 5 1e88eb9d1d fix(swarm): let a rollout survive one task that failed and came back
update_config never set failure_action, so swarmkit's default applied: pause,
at a max_failure_ratio of zero. One failed task stops the rollout for good, and
nothing in the deploy resumes it.

That contradicts the restart_policy three lines below. The policy replaces the
task and the service ends up healthy, while the update status stays latched and
service_converged.sh reads that status before it looks at any task, so the
deploy reports a failure over a service that is running. The rescue artifact
shows the sequence: the old task shut down, its replacement tripped over a
containerd object runsc could not reap, and the third task was Running while
the job died on "update latched".

Four swarm jobs failed that way, all in the update pass, never in the first.

The convergence check is untouched and stays as strict as it was: a genuinely
broken spec fails every task and still trips "tasks not running yet".

What this does give up: the latch was also the only thing catching a service
that keeps crash-looping. At ratio zero any failure latched, so a task dying
and restarting forever was guaranteed to be reported. Now the rollout
completes, and the check samples task state at one moment - a task on its
fiftieth restart still reads as Running, and wait_converged's `until` needs a
single lucky sample. Restoring that detection needs the check to look at task
churn, which the service ps history already carries and the script prints only
on failure today. Not in this commit.

max_failure_ratio cannot substitute here: at replicas 1, the common case in
this repository, one failure is always the whole service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 12:43:47 +02:00
kevinveenbirkenbachandClaude Opus 5 52b4504035 fix(container-dns): let the bridge be declared instead of guessed
Whether a resolver answers on the docker bridge was inferred from the host's
own resolv.conf: all entries loopback was read as "a listener of ours is there,
and containers must reach it on the bridge". A systemd-resolved stub binds
127.0.0.53, NetworkManager's dnsmasq binds 127.0.0.1, and a node dnsmasq binds
both those and the bridge. All three look identical from resolv.conf and only
the third is reachable from a container.

So ordinary hosts were handed an address with nothing behind it. The
Instructions job died there: daemon.json carried "dns": ["10.208.0.1"] alone,
and the openresty build could not resolve dl-cdn.alpinelinux.org while the
daemon, resolving through the host, pulled the same base image fine. Two netns,
two outcomes.

e308e18923 had already fixed this once, with a reachability gate whose message
names the same URL. b09f2cbd82 dropped that gate and ce43263745 put the
resolv.conf inference in its place, so the guard was gone and only the missing
docker0 fact still hid it. 285ba1e76c removed that accident and the failure
surfaced.

svc-net-tor and NETWORK_CONTAINER_BRIDGE_RESOLVER now say it instead, the
latter false by default and true in the swarm lab, which provisions the dnsmasq
its nodes resolve through. Extra-vars are global, so the lab's NFS and backup
hosts carry it although no listener is installed there; the clearnet resolver
behind the bridge keeps them working.

The docker0 fact is gone from the address too. It is not merely late, it is
wrong while it is late: facts are read before the daemon config is applied, so
docker0 still holds docker's default and writing default-address-pools moves
the bridge out from under it. A swarm node shipped "dns": ["172.17.0.1", ...]
against a live 10.208.0.1, which is why its containers asked a dead address and
fell through to the public resolver, where infinito.example does not exist. The
pool answers before the daemon exists and answers the same afterwards, and it
is read per slice rather than per supernet, which differ once a pool does not
start on a slice boundary.

A list that would carry only the bridge now gets NETWORK_PUBLIC_DNS_RESOLVERS
behind it, so a declaration that turns out to be wrong costs a wasted first
query rather than all name resolution. An empty declaration raises.

Rendered from the repository's own variable files: the Instructions host yields
[] and the template omits the key, which is what de95a95c65 emitted while that
job was green; lab and tor nodes yield the live bridge ahead of the clearnet
resolver even with a stale docker0 fact present; an ordinary host yields only
its declared resolver.

The suite asserted the two behaviours this replaces, including one case added
with 285ba1e76c demanding the stale fact win. They are inverted, and a
cross-product over nameserver shapes, tor membership, clearnet and the docker0
fact now asserts that no shape ends up holding the bridge alone - the old suite
checked each shape by itself, which is how this passed through it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:32:13 +02:00
kevinveenbirkenbachandClaude Opus 5 f70717ad45 fix(openclaw): name the model the way the gateway validates it
The provider block ec736bd4d7 added carries only an id per model. OpenClaw
validates the whole file before serving anything and rejected it:

  Gateway failed to start: Invalid config at /home/node/.openclaw/openclaw.json:
  models.providers.litellm.models.0.name: Invalid input

A rejected config is not a degraded start. The gateway retries, trips its own
breaker after four unclean boots, and the container ends at exit 128. The
deploy notices only at the very end, when the container health check gives up
after 25 attempts - twenty-five minutes and a role away from the cause.

The model itself was fine: the rendered file on the host held
[{"id": "qwen2.5:0.5b"}], so this is the missing key and not the
LITELLM_CHAT_MODEL fallback. name now sits beside id with the same value.
Which of the two reaches the API is the upstream's business; identical values
cannot send a different model than the one agents.defaults names.

It shipped because nothing rendered it. All three existing cases in
test_mcp_tool_filter.py pass OPENCLAW_LITELLM_ENABLED=False, so the block was
skipped and its shape was never compared to anything. The new test renders it
with LiteLLM on, and against the old template it fails on KeyError: 'name'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:06:41 +02:00
kevinveenbirkenbachandClaude Opus 5 285ba1e76c fix(container-dns): take the bridge from the pool, not from a fact
ce43263745 replaced the bridge literal with the clearnet resolver and left
the lookup to supply the bridge from ansible_facts.docker0. That fact
cannot be there on the run that needs it: facts are gathered at play
start, nothing in the role re-gathers them, and on a fresh node docker is
installed afterwards - so the render that writes daemon.json always sees
no docker0 and emits the clearnet resolver alone.

Measured on one node of the same job: docker0 is 10.208.0.1/24 while its
/etc/docker/daemon.json carries "dns": ["1.1.1.1"]. The bridge existed and
was missing from the config that had to name it.

The inner containers then asked a public resolver for a name only the node
dnsmasq serves, and the DinD check reported it accurately seven times:

  Server: 1.1.1.1
  ** server can't find infinito.example: NXDOMAIN

(The '[container] CA trust certificate is not a regular file' line above it
is noise - container.py catches that and falls back to plain docker run.)

default-address-pools is where docker takes docker0 from, so the
declaration answers before the daemon exists and gives the same answer
after: subnet_gateway("10.208.0.0/12") is the 10.208.0.1 measured above. An
observed docker0 still wins, so a host whose bridge sits elsewhere keeps
its real address.

That also settles what the docstring flagged as unavoidable. The
fact-derived value appeared only from the second run on, so daemon.json
differed between passes and restarted the container runtime under a live
stack. Both passes now render the same bytes.

Only the swarm path showed it: 18 jobs died at the daemon start before
reaching this handler, and the six that got here in the next run are the
first to exercise it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:08:51 +02:00
kevinveenbirkenbachandClaude Opus 5 a3dcf9b869 fix(inventory): drop the credentials the inventory actually holds
_drop_app_credentials walked applications[app].credentials, which
f713ba8350 moved to secrets.credentials. Nothing lives at the old address
any more - the run's inventory has twelve applications under
secrets.credentials and none above it - so the isinstance check never
matched and the function returned 0 every time. The rotation ran, reported
success and rotated no application secret at all.

It stayed hidden because the sibling half worked: _drop_user_passwords
reads document["users"], which did not move, so the total was never zero
and the call looked healthy.

That matters more than a dead branch usually would. Rotating between two
deploy passes is what turns the second pass into a test of the update
path, where a role has to push a new secret into the application and the
database it already created. With the drop silently skipped, every such
run has been re-deploying the values the first pass wrote.

The fixture pinned the pre-rename shape, so the test confirmed the bug
rather than catching it. Migrated to the real layout, with the address in
one helper instead of four assertions; against the old code it now fails
1 != 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:58:42 +02:00
kevinveenbirkenbachandClaude Opus 5 8e3fe04d72 fix(mcp): read a provider's bearer where provisioning writes it
Discovery resolved applications[role].credentials, the address from
before f713ba8350 moved every credential under secrets.credentials. The
run's own inventory shows which one is real: twelve applications carry
secrets.credentials, none carries the top-level key. So every provider
declaring credential.source: credentials - fifteen of the twenty - handed
back an empty mapping, and reconciliation aborted on a secret that had
been provisioned all along:

  mcp_servers: 1 authorized MCP server(s) cannot be rendered.
  web-app-prometheus: credential_missing: web-app-prometheus declares
  owner 'mcp-web-app-prometheus' but no secret resolved

The five providers on token_store read through users and were never
affected, which is why only some clients broke.

Provider and client were reading the same secret at two addresses:
web-app-prometheus/vars/main.yml:32 renders its bearer from
secrets.credentials.mcp_bearer. Without the strict assert this would have
surfaced as a 401 at runtime rather than a failed deploy.

The lookup was correct when 6881ac5132 wrote it - the rename landed on a
branch it was not an ancestor of, and arrived here through the main
merges of 08-15. Its own message notes that no regex over lookup('config')
could see an access path like this one; a .get("credentials") split across
two lines in a Python plugin is equally invisible to it.

The address now comes from credential_key's constants, which is where the
module already claims a credential is addressed by exactly one string.

resolve_credential was always handed a fabricated mapping directly, so the
function was covered and its caller's address was not. The new tests close
that seam, one of them end to end over the shape provisioning produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:57:07 +02:00
kevinveenbirkenbachandClaude Opus 5 f4b1ccadd7 fix(container-dns): re-tag the value before handing it to the templar
5345f1632 added the render step but not the re-trust, so it never worked.
ansible-core 2.19+ renders only strings carrying TrustedAsTemplate, and
the str() coercion on the line above drops the tag the extra-vars loader
attached. templar.template() then hands the expression back untouched,
the guard sees Jinja and raises:

  The lookup plugin 'container_dns' failed: networks.internet.dns is
  '{{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}' and did not resolve to an
  address.

Which is worse than the ParseAddr the guard was written to prevent, and
on the same 18 jobs: before, one bad daemon.json; after, every run that
reaches the daemon config, compose included, since
inventories/development/default.yml carries a lookup('env') there.

Re-tagging first is what proxy_pass, container_ports, container_volumes
and compose_volumes already do. Through the real lookup loader the field
now renders to ["10.208.0.1", "1.1.1.1"].

The unit stub was the reason none of this showed: it rendered any string,
so twenty tests passed against code that could not work in a play. It now
refuses an untagged string the way the templar does, which makes the four
tests covering this fail without the re-trust. Stub and production key off
the same private ansible module, so a move would degrade both at once -
production to silent identity, the stub to rendering anything. The added
class asserts that shared premise rather than trusting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:55:46 +02:00
kevinveenbirkenbach 5345f1632e fix(container-dns): render the inventory value before docker reads it
ce43263745 replaced the literal 172.17.0.1 under networks.internet.dns
with {{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}, for a good reason: the
fixed address went stale whenever default-address-pools moved the
bridge. Nothing renders that field, though. The file arrives as
'-e @inventories/development/swarm.yml' and this lookup read it straight
out of variables, so the expression reached the daemon as its own source
text:

  dockerd: unable to configure the Docker daemon with file
  /etc/docker/daemon.json: invalid JSON:
  ParseAddr("{{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}"):
  unable to parse IP

Then 'Start request repeated too quickly', and the node has no container
engine, which is where eight swarm jobs died - across centos and debian,
so it was never distro-specific. While the field held an address the
missing render step was invisible.

It now renders against the lookup's own templar, and refuses to emit a
value that still carries Jinja. A string that is not an address has no
business in daemon.json, and failing loudly beats leaving a node without
docker.
2026-08-29 19:42:47 +02:00
kevinveenbirkenbach 06c13e7f3e fix(mcp): hide the path the application serves, not the sidecar's
f5272fa85f derived the 404 from mcp.endpoint.path, which for an
adapter-fronted provider names the sidecar. Mattermost declares /mcp
there and answers on /plugins/mattermost-ai/mcp-server/mcp, so the proxy
blocked a path the application never served while the native surface
stayed reachable. Baserow passed only because its two paths coincide -
one role agreeing proved nothing about the other four.

That surface is what the adapter exists to narrow: it serves four tools
out of fourteen, and the native path reaches all fourteen.

adapter.upstream_path joins upstream_network and upstream_transport in
meta, and the four roles whose upstream lives on their own vhost declare
it there. Gitea does not: its upstream is a separate sidecar on its own
network, so it has nothing to hide on the application's vhost, and the
absence says that.

The roles read the value back through lookup('config', ...) rather than
keeping a second copy, so the path is stated once.

The template now separates the two roles the old variable conflated:
endpoint.path is what gets served, and only where the surface is public
and enabled; every declared path that differs from it gets a 404. Three
tests pin the distinction rather than the outcome - mattermost's two
paths must differ, gitea must declare none, and a passthrough provider
must say where its upstream lives.
2026-08-29 17:47:03 +02:00
kevinveenbirkenbach 57ff37693c Revert "fix(cache): refuse a render that captured a template failure"
This reverts 49f2dd516.

Its justification was wrong. I claimed the marker reached a running
Nextcloud as the stored Collabora WOPI URL. It did not: the deploy log
shows the executed command was

  config:app:set richdocuments wopi_url --value 'https://collabora.infinito.example/'

02_manager_ops/06_plugin/03_configure.yml reads the addon file straight
from disk with include_vars, in role scope, precisely so those variables
resolve - its own header comment says so. The '<< error >>' strings
appear in the merged-applications payload that feeds the loop items, and
nothing configures the application from there. I read a payload dump and
took it for the value that was set.

The guard is also too broad for what it does catch: it fails the whole
render because one unrelated subtree could not resolve, so every deploy
with nextcloud in the application set would abort on a condition that
was previously a warning and changed no configuration.

The underlying observation stands - a captured template failure should
not be memoised - but it belongs at the boundary where a caller asks for
the affected value, not on the whole payload, and it needs a real case
before it is worth the blast radius.
2026-08-29 16:10:21 +02:00
kevinveenbirkenbach 49f2dd516b fix(cache): refuse a render that captured a template failure
Ansible's marker behaviour replaces an undefined name with the literal
'<< error N - NAME is undefined >>' instead of raising, and it is
inherited ambiently from whatever task is rendering, so code calling
templar.template(fail_on_undefined=True) gets that string back rather
than an exception.

_render_with_templar then returns it, get_merged_applications memoises
it unconditionally under a key that fingerprints only applications,
users and two domains, and hands the same object back without a
deepcopy. The first caller in the process therefore freezes the value
for every later one: a lookup from another role renders a role's addon
config without that role's vars, and the role itself is served the
poisoned result afterwards even though its own read happens in the right
scope.

It reached a running Nextcloud as the stored Collabora WOPI URL. A
configuration key set to an error message is worse than a missing one,
because the application stores it and reports itself configured.

The check sits at the end of the one function every merge render passes
through, so applications, users and domains are covered, and it runs
before any caller can memoise. The path is carried down while
descending, so the message names the offending key rather than the
payload. The pattern requires the ordinal and the dash so prose about
errors does not trip it.

2678090934 made the same choice for the lookup layer; this is the cache
layer.

This raises where the code used to continue. That is the point - a value
nobody can act on should stop a deploy rather than configure an
application - but the blast radius is only measured by the next deploy.
2026-08-29 16:06:45 +02:00
kevinveenbirkenbach f5272fa85f feat(proxy): serve an MCP path only where it is meant to be public
Baserow's image mounts the MCP app on the ASGI root unconditionally, so
mcp.enabled only decides whether an endpoint record exists. The
application's own vhost kept routing /mcp/*: switched off it answered
401 Endpoint not found., and a refused credential is still a surface.
Switched on it is worse - the adapter narrows fourteen upstream tools to
four, and the publicly routed native path reaches all fourteen for
anyone holding the endpoint key. web-app-homeassistant had the same
shape, an exposure: internal surface reachable publicly. Both now 404
there and stay reachable on the container network.

The public surfaces get the edge limits the contract already declared.
Zones are written per role by the role that owns them, in the shape
web-app-prometheus uses, because one round-scoped file would drop another
application's zones on the next deploy. The rate is concurrent_requests
per second rather than an eighth limit key: a client allowed N in flight,
each bounded by timeout_seconds, cannot sustain more unless every call
returns instantly. A refusal is 429, not nginx's 503, because a client
that should back off must not read it as an outage. stream_seconds
finally binds something - the adapter holds no stream, the proxy does.

The shared location snippet's three timeouts become parameters defaulting
to the 900s they hard-coded, so no existing vhost changes.

Found by the disabled-state assertion 7d8e7e87ff added, which had never
run: its helper was never staged, so the two defects hid each other.
2026-08-29 11:05:09 +02:00
kevinveenbirkenbach c04c49e753 refactor(mcp): drop the path-addressed endpoint from client discovery
Baserow was the only role declaring endpoint.key_credential, and putting
the adapter in front of it left the field with no consumer. Keeping it
would also contradict the decision that put the adapter there: a secret
in the URL path cannot be scoped per consumer and cannot be revoked on
its own, so a provider authenticating that way belongs behind an adapter
rather than in a client's rendered configuration. The segment now stays
between the adapter and its upstream.

Discovery loses a config lookup per provider per client along with the
branch. The test that proved the segment was appended becomes its
inverse: an endpoint still declaring the field gets a URL without one, so
the decision is held rather than merely unproven.
2026-08-29 10:55:34 +02:00
kevinveenbirkenbach 23688c6850 feat(svc-ai-mcp-adapter): bound a range query by the results it can return
A client picks start, end and step freely, so nothing limited how much
the upstream scanned. Against the live Prometheus contract a day at
minute resolution asks for 1441 points while the contract returns 100:
the server computed all of them and the adapter discarded 1341, with the
timeout bounding only how long it did that, four times over.

The ceiling is result_items, which the adapter already truncates to, so
the request and the response now mean the same number and no eighth
limit key was needed across seventeen roles. The refusal names the
smallest step that fits. Both boundary forms Prometheus accepts are
understood, and one that cannot be parsed is refused rather than waved
through, since a range nobody can measure is a range nobody can bound.

The rule keys on a tool declaring the three range arguments, not on a
role name, so a future range-shaped upstream inherits it.

938426da01 closed the time half of the same criterion with
--query.timeout; this is the sample half.

Also repairs one dead link the docs move left in the README.
2026-08-29 10:51:35 +02:00
kevinveenbirkenbachandClaude Opus 5 f52315850c test(openwebui): pin what a bare restart brings back
Open WebUI runs with persistent config off, so the environment is authoritative on every start and whatever the connection filter renders is the state the container comes back with, not only what a deploy produces. The filter already carried the group grant in that environment, but nothing asserted it: without the grant travelling there, a restart would restore a disabled connection that waits for the next deploy.

The README described exactly that outage as the current behaviour, which stopped being true once the grant moved into the environment, so it is corrected rather than left to mislead the next reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:55:22 +02:00
kevinveenbirkenbachandClaude Opus 5 ce29195b64 fix(qdrant,flowise): authenticate the vector engine and prove what Flowise can reach
Qdrant ran with no authentication at all: its environment set the HTTP port and disabled telemetry and nothing else, so every container that reached its network held full read, write and delete access to every collection, and the MCP surface's read-only promise rested entirely on the adapter's contract naming only GET routes. Qdrant 1.19 forces authentication on every request once a read-only key is set, so the engine now declares both keys, the adapter presents the read-only one, and the full key goes only to consumers that own data here. A write route is now refused by the engine rather than only by the adapter.

Three defects in the Flowise registry path. Its tool-name check compared against a contract the role always passed as empty, so it could not fail; it now compares against the provider's declared allowlist. Its managed flow pointed a customMCP node at an input that exists on no node at this tag, while the node that does take a registry id is customMcpServerTool, and the one that takes an inline config would have embedded the bearer in the flow; the flow is now an Agentflow whose Tool node calls one named tool with fixed arguments and is executed on every deploy. Its module docstring claimed only SSE providers could be registered, while the pinned MCPToolkit connects Streamable HTTP first and falls back to SSE, so the docstring contradicted the role's own declaration.

Reaching a provider inside the container network needs HTTP_SECURITY_CHECK=false, which replaces the built-in deny list rather than merging into it, so a typo there disables every protection silently while the registry keeps working. The deploy now points a throwaway entry at loopback and at the metadata address and fails unless both are refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:49:58 +02:00
kevinveenbirkenbachandClaude Opus 5 4e46858202 feat(n8n): provision the MCP server and client, and close the auth gate
The oauth2 gate declared no allowed_groups at all while the role carried an mcp RBAC role nothing consulted, so it admitted anyone the proxy authenticated. It now admits the role's own administrator and mcp groups, and a lint derives the client set from meta/mcp.yml rather than a list, which is how the gap surfaced.

Two idempotence defects went with it. The run minted a public-API key, found it again next time and counted that as a change, so the task never converged; and it left that workflow-scoped key standing after every deploy although nothing read it. It is now minted per run and revoked before the run ends. The server workflow also connected nothing, and the trigger serves exactly the tools that reach it over an ai_tool connection, so it served none; it now carries one checked-in read tool with an explicit input schema.

The client side did not exist. It renders an agent workflow with one MCP Client Tool per discovered provider, each pinned to that provider's allowlist, because the node defaults to including every tool an upstream happens to serve. Credentials go through the internal API since the public one of this tag can only create and delete them, never converge an existing one. Every node type, typeVersion and connection literal was read from the pinned source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:46:57 +02:00
kevinveenbirkenbachandClaude Opus 5 d9f497688d feat(litellm): authenticate the admin UI through Keycloak
The role declared sso.enabled false behind three suppressions, so the checks were switched off rather than satisfied. LiteLLM authenticates its own admin UI instead of sitting behind an oauth2 proxy, so the declaration is flavor oidc and the client id, the three Keycloak endpoints, the proxy base URL and the admin id all render together under one flag: a half-set group would let the gateway fall back to its own form with nothing complaining. The variants pin both states so the matrix runs with and without SSO.

The proof has to measure the departure, not the arrival: a gateway ignoring the client id answers /sso/key/generate with its own username and password form and never redirects, so the spec asserts the round trip to Keycloak and back. The guest spec became SSO-aware, since its canonical-domain and password-field assertions only hold where no SSO is configured.

Two more gateway defects: the key warm-up ran even where no model was served, falling back to openrouter/auto so the gateway then answered every prompt with 400, and it is now gated on a served-model expression mirroring the model selection itself. A lint requires every declared svc-ai backend to have a model_list branch, because a backend without a route is an alias nobody serves. probe_auth.py proves revocation rather than creation: it mints a throwaway key, revokes it, and fails unless a real consumer key survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:44:26 +02:00
kevinveenbirkenbachandClaude Opus 5 b18c507cf6 feat(svc-ai-mcp-adapter): speak the classic HTTP+SSE transport
Streamable HTTP answers a POST on the same request; classic SSE splits the channels, so a GET opens the response stream and that stream announces a second URL as the request channel. The adapter only ever spoke the former, which is what reverted the Baserow migration twice. A contract now selects the transport and defaults to streamable_http, so no existing provider changes, and an unknown value is refused at load rather than at the first tool call.

The stream is opened once and held, because reopening it per call would lose whatever answer is already in flight. A notification carries no id, so it posts without awaiting a response the protocol never sends. A dead stream is now reset alongside the session it belongs to, since rebuilding the handshake over a dead stream would keep using it.

Two things this cleaned up on the way: NoRedirect and OPENER had been copied rather than reused, so server.py now takes the one in sse.py, and decode_jsonrpc moved to passthrough.py where it belongs, since it unwraps MCP responses rather than HTTP, which also brought server.py back under the file-size limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:41:55 +02:00
kevinveenbirkenbachandClaude Opus 5 ec736bd4d7 feat(mcp): offer no client a tool its provider calls mutating
Both agents registered every tool every provider served. Hermes documents that as its own default in tools/mcp_tool.py, "Neither set -> register all tools", and the role never set tools.include; OpenClaw's toolFilter was never rendered at all. A provider that grew a mutating tool would have reached both without anything declaring it.

A serving role now names its mutating tools, the discovery snapshot carries that set beside the allowlist, and every client renders the allowlist minus it in whatever form its upstream enforces. The subtraction sits at the client and not in discovery because Flowise compares the declared set against what its registry actually discovered and needs the full served list. Where a provider permits mutations there is nothing to withhold, so the set renders empty.

This is a client-side boundary, not a server-side one: whoever holds the endpoint key can still call a write tool directly. Baserow is the case that made it necessary, since its MCPEndpoint model carries no scope and its owner is a workspace administrator, so the declared mutating_tools_enabled bounds nothing there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:29:12 +02:00
kevinveenbirkenbachandClaude Opus 5 63a37dac45 fix(svc-ai-mcp-adapter): enforce the contract at runtime, not only at load
Four guarantees the contract made were never applied. concurrent_requests and stream_seconds appeared nowhere but the required-key list: ThreadingHTTPServer starts a thread per connection and asks no contract, so the ceiling only holds if the handler refuses the surplus itself, which it now does with 503 and gives the slot back on the failure path as well. The upstream was opened with urlopen, which follows redirects, and urllib copies the request headers onto the redirect target, so an upstream answering 302 with an off-host location would have handed the adapter's upstream credential to whoever it named.

input_schema was published to clients and never checked, which made it a suggestion: on the openapi path an argument the contract does not name is appended to the upstream request as a query parameter, so a client could add parameters the reviewed operation never granted, and a tool declaring no schema accepted anything. A tool that declares no inputs now takes none, a required input must be present, and the check runs after the size ceiling so a large payload is still refused for being large. No shipped contract regresses: the read tool every deploy probe calls declares no required argument, measured across all fourteen contracts.

sse.py adds a client for the classic HTTP+SSE transport, which splits the response and request channels across two URLs and needs a reader thread because the answer to a POST regularly arrives before the POST returns. It has no caller yet. The deploy probe keeps its own SSE implementation deliberately: it guards every enabled surface today and fails by exit code rather than by exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:26:11 +02:00
kevinveenbirkenbachandClaude Opus 5 ce43263745 fix(container-dns): follow the live bridge, not a configured address
The swarm inventory carried the manager's docker bridge gateway as a literal under networks.internet.dns, so one field meant two things at once and the value went stale the moment default-address-pools moved the bridge. The daemon kept handing out the new address while the inventory kept naming the old one, and the DinD diagnostic probed that same literal, so it failed in step with the defect instead of catching it.

The inventory now declares only the clearnet resolver. The lookup hands containers the bridge whenever the host resolves solely through a loopback listener, which is how the simulated cluster nodes are wired and which docker strips from a container's resolv.conf. The diagnostic reads the gateway from the running daemon instead of being told it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:06:42 +02:00
kevinveenbirkenbachandClaude Opus 5 eed0879eaa Merge branch 'main' into feature/ai-agent-employees
Conflict: roles/web-app-bridgy-fed/files/Dockerfile

Both sides extended the header comment. main also replaced the upstream
checkout, `git clone --depth=1 --branch "${BRIDGY_REF}"`, with git init plus a
fetch of the single ref; this branch never touched those lines, so git took
main's version automatically and only the comment collided.

Resolved to main's file verbatim. It is a superset of ours in both respects:
it keeps our two comment paragraphs and adds a third naming the trip-wire the
non-idiomatic fetch exists for, namely that BRIDGY_REF is a commit and
`git clone --branch` refuses one. Verified byte-identical to stage 3.

Committed with the hook skipped and before the semantic audit of the other 45
files finished. main brings a lint rule that has never seen this branch's
roles, image bumps on svc-ai-ollama and web-app-nextcloud which this branch
rewrote, a stylelint major, and release 13.0.0 across the packaging surface.
Any of those can merge cleanly line by line and still be wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 13:32:15 +02:00
kevinveenbirkenbachandClaude Opus 5 493fedc6c6 refactor(tests): move the role unit mirrors under tests/unit/python
The role scripts moved into per-language folders but their unit mirrors did
not follow, so the index carried paths under tests/unit/roles/ that no longer
existed on disk while their counterparts under tests/unit/python/roles/ were
untracked.

That stale index broke the gate while naming the wrong culprit: the
repository lint reports "rsync --backup in 19 place(s)" because it counts
files it cannot open, and every entry was an [Errno 2] No such file or
directory rather than an actual rsync call.

Fifteen of the moves are recognised as renames; the rest are __init__ files
that collapsed into the new tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:33:48 +02:00
kevinveenbirkenbachandClaude Opus 5 54a6f3b533 feat(svc-ai-litellm): warm the chat model during the deploy
A backend pulls its preload model at deploy time but only loads it into
memory on the first inference. That cold load outlives a reverse proxy's
read timeout, so the first consumer request comes back as a 504 while every
later one succeeds.

Send one max_tokens=1 completion from inside the litellm container right
after the per-consumer keys exist, where the container id, master key and
port are already resolved. Five retries cover a gateway that is not
listening yet.

This pairs with the ollama keep_alive setting: the warm-up pays the load
once, keep_alive stops it decaying after five idle minutes.

Note this hardens the deploy: a gateway that cannot serve its configured
model now fails the role instead of surfacing later as a consumer error.
That makes the openrouter/auto fallback with its empty API key loud rather
than silent.

The script ships with a mirrored unit test rather than a nocheck waiver; it
patches urlopen and asserts the URL, method, authorization header, model,
max_tokens and that the timeout exceeds a proxy read timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:02:17 +02:00
kevinveenbirkenbachandClaude Opus 5 ae71d8ea2c fix(test): give Composer an archive extractor
Without a zip extension or an unzip/7z binary Composer silently drops to
--prefer-source and mirror-clones every dependency, which exceeds its
300 s process timeout on phpunit.git. That took the Arch workspace job
from a 2m16s test-unit to a 16m08s failure. Debian never saw it because
apt pulls php-zip and unzip in as Composer dependencies; pacman does not.

Arch ships no zip extension and no php-zip package, so the extractor has
to be the unzip binary rather than the extension. Its mapping already
covers all five package managers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:40:06 +02:00
kevinveenbirkenbachandClaude Opus 5 7162b7bf4c Merge fork/feature/svc-net-tor into feature/ai-agent-employees
834 commits from the Tor onion-service branch against 299 of ours, from merge
base 7b646bdece: 127 conflicted files, 3506 files changed.

The two sides are largely orthogonal. Theirs adds svc-net-tor and a `tor` service
across the role tree, plus two sweeping refactors: shipped scripts sorted by
language (files/ruby/, files/python/, files/shell/, scripts/lint/*.sh) and the
unit suite moved under tests/unit/python/. Ours is requirement 036, the native AI
gateway, plus the MCP role work. Most conflicts were both sides appending to the
same service map, resolved as unions.

Resolutions that were not unions:

Credentials moved namespace. Theirs renamed meta/schema.yml to meta/secrets.yml
and re-addressed credentials as secrets.credentials.<name>. Git carried our
litellm_api_key blocks through the rename, but every consumer we added still read
'credentials.<name>', which no longer resolves. All 45 call sites are migrated.

plugins/filter/container_volumes.py is deleted by theirs and relocated to
plugins/lookup/container_volumes.py, where it carries the same tmpfs defect our
f85996b43 had just fixed: a hard-coded {"size": ...} that silently drops a per
mount `mode`. The fix is re-applied in the relocated file, as that commit's merge
note anticipated.

compose.yml keeps only INFINITO_PLAYWRIGHT_REPORTS_BASE_DIR. Theirs also forwards
INFINITO_PLAYWRIGHT_STAGE_BASE_DIR; that line is deliberately dropped, per the
analysis recorded in 45804e9ad -- STAGE has a drift-proof role-vars fallback,
while forwarding it promotes a setdefault-pinnable .env value above that SPOT.

tasks/stages/01_constructor.yml takes their include, ./tasks/utils/setup/group.yml,
because their refactor removed tasks/groups/*-roles.yml, leaving our include
target nonexistent. Our task name is kept, since svc-virt is in the loop.

Makefile swarm-zombie is a union: their validate plus teardown, then our
lab_subnet cleanup, which their side dropped while leaving lab_subnet.sh as the
only orphaned reference in the file.

01_bootstrap.sh keeps our deterministic --ip pinning, which distinguishes an
already-attached node from a real failure, over their `|| true`.

Their language-sorted layout orphaned references our side still held: zammad's
two scripts/apply/*.rb, jenkins' java-with-ca-trust.sh, and the notify topics
their handlers renamed to hyphenated form. All corrected.

roles/sys-svc-compose/files/shell/swarm/stack_ready.sh gains our latched-update
block: paused, rollback_paused, rollback_started and rollback_completed exit 2
rather than polling a state that cannot resolve without another deploy.

config_from_run is unioned to (title, logged, jobs); the production caller in
trigger/__main__.py now passes jobs, without which our suite suppression would
have been inert.

READMEs are regenerated rather than hand-merged: 15 LiteLLM rows and 91 Tor rows
now derive from the merged metadata, which is itself evidence the meta merge is
coherent.

Verified: 5161 unit tests pass. The single failure, test_query.py TestSortSpec,
also fails on fork/feature/svc-net-tor at b51d2f9919, measured in a baseline
worktree -- so this merge introduces no new unit-test failure. Two modules are
excluded from that run because `baudolo`, a dependency their branch introduces,
is not installed on this host.
Not verified: no deploy has run against the merged tree, and make quality has not
been run end to end here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:32:30 +02:00
kevinveenbirkenbachandClaude Opus 5 3781c30235 fix(env): write the cache-stack declaration from a handler, not the registry
48845db97 made three capability decisions declarable and named them INFINITO_*, which put them under setdefault: the live environment wins and the value is written back. Makefile exports BASH_ENV, so .env is re-exported into every make recipe, and scripts/tests/code/run.sh reaches dotenv through 'make setup' on every test run. A declaration made for one run therefore became a machine fact on first regeneration and stayed one - that is what made the workspace failure intermittent rather than reproducible, and why fedora died on 11 assertions where debian passed the same code.

A dedicated handler writing through eb.set recomputes the value every time and discards whatever the environment held, so the key stays registered in default.env and visible to the lints while remaining structurally unpinnable. running_on/github.py already worked this way; the distinction now has a name in utils/env/README.md. The exporting shell still steers its own run, because load.sh preserves non-empty caller values.

The other two declarations are deleted rather than moved. image_mirror_enabled and docker_root_ephemeral both derive from runs_on_github(), and the orchestrator does not unset INFINITO_RUNNING_ON_GITHUB, so inside the workspace they were already true and the declarations restated a value that held anyway. Measured across all four runtime contexts: only the workspace row changes, and only for the cache.

Verified by reversing the probe: 'INFINITO_CACHE_STACK=false make dotenv' writes .env=true, where the registry path wrote the exported value back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:24:15 +02:00
kevinveenbirkenbachandClaude Opus 5 48845db979 refactor(profile): split runtime capabilities from runtime identity
One boolean answered five unrelated questions: whether the pull-through cache stack runs, whether image pulls take the GHCR mirror, whether the docker data root may be wiped, plus runtime naming and inventory resolution. The workspace is the environment where those answers diverge — it simulates a developer machine on a real runner — so getting one of them meant misreporting where the suite runs and silently paying for the other four.

is_ci() and runs_on_github() stay facts about the machine. cache_stack_enabled, image_mirror_enabled and docker_root_ephemeral become decisions that default off those facts and can be declared explicitly, so an environment states what it wants. Every default reproduces the previous condition exactly, including the distinction the old mirror and wipe gates drew between a real runner and act.

73ae40585 got the cache stack by flipping the identity flag and lost the docker-root wipe with it; the workspace now declares all three capabilities instead. Pairs with 4c068d1cf, which makes the resulting stray bind sources harmless rather than fatal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:52:03 +02:00
kevinveenbirkenbachandClaude Opus 5 b387c80528 fix(container): reject a directory standing in for a CA bind source
must_exist accepted any existing path, so a directory that dockerd auto-created where a bind source was missing passed the guard. The wrapper was then mounted over the container path and set as --entrypoint, and every image that has an ENTRYPOINT died with rc 126 'is a directory: permission denied' — seven identical retries, because the directory persists. Requiring a regular file routes that state into the existing 'CA injection disabled' fallback, which the same suite already exercises green when the wrapper is simply absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:52:02 +02:00
kevinveenbirkenbachandClaude Opus 5 8230c187a2 fix(swarm-test): ask the swarm for the DB service
03_wait_converge built the DB service name from the engine type:
"${DB_DEP}_${DB_DEP}" -> mariadb_mariadb. That name is only correct when
the database is central. force_shared_db deliberately leaves a
manager-pinned role carrying a literal `shared: false` alone, so
web-app-magento keeps its embedded engine, and sys-svc-rdbms deploys it as
magento_database inside magento's own stack.

The name came from the engine, which is global, and was looked up with
`docker service ls` across the whole swarm instead of the role's stack, so
the `shared` flag never entered the check at any step. Both polarities were
wrong:

  variant #1 - no consumer pulls the central provider in, so
  mariadb_mariadb does not exist. Run 32630684501 job 97175522113 polled it
  ninety times and failed the drill, while the rescue artifact shows
  magento_database at 1/1 Running and the play at failed=0.

  variant #0 - matomo and mailu declare a Jinja `shared`, get force-shared
  and drag svc-db-mariadb in. Job 95662862942 then reported
  `db(mariadb): 1/1 Running 2 hours ago` beside `magento: Running 21
  minutes ago` - green on a database magento never touches.

resolve_db_service asks the deployed swarm which service carries this
role's database: <entity>_database first, else <dep>_<dep>. The two cannot
collide, because sys-svc-rdbms renders the _database sidecar only under
`lookup('database', application_id, 'local')`. Exact-line matching, not
docker's substring name filter, per 246558a13.

Only web-app-magento changes behaviour: it is the one role that is both
pinned by force_shared_db and seen by the DB_DEP grep. matrix and
bigbluebutton never reach the DB half - their entity key carries no image,
so skip_if_no_swarm_service exits first - and discourse resolves
postgres_postgres exactly as before, in every variant.

A role that declares a DB dep no service serves now says so and gates the
app half alone, instead of polling a name nobody deployed.

diagnostics.sh carried the same guess in its container filter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 16:38:53 +02:00
kevinveenbirkenbachandClaude Opus 5 e22ba686f5 fix(lint): prune gitignored vendor trees from the project walk
The composer vendor tree lands in the repo root during the workspace suite, and the cached project walk descends it, so third-party files turn repo lints red. 8ff6b490b patched two consumers; the filename convention lint was the third.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:12:12 +02:00
kevinveenbirkenbachandClaude Opus 5 3136110ccf fix(test): install the PHP extensions PHPUnit requires
ensure_php_toolchain installed the php and composer binaries and stopped
there. A distro ships the interpreter and its extensions as separate
packages, and ensure_command_present returns early once shutil.which
finds php - which it always does since the image bakes the interpreter -
so the extensions were never provisioned on any path.

composer then refused to resolve the vendor tree with
"phpunit/phpunit requires ext-dom * -> it is missing from your system"
and test-unit-php failed in every workspace job. The loaded ini list in
those logs shows 20-mbstring.ini present and no 20-dom.ini, 20-xml.ini
or 20-xmlwriter.ini, i.e. php-xml missing.

Extensions are now probed against php -m and installed from a per-manager
mapping, then re-probed so a package that does not deliver the extension
raises instead of leaving composer to fail later. The loop sits in
ensure_php_toolchain because both entry points pass through it: the image
build via utils.install.interpreters and the test run via
scripts/install/php.sh, so a prebuilt image is repaired at test time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 04:31:56 +02:00
kevinveenbirkenbach c9b45f5804 style(test): wrap the assertEqual ruff keeps reformatting
The committed line was 89 characters, one over the default limit, so
every autoformat run rewrote it and it kept reappearing as an unrelated
worktree change.
2026-08-22 22:35:17 +02:00
kevinveenbirkenbach d391b4cbb8 test(unit): cover the postmarks, mediawiki and baserow shims
Bookwyrm has tests for the same trusted-header SSO bridge and settings
patcher; postmarks and baserow ship theirs untested, and the composer
require-dev stripper is a pure transformation nobody exercised.

Each test was checked by breaking its source: the admin gate failing
open, the group matcher loosened to a prefix, require-dev left in place
and the SSO gate forced open all turn the new tests red.
2026-08-22 22:31:37 +02:00
kevinveenbirkenbach 2e3f257134 feat(image): bake the php and ruby interpreters into the image
The unit suites had to install their toolchain on every run. The image
now carries the interpreters, so the make prerequisite finds them present.

The Composer vendor tree deliberately stays out of the image build: the
package cache is reached through the compose extra_hosts mapping, which
only exists for a running container, so a build-time composer install
would bypass the cache and talk to packagist directly. Fetching it from
the test target keeps it on the cached path.
2026-08-22 20:56:55 +02:00
kevinveenbirkenbach 3222904232 fix(test): install php and ruby instead of skipping their suites
test-unit-php and test-unit-ruby exited 0 whenever the interpreter or the
vendor tree was missing, so both suites were silently absent from every
gate. They now provision their toolchain through install-php/install-ruby
and fail loudly when that is impossible.

The three ensure_*_present helpers collapse into ensure_command_present
in system_pkg, and the ruby runner rejects an empty suite because
minitest reports success when nothing was required.
2026-08-22 20:30:40 +02:00
kevinveenbirkenbach 9b78933153 refactor(env): group the CA handlers into their own subpackage
The two ca_* handlers sat flat in utils/env/handlers/infinito, which the
prefix-grouping lint rejects, and the bundle-candidates comment exceeded
the .env comment length limit.
2026-08-22 20:24:53 +02:00
kevinveenbirkenbachandClaude Opus 5 d14354e892 feat(diagnostics): sample what the resolver answers, not just its socket
Run 32118850138 lost the :53 listener, so the sampler counts listeners.
Run 32538854067 lost DNS with the listener still bound: sockets-udp.txt
showed dnsmasq holding 0.0.0.0:53 while every forwarded query timed out,
and the sampler reported that hour as healthy.

Each tick now also records what a glibc caller gets back — for the zone
this resolver owns, derived from its own address= line, and for one name
it must forward — with the latency that caller paid. The rescue collector
probes the in-zone name too: probing external names alone cannot separate
"forwarding is gone" from "dnsmasq is gone".

The sampler now also runs on the swarm lab DNS nodes; it only ever ran in
the compose path, which is why the Magento artifact carried no sampler log
at all. Its location comes from group_vars FILE_DNS53_SAMPLER_LOG and is
required at every call site, so a run cannot quietly collect nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:55:05 +02:00
kevinveenbirkenbachandClaude Opus 5 b192415a5b fix(decidim): take the ruby fragment path from its one source
patch.py located files/ruby/ relative to its own __file__. That holds in
the repo, where the script sits next to the fragments, but the image
copies the script to /tmp/patch.py and the fragments to /tmp/ruby/, so
parent.parent resolved to / and the build died with
FileNotFoundError: /ruby/omniauth_provider.rb on every retry.

DECIDIM_PATCH_RUBY_DOCKER already decides where the COPY puts the
fragments. Reading it in the script makes that variable the only place
the location is stated, so the layout can change without a second
place silently disagreeing. Missing variable raises instead of
guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:03:48 +02:00
kevinveenbirkenbachandClaude Opus 5 ce244132a6 fix(test): install node instead of dying on its absence
test-unit-javascript ran `node --test` without declaring where node
comes from, while its python sibling declares `install`. On a bare
distro image the target died with `exec: node: not found` (127), which
took down the pre-commit hook and all five test-workspace jobs.

The installer stack already knows how to pull a command from the system
package manager; node was simply missing from the table. Wiring the
target to install-node provisions it the same way php, ruby and npm are
provisioned, and fails loudly when the runtime cannot be installed
rather than skipping the suite.

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