Commit Graph
14682 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 4db015f6a9 fix(ci): honour the decline contract when zpool create fails
The script promises at :12-13 that a host which cannot deliver the stated
filesystem declines and reports when REQUIRED is false. The zfs branch did not:
`zpool create` stood unguarded, so a random pick that the host could not satisfy
exited 1. In run 30781723102 that killed the swarm lab bring-up on all five
nodes of the nested svc-db-postgres drill (compose/swarm/playbook.yml:84) and
cascaded through swarm-zombie to a red Workspace (debian:bookworm) job.

The pre-flight could not have caught it: it only tested that /dev/zfs exists as
a character device, and on those nodes it did while the module was unusable.
Guarding the real operation instead removes the predictor entirely - whatever
makes zfs undeliverable now routes through decline, and REQUIRED=true still
fails hard there. The teardown is required because a loop device is already
attached to the image at that point and decline exits 0.

This does NOT fix why the module is unusable on debian nodes. That is
pre-existing and was already silent: the mariadb drill in the same job declined
zfs on all five nodes and stayed green, while ubuntu applied zfs on ten. The
change makes the two paths consistent; it does not restore the lost coverage.

To make the loss diagnosable rather than invisible, modprobe's output is no
longer discarded into `|| true` but reported and attached to the decline reason,
and verdict() now records the reason in the step summary, folded to one line so
the markdown list item survives a multi-line stderr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:00:40 +02:00
kevinveenbirkenbachandClaude Opus 5 e8a3b6bf3d fix(sys-ctl-rpr-container-hard): do not run the repair during the deploy
Under MODE_DEBUG, sys-service maps SYS_SERVICE_DEFAULT_STATE to 'restarted', so
the role did not merely install and enable the weekly unit - the handler started
it mid-play. Its ExecStart runs files/script.py over DIR_COMPOSITIONS without
--only and with no health filter, i.e. `compose down` + `compose up -d` on every
project on the host, unlike sys-ctl-rpr-container-soft which restarts only
unhealthy or exited ones.

In run 30781723102 that destroyed a matrix stack the role had just proven
healthy: the loopback probe answered 200 from Synapse/1.157.2 at 08:34:20, the
handler ran 08:36:27-08:37:33, and the kill markers list all 17 projects with
matrix at 08:37:12. Because the mdad bootstrap gate gets its marker from a
volume but the synapse unit from the container rootfs, the recreated container
re-ran the full cold bootstrap and was still installing galaxy roles 13 minutes
later, so the readiness probe measured 502 for its entire budget.

system_service_suppress_flush skips only the state task in the handler; the unit
file, the enable and the timer are unaffected, and the repair still runs on its
schedule and via OnFailure. The unit does not reach the sys-service-terminator
either, since the final run list is written only when
system_service_force_flush_final is set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:00:39 +02:00
kevinveenbirkenbachandClaude Opus 5 115cc885b0 fix(ollama): gate async on ASYNC_ENABLED like poll
The pre-pull task set `async` unconditionally while gating only `poll`. With
async disabled, `poll` became `omit` and ansible fell back to its built-in 15s
poll while `async` still routed the task through async_wrapper: ansible
collected the job itself and then ran `async_status mode=cleanup`, deleting the
job file that tasks/utils/async/reap.yml expects to read. The reap reported a
successful `ollama pull` as a failed async job, failing web-app-nextcloud 2 in
run 30781723102.

Both keys are now switched off together, matching all six peer call sites. On
the resulting synchronous path the reap's `async_reap_tolerate` list no longer
applies, so the tolerated "up to date" answer is carved out in `failed_when`
instead. It keys on `failed` rather than `rc`, so a module-level failure that
carries no rc is not swallowed.

async_failures._which() now honours loop_control.loop_var: the fired result
records the chosen name under `ansible_loop_var`, so a batch failure names
`smollm2:135m` instead of the bare job id.

A second lint case asserts that async and poll carry `omit` together - the rule
this call site was the only one of seven to break.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:00:39 +02:00
kevinveenbirkenbachandClaude Opus 5 98fbfeea23 fix(merge): reconcile the MCP work with the incoming conventions
The merged branch renumbered the Nextcloud tasks to 01/02, leaving the
MCP tasks stranded at 09 with a gap the numbering lint rejects; rename
them to 03 and follow every reference.

The Mattermost select statements only formed a prefix group once the
incoming branch's own select landed beside them, so they move into
sql/select/, and the two user-scoped ones again into sql/select/user/.

Pin the object kind on the kata isolation inspect: this repository names
a role's overlay network after the same entity as its main container, so
an untyped inspect can resolve the network and report a missing State key
instead of the missing container. Suppress the address-lookup rule there
because both callers gate the assertion on compose, where the container
name is the address.

Give the sandbox-runtime test the data-root variable the incoming
daemon.json template reads. Both sides were green alone: their template
gained the variable, this test renders it under StrictUndefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:31:49 +02:00
kevinveenbirkenbachandClaude Opus 5 f0c2c946f0 test(lint): use ROLE_FILE_VARS_MAIN in probe guard
The role-file SPOT lint rejects a pathlib chain ending in a literal
role filename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 05:23:46 +02:00
kevinveenbirkenbachandClaude Opus 5 aceebcda35 test(lint): guard container_healthcheck call sites
A service without a healthcheck.flavor or an explicit test only breaks
while its compose file renders, one role at a time during a deploy.
Replay the lookup's own validation against the static config so the
failure surfaces in CI, and reject declarations the lookup would refuse
anyway: an unknown flavor, or a flavor and a test fighting over the
same entry.

Both tests share _probe_defect and import PROBES from the same SPOT the
lookup uses, so the known flavors cannot drift apart. 49 of the 56 call
sites resolve statically; the rest set service_name in an included base
template and are skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 05:16:18 +02:00
kevinveenbirkenbachandClaude Opus 5 d68e9f376f fix(healthcheck): render raw play vars in lookup
container_healthcheck resolves application_id from the play vars
instead of taking it as a positional term. Vars handed over by
include_role arrive unrendered, so under sys-stk-full the lookup got
the literal '{{ sys_stk_full_application_id }}', resolved no app and
refused to render the dashboard compose file.

Template such values before use. container_hostname had the same
defect: it is a Jinja string in every role's vars and was baked
verbatim into the curl Host header. Raise when a value survives
templating unrendered, since ansible-core returns untrusted templates
as-is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 05:15:18 +02:00
kevinveenbirkenbach 255ee13f60 Merge branch 'feature/swarm-nfs' into feature/ai-agent-employees 2026-08-03 04:21:37 +02:00
kevinveenbirkenbachandClaude Opus 5 f9e6c7b9da style(tests): reformat the async failure tests
Output of the repository formatter; the syntax tree is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 04:05:39 +02:00
kevinveenbirkenbachandClaude Opus 5 e550fad7e3 fix(nextcloud): keep the ldap user list filter on one line
`|-` preserves the newlines that surround the `{% if %}` and `{% endif %}`
lines, so the rendered value carried leading and trailing whitespace instead
of a bare filter. An LDAP filter with leading whitespace is rejected by the
server, and Nextcloud answers with an empty user list rather than an error.

A double quoted scalar cannot hold a newline by construction. The branch is
unchanged: the full user filter when the user directory is enabled, `()`
otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 04:05:38 +02:00
kevinveenbirkenbachandClaude Opus 5 e3e81e816f refactor(healthcheck): declare probes in services.yml, render them from one lookup
Seven include templates under sys-svc-container decided how a container was
probed, and they read their inputs from template-local `{% set %}` scope.
That scope leaks: a value set for one service silently reached every service
below it, which is how taiga's front and gateway ended up probing a port
declared for a different service, and how bluesky's frontend probed a PDS
endpoint.

Probes now live in utils/docker/healthcheck.py, one class per flavor behind a
PROBES registry, and each service declares its own:

    healthcheck:
      flavor: curl
      path: health/ready
      start_period: 20m

`port_key` picks another entry under ports.internal for the services that
expose their probe elsewhere (keycloak on management, nextcloud talk on
signaling). Without a flavor the entry must carry an explicit test argv, so a
missing declaration fails loudly instead of rendering a probe against nothing.

Rendering HEAD's templates against the new output confirms 49 of 56 call
sites are byte-identical; the remaining seven are covered by an Ansible run.
Three probes change on purpose: erpnext's backend gains a check it never had,
bluesky's frontend drops the leaked PDS path, and msmtp_curl moves from a
hardcoded localhost:80 to the service's own port on 127.0.0.1 like every
other flavor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 04:05:38 +02:00
kevinveenbirkenbachandClaude Opus 5 1f97d5cb97 test(lint): reject role vars in role meta files
The applications registry is built before any role-vars binding, so a
`{{ SOME_ROLE_VAR }}` in meta/*.yml stays an unrendered Jinja string and
silently resolves to nothing. The sibling rule already covers
`application_id`; this extends it to every key declared under
`roles/*/vars/`.

String literals, keyword arguments and filter or attribute names are
stripped before matching, so `lookup('domain', ...)` and
`lookup('rbac_group_path', application_id=...)` are not mistaken for
variable reads. Scope stays at meta/*.yml: files under meta/addons/ are
rendered inside the role's own play, where role vars are bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 04:05:38 +02:00
kevinveenbirkenbachandClaude Opus 5 888a0244c2 fix(lint): satisfy the redundant-default and cached-read rules
`shell` populates stdout on every path where changed_when is evaluated,
so the default filter guarded nothing: a non-zero exit and even a chdir
failure both carry stdout, a skipped task never evaluates the condition,
and on a module failure Ansible records the conditional error in
changed_when_result while still reporting the real cause.

The async lint walked the tree itself instead of going through the
shared cache. Both selections cover the same 1079 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 04:05:38 +02:00
kevinveenbirkenbachandClaude Opus 5 2a7f34113d docs(sys-service): record why the unit handlers stay async
The async on these two handlers is not an oversight to be swept with the
rest: system_service_async resolves to omit whenever a caller sets
system_service_force_linear_sync, so it is an opt-out the calling role
decides. The backup and repair roles ask for linear execution, the cleanup
and alarm roles deliberately do not.

That also settles the Result gate below them, which reads systemctl show
and looked pointless under fire and forget. It is not: it runs meaningfully
for exactly the callers that asked to wait.

Comments only, no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 04:05:38 +02:00
kevinveenbirkenbachandClaude Opus 5 efe6bad3d5 test(lint): require a written rationale for every async
An async task is a deployment wide decision taken in one line. Whether it
holds depends on things the line does not show: in swarm a delegated
command crosses a node boundary and runs far longer than the same command
in compose, so a window that never opens on one node routinely opens
across three, and the playwright run starts the moment the play ends, so a
command still writing in the background surfaces as a flaky spec rather
than a failed task.

Require one sentence per async naming what does not consume the result.
"It is slow" is why async is tempting, never why it is safe, and the
docstring says so. There is no nocheck escape: a task that cannot be given
the sentence has not earned its async.

The comment lint learns `rationale:` as a marker so those lines are not
read as narration. It is deliberately narrower than the exception marker
next to it: a rationale is demanded by another lint that refuses the
construct without it, so it cannot be self granted, which is exactly the
failure mode of comments that explain themselves.

This lands after the change it describes, so every commit stays green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 04:05:38 +02:00
kevinveenbirkenbachandClaude Opus 5 cd1225d2d9 fix(async): collect fire-and-forget results
The repo's async convention is fire and forget: ASYNC_POLL is 0 whenever
ASYNC_ENABLED is true, and the swarm test lane forces it true through
extra vars. A task dispatched that way returns a stub, so a changed_when
or failed_when reading its own register evaluates against nothing and can
never report changed and never fail. Several tasks carried a
"not ASYNC_ENABLED and" prefix in front of those conditions, added to
avoid touching undefined rc and stdout, which turned the guard off
entirely rather than adapting it.

That is how the nextcloud plugin configuration stayed broken unseen: the
batch reported ok after 0.8s while its delegated commands kept running,
the next task read the half written configuration, and no failure in any
of them could surface.

Every async in the repo was decided individually rather than swept. Where
nothing consumes the result, the async stays and a one line rationale now
records what does not read it. Where a later task, a handler or a browser
does consume it, either the async goes or the result is collected.

Collecting is what the new reaper does. It waits for a whole batch, then
reports every broken job at once instead of aborting on the first, so one
run shows whether a single item or the whole set is broken. Deciding what
counts as broken lives in a filter plugin, because the distinction is
subtle enough to deserve tests: a module reports failure in msg, a command
in rc and stdout, an idempotent API answers "already exists" and means
success, and a job that cannot be read at all comes back marked finished
with no outcome, which would otherwise read as green.

Timing cost is roughly one to two minutes on a deploy measured in hours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 04:05:38 +02:00
kevinveenbirkenbachandClaude Opus 5 1f5eae2dbb fix(nextcloud): hold followers until the init lock clears
Gating on occ status alone opens too early. Upstream's entrypoint holds
its init lock across a range that outlives the install itself: the lock
is taken before the version check and released only after the trusted
domain configuration and the post-installation hooks have run, while
occ maintenance:install returns earlier. In that window a follower sees
installed, out of maintenance, no pending database upgrade and a
matching version, execs the entrypoint, loses its non-blocking flock and
falls into the blocking one. Over this export that call returns EIO and
the task dies, which is the failure the lock probe existed to prevent
before it was dropped.

Take the probe as the first condition rather than the last. Correctness
aside, it also removes the load the poll was adding: while the leader
holds the lock a follower now performs one open and one flock every ten
seconds instead of a full PHP bootstrap and a database connect against
the very volume the leader is still writing.

The probe runs in a subshell so the descriptor closes before the gate
hands over, otherwise upstream would block on a lock this script still
held. Friendica's gate uses the same shape, so both roles now read alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 04:05:37 +02:00
kevinveenbirkenbachandClaude Opus 5 c7ddf6eb24 Merge branch 'feature/swarm-nfs' into feature/svc-net-tor
Three conflicts, each resolved by combining both sides rather than picking one.

compose.yml passes both env vars through to the container: swarm-nfs adds
INFINITO_SWARM_MGR_IP, this branch adds INFINITO_TOR_SOCKS_PORT, and neither
substitutes for the other.

daemon.json.j2 takes the data-root block from swarm-nfs and keeps HEAD's dns
key. The incoming side writes networks.internet.dns directly; lookup(
'container_dns') already returns that value as its clearnet entry and prepends
the docker0 bridge on a Tor node in compose mode, so the incoming form is a
strict subset and dropping it loses nothing.

The WordPress CA-trust addon spec gains skipUnlessServiceEnabled("sso") from
swarm-nfs alongside HEAD's gotoOnion and resolveTimeout, and asserts on the
Plugin Name the mu-plugin header declares - the mustuse screen renders that,
not the filename - with resolveTimeout applied to the literal the incoming
side left bare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:47:10 +02:00
kevinveenbirkenbach 4b09b76f8f fix(nextcloud): write the plugin configuration before anything reads it
The batched occ config:app:set ran fire-and-forget. It reported ok after
0.8s while 41 delegated docker execs kept running for roughly 40 more
seconds, and the very next tasks in the same file include the addon hook,
which activates the LDAP configuration and calls occ ldap:search.

A process snapshot taken 7 seconds after the failure shows the batch alive
and working on key 24 of 41. ldap_host is key 23, ldap_login_filter 24 and
ldap_port 32, so the search read a configuration whose mandatory fields were
still empty. ldap_configuration_active is key 11, which arms the config long
before those fields exist, so user_ldap's doCriticalValidation rejects it and
Connection.php:249 reports "Connection to LDAP server could not be
established" without ever opening a socket. That is why the LDAP server logs
show nothing: the swarm variants lose the race because every occ call crosses
a node boundary.

The guards hid it. failed_when and changed_when were both prefixed with
"not ASYNC_ENABLED", so with async on — everywhere except debug mode — no
failure in those 41 commands could ever surface and the task never reported
changed.

Waiting costs about 93 seconds across the 22 addons that carry configuration,
against a deploy measured in hours.
2026-08-03 00:37:37 +02:00
kevinveenbirkenbach 358023e659 Merge branch 'feature/swarm-nfs' of https://github.com/kevinveenbirkenbach/infinito-nexus-core into feature/swarm-nfs 2026-08-03 00:23:18 +02:00
kevinveenbirkenbachandClaude Opus 5 15666299e7 fix(nextcloud): healthcheck the fpm listener, not just state
occ status reads the shared volume, so it reports installed: true from
any replica whose php-fpm never started. That is how two of three swarm
tasks stayed healthy with FailingStreak 0 while serving nothing: swarm
kept them in the service VIP backend set and roughly two thirds of
fastcgi connects were refused, which the front nginx turned into 502.

Require the listener before the state check. A socket probe against the
declared fpm port fails immediately when nothing is listening, and only
then does the existing occ status run, so both claims have to hold.

The port comes from the same single point of truth the nginx sidecar
uses for its fastcgi target, so the probe and the proxy cannot drift.

The cron service keeps the state-only check on purpose: its entrypoint
is cron.sh and it listens on no port.

This makes a replica that is stuck before php-fpm visible instead of
advertised. A deploy whose replicas do not serve will now fail to
converge rather than report success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:21:55 +02:00
kevinveenbirkenbachandClaude Opus 5 e6ce6d2df9 refactor(nextcloud): single-source the fpm port and app user
The php-fpm listener port lived as a literal 9000 in the nginx sidecar
config, and the application user as a literal www-data in both
healthchecks, while the role already carried NEXTCLOUD_DOCKER_USER and
declares every other internal port in meta/services.yml.

Declare the listener as services.nextcloud.ports.internal.fpm and read
it through the config lookup, the same shape the moodle and magento
roles use for their fpm listeners and the same shape this role already
uses for the signaling and recording ports. That also satisfies the
port lint, which requires every *_PORT constant in a role's vars to
resolve through the lookup rather than carry a literal.

The rendered output is unchanged: the port is still 9000 and the user
is still www-data. After this change no www-data literal remains in the
role's code; the occurrences under docs/ stay as they are, since those
are copy-paste operator recipes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:18:24 +02:00
kevinveenbirkenbachandClaude Opus 5 909c4fb166 fix(friendica): lock the init gate on a writable fd
The gate is the image entrypoint and holds back every swarm task slot
other than 1 until slot 1 has initialised the shared volume. Its lock
probe was

    flock -n "$APP_DIR/friendica-init-sync.lock" true

which never succeeds on an NFS-backed /var/www/html: flock(1) opens a
path argument O_RDONLY and takes an exclusive lock, while flock over NFS
is emulated as a whole-file POSIX write lock and needs the descriptor
open for writing, so it returns EBADF. The probe sat in the loop's break
condition, so the loop never terminated and slots 2 and up never reached
the entrypoint. The stderr redirect hid the error, which is why this
went unnoticed.

Open the descriptor for writing instead, in a subshell so the lock is
released again before the gate hands over, and let errors surface.

The two file predicates cannot carry this on their own: VERSION is
rsynced into the volume before autoinstall runs, and the installer
writes local.config.php in createConfig(), ahead of installDatabase().
Both are therefore already true while slot 1 is still installing. The
lock is the only signal that tracks the critical section itself.

Read the config path inside the non-primary branch as well, so compose
and slot 1 start even when the variable is absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:16:07 +02:00
kevinveenbirkenbachandClaude Opus 5 c18cb26ef5 fix(nextcloud): gate replicas on occ status
The init gate is the image entrypoint, so it decides when a swarm task
starts php-fpm. For task slots other than 1 it spun on

    flock -n "$INIT_LOCK" true

against /var/www/html, which is an NFS4 export in swarm. flock(1) opens
a path argument O_RDONLY and takes an exclusive lock; over NFS flock is
emulated as a whole-file POSIX write lock, which requires the descriptor
to be open for writing, so the call returns EBADF on every iteration.
The loop reads any non-zero exit as "lock still held" and never breaks.
Slots 2 and 3 therefore never reached the entrypoint and never started
php-fpm, while their occ-status healthcheck kept passing off the shared
volume, so swarm left them in the service VIP backend set and roughly
two thirds of fastcgi connects were refused.

The version.php comparison that preceded the lock is not a sufficient
gate on its own: version.php is rsynced before occ maintenance:install
runs, so it flips true mid-install.

Replace both loops with a readiness poll that only passes once the
instance reports installed, out of maintenance, with no pending database
upgrade, and at the image version. That needs no file locking, so the
mount semantics stop mattering, and it holds for the upgrade path too.
Matching is plain POSIX case rather than grep so busybox flag variants
cannot change the outcome.

occ refuses to run as root, so the poll needs the application user. The
gate ships from files/ and cannot template, so the user arrives as an
environment variable fed from the existing NEXTCLOUD_DOCKER_USER single
point of truth. The lookup sits inside the non-primary branch, which
keeps compose and slot 1 starting even without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:12:16 +02:00
kevinveenbirkenbach 3a9ab114a5 build(test): replay the logs of failed targets after the summary
drain_one() prints each target's log the moment that target finishes, so with
four suites running concurrently the output of the red one lands somewhere
between three green ones. The rc table at the end names which target failed
but not why, and finding the assertion meant grepping several thousand lines
of interleaved output.

The failed targets' logs are now replayed after the summary, each between
markers carrying the target name, followed by a FAILED TARGETS line. A green
run prints nothing extra.
2026-08-02 22:54:23 +02:00
kevinveenbirkenbach f99d1654aa test(perf): budget the runtime suite in CPU seconds and take it out of the gate
The wall-clock budgets in this suite asserted a property the test cannot
control. `make test` dispatches four suites concurrently through
scripts/make/parallel.sh, each running `pytest -n auto`, so on a 12-core box
up to 48 workers contend for 12 cores and a 14s single-threaded render
stretches to anywhere between 65s and 141s. Five consecutive local gate runs
failed here on four different assertions while CI, whose integration suite was
green throughout, saw nothing.

Measurement, not guesswork: the render costs ~14s of CPU and wall matches CPU
almost exactly when the suite runs alone, so CPU seconds are the contention-
invariant quantity. An A/B against the 2026-07-30 tree renders 12% slower than
HEAD, which rules out a regression as the cause of the breaches.

The assertions therefore measure `time.process_time()`, and both cold-render
ceilings collapse into one 30s CPU budget. That is stricter than the 60s and
100s wall-clock ceilings it replaces, since CPU time never exceeds wall time.
The cold-to-hot ratio assertion is untouched: numerator and denominator are
sampled in the same process under the same load, so it was already immune.

The suite moves to tests/performance/, which takes it out of the `test`
fan-out because run.sh derives the suite directory from the test type, and
gains a `test-performance` target plus the alias the lint requires. What
remains in the commit gate is deterministic.

Two redundant cold renders are gone with it: the applications and domains
budgets each had a second test that re-rendered the same state purely to
assert a ceiling the first test had already measured. All eight assertions
survive as subTests, with each cold-render check placed immediately after its
measurement so an exception in the hot loop cannot swallow its verdict. The
file drops from 88s to 49s.
2026-08-02 22:45:43 +02:00
kevinveenbirkenbach fed31a8f75 Merge branch 'feature/swarm-nfs' of https://github.com/kevinveenbirkenbach/infinito-nexus-core into feature/swarm-nfs 2026-08-02 22:37:36 +02:00
kevinveenbirkenbachandClaude Opus 5 f21fd59548 docs(mcp): tick what is proven and align the criteria with what was built
Tick 41 criteria across the MCP and AI requirements, each against a file
that carries the content or a line in a deploy log, leaving anything that
merely looked plausible unchecked.

Reword five criteria the implementation contradicted. Credentials now
follow their origin: a secret this deployment generates stays a vault
credential, one the application issues cannot be, because the vault
cannot generate it. OpenProject is licence-gated rather than pending.
Flowise prepares its instance rather than registering servers, since it
exposes no registry to register them in. A client is held to the server
list only where upstream offers one.

Replace the five audit-artifact criteria with three that describe what
the tree actually enforces. The generator and its committed file were
dropped: once every fact moved into the role that owns it, the artifact
was a copy that could only go stale, and grep answers the same question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:32:37 +02:00
kevinveenbirkenbachandClaude Opus 5 3e46232c9a feat(swarm): constrain placement to a role's declared platform arch
Read platform_arch from the role config and append a node.platform.arch
constraint where it is set, so a role whose image exists for one
architecture only cannot be scheduled onto a node that could never run
it. LM Studio ships x86_64 alone and is the first consumer.

The constraint lives with the other placement rules rather than in the
consuming role, because architecture binding is a property any role with
a platform-specific image can carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:31:29 +02:00
kevinveenbirkenbachandClaude Opus 5 70d5087ea0 feat(mcp): declare the blocked upstream paths and gate every served one
Six roles have an upstream MCP path this deployment cannot use, so they
declare a blocker instead of a direction. Without it they would read as
"upstream has no MCP", which is false for all of them and worst for
OpenProject: the server ships, it is Enterprise-only, and upstream's own
request specification asserts a 404 on the Community edition this role
deploys. Jira, Confluence, WordPress, Odoo and Discourse are hosted-only
or unreviewed third-party paths.

The dynamic-flag suppressions are load-bearing: a blocker block is
mutually exclusive with a served surface, so a group_names expression
there could never evaluate true.

Add the test that keeps a served surface honest: every role declaring one
must gate it on the role's MCP-enabled flag, so switching MCP off removes
the endpoint instead of leaving it reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:29:44 +02:00
kevinveenbirkenbachandClaude Opus 5 ab444915be feat(openwebui,flowise): wire the web UIs to the discovered MCP servers
Register every discovered server as an Open WebUI tool server, skipping
the auth schemes its ToolServerConnection cannot present so none is
stored with an unusable credential. Serialise that value with compact
separators: a space makes dotenv_quote wrap it, swarm keeps the quotes
inside the value, and Open WebUI then parses a string instead of a list
and crash-loops on startup.

Prepare the Flowise instance instead of registering servers in it.
Flowise 3.1.3 exposes no instance-level MCP registry; MCP lives in a
per-flow Custom MCP node. The role pins the transport to URL-based
servers so no flow can spawn a local command, and relaxes the host deny
list far enough to reach a container origin while loopback and the
cloud-metadata addresses stay denied. Selecting a server inside a flow
stays an operator step, and the README says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:27:46 +02:00
kevinveenbirkenbachandClaude Opus 5 e9af031215 feat(hermes,openclaw): consume the discovered MCP servers
Render every discovered server into the agent's own config, filtered to
the auth schemes a client can actually present, so no entry carries a
credential the server would reject.

Render hermes' config after the stack exists and before the handlers
flush; the other order fired compose up while the config was still
missing.

Own the config file as the container user. Rendered as root it stays
unreadable to the agent, which then falls back to its built-in defaults
silently and reports only that the server is unknown, pointing the reader
at the wrong layer.

Assert what these clients expose: neither ships an admin surface listing
its configured servers, so the specs hold the API to refusing an
unauthenticated caller, which is what keeps the per-server bearers out of
reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:26:32 +02:00
kevinveenbirkenbachandClaude Opus 5 a2be4f081a feat(nextcloud): serve MCP through the Context Agent ExApp
Nextcloud carries no MCP server of its own, so the role runs the Context
Agent as an ExApp: render its container while MCP is enabled, wait for
its heartbeat, register it with AppAPI, add the MCP route, enable it, and
only then mint the app password clients present.

Split the credentials by origin. The shared secret between the AppAPI
proxy and the ExApp is generated by this deployment and stays a vault
credential; the app password is minted by Nextcloud through occ and is
persisted through sys-token-store instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:24:33 +02:00
kevinveenbirkenbachandClaude Opus 5 f2fe5e94b1 feat(moodle): serve MCP through the build-time webservice plugin
Install the protocol plugin during the image build, because a Moodle
plugin must match the pinned release and installing it at runtime would
leave the two free to drift.

Provision the webservice and its token through PHP, and attach only
read-only functions, so the read-only tool policy is enforced by the
service definition rather than declared in metadata.

Address the endpoint as its canonical origin. Moodle compares every
request against wwwroot, so a probe over the internal HTTP origin was
answered with an HTML redirect instead of JSON and every retry hit the
same wall; the verify step now sends the canonical Host and the
forwarded-proto header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:21:04 +02:00
kevinveenbirkenbachandClaude Opus 5 3f900c7600 feat(mattermost): enable the Agents MCP server through the database
Mattermost keeps the plugin's MCP server switch in the database rather
than in a config file, so the role reads the plugin state, flips the
server on, and reads back the personal access token through SQL, with
reading and writing statements kept in separate directories.

Take over the token the server issued instead of generating one: the
value only exists once Mattermost has minted it, so it is persisted
through sys-token-store rather than declared as a vault credential.

Calls execute as the account the token belongs to, so the surface is
bounded by that account's permissions rather than by an administrator
role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:19:29 +02:00
kevinveenbirkenbachandClaude Opus 5 41efbeda11 feat(jenkins): serve MCP behind the controller's own credentials
Pin the MCP Server plugin and let a boot hook mint the API token MCP
clients present, then take that token over into sys-token-store and
verify it against the controller before the deploy continues.

Wait for the controller to answer before probing. A JCasC change restarts
Jenkins, and the probe carries no retries of its own, so a single 5xx
during startup failed the deploy with a credential that was valid all
along.

Authenticate with HTTP Basic. The plugin offers no bearer, which is why
basic_auth entered the service contract instead of the role working
around it.

Assert the guarded surface, not the handshake: the plugin answers
initialize anonymously because that call is capability negotiation and
carries no data, while an unauthenticated tools/list is refused and
leaks no tool inventory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:18:02 +02:00
kevinveenbirkenbachandClaude Opus 5 efee415b21 fix(jenkins): render exactly one trailing slash on the root url
url.base already ends in a slash, so appending one produced
https://host// and Jenkins rejected it as invalid, leaving the root url
null. Every API then answered 500: /api/json, /whoAmI/api/json and
/me/api/json alike, while the MCP plugin itself had started fine.

Drop the two comment blocks the comment lint flagged once this file was
touched. Their content: since oic-auth 4.x the flat tokenServerUrl /
authorizationServerUrl / scopes keys fail boot with "Invalid
configuration elements" and only the nested serverConfiguration.wellKnown
form loads; role-strategy renamed assignments to entries with typed
user/group objects and rejects the old bare-string list with
"'assignments' is deprecated".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:16:04 +02:00
kevinveenbirkenbachandClaude Opus 5 9e42610c02 feat(gitlab): mint the MCP token through the CNG rails entrypoint
Provision a personal access token carrying the mcp scope through the
rails runner. The role deploys the cloud-native images, which ship no
omnibus gitlab-rails wrapper, so the invocation names the rails binary
under /srv/gitlab/bin.

Attach clients through the role's overlay, keyed on the MCP service
flags, so a client container reaches the endpoint without publishing it.

Record what upstream does not enforce: the mcp scope grants read and
create, GitLab offers no server-side read-only mode, and the only filter
is a per-request header this deployment does not send. The tool policy in
meta/services.yml is therefore declarative, and every call carries the
blast radius of the root account. The endpoint additionally requires
Premium or Ultimate, so a Community deployment cannot reach it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:11:39 +02:00
kevinveenbirkenbachandClaude Opus 5 3036b7ba19 feat(gitea): serve MCP through the project-owned sidecar
Gitea ships no MCP server, so the role runs the project-owned gitea-mcp
package as a sidecar next to it and points it at the instance over the
container network.

Two things the image forces. Its command carries the binary path
explicitly because the image declares no ENTRYPOINT, so a bare flag list
would replace the binary instead of extending it. The port is stringified
because Ansible's native types make a bare lookup an int, and compose
rejects a non-string in a command array.

Keep the surface internal. The public vhost answers a bearerless probe
with an SSO redirect, so the guest spec pins maxRedirects to 0 and
asserts no MCP response is ever served; following the redirect would land
on a login page and pass for the wrong reason.

Calls run with administrator rights, so the tool policy is pinned
read-only and the sidecar starts with the read-only flag and a scope
list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:10:15 +02:00
kevinveenbirkenbachandClaude Opus 5 d350e88c6b feat(homeassistant): carry the MCP provenance in the role
Move source, minimum version and upstream notes into the role's own mcp
block, so the facts sit next to the surface they describe.

Drop the mcp_token credential declaration. Home Assistant issues that
token itself and it is persisted through sys-token-store, so a vault
credential of that name was never read and pointed at a place the value
does not live.

Document the surface: the endpoint answers an unauthenticated request
with 404 rather than 401, and the enforced control is entity exposure,
not a read-only tool policy. tools/list offers write tools, but an entity
that is not exposed to Assist stays unreachable through them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:07:45 +02:00
kevinveenbirkenbachandClaude Opus 5 cc50e92b2d feat(baserow): serve MCP and address containers by node in swarm
Provision the native MCP server surface: create the endpoint, its owner
and its workspace, probe it on the internal origin, and refuse an
endpoint that never serves the stream. Baserow addresses a session
through the URL path, so meta/services.yml declares the endpoint key and
suffix the discovery layer composes the client URL from.

Split the role into a run-once guarded core. Registering the MCP surface
makes baserow a shared provider, and the variant-aware loader checks the
run-once flag before loading; without the flag as the first task a second
load request can include the role again.

Address the container by its hosting node. Migration and bootstrap ran
`container exec` on the manager while the replica sits on a worker, so
the resolved id existed nowhere the command could reach it. Re-resolve
after the post-migration restart as well: `service update --force`
replaces the container, and the bootstrap was still holding the id of the
one it replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:06:11 +02:00
kevinveenbirkenbachandClaude Opus 5 5583a2cf74 feat(mcp): express blocked surfaces and path-addressed endpoints
Add a blocker vocabulary so a role whose upstream MCP path this
deployment cannot use says why instead of reporting an absence. Without
it OpenProject reads as "upstream has no MCP", which is false: the server
ships, it is Enterprise-only. A blocker and a served surface are mutually
exclusive, so a role cannot claim both.

Add endpoint.key_credential and endpoint.suffix for servers that address
a session through the URL path. Baserow streams from
{base}/mcp/{key}/sse, and endpoint fields must stay literal, so the
composition belongs in the discovery layer. A server that declares a path
key without a resolvable value is dropped rather than handed to clients
half-built.

Carry the upstream provenance in the role block, so the audit derives it
instead of keeping a second copy.

Require an explicit mutating_tools_enabled: false for a privileged
auth_subject; the rule previously only fired when the key was present and
true, so omitting it slipped an administrator-scoped surface through.

Move the MCP filters into plugins/filter/mcp/ so they stop sharing a name
prefix with their siblings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 21:58:01 +02:00
kevinveenbirkenbach da4fc95b7e test(objstore): resolve both consumer scenarios from one shared render
The suite rendered the merged applications tree twice, once per scenario.
Under xdist that competed with the runtime-performance budgets and pushed
the cold applications render from well under its 60s budget to 96s.

Both scenarios now resolve against a single render: the second lookup call
hits the applications cache, so the file costs one render instead of two
and its runtime halves.

Each scenario keeps exactly one consumer, which is what makes the volume
bound bite -- with one consumer the budget is two grow batches and dropping
that consumer leaves one, which is the budget that starved the matrix
collection. Merging the scenarios into a single set would have made the
bound tolerate a dropped consumer. The consumer sets are now asserted by
equality rather than membership, so a role that is silently added is caught
as well as one that is dropped.

The role path and the file read go through the ROLE_FILE_META_SERVICES
constant and the cached reader, as the repository's own lints require.
2026-08-02 20:44:40 +02:00
kevinveenbirkenbach fb5456b4e3 style: apply the formatter to this session's new lookup and tests
Line reflow only, written by the autoformat step after the files were
committed. No semantic change.
2026-08-02 19:52:24 +02:00
kevinveenbirkenbach f3a1102980 test(lint): share the project walk in the include-resolution lint
The lint walked each role's tasks directory itself, once per role. The
project's own test_no_raw_project_walk rejects that: utils.cache.files
shares a single filesystem walk and one read per file across the whole
pytest session, so 251 separate directory traversals are 250 too many.

Roles are now identified from the relative path of each yielded file
instead of by iterating the roles directory. The scanned set is unchanged
at 806 files, with empty differences in both directions, and the
unresolvable-target injection still produces the expected finding.
2026-08-02 19:52:23 +02:00
kevinveenbirkenbach 481c97563d fix(seaweedfs): size the volume budget from the roles that actually bind the store
The consumer count came from roles_with_service, which builds dashboard card
targets and therefore requires a single canonical domain. web-app-matrix
declares domains.canonical as a mapping (synapse, element) and was dropped;
in a deployment where it was the only consumer the count reached zero, the
server started with -volume.max=7, the unnamed default collection took all
seven slots on first write, and every S3 PUT for the matrix collection failed
with "No writable volumes and no free volumes left". Synapse answered 500 on
media upload and no avatar ever reached the bucket. web-app-openwebui carries
the same mapping shape and the same latent failure.

Consumers are now enumerated by the binding itself: a role is a consumer when
its merged config binds the engine and marks it shared, whatever the role is
called and however many domains it serves. The S3 identity file and the volume
budget read that one list, so the identities granted write access and the
slots budgeted for their collections cannot drift apart.

Selection reads the merged applications config rather than meta/services.yml,
because an operator may bind a store through the applications inventory
variable alone; that path is covered by a test so a later optimisation cannot
quietly reintroduce a naming or declaration assumption.
2026-08-02 19:52:23 +02:00
kevinveenbirkenbach 91320b8deb refactor(nextcloud): make main.yml the orchestrator and fix the flush order
main.yml now reads as the chronological sequence it describes: it includes
01_fullstack.yml, then the single IS_STACK_HOST-gated 02_manager_ops.yml,
whose substeps live in the matching 02_manager_ops/ directory numbered in
execution order. The plugin pipeline moves under its parent step so its
include resolves against its own directory instead of an inherited search
root.

The flush that deploys the stack moves out of the config step to the top of
02_manager_ops.yml. It previously ran after the swarm resolver, which then
waited 600 times for a service that did not exist yet and killed two CI jobs
with "no such service: nextcloud_nextcloud". The second flush and the
re-resolve behind it are removed: neither included file notifies a handler,
so they could never fire.

A new lint pins what made this refactor risky. A relative include is resolved
at runtime, so a typo or a moved file survives ansible-lint and
--syntax-check and only fails mid-deploy; worse, a bare name that matches
under two search roots silently shadows one file with the other.

Comments that only narrated code are dropped from the addon hooks and the
plugin install step.
2026-08-02 19:52:22 +02:00
kevinveenbirkenbachandClaude Opus 5 cb833218b0 fix(nextcloud): move apply into the include mapping where ansible reads it
apply is a parameter of the include_tasks action, not a task keyword. Written as a sibling of the module Ansible sees two action statements and aborts: 'conflicting action statements: include_tasks, apply' at 08_manager_ops.yml:9. All five delegated includes from a963280c1 carried it that way.

Nothing static caught it. ansible-lint passes the file, and ansible-playbook --syntax-check - which scripts/lint/ansible.sh runs and make quality-high covers - reports 0 failures against the broken tree, because a task file reached through a dynamic include_tasks is first parsed at runtime. The play therefore died 26 minutes into the deploy, after everything before it had run.

The new lint reads every task file on its own and rejects apply beside a module, which is never valid. Verified both ways: the sibling form is reported, the nested form is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:58:20 +02:00
kevinveenbirkenbachandClaude Opus 5 8597d0d01c fix(seaweedfs): give the free-space guard a floor larger than one volume
f956de889 made the slot ceiling generous, which removed the accidental brake the old autosizing had provided: while the ceiling was derived from free disk space, growth stopped long before the disk filled. With a fixed ceiling the only thing between growth and ENOSPC is the free-space guard, and its stock 1% is 300 MB on a 30 GB data root - less than the 1024 MB unit SeaweedFS allocates in, so a single growing volume can outrun it.

The floor is now expressed as whole allocation units rather than a percentage, which is meaningless without knowing the disk size. Two units: one for the growth in flight, one for concurrent writers. The size limit is stated alongside it so both come from one constant instead of the floor deriving from a value the image injects - verified that our flag wins, since -master.volumeSizeLimitMB=512 makes the master report 512 MB.

-volume.minFreeSpace replaces the deprecated -volume.minFreeSpacePercent, per the image's own help text. The rendered command was run against chrislusf/seaweedfs:4.39: master, volume server and filer all start.

Left open deliberately: web-app-seaweedfs declares min_storage: 304MB, which cannot hold with a 2 GiB floor. That value feeds the CI storage filter in scripts/meta/resolve/apps.sh, where 60GB is the budget, so nothing is dropped today - but the declaration understates what the role now needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:04:18 +02:00
kevinveenbirkenbachandClaude Opus 5 1c6249b7eb fix(seaweedfs): derive the volume-slot ceiling from the consumer count
a3fcd3e7f stopped the entrypoint from autosizing the ceiling out of free disk space, but replaced it with a flat 64 that I adopted without deriving. Each bucket is a SeaweedFS collection and the master grows one in batches of seven - 'create 7 volume' in the artifact, copy_1 = 7 in the image's own master scaffold - so 64 covers nine collections. Twenty-five roles declare the service, and the tenth consumer would have hit 'No writable volumes and no free volumes left' again, just later and without the disk-space link.

The ceiling now comes from volume_slots(): one grow batch per bucket plus one for the default collection, with the consumer count read from roles_with_service. Note what that count is: the consumer gate gets whether seaweedfs is in group_names, not whether the consumer itself is installed, so the value is derived from the repository rather than from the host. It grows with the codebase, which is what the ceiling has to keep up with.

The collection count is a required parameter with no default. A silent fallback would be worse here than a loud failure: without the flag the stock entrypoint injects -volume.max=0 and the autosizing trap is back.

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