Core `auth`, the list of enabled auth plugins, was written only by
05_ldap_sync.yml. Variant 1 of meta/variants.yml is SSO without LDAP and
skips that file, while 04_oidc.yml sets every mdl_user row to
auth='oidc'. Those users point at a plugin the site never enabled.
Moodle 5.3 rejects such a user in validate_auth_not_disabled(), so after
the second deploy of variant 1 the MCP service account, now auth='oidc',
gets "wsaccessusernologin" and "List the MCP tools with the minted
bearer" fails all 30 attempts (main run 39841510219, job 120466819062).
Before 5.3 only a literal 'nologin' was refused, which hid the gap. Users
of variant 1 could not log in at all for the same reason.
04_oidc.yml now enables oidc itself; 05_ldap_sync.yml runs after it and
widens the list to oidc,ldap where both are on. alternateloginurl moves
out of the loop that passes --component=auth_oidc, because core reads it
from mdl_config, not from the plugin's config.
Cherry-picked from dcb8f6866b on feature/store-i18n, with main's
MOODLE_OIDC_PLUGIN_URL_PATH kept for the login URL (Moodle 5.3 serves
the plugin under auth/oidc).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Proxy:
- server_tokens off, X-Powered-By stripped from upstream responses,
HSTS max-age raised to one year.
- No global X-Content-Type-Options: under nosniff, ORB (step 3.4)
blocks no-cors cross-origin responses typed text/html, e.g. tracking
beacons answered with an empty 204.
PHP (CVE-2026-109618, CVE-2026-109619):
- New role sys-svc-compose-php installs a shared ini (expose_php Off,
session.upload_progress.enabled 0, fatal_error_backtraces 0 for
PHP 8.5, interim memory_limit -1) and a PHP-FPM [www] drop-in
(pm.max_requests = PHP_FPM_MAX_REQUESTS) once per stack host,
before 04_files renders any compose file.
- The host file names carry a sha1 prefix of the controller-side
source, so a content change alters every PHP app's compose.yml and
recreates it, also on remote deploys where compose_volumes cannot
read the stack host file and hashes the path instead.
- 20 PHP roles mount the ini as a compose/swarm config into their
image's scan dir; moodle, nextcloud and shopware also mount the FPM
drop-in. Magento keeps its own pool: its php-fpm.conf has no
include= and is rendered without notify.
- Joomla's php-upload.ini now loads as zzz-joomla-uploads.ini so its
own memory_limit wins: the media API compares Content-Length with
toBytes(memory_limit) and rejects every upload at -1.
TODOs record the memory_limit revert condition and accepted
trade-offs, Akaunting on PHP 8.1, FusionDirectory on PHP 7.4 and the
public Keycloak admin console.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The nginx sidecar's static location (`location ~* \.(css|js|...)$`) ended
in `try_files $uri =404`. Moodle 5.3 serves its ES modules from
`/core/esm/<rev>/...` through r.php, and those paths do not exist on
disk. Regex locations win over `location /`, so every module URL ending
in `.js` (`bootstrap/bootstrap.js`,
`@moodlehq/design-system/theme-boost/index.js`, ...) got nginx's own 404
and never reached PHP; both are preloaded on every page.
Fall back to `/r.php` like `location /` does. Files that exist on disk
are still served directly with their cache headers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Moodle 5.3 marks `external_generate_token()` as a final deprecation
(`#[deprecated('\core_external\util::generate_token', since: '4.4',
final: true)]` in public/lib/externallib.php), and
`core\deprecation::emit_deprecation_notice()` throws a coding_exception
for every final deprecation, whatever the debug level. The MCP
provisioning script called it whenever no permanent token existed yet,
so every fresh install of the variant that enables MCP failed at
"Enable web services and mint the MCP bearer" (main run 39494396920,
Moodle#1: `Error code: codingerror` from deprecation.php:273 via
externallib.php:30).
The variant had not run in CI since the Moodle 5.3 bump, which is why
this surfaced only now.
Mint the token with `\core_external\util::generate_token()`, which
takes the service record instead of its id, and create the service
account with `\core\user::create_user()` in place of
`user_create_user()`, deprecated since 5.3 (MDL-82650). Neither needs
externallib.php or user/lib.php any more.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
config.php set `$CFG->debug = 32767` whenever MODE_DEBUG was true, as
the development and CI inventories set it. Moodle 5.3's
`core_component::is_developer()` treats any debug bit defined in
config.php as developer mode, so `init()` skipped
`$CFG->cachedir/core_component.php` and ran `fill_all_caches()` on
every PHP request: a walk over every plugin directory and every
`classes/` tree. The autoload in setup.php triggers it before the
ABORT_AFTER_CONFIG return, so even font.php and requirejs.php paid it.
On local disk the walk is cheap. In swarm the code tree is an NFS4
mount, and the walk turns into GETATTR round trips: main run
39494396920 served `/theme/font.php` in no less than 1.6 s and
`/lib/requirejs.php` in no less than 2.0 s, the five FPM workers per
replica queued, and the CSP check timed out loading the academy page
after 20 s on both Moodle swarm jobs.
Write `debug` and `debugdisplay` into the site config with
`admin/cli/cfg.php` instead, on every deploy and as integers (32767/1
in debug deployments, 0/0 otherwise). `is_developer()` reads only
values defined in config.php, so the component cache is honoured,
while `setup.php` still applies the stored debug level once the
database is up. The assignments leave config.php entirely, because
cfg.php refuses to change a name config.php defines, and
MOODLE_DEBUG leaves env.j2 with them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
provision_ai_gateway.php and provision_mcp.php shared a prefix inside one
directory, which test_prefix_grouped_files rejects. Both move into
files/php/provision/ as ai_gateway.php and mcp.php, the layout
web-app-n8n already uses for its Python provisioning scripts.
The two lookups that feed the scripts to the container follow, and the
unit test moves to its mirrored path as provision/AiGatewayTest.php.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Moodle 5 keeps AI provider settings in provider instances, not in the
plugin configuration. The deploy kept writing aiprovider_openai/* through
cfg.php and read the same value back, so its own check stayed green while
no provider existed. The gateway spec then found no settings page and
web-app-moodle#2 failed in run 37646050826.
The deploy now pipes files/php/provision_ai_gateway.php into the
container. It finds the instance by class and name or creates it through
core_ai\manager, points the three text actions at the chat endpoint,
switches image generation off, stores the virtual key and prints what
Moodle stored. The task asserts on that output. An instance an
administrator added is left alone.
The spec finds the instance by MOODLE_AI_PROVIDER_NAME on the providers
page and reads the endpoint from its generate_text action page.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The entrypoint removed the old tree before it copied the new one. A
copy that failed left the volume without a working Moodle, and every
restart removed again instead of recovering. It now copies into a
staging directory inside the volume and swaps the trees once the copy
is complete, so a failed copy leaves the old tree in place.
An image that ships an older release than the volume records no longer
replaces it and exits with a message instead. Two instances on
different releases would otherwise overwrite each other's tree.
upgrade.php reports "No upgrade needed" on stderr, so the task counted
every run on a current site as a change. It reads stderr now.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pin moved from 4.5.11 to 5.3.0, and the role fitted neither a new
install nor an existing one.
Moodle 5 serves from public/ and routes every other path through
r.php. nginx now uses public/ as its root, hands unknown paths to the
router and passes PATH_INFO for scripts with slash arguments, and
config.php declares the router as configured. The paths the role checks
and writes (version.php, auth, webservice, the OIDC plugin) moved below
public/, the URL of the OIDC plugin stays auth/oidc. The source archive
no longer contains vendor/, so the image installs the Composer
dependencies it declares. The OIDC plugin is pinned to v5.2.1, the
newest release.
An existing site kept its old code: the entrypoint filled the code
volume once and never again. It now records the release it copied and
replaces the tree when the image carries another one, and a new task
runs admin/cli/upgrade.php. Plugins that were installed through the web
interface are not carried over, the README says how to ship them.
A site installed with 5.3 forces login by default, which 4.5 did not,
so its start page answers 303 to the login page and the webserver
health check refused it. The role declares 200 and 303 as the codes
that check accepts: 200 is what an upgraded site keeps answering.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Memcached, Joomla, Moodle, Odoo, Shopware, Snipe-IT and SuiteCRM set
their cache size, PHP memory_limit, OPcache, worker limits and search
heap as literals next to a mem_limit they never read. They now ask
lookup('service_memory_mb') for a share of that limit. The shares are
chosen so every rendered value stays what it was.
Where the parameter lives in a file that cannot hold a template, the
value is handed in: Shopware's init script reads INIT_PHP_MEMORY_LIMIT
and refuses to start without it, SuiteCRM's image takes it as a build
argument. Shopware's worker gets --memory-limit as a command appended
to its entrypoint, and Moodle's OPcache size moves from the static ini
into the rendered one.
The performance.php.memory_limit keys of Joomla and Moodle are gone
from services.yml; nothing else read them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The version-source updater only read meta/services.yml. An addon pinned in
meta/addons/<id>.yml stayed where it was: the ONLYOFFICE connector for
Nextcloud sat on v10.1.2, which does not install on Nextcloud 35, until
someone bumped it by hand.
The updater now reads addon files too. An addon declares its source in the
`update:` block it already has, beside monitored, catalog and upstream_id.
A monitored addon of the github-releases catalog needs nothing more: its
upstream_id names the repository whose tags are its versions. The
update-version-sources job opens the PRs, one per role.
Every pinned addon is wired:
- nextcloud onlyoffice carries its own version pin now. Its archive URL
stays a literal that carries the pin, because
test_pinned_addon_archives_available reads it as written
- wordpress mcp-adapter, moodle auth_oidc and jellyfin sso-authentication
follow the tags of their repository, moodle within its v4.5 series
- the three xwiki extensions read their Maven metadata
- pretix-oidc reads the PyPI simple index. The JSON API answers 404 behind
the package cache the test stack routes pypi.org through
- joomla's plugin is built here and jellyfin's ldap-authentication is bound
to the md5 of its archive; both are opted out with that reason
A bump rewrites the pin and the config.archive line together, and an
archive that does not carry the pin fails test_version_keys, so the two
cannot drift apart again. test_version_pins_are_watched covers addon pins
as well: a new one without a source or a reasoned opt-out fails the lint.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Docker updater watches image plus version, the repository updater
repository plus ref. A dozen pins fell outside both - an app_version, a
release, a tag in a registry neither speaks - so nothing ever told anyone
they had gone stale.
Such a pin now names its upstream next to itself in an `update:` block:
git_tags, registry_tags, npm, http_regex or a role-local script, with
`match` selecting a tag flavour and `strip` removing a prefix the pin does
not carry. utils/update/source.py resolves them beside the two existing
updaters, `python -m cli.contributing.update.source` bumps them, the
update-version-sources job opens one PR per role, and
tests/external/update/source warns while a bump is pending.
Two lints hold the rule: every semver pin is watched by one of the three
updaters or opted out with a reason, and a semver only ever sits behind
`version`, `<what>_version` or `ref` - which renames moodle's release to
app_version and simpleicons' local_version to local_tag, the latter being
the tag of a locally built image rather than an upstream version. A
declaration naming a key the entity does not carry, or a type the resolver
does not know, fails the lint instead of resolving nothing in silence.
Picked from feature/store-i18n (94d607fd0a) onto current main. Two things
differ from the original: svc-ai-lmstudio no longer carries an app_version
pin, so its declaration is dropped, and web-app-stalwart's webui_version,
added since, declares the git tags of the webui repository.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MAIL_PROVIDER moved to web-app-stalwart without a make docs run, so every generated README still drew Mailu as the email dependency. This is the generator output for the current tree: the root role table and each role cosmos now name Stalwart, Mailu loses its dependents, and Stalwart gains its swarm marker, the Tor dependencies and the 0..0 edges.
The hand-written cosmos of svc-net-tor-smtp gives way to the generated one, which no longer shows the SOCKS5 hop through svc-net-tor because the role does not declare that dependency.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The list form could publish a host:container pair and nothing else, so thirteen blocks stayed hand-written: the ones needing a protocol suffix, a port range, the swarm long syntax with mode host, or a container-port-only publish. Each of those duplicated numbers the role already declares in meta/services.yml, which is the drift the lookup exists to remove.
The lookup keeps the list form unchanged for its 93 existing call sites and gains a dict form: transport emits one item per protocol, container 'same' serves the services that cannot remap (STUN/TURN, media relays), mode 'host' emits the long block an edge role needs in swarm so the client address survives the hop, publish 'ephemeral' emits the container port alone, and key names the block for Discourse's launcher file, whose expose: list is what becomes docker run -p there. A declared {start, end} mapping renders as start-end on both sides, so a relay range needs no term of its own.
svc-prx-openresty had no ports block at all and bound 80 and 443 as literals in its template, while torrc.j2 carried a third literal 80 for the hidden service. None of the three referenced the others. openresty now declares the ports, the compose template reads them, and the onion's port 80 comes from ports.onion.http through TOR_ONION_EXTRA_PORTS.
Three blocks stay raw, each with the reason it cannot route through one call: keycloak's sso_proxy maps a published port owned by the consuming role to a container port owned by web-app-keycloak, and matrix's ansible flavor takes element on 8080 and federation on 8048 from the upstream playbook rather than from this repo's ports.internal.
The enforcing lint scanned only files with compose in the name and only double-quoted list items, so nine of the thirteen blocks were invisible to it. It now reads every role template that can carry a ports block, catches unquoted items, protocol suffixes, ranges, flow style and the long syntax, and tracks the block by the indentation of its key: keying on the next line closed the block on anything shaped like a key, which target, published, protocol and mode all are. Under expose: only a real mapping counts, because there the single-segment form documents a container port and publishes nothing.
Verified by rendering all 110 container_ports call sites through the real templar, the real lookup and the real merged applications view.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourteen roles repeated the same two lines to say they use the gateway: a
`shared` flag gated on group_names, and an `enabled` flag conjoining that gate
with LITELLM_CHAT_MODEL_SERVED. Both are now single-sourced.
`shared` becomes literal true. The flag means the provider is a shared instance
rather than one deployed per consumer, and a central gateway is always that;
the dynamic form only said "shared if deployed", which `enabled` already
decides. The 50 shared pins under `litellm:` leave meta/variants.yml with it.
The tree had already noticed: 20 of those 50 lines carried
`# nocheck: variants-static-override`, two of them with copy-pasted text about
container_backup. This narrows e5de4e085a for one service key rather than
reversing it; every other dynamic flag keeps the group_names form.
`enabled` becomes `{{ LITELLM_USABLE }}`, defined in group_vars/all/16_ai.yml.
That shorthand is only possible because is_explicit_truth learns about it.
The resolver reads a dependency out of the flag TEXT — "in group_names" in
value — so a constant hides the role id and the provider silently leaves the
static closure of every role referencing it. Measured: the old expression
resolves True, `{{ LITELLM_USABLE }}` resolves False. GROUP_GATED_CONSTANTS
names the constants whose own definition carries the gate, and the docstring
states the obligation: a SPOT of this kind that is not listed there drops its
provider without a word.
The wider fix, accepting any Jinja as dynamic, was measured and rejected: 35
other flags carry a Jinja value without the marker (MCP sidecars, javascript
and SSO gates, recaptcha), and they would all enter closures they are not in
today.
A literal shared flag is no longer dynamic, so test_dynamic_flags wants the
`# nocheck: dynamic-flag` its own message documents for legitimate exceptions;
where a marker already existed the rule list is extended rather than a second
comment added.
Behaviour is unchanged where it counts. The closure keeps litellm with the same
app counts (wordpress 26, openwebui 23, hermes 23, mediawiki 22, flowise 23),
unrelated Jinja still resolves False, and the truth table of the new enabled
form matches the old one in all four served/deployed combinations. Three roles
resolve without litellm, and `git show HEAD:` proves that predates this change:
zammad pins enabled false in variant 0 at line 39, nextcloud at line 97.
Six unit tests cover the resolver, including one asserting that every
registered constant is actually defined in group_vars, so an entry cannot
become a dead promise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The OIDC step runs `container exec <db> mariadb -e "UPDATE ..."`, and the
address it used named the shared service unconditionally, so it always
resolved to the literal `mariadb`. meta/variants.yml splits exactly here:
variant 0 sets mariadb.shared true, variant 1 sets it false and gets a
dedicated container. On the dedicated one the task died with "Error response
from daemon: No such container: mariadb" after an otherwise clean deploy,
ok=801 changed=147 failed=1 in job 103734390485 of run 34760282443.
The database lookup already carries that branch, container = dbtype when
central and <entity>-database otherwise, and its address key adds a second
branch the old value also got wrong: under swarm it resolves through
resolve-container-id instead of handing back a bare name.
This drops the one-off in favour of the house pattern. web-app-listmonk and
web-app-matrix already build their container exec target the same way;
web-app-moodle was the only role reaching for container_address against a
foreign role id.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`process.env.X || <value>` is a second source for something the deployment
already renders, and the two drift without a sound. playwright.config.js read
`process.env.PLAYWRIGHT_ACTION_TIMEOUT || 30_000` while nothing ever set that
variable, so every run used the unscaled 30 s and no failure said so.
Normalising an absent variable stays allowed, because it substitutes nothing:
`|| ""`, `|| '[]'`, `|| false`, `|| 0` and falling through to another
`process.env` entry only let the caller test absence itself.
The six sites the rule found:
n8n and flowise built a Postgres port from `|| '5432'` while env.j2 renders it
from `lookup('database', application_id, 'port')`. Node throws
ERR_SOCKET_BAD_PORT on the resulting NaN, so an unfed port now names itself
instead of connecting somewhere plausible.
The bluesky broker did the same with BROKER_PORT, which compose.yml.j2 renders
unconditionally. Its INSECURE_TLS default was `"false"`, which is what `""`
already evaluates to.
matomo defaulted MATOMO_TRACKING_SCOPE to "sub"; the only comparison is against
"root", so `""` takes the identical branch.
moodle and simpleicons defaulted a name their playwright.env.j2 always renders.
simpleicons already asserts the value is truthy; moodle now fails on the scope
it cannot find rather than on a name nobody configured.
Four values the code itself owns keep a default under `// nocheck: env-default`:
PDS_INTERNAL_URL (which falls through to requireEnv, not to a literal),
HANDOFF_COOKIE_NAME, HANDOFF_COOKIE_MAX_AGE and PASSWORD_SALT_HASH_ROUNDS. No
role renders any of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At -vvv Ansible prints each module argument and result, so a password in
a command line, a request body, a header or a fact lands in the deploy
log. Nothing enforced masking outside the MCP task files, and no check
looked at parents at all.
A new lint, test_credential_tasks_are_masked, finds tasks whose printed
parts (module arguments, environment, loop) reference an identifier ending
in password, secret, token, api_key or private_key, or
secrets.credentials. A task passes when it or an enclosing block sets
no_log, when every file that pulls it in does so through include_tasks
with apply.no_log or import_tasks with no_log (transitively), when the
secret only reaches a parameter the module masks itself, or when the
module masks its values. no_log on an include_tasks task itself does not
count, because it does not reach the included tasks. The rule can be
suppressed per task with `# nocheck: credential-masking`, now listed in
the suppression catalog, and the lint tests its parent and suppression
logic on synthetic cases.
It found 39 unmasked tasks in 19 roles, all real: join tokens, client
secrets, API tokens in Cloudflare headers, database passwords in
command lines, the ChatGPT bot login body and more. Each now sets
no_log from MASK_CREDENTIALS_IN_LOGS. The sys-dns-hetzner-rdns alias
hetzner_no_log, which only restated that switch, is gone.
Not covered yet: template and copy tasks whose rendered file carries a
secret print it as a diff under --diff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PASSWORD_TOKEN_RE matches the token anywhere inside a Jinja expression, so a
path or a filename carrying it demands "| quote" as loudly as a credential
does. It fired on Moodle upstream CLI script:
roles/web-app-moodle/tasks/01_manager_ops.yml:55: In shell tasks, password
expressions must include quote: {{ [MOODLE_CLI_DIR, reset_password.php] | path_join }}
The credential on the next line already carries the filter. Listmonk hit the
same shape earlier and was resolved by renaming the file, which is not open
here: the name belongs to Moodle.
The rule had no suppression at all - it never called is_suppressed_at - so a
marker in the role would have been silently ignored. Teach it the marker, then
use it.
Suppression is honoured at the finding line OR at the shell: key that opens the
block, and the second placement is the one that matters. A marker beside the
finding necessarily sits inside the scalar, and this block is folded: lines
join with spaces, so a hash would comment out the rest of the command and
swallow --username, --password and --ignore-password-policy. On the shell: key
it is a YAML comment instead. The failure message now says so, so the next
false positive explains itself rather than repeating the discovery.
Verified the command is untouched by loading the task and reading the string
Ansible would hand the shell: no hash, no marker, byte-identical.
Note the cost: the marker grants the whole block, not one expression, because
a per-expression marker cannot be placed. This block loses the guard against a
future unquoted addition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
install_database.php was the only place that ever wrote the Moodle site
administrator password, and its failed_when tolerates "Database tables already
present". That is not laxity: upstream aborts in install_database.php before
install_cli_database() writes the password once tables exist, so the role
swallowed exactly the path in which the write is unreachable. After the
credential rotation gate Moodle therefore kept the install-time secret while
templates/playwright.env.j2 rendered the rotated one, and the login answered
"Invalid login, please try again". The same job proves it: variant-2/sync
reports failures="0" with the spec passing in 5.6s, variant-2/async
failures="1", and only the rotation gate lies between them.
The deeper problem was the credential itself. secrets.credentials.user_password
described itself as "Initial password for the Moodle admin user" - a role-local
secret for an account that is not role-local. Moodle hosts the platform
administrator, so read that user instead, the way web-app-baserow,
web-app-gitlab, web-app-penpot, web-app-matrix, web-app-bluesky and
web-app-pihole already do. lookup(users, administrator).password is stable
because roles/user-administrator/meta/users.yml pins it to
ansible_become_password; only a user with no declared password re-renders
{{ 42 | strong_password }} on every evaluation.
vars/main.yml now holds username, password and email as one constant each, so
install, realign and the Playwright env cannot drift apart again, and the
now-unread credential leaves meta/secrets.yml.
Realigning uses admin/cli/reset_password.php. Verified against the pinned
release v4.5.11: --username and --password are non-interactive (lines 35-37,
prompts only when empty at 76 and 88), --ignore-password-policy is required
because the platform password is not chosen to satisfy Moodle policy (line 96),
and the "when" gate is required because line 84 resolves only auth=manual rows
while tasks/04_oidc.yml rewrites every row to auth=oidc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Commit 1cd9578b8 moved the OIDC plugin version out of a hardcoded string
into meta/addons/auth_oidc.yml so the update pipeline tracks it. That
declaration is service-coupled (enabled reads services.sso.enabled), which
obliges the role to ship an integration hook and a per-addon Playwright
spec. Both were missing, so test_addons_integration_tasks and
test_addons_playwright_spec went red.
The hook has no work to do: files/build/install/auth-oidc.sh bakes the
plugin into the image and tasks/04_oidc.yml configures it, included by
tasks/01_manager_ops.yml on the same services.sso.enabled flag. It records
where the wiring actually lives so the coupling stays greppable.
The spec does not follow the pretix shape of hunting a login-page link,
because tasks/04_oidc.yml sets alternateloginurl and /login/index.php
bounces straight to the plugin. It asserts the bounce instead: the login
page must land on the Keycloak openid-connect authorization endpoint
carrying a client_id and a redirect_uri pointing back at auth/oidc.
Rendering the addon flags into playwright.env.j2 is what makes that spec
real. Without lookup('addon_env_flags'), AUTH_OIDC_ADDON_ENABLED is
undefined, skipUnlessAddonEnabled skips forever and the spec is a check
that can never fail.
download.moodle.org returns 403 to GitHub-hosted runners while serving the identical artefact elsewhere: 12 failures across three runners over twelve hours, each 40-66ms after TLS, with curl's four retries and Ansible's three exhausted. The GitHub tag archive is content-equivalent - both tarballs extracted and diffed over 39312 entries, the only delta being githash.php, which nothing in the 4.5.11 tree includes - and needs --strip-components=1 because it roots at moodle-4.5.11/ rather than moodle/.
auth-oidc.sh was the next landmine, two Dockerfile lines behind the fixed one: an anonymous api.github.com tag query, 60 requests per hour per runner IP, no retry, -s swallowing the rate-limit body under set -e, and a floating plugin version as the build input. It now reads a new meta/addons/auth_oidc.yml, which declares bridges: [sso] and update.monitored so the version is watched by the update pipeline instead of frozen.
MOODLE_BRANCH and MOODLE_TARBALL_FILENAME die with the old URL; the latter was already dead, demanded only by its own :?required guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
svc-ai-litellm has two variants: 0 with both backends on, 1 with both off. The planner assigns a dependency round_index when it has that many variants and falls back to 0 otherwise (inventory/planner.py:111), so round 1 is the one round in which the gateway deliberately serves nothing - a topology that is green in its own job.
Commit e0048f157b gated 14 consumers on LITELLM_CHAT_MODEL_SERVED so a consumer switches itself off when no model is served. Exactly two of them pinned litellm on in round 1, and a variant's literal beats the base Jinja gate, so mattermost rendered LITELLM_SERVICE_ENABLED=true against an empty model_list. The Agents plugin then failed client-side with 'bifrost error: model is required' 47ms after the prompt, never reaching the gateway.
Moving both pins to variant 2 lands them where the fallback supplies svc-ai-litellm=v0 with svc-ai-ollama=v0. Measured with the planner: round 2 pulls both. Both polarities stay pinned, so the variant-coverage lint still holds. The contract assert now also refuses an empty model, which the stored-vs-intended comparison read as converged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gateway publishes only models it has a backend for and renders model_list: [] when neither ollama nor lmstudio is deployed and no OpenRouter key is set. LITELLM_CHAT_MODEL fell back to openrouter/auto without checking that key, so consumers were configured for a model the gateway does not carry and every prompt failed with a 400.
Observed in the Mattermost#1 CI job: litellm.log carries 12 occurrences of "Invalid model name passed in model=openrouter/auto" next to 400 Bad Request, and tests/test-litellm-agent.js failed on every attempt while the rest of the suite passed.
Make the fallback conditional on the key so the name is empty when nothing is served, promote LITELLM_CHAT_MODEL_SERVED from the role vars to group_vars where consumers can see it (it was the third copy of this logic and the only correct one), and switch the 14 consumers from "litellm is deployed" to "litellm serves a model". The Playwright service flag reads the same lookup, so the spec skips together with the Ansible gate. The bool filter carries weight: the folded scalar yields the string False, which Jinja treats as truthy.
A new unit test renders both expressions against Jinja and pins the invariant that a model name exists exactly when a model is served, including the case where ollama preloads embedding models only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
supported_version and minimum_version sat in 24 roles and were read by no template, no task and no lookup. Their only consumer was the audit report version column, which turned false the moment an automated image bump ran: qdrant reported a verified version nobody had verified, two releases long. Both leave MCP_KEYS, so a role that declares them again fails the schema lint instead of carrying a field with no effect.
Requirement 025 mandated minimum_version as a MUST and 035 carried it in an example; both are corrected here, otherwise the repository would contradict its own code.
The gate then surfaced five dead docs.mattermost.com links. Mattermost moved to /administration-guide/ and /deployment-guide/, and every replacement was probed for 200 rather than guessed. The MCP page is gone with no successor, so mattermost now points at mcpserver/README.md in the Agents repository, the source rather than a rendered mirror.
Both concerns share three files - mattermost's meta/mcp.yml, requirement 025, and the generated audit.md, which derives from a source of each - so splitting them would commit an artifact that does not match its sources.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Production block never set one. inventory provision writes only
ansible_connection for a local host, so DOMAIN_PRIMARY stayed at the
group_vars default of infinito.localhost and the deployment served a zone its
own resolver knows nothing about. CoreDNS templates answers for
INFINITO_DOMAIN_RE and nothing else, so every lookup of the deployment's own
name came back NXDOMAIN.
Mastodon is where that surfaced. tootctl accounts create validates the address
through EmailMxValidator, which resolves MX and then, for the domain itself,
A and AAAA. All six queries returned NXDOMAIN in run 33574056480, the
validator recorded email :unreachable, and the admin account was never created.
Only the guide rows were affected: the matrix rows write
DOMAIN_PRIMARY: "{{ lookup('env', 'INFINITO_DOMAIN') }}" into their host_vars
and resolve fine.
The template now carries DOMAIN=<your-domain> in both the host and the
container form and threads it into --vars, with the container form passing it
through the docker run boundary as well. The two guide runners substitute it
the way they already substitute the SSH key, so the replay deploys under
INFINITO_DOMAIN like every other CI deploy. compose_deploy.sh moves its env
load ahead of the substitutions to have the value; host_deploy.sh had no env
load at all.
A production reader is asked for a domain now instead of silently inheriting
infinito.localhost, which no real deployment wants either. The shipped default
keeps its value: sys-svc-nss documents that *.localhost resolves through the
myhostname NSS module without any zone, and host-side tasks lean on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An MCP client spec signs in through the client's own OIDC button, which
redirects to Keycloak. Every provider put its MCP pairing in the lean variant,
and that variant pinned sso off - so the round that exists to prove the pairing
was the one round without an identity provider. Run 33574056480 shows what that
costs: openwebui's MCP specs reached _shared.js:131 and died on "openwebui OIDC
sign-in button must be visible on the auth page".
The rule now demands two things of the variant that carries the consumers: the
provider pins sso on, and each admitted consumer, in the variant that round
actually pulls in, does not pin it off. The second half matters because a round
deploys every dependency at its own index and falls back to variant 0 only when
the dependency has fewer, so the client shipping alongside is that one and not
the client's variant 0. A role that declares no sso, or pins it to a literal
false because upstream speaks no OIDC, is exempt from a flag it cannot have.
Twenty roles moved to satisfy it. Ten already had a second sso: false pin and
only needed the flip. Six could host the pairing in variant 0, which already
has sso on, and dropped back to two variants. Six kept a third lean variant to
carry the false polarity variants-coverage wants. moodle and mattermost took
swaps instead: moodle's variant 1 is the mandated LDAP-only branch, and
mattermost was already at the three-variant cap with sso on in its variant 2.
nextcloud also turns context_agent on in that round, the ExApp that serves the
endpoint - with it off the round proved nothing it was built to prove.
The rule moves to tests/lint/ansible/services/ because it reads meta YAML and
executes nothing, which is where integration.md says such a check belongs, and
its suppression key joins the catalog it was never listed in.
nextcloud_exapp_reregister becomes nextcloud_exapp_renew: mcp-secret-masking
matches `register:` unanchored, so the substring in the old name read as a task
that prints a secret. register.yml already masks the two tasks that carry one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mcp-consumer-variant guard demanded three things of a provider's MCP
round: every admitted consumer enabled, each of them pinned `services: null`
so it dragged no dependencies along, and no other service left enabled. Two
of those were the rule legislating a round's contents rather than checking
that the pairing gets proven at all.
Keep the first and drop the rest. What else the round carries is the role's
own call, so the tor/litellm exemption, the datastore exemption and the
`allowed` set they fed have no reader left and go with them.
Strip the 63 `services: null` pins the rule forced across 20 variants, all
written bare as `services:`. The topic-emptying capability behind them
stays: apply_topic still normalises None to {} for the inventory payload,
and the unit tests still cover it. Only the uses and the mandate are gone.
That leaves overridden_providers scanning a repository with nothing left to
find, so its three assertions passed over an empty dict and would have
stayed green however the function broke. Rebuild them on a synthetic role
tree, which also covers the case the repository scan never held: a variant
naming a plain flag is not an override.
Removing the pins changes what deploys. Measured across all 20 variants: 18
identical, prometheus#1 grows 23 to 31 roles, moodle#2 grows 31 to 32 and
crosses the memory budget until the following commit shrinks it back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A round deploys a provider once, so the role that pulls it in is the only place that can say how lean it has to be. A variant entry may now carry any application config topic under a service it depends on: a mapping deep-merges, so naming one key changes that key alone, and null empties the topic. Empty was the case that needed a marker of its own, because merging an empty mapping is a no-op and would leave exactly the services the round was trying to shed.
The topic vocabulary and the merge semantics live in utils.roles.applications.topics, which is also where resources.py now gets them rather than spelling the same null-or-merge out a second time. Collecting who demands what stays in the inventory tree, refuses two roles demanding different values for one provider in the same round, and skips a role's own entity entry so the existing services.<own>.users declarations are not read as overrides.
The CI set-cover learns the same fact. Its greedy cover retires a role's variant-0 row when an earlier row of another role embeds it, on the stated premise that embedding pulls the provider's variant 0. A variant that dictates that provider's config breaks the premise from the other side: whatever ran, it was not the provider as the provider declares it. Such a pair now covers nothing, so the retired row keeps its job, its artefact and its verdict.
Measured on the current tree: 20 override pairs, 41 covered rows before and after, so nothing is revoked today. The rule bites the moment an override lands in a row that covers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The contract check ran as an Ansible task in the middle of every provider deploy, and the two client checks ran the same way. A test that is part of the deploy cannot be run without deploying, cannot be re-run on its own, and reports its verdict as a deploy failure. Twenty-seven task files lose 491 lines to this.
It is now what it always was. Each provider ships files/test/test.sh, templates/test.env.j2 and meta/tests.yml, and test-e2e-cli stages one shared harness beside it: run.sh drives contract.sh in a throwaway container on the provider's own network, then isolation.sh for adapter-fronted providers. The read probe each provider offers moved from the calling task into meta/mcp.yml next to the allowlist it must satisfy, and the schema now rejects a read probe naming a tool outside that allowlist.
The bearer the test presents is the one a client resolves through mcp.credential, via the new mcp_client_bearer lookup over resolve_credential, which moved to utils.roles.applications.mcp so it is the same function mcp_servers renders every client connection with. The probe presented whatever the calling role passed, which for the fifteen adapter-fronted providers is a different secret than any client holds.
Sidecar isolation is read from the engine rather than measured inside the container, so it no longer needs a delegation to the node holding the task: attachment is compared by network name, which drops the gwbridge special case, and a swarm worker says it cannot measure instead of skipping silently.
Retrying is bounded by wall clock against MCP_DEADLINE_SECONDS, which scales through the same lookup('timeout') as the harness budget it has to stay under. A flag that is neither true nor false aborts rather than reading as off.
mcp-provider-probe becomes mcp-provider-cli-test: an enabled provider must ship the test rather than include the task. mcp-probe-bearer becomes mcp-adapter-bearer, tying ADAPTER_BEARER to the declared credential key now that no probe include carries it. A new integration test renders the shared env template for every provider through Ansible's own templar, so a later change to it breaks visibly instead of poisoning twenty deploys quietly.
Hermes and OpenClaw keep their client-side proof as CLI tests of their own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
83c1c729de gave every provider a group_names-gated services entry per MCP
client, including the clients it refuses with mcp_consumer: false. The gate
is what makes the inventory resolver plan provider and client into one round,
so 19 of the repo's 82 co-deploy pairs started a client for a pairing the
provider had already declined. flowise is a live MCP client of qdrant,
homeassistant and prometheus; the other 17 providers were bringing up its
node stack for nothing.
The entries now read enabled: false, shared: false, mcp_consumer: false, and
the variants that pinned them to true are gone. Setting those variants to
false instead is not an option: test_variants_no_static_override forbids any
variant override of a literal base value in either direction.
bond leaves with them, and a new lint holds that line: collect_role_resources
skips a literally disabled entry before it reads any resource key, so a bond
there describes a cost nobody pays. Thirteen entries unrelated to MCP carried
one and are cleared too.
test_mcp_consumer_services_entry demanded the group_names shape for every
client entry, refused ones included, and now demands the opposite there.
dynamic-flag keeps its rule and takes its own documented per-key exemption.
make docs redraws twenty graphs: the edges go, the nodes stay as
'openwebui ❌' and 'flowise ❌', so the refusal is visible rather than read as
an integration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
enabled and shared were hand-maintained in 24 roles and wrong in both
directions. Both now resolve from the mcp_consumer edges already declared
in meta/services.yml: a server takes the consumers it admits, a client
takes the servers that admit it, and the pair renders as "'peer' in
group_names or ...", or False when no peer is admitted at all. setdefault
keeps meta/mcp.yml and meta/variants.yml as the override channel, so
pinning either key still wins.
The resolved block replaces the mapping instead of being written into it.
_build_role_base_config parks the YAML cache's own object under mcp, so an
in-place setdefault injected the derived keys into every later raw read and
made test_mcp_schema pass alone but fail in a directory run.
The derivation keys on peer presence, never on a peer's own value: a
re-entrant read under _RENDER_GUARD returns the unrendered dict.
Four lints now ask the resolver instead of reading meta/mcp.yml, which they
could not do while the key was hand-written. That is what exposed the eleven
missing hostile-path specs the previous commit closed.
Carries one change that belongs to the WordPress probe but shares a file
with this one: the tool allowlist moves to the hyphenated names the adapter
actually serves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moodle#1 of run 33414875521 failed in PASS 2 with rc 137, no output at all:
rc: 137, delta '0:00:01.530480', start 20:05:15.897321, stdout '', stderr ''
The rescue bundle names the killer to the millisecond:
moodle_moodle.3.mwdxfc7z6m3q877dr3ow1f5qr finished = 20:05:17.418099896Z
install task end = 20:05:17.427801
Ten milliseconds. OOMKilled is false on all six containers, so the surviving
alternative -- a per-cgroup OOM against mem_limit: 2g -- is out.
PASS 2 re-rendered the env file and rotated the file-backed secret hashes, so the
stack deploy changed the moodle and cron specs and swarm rolled every replica.
The rescue tree shows two generations for each moodle_moodle slot and for
moodle_cron, and exactly one for each moodle_nginx slot; moodle and cron are the
two services carrying env_file. Slots went at 20:05:17, 20:05:36 and 20:05:55 --
19s apart, which is parallelism: 1 plus delay: 10s. Whichever slot the exec drew,
it was going to be killed: the install needs ~176s and the roll takes ~39s.
The convergence gate did not stop this and structurally cannot. It is a handler,
it returns before the task starts, and a barrier at time T cannot protect an exec
that begins at T+1.6s. The same-pass control settles what does work:
svc-db-mariadb went through the identical gate (0.517s, zero retries) and its next
in-container exec survived, because it has retries. This task was the only
in-container command in the role without them -- the health probe above it has 120
and the mail-marker probe below it has 30.
MOODLE_CONTAINER_ADDRESS expands to a literal $(resolve-container-id ...) that is
re-executed per attempt, so a retry re-resolves onto the surviving container by
itself. until gates only on the SIGKILL signature; a genuine install failure
satisfies rc != 137 immediately and the existing failed_when still decides.
Not covered: this exec drew the first slot replaced and died at 1.5s, before any
DDL. A kill at second ~100 of ~176 could leave a half-written schema that matches
neither 'Moodle is already installed' nor 'Database tables already present', and
the retry would then fail differently. Unmeasured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moodle#0 of run 33414875521 failed its litellm spec in the login precondition.
The page snapshot names the cause rather than the symptom:
Blocked http://auth.4fc5...onion/realms/infinito.nexus/protocol/openid-connect/token
line 90 of /auth/oidc/classes/httpclient.php: call to curl->request()
line 3814 of /lib/filelib.php: call to curl->trigger_url_blocked_event()
Not an auth failure: the browser leg finished and came back with a code. What
died is the server-side token exchange.
On an onion node container_extra_hosts pins the Keycloak authority into the
container's /etc/hosts, for exactly this call -- its docstring says so: "code-to-
token exchange, JWKS, userinfo ... Pinning the provider to a reachable address
sends those calls over the local network to the proxy that already serves the
vhost." The pin points at host-gateway, which is 10.208.0.1 here because
08_networks.yml overrides docker's pool to 10.208.0.0/12.
MOODLE_EGRESS_BLOCKED_HOSTS then covered that address with 10.0.0.0/8. One half
of the deploy made Keycloak reachable and the other half blocked the address it
had just published.
b23653adf8 stated its intent as "172.16/12 and 192.168/16 become reachable so the
container network resolves, while 10.0.0.0/8, link-local and loopback stay
blocked", and README.md recorded the premise "Keycloak answers on 172.16.0.0/12".
It does not. The commit opened the two RFC1918 ranges this deployment does not
use and kept the one it does. 0f6728a4c1 is the same class of bug on a different
mechanism: an iptables range that also swallowed 10.208.0.0/12.
Measured rather than reasoned, with the exact --add-host the run emitted:
gethostbynamel("auth.4fc5....onion") => ["10.208.0.1"]
address_in_subnet("10.208.0.1", "...,10.0.0.0/8", true) => true
address_in_subnet("10.208.0.1", "...", true) => false
Two positive controls in the same job: prometheus-sso-proxy redeems a code
against the byte-identical URL and passes, and so does mailu. Moodle is the only
role in the run that puts an application-level SSRF blocklist in front of its own
HTTP client, which is why only Moodle broke.
No new security decision. README.md already carries the trade-off the earlier
commit accepted -- Moodle has no allowlist, so one internal host cannot be
permitted without its range -- and this extends it to the range the deployment
actually occupies. Loopback, 0.0.0.0, the metadata address and 0000::1 stay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
allowed_consumers was twenty hand-kept lists of which fourteen were identical, so every new client meant an edit in every provider. A client now declares services.<own-entity>.mcp_consumer: true once and a provider refuses one with mcp_consumer: false, which is also what tells a deliberate exclusion apart from a forgotten entry.
The group_names-gated consumer entries give the inventory resolver the co-deploy edge meta/mcp.yml never had: one of sixty-nine matrix rounds carried an MCP client before, twenty-three role/round pairs do now. bond 0.5 keeps the clients out of round 0 so the host memory budget still holds.
BREAKING CHANGE: meta/mcp.yml no longer accepts allowed_consumers; declare services.<entity>.mcp_consumer on the client instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The container_address lookup does not return a container name in swarm. It
returns a shell fragment, "$(/usr/bin/resolve-container-id <stack> <service>)",
which is only a name once an interpreter evaluates the substitution. The shell
module provides one. The command module does not -- it execs the binary directly,
so the fragment arrives at container exec as literal text and the call dies with
"No such container: $(...)".
Compose hides the entire class: there the same lookup yields a bare name and
command: works. The failure exists only in swarm, and no swarm run had touched
these paths -- both were added by feature commits (b23653adf8, 0421ebeb16) that
wrote the same argv shape independently, which is the argument for a rule rather
than six edits.
Six call sites moved to the shell module: the moodle egress task, the three
moodle AI-gateway tasks, and the two wordpress AI-Engine tasks. The wordpress
pair keeps its stdin, so it uses the dict form with cmd:. Moving off argv exposes
the surrounding values to word splitting, so they now go through quote -- the
address itself must NOT, because single quotes would suppress exactly the
substitution this depends on.
test_argv_exec_needs_shell.py closes the gap that let this through.
container-exec-resolver checks that the resolver is used; it has nothing to say
about whether anything can evaluate the result. The new rule checks that other
half. It reuses the sibling's argv parser, which recognises only container/docker
in first position, so an argv list that does start an interpreter
(argv: [bash, -lc, ...], as joomla does) is correctly not matched.
Run against the tree the rule reported exactly the five sites found by hand plus
bigbluebutton, and passed over joomla and the discourse task, whose address is a
config lookup rather than the resolver. Bigbluebutton is pinned to compose via
compose_mode_force and can only ever see a bare name, so it takes a nocheck.
Five container-exec-resolver markers disappear with the argv form: under shell
the resolver is the ordinary path, not an exception.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1f870b1e5 left two marks the layout rules reject. friendica's utils/ held
patch_config.yml beside patch_db_credentials.yml, which is a shared
delimiter-bounded prefix, and its numbered files ran 00, 01, 02, 04, 05 with
nothing at 03 after the config patch moved out of the sequence.
The prefix becomes the folder it was standing in for, the way 8e8dbbd08c did it
for the AI files. All four roles move, not only the one the lint could see: the
path post_deploy.yml resolves a role's declaration through has to read the same
everywhere, so a folder in one role and a prefix in the other three would be no
domain at all.
utils/patch_db_credentials.yml -> utils/patch/db_credentials.yml (4 roles)
utils/patch_config.yml -> utils/patch/config.yml (friendica)
The numbers close up behind the file that left: 04_addons and 05_user_bootstrap
become 03 and 04.
Four references follow: the include in 01_manager_ops.yml, the lookup in
post_deploy.yml, the caller path in the unit test that reads each role's
declaration, and a requirement doc that linked the old addon file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3831c8f804 gave matomo, moodle and nextcloud a patch each, called from their
own 00_core.yml. That file only runs while the role's run-once flag is unset,
so the patch runs on the deploy's first pass and never on the second -- which is
the only pass where a rotated credential exists. The app keeps the password it
was installed with while the engine moves on, and every request answers 500.
Run 33317483326 shows it on friendica, which had the same shape: svc-db-mariadb
altered the user at 15:39 and again at 16:26, either side of the 15:58 rotation,
while the app's config was patched once. The engine said what followed:
[Warning] Access denied for user 'friendica'@'192.168.201.4' (using password: YES)
Five failed healthchecks in a row, swarm replaced the task, and the convergence
gate reported "tasks not running yet" for a service that started correctly every
time.
The call moves to post_deploy.yml, which runs after every deploy and sits behind
no role's flag. It includes roles/<app>/tasks/utils/patch_db_credentials.yml when
that file exists, so a role declares what to patch and nothing else: friendica
joins the other three with its volume, its config path and its expression.
It cannot live in the role. 00_core.yml has to set the run-once flag as its very
first task or load/app.yml re-includes the role and recurses, which
tests/lint/ansible/roles/test_service_core_first_task_run_once.py enforces, and
everything after that flag is closed to a second invocation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The body was a Jinja-interpolated shell string inside the task. As a file it
can be read, shellchecked and run, and the three parameters travel through the
environment the way the password already did.
That forces one thing into the open. The old expression carried
${PATCH_PASSWORD} and the shell expanded it while assembling the command; a
value arriving in an environment variable is not expanded a second time, so the
substitution has to be explicit. The consumers now write @PASSWORD@ and the
script replaces it, which is also the version a reader can follow without
knowing that rule.
The script refuses to run without its parameters rather than treating an unset
one as empty, because an empty volume name would look exactly like a node that
does not hold the volume: a silent success on every host.
Tests move with it, to the files/ segment every other test of a shipped script
uses. They run it against a stubbed `container` in three states per consumer -
no volume, volume without the config, both present - and assert the marker the
task reads as a change. Five mutations of the script were checked against them:
dropping the substitution, the marker, the existence test, the parameter guards
and the volume filter each turn the suite red.
Layout follows what the linters already require: files/shell/ is enforced by
role-files-layout, and 65e34746fc names ansible.builtin.script as the sanctioned
way to run one from a task.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Baserow's image mounts the MCP app on the ASGI root unconditionally, so
mcp.enabled only decides whether an endpoint record exists. The
application's own vhost kept routing /mcp/*: switched off it answered
401 Endpoint not found., and a refused credential is still a surface.
Switched on it is worse - the adapter narrows fourteen upstream tools to
four, and the publicly routed native path reaches all fourteen for
anyone holding the endpoint key. web-app-homeassistant had the same
shape, an exposure: internal surface reachable publicly. Both now 404
there and stay reachable on the container network.
The public surfaces get the edge limits the contract already declared.
Zones are written per role by the role that owns them, in the shape
web-app-prometheus uses, because one round-scoped file would drop another
application's zones on the next deploy. The rate is concurrent_requests
per second rather than an eighth limit key: a client allowed N in flight,
each bounded by timeout_seconds, cannot sustain more unless every call
returns instantly. A refusal is 429, not nginx's 503, because a client
that should back off must not read it as an outage. stream_seconds
finally binds something - the adapter holds no stream, the proxy does.
The shared location snippet's three timeouts become parameters defaulting
to the 900s they hard-coded, so no existing vhost changes.
Found by the disabled-state assertion 7d8e7e87ff added, which had never
run: its helper was never staged, so the two defects hid each other.
Every role-local MCP spec gates itself on the service flag and skips when the surface is off, so nothing ever checked that switching it off removes it. A surface still serving after the flag went false was indistinguishable from one that was never deployed: both read as a green skip. The gating helper had no inverse at all, so there was no way to assert what a disabled service must not do.
A shared assertion now probes the endpoint with both a GET and a POST while the flag is false and fails on any answer that shows something listening. It counts 401 and 403 as listening, which is the point: a refused credential still means the surface is there. The check is transport-agnostic because the roles differ in their enabled-state probes, over SSE, Streamable HTTP or plain JSON-RPC, while an endpoint that is gone answers none of them. Each role passes its endpoint URL as a thunk, since some resolve it only once a test is running.
The coverage lint requires the registration, and derives the role set from meta/mcp.yml rather than from a filename convention, which is how the tenth role surfaced: its spec is not named like the other nine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four unmarked `|| true` remained in this file after c505ce7466 grandfathered
the corpus; they entered later and left the file in violation of shell-or-true.
Handling them beats marking them.
cp -f writing config.php is the one that mattered: swallowing its failure let
Moodle boot against a missing or stale configuration instead of stopping. It
now fails.
The chown and chmod calls can only fail for lack of privilege, so the root
check states that condition instead of discarding every error. The image
declares no USER over php, and the compose template sets no user:, so this
runs as root today; the check guards a future USER rather than an observed
failure.
moodle_refresh_config inverts its guard to an early return so the copy sits one
level shallower.
The five grandfathered `|| true` keep their nocheck and their TODO, including
the chown three lines above the one hardened here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twenty-five specs called the Playwright APIs with default or hardcoded
timeouts. Routed over Tor those budgets are too small, so a spec fails on
transport latency rather than on the behaviour it asserts.
Route the waits through resolveTimeout and reach pages via gotoOnion, the
helpers the tor branch introduced for exactly this. The remaining changes in
the diff are import wrapping around those calls.
The repository lint enforces this: a raw numeric timeout in a role spec is
rejected with a pointer to resolveTimeout, which is how a hardcoded 500ms
wait added during this session was caught.
Merge work rather than authored here; not deployed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The relocation in dfe7a7fdd moved provision_mcp.php,
provision_mcp_identity.php and the ai_engine pair into files/php/ but left
their callers pointing at the old locations, so moodle's and snipe-it's MCP
provisioning and wordpress's AI Engine configuration resolved a path that
no longer exists.
The reference sweep that accompanied the move searched for python, shell,
ruby and sql folders and never looked for php, so these four lookups were
missed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merged branch moved every role-local script out of files/ into a folder
named for its language (files/python/, files/php/, files/shell/), but the
moves lost their staging and never landed. HEAD still carried the old paths
while the worktree had the new ones.
Move the 24 remaining scripts and update the six places that resolve them:
the mcp-adapter instance/isolation/probe tasks, the litellm key task, the
adapter README and the endpoint-path lint. Every changed line in those
files is a path.
Also drop roles/sys-svc-compose/files/swarm/stack_ready.sh, a stale
duplicate of the copy already tracked under files/shell/swarm/.
The added comments are the branch's own `nocheck: mirrored-unit-test`
directives, which state why a script whose measurement only exists inside
its container cannot have a mirrored unit test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>