Commit Graph
14682 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 bb2b28a1a6 feat(ci): mark clearnet jobs too, and add enforced and exclusive tor modes
The onion glyph alone left a job name ambiguous: no glyph meant either
'clearnet' or 'this mode has no tor axis'. Swarm and compose entries now open
with the onion or the globe, so the name states which of the two networks the
job tested. Host keeps neither — it shares the matrix builder but has no onion,
and a glyph there would claim something the job never did.

'enabled' becomes 'enforced' so the manual selector says what it does: tor on
every role that can take one, not tor on everything.

'exclusive' is the new mode. keep_tor_variants runs before assign_tor and cuts
each entry down to its tor-capable variants, dropping entries left with none, so
a run tests the onion and nothing else. A bundle can come out with a hole in it
— variants 0 and 2 capable, 1 not, yields '0,2' — which filter_plan_to_variants
already handles by set membership; both shapes are now pinned by tests.

Note the word is overloaded: services.tor.exclusive drops a deployed app's
clearnet vhost, while this mode only narrows what CI runs. The docstring says so
where a maintainer will look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:18:10 +02:00
kevinveenbirkenbachandClaude Opus 5 ef5ea7cb55 refactor(roles): name the credential file meta/secrets.yml
meta/schema.yml held credential definitions and their runtime values, but the
name said 'schema' and read like structural metadata next to services.yml and
volumes.yml. Nothing in the file is a schema in the sense the other meta files
use the word, and a contributor had to open it to learn it holds secrets.

62 role files renamed. ROLE_FILE_META_SCHEMA becomes ROLE_FILE_META_SECRETS and
keeps its role as the SPOT the path is built from.

Two readers bypassed that constant and spelled the name themselves:
utils/cache/applications.py built meta_dir / 'schema.yml' twice, and
plugins/lookup/application_gid.py carries the filename in the marker list it
detects application roles by. Left behind, the rename would not have raised —
credentials would have resolved empty and the role would have stopped counting
as an application.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:08:06 +02:00
kevinveenbirkenbachandClaude Opus 5 7f81d26340 fix(lint): close the blank line the rerun doc left behind
3a1ef70cb removed the rerun workflow's rows from the actions configuration
page and left two blank lines at the end of the file, which MD012 rejects.
That is what failed the Lint job on 777f7f0fa.

The two Python files carry the reflow ruff 0.16.2 produces; 6320da703
brought that version in with the main merge. Committing it keeps the tree
matching the formatter, since scripts/lint/python.sh runs ruff format in
write mode and would otherwise rewrite them on every run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 11:08:49 +02:00
kevinveenbirkenbach 8435eda8b3 fix(package-cache): use Fedora mirror redirector 2026-08-12 03:42:38 +02:00
kevinveenbirkenbach 7091040be3 fix(cli-tests): reset role deployment mode 2026-08-12 03:39:18 +02:00
kevinveenbirkenbach a0f2637097 fix(sso): share sessions across app domains 2026-08-12 03:36:19 +02:00
kevinveenbirkenbach 777f7f0fa8 Merge remote-tracking branch 'origin/main' into fix/playwright/env-template-gate 2026-08-12 03:00:40 +02:00
kevinveenbirkenbachandClaude Opus 5 27bed25eb3 style: drop the Exception-prefixed rationale comments
b384f4825 put two of them above churned_past_grace and has_fatal_task, and
e41508f48 put one in the force_shared_db module docstring. The prefix is not
a licence to keep explanatory prose; the comment rule wants it deleted.

The docstring paragraph keeps its content and loses only the prefix: it
records that Magento hard-fails setup:install against the central MariaDB
major, which is the reason the manager-pinned carve-out exists at all.

No executable line changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:59:21 +02:00
kevinveenbirkenbachandClaude Opus 5 9195a0e941 fix(opentalk): hold the recorder at the version its config matches
3f51d4ed9 bumped the recorder from v0.16.2 to v0.17.1. Upstream 0.17 drops
the rabbitmq transport for REST and reshapes [controller]: MR !746, listed
under the 0.17.0 changelog, replaces domain and insecure with url and a
mandatory api_key. recorder.toml.j2 still writes the old shape, and 0.17
also started warning about unknown configuration fields. Back to v0.16.2
until the template is migrated.

The frontend, the controller and the recorder are released as one
coordinated set, so all three carry a nocheck marker now. Bumping them
individually is what produced this. LiveKit and RabbitMQ keep updating:
they version independently of OpenTalk.

The marker sits after the value; utils/update/docker.py resolves it with
the default same-or-above placement, which the suppression catalog lists
for docker-version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:53:18 +02:00
kevinveenbirkenbachandClaude Opus 5 49aa8183d0 fix(espocrm): match LDAP users on the attribute the directory actually uses
The login filter searched sAMAccountName, an Active Directory attribute.
This directory is OpenLDAP: LDAP.USER.ATTRIBUTES.ID resolves to uid, and
svc-db-openldap creates every user as dn: uid=<name>,<ou>. The filter could
not match a single entry, so LDAP login never worked. web-app-semaphore and
web-app-minio already build their filter from the same variable.

AUTHENTICATION_METHOD was written by both branches, so enabling LDAP and
OIDC together emitted the key twice and the outcome depended on the reader
taking the last duplicate. It is now set once, above both blocks.

OIDC_AUTHORIZATION_REDIRECT_URI is dropped. EspoCRM derives the callback in
ConfigDataProvider::getRedirectUri() as siteUrl . '/oauth-callback.php' and
reads no configured value; the oidcAuthorizationRedirectUri key exists
upstream only in entityDefs, the Oidc authentication metadata and the i18n
labels, and its settings field computes the same URI client-side purely to
show the admin what to register with the IdP. The path we set,
/oidc/callback, does not exist in EspoCRM at all.

Rendering the template across all four LDAP/OIDC combinations before and
after shows no other key changing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:44:59 +02:00
kevinveenbirkenbachandClaude Opus 5 6b941c1e51 fix(mobilizon): trust the internal CA on the server-side OIDC calls
Erlang's ssl stack does not read SSL_CERT_FILE, and hackney ships certifi,
so the Keycloak strategy only trusted public roots. Every server-side token
and userinfo call against the stack's own Keycloak failed certificate
verification.

The CA plumbing already mounts the anchor and exports SSL_CERT_FILE into
every compose service, so the config reads that path and hands it to the
strategy as cacertfile. Ueberauth merges the whole keyword list into
OAuth2.Client.new/1, where request_opts is a struct field that oauth2 2.1.0
forwards to hackney unchanged.

The key is ssl_options, not ssl: Mobilizon configures
Tesla.Adapter.Hackney, whose call passes opts straight into
:hackney.request/5, and hackney reads maps:get(ssl_options, Opts, []). An
ssl key would have been dropped without a word. Only the httpc adapter
spells it ssl.

Overriding ssl_options replaces hackney's default trust store, so
customize_hostname_check restores the https hostname match that
ssl_verify_fun would otherwise have provided.

With no SSL_CERT_FILE the value stays [] and nothing changes.

Verified by rendering the template with Ansible's Jinja settings and
evaluating it under Config.Reader on elixir:1.17-alpine: the empty branch
yields [], the populated branch yields the ssl_options list with
:public_key.pkix_verify_hostname_match_fun/1 resolved to a real function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:37:13 +02:00
kevinveenbirkenbachandClaude Opus 5 e6fb78af49 fix(suitecrm): generate the Symfony runtime autoloader in the image
public/index.php requires vendor/autoload_runtime.php, which symfony/runtime
writes from its ComposerPlugin on the post-autoload-dump event. Composer's
AutoloadGenerator guards that dispatch behind `if ($this->runScripts)`, and
every composer install in this Dockerfile passes --no-scripts, so the event
never fired and the file was never written. The image shipped, then fataled
on the first request.

A dump-autoload without --no-scripts fires the event. It fires nothing else
here: SuiteCRM 8.9.1 declares no post-autoload-dump script, and its
auto-scripts (cache:clear, cache:warmup, assets:install,
scrm:copy-legacy-assets) plus Google\Task\Composer::cleanup hang off
post-install-cmd, which dump-autoload does not dispatch. The only listener
left is the runtime plugin.

It runs after the --no-dev prune rather than after the first install, so the
autoloader reflects the vendor tree that actually ships and nothing re-dumps
behind it.

The test -f after the seed COPY turns a future regression into a failed
build instead of an image that only breaks at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:21:47 +02:00
kevinveenbirkenbach 661dfbf08f Merge remote-tracking branch 'origin/main' into feature/ai-agent-employees 2026-08-12 02:17:05 +02:00
kevinveenbirkenbach 1bc8714c49 fix(nextcloud): pin MCP tool categories 2026-08-12 02:11:14 +02:00
kevinveenbirkenbach 8358e423a4 feat(mattermost): proxy MCP through adapter 2026-08-12 02:09:11 +02:00
kevinveenbirkenbach 838b64a6bf feat(gitlab): proxy MCP through adapter 2026-08-12 02:02:40 +02:00
kevinveenbirkenbach 5fa47cff67 feat(mcp): proxy native upstream sessions 2026-08-12 01:55:48 +02:00
kevinveenbirkenbach fd09b3b6c6 feat(mcp): validate role-aware tool contracts 2026-08-12 01:50:14 +02:00
kevinveenbirkenbachandClaude Opus 5 2cf3c5a708 fix(ci): cancel a closed PR's orchestrator without waiting for a runner
The PR-close canceller had no concurrency group at all, so cancelling
depended entirely on scripts/github/cancel/pull_request_runs.sh, which
needs a runner before it can call the API. Runners were 20 minutes to 21
hours out, and run 31451265380 stayed alive that whole time.

The canceller now joins the group entry-pr-change-orchestrate.yml renders
for the same PR. GitHub clears the group when the run is created, before a
runner is assigned, so the orchestrator dies immediately and the script
becomes a backstop instead of the only path.

The group spells pull_request out literally rather than interpolating
github.event_name. This workflow is triggered by pull_request_target, so
the interpolated form would join the twin the orchestrator's own job
guards already skip on a same-repo PR and leave the working run untouched.
A fork PR runs both twins; the pull_request_target one is still left to
the script.

The branch-delete sweep filtered runs by head_branch alone and shot this
canceller mid-cancel. It now excludes the canceller's own path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 01:49:28 +02:00
kevinveenbirkenbach f14ac703d0 feat(mcp): support role-specific credentials 2026-08-12 01:47:40 +02:00
kevinveenbirkenbach 80da75355c feat(mcp): split reader and writer roles 2026-08-12 01:43:40 +02:00
kevinveenbirkenbachandClaude Opus 5 e41508f48b fix(swarm-test): honour a role's pinned local database
force_shared_db flipped every DB service to shared with no exemption, so
web-app-magento's deliberate embedded MariaDB 11.4 - declared shared:
false with a comment warning that no bot may move the major underneath
it - became dead config and setup:install hit the central engine's 12.3.2,
outside Magento's supported range. The run log names the override:
'swarm force-shared: web-app-magento.services.mariadb -> shared=true'.

Exempts a DB service whose role meta declares a literal shared: false AND
whose role is manager-pinned. The second condition is not decoration:
utils/storage/nfs.py keeps a manager-pinned role's volumes node-local, and
InnoDB over NFS is why the harness force-shares in the first place. Of the
two roles with a literal local DB, magento qualifies and bigbluebutton
(unpinned) does not, so its behaviour is unchanged.

derive_includes._force_shared_db_view documents itself as mirroring
force_shared_db, so it takes the same exemption - without it the dep walk
would compute magento's include closure against the central engine while
the deploy runs a local sidecar.

The sidecar carried no deploy block at all, so an exempted service would
have been free-floating with one task per node over a single-writer
datadir. The block now comes from a container_deploy lookup shared with
sys-svc-container/templates/deploy.yml.j2, which replaces the replicas
ternary and the duplicated placement constraint there. Its unit test
caught a real defect while being written: with compose_mode_force=swarm
over DEPLOYMENT_MODE=compose the delegated compose_replicas returned
empty, emitting a deploy: key with no child.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 01:36:18 +02:00
kevinveenbirkenbach 9a8f6087d7 fix(install): verify installed command availability 2026-08-12 01:33:19 +02:00
kevinveenbirkenbachandClaude Opus 5 dd9b69c019 Merge origin/main into feature/svc-net-tor
22 commits, mostly dependabot and docker image-version bumps, plus two fixes:
5153f3030 loads keycloak-js 26 as an ES module, 87a053518 makes the coredns
Corefile readable to the container user.

One conflict, in roles/web-app-dashboard/templates/javascript/oidc.js.j2, resolved
as a union because the two sides touched the same line for unrelated reasons.
edb96e9ae had hoisted the asset URL into KC_JS_ASSET_URL and rewrote it onto the
onion mirror; 5153f3030 changed the path to lib/keycloak.js and declared a local
Keycloak binding, since the adapter now arrives as a module default export instead
of a window global. The onion rewrite stays at the conflict site, the local binding
is kept because loadKeycloakAdapter returns the constructor rather than setting a
global, and the path change moved to the constant's definition.

An import() fetch always runs in CORS mode, which is why 5153f3030 gave the CDN
aca_origin. The onion route reaches the same asset through web-svc-mirror, whose
proxy.conf.j2 already sets that header, so the onion flavor needs no counterpart.

make quality green on the merge result: test-external, test-integration, test-lint,
test-unit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:34:05 +02:00
kevinveenbirkenbach 6ae1ed4498 Merge remote-tracking branch 'origin/main' into update/docker-image-versions-web-app-nextcloud-20260808-6d346a4 2026-08-12 00:33:27 +02:00
kevinveenbirkenbachandClaude Opus 5 d893114a2a style(peertube): wrap the insecure-issuer expression at the column limit
Autoformat output, not a hand edit. YAML folds a plain scalar's continuation
line to a single space, so Jinja still parses OIDC.CLIENT.ISSUER_URL is
search(...) as one expression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:14:50 +02:00
kevinveenbirkenbachandClaude Opus 5 ddfb86e650 feat(ci): give the deploy matrix a tor axis with an onion marker
Tor coverage was decided per role variant and nowhere else: 1d9d355bc pins
services.tor.enabled true in variant 0 and false in every other variant, so an
operator had no way to steer it and a job name never said whether it ran behind
an onion. INFINITO_TOR now controls the axis from manual steer down to the
drill, and the entries that get an onion wear one in their label.

assign_tor decides per entry. 'enabled' gives tor to every capable entry,
'disabled' to none, 'auto' to every second one with the run number's parity
flipping which one starts — measured over the full matrix: 397 entries, 257
capable, 129 on even runs, 128 on odd, union 257, overlap 0. Two consecutive
runs therefore cover every capable entry exactly once.

tor_capable reads the entry's own variants, not just meta/services.yml.
Reading only the base config claimed an onion for 131 of 388 entries whose
variant had pinned the gate off — a third of the glyphs would have lied. A
bundle counts as capable when any variant it covers can use tor; a reactive
Jinja flag counts as capable, since that is the role saying 'onion when the
node is dark'.

The 'disable' token is the working half: it prunes svc-net-tor from the include
closure (utils/tests/swarm/derive_includes.py) and clears the service flags in
host_vars (cli/administration/inventory/provision/services_disabler.py). Both
halves are required — with only the first, extend_inventory walks the provider
back in. It runs after the variant overlay, so it also overrides a variant that
pinned the gate true.

Only swarm and compose join the axis. Host discovery shares the same matrix
builder, so without the allowlist its jobs would carry onion glyphs for a mode
that has no onion at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:13:19 +02:00
kevinveenbirkenbachandClaude Opus 5 9430b25006 fix(nextcloud): let the server-side HTTP client trust the internal CA
integration_mastodon's connect returned 401 with
'cURL error 60: unable to get local issuer certificate ... for
https://microblog.infinito.example/api/v1/apps', byte-identical across
attempt and retry. The CA plumbing the repo already injects - the CA
mount, SSL_CERT_FILE, CURL_CA_BUNDLE, php.ini curl.cainfo - cannot reach
that call: OC\Http\Client\Client pins Guzzle's VERIFY to the
certificate manager's own bundle, which defaults to the shipped
resources/config/ca-bundle.crt.

occ security:certificates:import is the only lever that reaches it.
Verified in nextcloud:34: the command is registered as
'security:certificates:import' taking a required path, and
addCertificate() calls createCertificateBundle(), which writes the merged
rootcerts.crt that getAbsoluteBundlePath() then returns. Public TLS keeps
working because the import appends to the shipped bundle. Every
integration_* app talking to an internal host over HTTPS was broken the
same way, not just Mastodon.

Registers the GitLab OAuth application with the scopes the client
actually requests: integration_gitlab/src/utils.js:26 sends
'read_user read_api read_repository', and Doorkeeper's ScopeChecker does
plain string-set containment, so 'api' does not subsume 'read_api' and
consent would have been refused. Also least privilege.

The gitlab spec sampled the URL the page settled on after GitLab's 302,
but GitLab keeps the return path in session[:user_return_to], so that URL
carries no query and the assertion looked for evidence GitLab structurally
does not provide. It now captures the authorize request itself and
additionally asserts redirect_uri points back at this Nextcloud and that
the partner answered below 400.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:12:30 +02:00
kevinveenbirkenbachandClaude Opus 5 5aca44ee8c fix(wordpress): pin the email service across the variants and register its flag
c8c996083 declared services.email so the msmtp_curl healthcheck would finally
emit its mail branch, but stopped at the declaration. Three integration tests
went red on the omission: bond>=1 requires a dynamic flag to be pinned true in
variant 0, the coverage rule additionally requires a variant pinning it false,
and every service with an 'enabled:' key needs its <NAME>_SERVICE_ENABLED line
in playwright.env.j2.

The suppression above the flag is the repo-wide convention for email — no spec
anywhere gates on it, because delivery is covered by the sys-svc-mail
integration tests rather than the persona surface; gitea and nextcloud carry
the identical marker.

Pinning email.enabled true in variant 0 does not grow the closure: derive_includes
reports 24 roles for variant 0 with mailu both before and after, because mailu was
already reachable through another edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:10:22 +02:00
kevinveenbirkenbach 1bce81ab8c Merge pull request #521 from infinito-nexus/update/docker-image-versions-web-app-wordpress-20260811-8b2084f
update(images): update Docker image versions (web-app-wordpress)
2026-08-12 00:09:03 +02:00
kevinveenbirkenbach d7a0b555f9 Merge pull request #520 from infinito-nexus/update/docker-image-versions-web-app-snipe-it-20260811-d88ee2f
update(images): update Docker image versions (web-app-snipe-it)
2026-08-12 00:08:40 +02:00
kevinveenbirkenbach 2b8d7cc890 Merge pull request #519 from infinito-nexus/update/docker-image-versions-svc-ai-ollama-20260811-28c1e29
update(images): update Docker image versions (svc-ai-ollama)
2026-08-12 00:08:14 +02:00
infinito-nexus-updater[bot] 5beddf400a update(images): update Docker image versions (web-app-wordpress) 2026-08-11 22:05:39 +00:00
infinito-nexus-updater[bot] 42922b91b7 update(images): update Docker image versions (web-app-snipe-it) 2026-08-11 22:05:35 +00:00
infinito-nexus-updater[bot] 7aceb292cb update(images): update Docker image versions (svc-ai-ollama) 2026-08-11 22:05:29 +00:00
kevinveenbirkenbach e31c063c1b Merge pull request #512 from infinito-nexus/dependabot/npm_and_yarn/roles/web-app-littlejs/files/fortawesome/fontawesome-free-7.3.1
chore(deps): bump @fortawesome/fontawesome-free from 6.5.1 to 7.3.1 in /roles/web-app-littlejs/files
2026-08-12 00:05:02 +02:00
kevinveenbirkenbachandClaude Opus 5 cf69003a03 fix(bookwyrm): bootstrap the schema once instead of once per replica
The service resolves to three replicas - no replicas key, so
len(groups['web-app-bookwyrm']) over mgr-01, wrk-01 and wrk-02 - and
every one of them ran 'manage.py migrate' and 'manage.py initdb' from
its start command against the same Postgres.

The migration race self-healed; the seed race did not. init_settings()
is an unguarded check-then-create on a table with no unique constraint,
so two racers both inserted a SiteSettings row and every later
trusted-header login died in user.save(). That is the Django 500 page
photographed by all three failing specs, and the rescue artifact shows
five containers 12-14s apart each dying on a different migration,
alternating 'already exists' and 'does not exist'.

Moves both commands into a one-shot migrate service pinned to one
replica. bookwyrm and worker keep the topology default, so the swarm job
still exercises the multi-replica path. Pinning the bookwyrm key instead
would have been wrong: resource_filter falls back to the role's entity
name, which IS 'bookwyrm', so it would have silently pinned worker too -
invisibly to test_single_replica_justified, which only sees a literal
replicas: 1 line.

Drops the '|| true' around initdb. Command.handle() at v0.9.2 is a plain
sequence of get_or_create steps with no already-initialized abort, so
there was nothing legitimate for it to swallow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:01:42 +02:00
kevinveenbirkenbachandClaude Opus 5 ff8a16a345 fix(funkwhale): create the database schema and wire the session cache
The role had no migration step anywhere in its history
(git log --all -S migrate over the role is empty). The api container runs
'command: gunicorn', which the image entrypoint execs straight into
without touching the database, so the schema was never created and
POST /api/v2/users/login died with
django.db.utils.ProgrammingError: relation "users_user" does not exist.

Adds a one-shot migrate service using the image's own documented
bootstrap. Verified against funkwhale/api:2.0.6 on an empty postgres:
'migrate' runs funkwhale-manage migrate without exec, applies the full
graph and exits 0, so the container reaches Shutdown/Complete and the
swarm converge gate's completed-oneshot carve-out accepts it. The same
probe showed the api container boots and stays up against an empty
schema rather than exiting, so it cannot trip the gate's fail-fast path
while migrate is still running.

CACHE_URL is the second blocker on the same request path: funkwhale keeps
sessions in the cache, so without it auth.login() writes to the image
default redis://127.0.0.1:6379/0 where nothing listens - the celeryworker
already proved that with ECONNREFUSED against that exact URL. Migrate
alone would have moved the 500 from the database layer to the session
layer.

LDAP_USER_ATTR_MAP was single-quoted JSON while funkwhale parses it with
env.list() then split(":"), which yielded keys like '{"username' and
populated no profile field.

The native-login spec asserted on the canonical domain as a substring.
Keycloak's authorization URL carries that domain inside redirect_uri and
oauth2-proxy serves /oauth2/* on the canonical host, so the poll could
never become true; it now requires the host AND the absence of the auth
chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:56:08 +02:00
kevinveenbirkenbachandClaude Opus 5 960da94f7b fix(flowise): widen the swarm converge budget
The flowise image measures 5.73 GB, the largest in the fleet, and nothing warms a worker's image cache before a stack deploy, so the first worker-side pull is charged against the converge probe's budget. Docker events on the worker show 409 s of pull before container create, while the 150-retry default expires after roughly 370 s, so the stack was reported as not converged 45 s before it came up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:52:29 +02:00
kevinveenbirkenbachandClaude Opus 5 a35dca8a25 test(mcp): cover the probe's drift-versus-policy split
assert_surface decides which of two different readers a failure belongs to,
and nothing exercised it: contract.py only runs inside the probe container
against a live server, so the logic was verified by reading alone.

The load helper patches the environment before import because the module
reads MCP_URL, MCP_TRANSPORT and MCP_AUTH_HEADER at import time.

Five cases: a matching surface passes; a policy mismatch fails; drift is
reported before policy when the recorded surface is stale; a recorded
difference still fails policy while no gateway narrows it; and a provider
that records no difference is judged on policy alone, which is the case for
every role whose allowlist already equals what its upstream serves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:18:32 +02:00
kevinveenbirkenbachandClaude Opus 5 d34666c3a8 fix(e2e): follow a window.open IdP handoff in the shared OIDC login helper
clickOidcLoginLink watched only the opener. EspoCRM hands off to
Keycloak via window.open, so the opener correctly never navigates and
the helper abandoned a fully rendered Keycloak form in the child window;
the persona then died 45s later at the generic 'did NOT reach an
authenticated surface' gate. The trace shows BrowserContext.page with an
openerPageId, and the click itself succeeded - 'element is visible,
enabled and stable' with no error on the after-event. The [disabled]
Sign in button in the snapshot is EspoCRM's own signIn() disableForm(),
set after the click, not before it.

The helper now races the opener against the popup with Promise.any over
branches that only fulfil once a page reaches openid-connect/auth, and
returns that page; biber and admin pass it to performKeycloakLogin.

Promise.any rather than race: EspoCRM closes the popup after it
authenticates the opener by XHR, and a race would let that losing branch
resolve null and truncate the opener's budget. Measured on a stub
harness: popup closed at 500ms with the opener reaching the IdP at
1200ms returns the opener, where a race returns null. The 15s popup
window matches the idiom already used in the nextcloud addon specs; the
2s window this replaces would have returned null for any adapter that
runs PKCE or discovery before window.open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:11:13 +02:00
kevinveenbirkenbachandClaude Opus 5 71d4b02e78 fix(mcp): tell upstream drift apart from a policy mismatch
The probe compared the served tools against the allowlist and nothing else,
so a vendor growing the tool surface produced the same failure text as a
provider serving the wrong set. The August image bumps therefore read as
contract violations, which sent them to the wrong reader: the contract was
fine, the recorded surface had gone stale.

Compare against upstream_serves first and report what appeared and what
disappeared, then compare against the allowlist. Two failures, two audiences.

Both transports carried the identical comparison, so the check lived twice;
it is now one function.

A provider that records no differing surface, or records it as dynamic,
passes an empty list and is judged on policy alone. Without that, nextcloud's
runtime-composed surface would report drift on every deploy.

Resolving the value needs its own task: within one set_fact a variable is not
guaranteed to see another.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:02:10 +02:00
kevinveenbirkenbachandClaude Opus 5 cfd5c760e2 feat(mcp): separate what the platform permits from what the upstream serves
tools.allowlist meant two things at once: the surface a consumer may reach,
which is a decision of ours, and the surface the upstream offers, which the
vendor changes without asking. Conflating them made a version bump read as a
contract violation, and left no place to record what was actually measured.

Split them. allowlist stays policy; upstream_serves records the observation at
supported_version. State the second only where it DIFFERS, because a provider
that serves exactly what is allowed would otherwise carry the same list twice
and the copies would drift apart. Absence means identical.

The difference between the two measures unenforced exposure: mattermost 103,
gitlab 2, and zero wherever the fields collapse. A non-empty difference is what
tells us a provider needs a gateway in front of it.

Require a non-empty allowlist for an enabled, non-adapter, server-capable
provider. An empty one used to make the probe's comparison vacuous, which read
as a pass while every tool the upstream serves stayed reachable. Nextcloud was
in exactly that state.

Three exemptions, all semantic: an adapter authors its tools here against a
REST upstream, so there is nothing to observe; a provider declared
enabled: false is never admitted; and upstream_serves: dynamic covers an
upstream composing its surface at runtime, which nextcloud does by proxying
MCP servers an administrator configures.

Reject an allowlist naming a tool the upstream does not serve. That found
mattermost's add_user_to_channel, gone since 11.10.0 renamed it
add_channel_member, which would have been advertised and never callable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:59:25 +02:00
kevinveenbirkenbachandClaude Opus 5 db982dcaed feat(svc-ai-mcp-adapter): front an upstream that already speaks MCP
The adapter could only translate REST to MCP, so a provider whose upstream
already serves MCP tools had no way to sit behind it. Those providers are
exactly the ones with no enforcement point: their declared allowlist is a
statement about the upstream rather than a constraint on it, and a client
can reach any operation the upstream happens to serve by naming it.

Add the policy layer for that case. An MCP upstream exposes no HTTP method,
so a tool's blast radius cannot be inferred the way the REST layer infers
it from GET/HEAD; every tool must declare whether it mutates, and one that
does not say is refused at startup rather than assumed harmless.

Forwarding builds its own JSON-RPC envelope with a fixed method and a fresh
id. The client's envelope is never passed through, or a caller could reach
another method by nesting it in params. A single SSE frame is unwrapped,
because a streamable-http server may answer one call that way and plain
JSON parsing would choke on the data prefix.

Routing is deliberately total: a contract that omits upstream_kind is a
REST one, which is what every adapter renders today, so the ten existing
instances are untouched.

Report an upstream that grew a tool instead of filtering it forever, and
one that dropped a contracted tool, so drift is visible either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:13:46 +02:00
kevinveenbirkenbachandClaude Opus 5 f10bb0415d fix(swarm): fail the convergence gate on an update it cannot leave
swarmkit's default failure_action is pause, and the updater returns early
on a paused state without ever reaching its completion branch, so a
service that latches never leaves that state on its own. The gate every
role runs read replica counts and nothing else, so a latched update looked
exactly like a slow one and burned the full retry budget before failing
without a cause. That is what turned one corrupted openclaw task into a
25-minute silent timeout.

Read UpdateStatus before comparing replicas. The order carries the fix: a
rolled-back service reports N/N of the PREVIOUS spec, so a replica check
alone reports it as converged. Guard the format against a service with no
update in flight, since a bare .UpdateStatus.State nil-pointers and would
fail the whole multi-service inspect.

The handler has to honour the give-up or the gate keeps polling. It stops
only on the explicit give-up and still retries every other exit code, so a
timeout returning 124 does not turn into a failed deploy. That deviates
from wait_converged.yml, which stops on anything but 1; the deviation is
deliberate.

Stop treating rollback_completed as success in the per-service probe. It
is inert today because nothing here issues a rollback, and it is the guard
that keeps a silent revert from returning as a green deploy.

failure_action is deliberately left at the default. Rolling back would
revert a failed image bump while reporting success, and a daily cron opens
those bumps; it would also place the old binary against a schema ~28 roles
migrate on start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:11:11 +02:00
kevinveenbirkenbachandClaude Opus 5 b384f48258 fix(swarm): read one converge budget and give up on a terminal task
Two callers polled the same stack_ready.sh with different budgets: the
handler read swarm_stack_converge_retries while post_deploy hardcoded
150. Eleven roles had set the override and were unprotected at the
second caller anyway. Both now read one SPOT in group_vars.

The gate also had no way to give up. A crash-looping espocrm burned 600
polls (20 min) before failing, while flowise was killed 45 s short of a
409 s image pull. has_fatal_task now exits 2 - and both callers stop at
once via 'until: rc in [0, 2]' - when a slot's newest attempt is
Rejected or Pending, or when a slot burned three attempts after the
service churned past swarm_stack_fatal_grace. Patience only grows where
nothing is broken.

report_tasks loses its 'awk !/error=$/' filter, which printed only rows
that HAVE an error and so stayed silent in exactly the no-error case
that occurred.

Measured on a live single-node swarm: a service failing 3x and
converging on the 4th returned rc 2 at t+15s before this change and rc 0
at t+32s after; a 3-replica stop-first rolling update gave 68/68 samples
rc 0; a permanent crash loop still reaches rc 2. The deploy epoch comes
from 'docker service inspect .UpdatedAt.Unix' rather than a /tmp marker,
which was reproduced contaminating a later deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:38:03 +02:00
kevinveenbirkenbachandClaude Opus 5 3a1ef70cbb chore(ci): drop the cancelled-run rerun automation
The hourly sweep fought the rest of the CI control plane instead of
helping it: it preempted itself (17 of the last 29 runs concluded
cancelled 1-2s after their successor was created, for 8s of work behind
a 74-80min queue wait) and shipped MAX_ATTEMPTS=0, which the script
reads as no ceiling while its own README promised the opposite. The 24h
age cutoff could not bound it either, because every rerun refreshes the
run's updatedAt and so keeps it inside the window.

Removes the workflow, the two scripts it was the sole consumer of, its
unit test, and the CI_RERUN_* documentation that would otherwise point
at a deleted file. The workflow is disabled in both origin and fork so
no sweep fires before this lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:20:20 +02:00
kevinveenbirkenbachandClaude Opus 5 e3f1fab9c2 feat(web-app-mattermost): keep the configuration in postgres, not on a shared volume
Without MM_CONFIG mattermost uses its file store at
/mattermost/config/config.json, which lives on a volume the swarm rewrite
places on the shared NFS export. The role is unpinned, so it runs one task
per node, and mattermost rewrites that file at startup while normalising
and migrating its config. Several replicas then write the same file over
NFS.

Point MM_CONFIG at the database instead. Read at the pinned 11.10.0 tag:
getConfigDSN falls back to env MM_CONFIG, server.go hands the result to
NewStoreFromDSN, and that selects NewDatabaseStore whenever IsDatabaseDSN
matches a postgres:// prefix. The file store is then never constructed.
Nothing in this role reads or patches config.json; the only reference is
the volume mount.

This externalises the state rather than capping the role at one replica,
so mattermost stays horizontally scalable.

Resolve the DSN once in vars/main.yml from the database lookup's url_full,
which already assembles scheme, credentials, host, port and name, and
reference that constant from both MM_SQLSETTINGS_DATASOURCE and MM_CONFIG.
The template previously spelled the DSN out across six lookups and
hardcoded the postgres:// scheme that url_full derives. Appending options
to url_full follows the pattern web-app-openproject already uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:43:29 +02:00
kevinveenbirkenbachandClaude Opus 5 b1ca39e9dd feat(cli): stop retriggering CI suites the source run already passed
A retrigger carried the workspace and instructions toggles over from the
run title, so a suite that had already gone green was dispatched again
and spent runners on work with a known answer.

Derive each toggle from the source run's job record instead: a suite is
turned off only when every one of its jobs reports success. A suite that
was skipped, cancelled or never dispatched has not passed, so it is still
asked for; absence of a failure is not evidence of a success. One failing
shard keeps the whole suite on.

Match against the job path rather than the run title by dropping the
workflow-name segment, and fail open when nothing matches, so an unknown
job layout re-runs the suite rather than silently skipping it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:28:35 +02:00
kevinveenbirkenbachandClaude Opus 5 80ee933260 fix(web-app-jellyfin): converge the MCP password instead of trusting a token
The provisioner probed a stored token first, so a token that outlived a
password change kept the run green while the deployed password no longer
authenticated. Authenticate with the inventory password first and consult
the stored token only once that succeeds; when it fails, converge the
password onto the existing user.

The write targets /Users/Password?userId=<id>: omitting the query
parameter retargets the caller's own account, and an administrator acting
on another user is why no current password is required. Accept 200 as
well as 204, matching the sibling calls; demanding 204 alone would have
failed a successful convergence.

The repair branch is only reached when authentication failed and always
writes, so it now reports CHANGED. It previously printed UNCHANGED
whenever the user already existed, which told Ansible nothing had changed
while a credential had just been rotated.

configure-auth.sh no longer continues past an administrator it cannot
authenticate. Both call sites logged a warning and carried on, finishing
green with SSO and LDAP silently unconfigured.

Adds the role's first unit tests, covering token reuse, token staleness,
the convergence write and its rejection path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:26:35 +02:00