Context Agent 2.10.0 (bumped in #1219) moved to a multi-stage image whose
runtime stage ships no curl ("no vim, no git, no curl, no pipx/poetry",
nextcloud/context_agent Dockerfile at v2.10.0). The curl healthcheck then
fails with `exec: "curl": executable file not found in $PATH`, the task
never leaves `starting`, and the swarm converge wait gives up on
nextcloud_context_agent (main run 40401200083, Nextcloud#2).
Switch to the http_status flavor, which speaks HTTP over bash's
/dev/tcp and still requires a 2xx/3xx answer from /heartbeat.
Co-Authored-By: Claude Opus 5.5 <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>
Nextcloud#3 of this PR (run 38899717331, job 116977335535) failed
`tests/addons/integration_openproject.spec.js:30` in both retries: the
OpenProject admin section never rendered. The deploy had already logged
why: `occ app:install integration_openproject` answered `App "OpenProject
Integration" cannot be installed because it is not compatible with this
version of the server`, and the non-mandatory plugin was skipped while
the addon's `required: true` still sent the spec in.
Upstream, the latest stable release v3.2.0 declares
`<nextcloud min-version="33" max-version="34" />`; only the v3.3.0
nightlies (latest 2026-10-15) declare `max-version="35"`.
Keep `34-fpm-alpine` and mark the line `# nocheck: docker-version`, which
`utils/update/docker.py:suppressed_services` reads, so the updater stops
reopening the 35 bump. The nginx and context_agent bumps of this PR stay.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`retries: 30` with `delay: 10` only bounded the wait while each
`occ onlyoffice:documentserver --check` failed fast. onlyoffice v10.2.1
gives the health check and the version command 60 s each and the
conversion 120 s, so a document server that accepts connections but
does not answer held the deploy for up to two hours before it failed.
The retries now also stop once a deadline of
`lookup('timeout', 300)` seconds, set right before the check, has
passed. A per-attempt `timeout:` would not bound the total: ansible-core
2.21 leaves the `until` loop on the first timed-out attempt, so only a
limit below onlyoffice's 60 s would end a hanging server's loop.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The role writes onlyoffice's settings with one occ call per key,
DocumentServerUrl first and jwt_secret fourth. Nextcloud loads app
config once per process, so a cron.php that starts inside that batch
runs onlyoffice's EditorsCheck job, registered when the app was
enabled, with the document server URL but without the secret. The
document server answers "Invalid token", EditorsCheck stores it in
settings_error, and onlyoffice stops loading its Files integration;
EditorsCheck itself skips every later run while that error is set.
Seen in run 38455782184 (Nextcloud#4, swarm): cron.php started at
06:50:00 inside the batch that ran from 06:49:59 to 06:50:04, logged
"Invalid token" at 06:50:22, and both Playwright passes found .docx
files offered for download only.
DocumentServerUrl is now written last, so a process that cached the
config before the batch ended sees no URL and EditorsCheck returns
early. After the batch, `occ onlyoffice:documentserver --check` sets
settings_error from a fresh check: a redeploy clears an error latched
earlier, and a document server that stays unreachable fails the
deploy. A unit test keeps the URL last.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
occ config:app:set asks for a confirmation when it is given a type, and
without a terminal it aborts with exit code 1. The typed Talk server
lists from the previous commit therefore stopped the deploy at the
plugin configuration.
Entries with a type are now written with --no-interaction. Measured on a
live Nextcloud 35 with dummy values: stun_servers, turn_servers and
recording_servers are stored as arrays, Talk lists exactly the written
servers, and a second identical call leaves the value alone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Talk 25, the release for Nextcloud 35, reads stun_servers and
turn_servers through the typed app config and declares both as arrays.
Talk 24 read them as JSON strings. The role wrote them without a type,
so on Nextcloud 35 the Talk settings showed no STUN and no TURN server
although the deploy had set both.
A plugin configuration entry may now carry a type, which is passed to
occ config:app:set. The three Talk server lists use it. Entries without
a type are written as before.
Not yet run against a live Nextcloud 35.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nextcloud 35 has no app store release of BPM Files and News yet, and the
role skips an addon it cannot install. Their tests ran anyway and failed
on the missing app. Recognize installs on 35, but its test trips over a
second element with the id it looks for.
An addon test runs only for an addon that is enabled and required. With
required set to false the three addons are still installed whenever a
compatible release exists, and their tests no longer run. metadata is
declared the same way already.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A variant that pins `services.tor.enabled: true` while enabling a role whose
own `meta/services.yml` pins `tor.enabled: false` declares a combination the
deployment cannot hold. The role rides no node onion, so on that round its
surface lives at a clearnet host no onion browser reaches, and the dashboard's
`frame-src 'self' http://*.<onion>` forbids embedding it outright. Run
36651124458 job 109692287333 shows the result: five specs fail at once,
dashboard tile, matomo tracker, css injection, logout and the coturn metric
label, all on hosts that are the only non-onion ones in the whole job.
web-app-nextcloud variant 11 was such a round: bigbluebutton on, tor on, while
bigbluebutton declares no onion. Variant 8 already answers the identical
question for PeerTube (65538c40da, "clearnet, because the OIDC plugin rejects a
plaintext onion issuer"), so variant 11 now does the same. With that,
bigbluebutton never meets a tor node and needs no consumer gates of its own.
Two roles keep a gate, for reasons that differ from each other:
web-app-seaweedfs is not clearnet-only. It deploys on tor as the object store
other roles reach over the internal network, and only its browser frontend is
off there, which `frontend.enabled` on main already says. Its logout, dashboard
and prometheus declarations claimed a surface that round does not deploy, so
they take the same gate the file's own frontend and sso blocks use.
web-svc-coturn belongs on a tor node and cannot serve one: STUN/TURN runs on
UDP plus a 20000-port relay range that no onion descriptor can publish, and its
only HTTP is a gateway answering a plaintext OK. Deploying it is right,
advertising it as a prometheus consumer is not, because that scrape goes
through the browser path. It is exempt from the rule itself, since it claims no
visitable surface, and its `dynamic-flag` markers now carry the reasons the
rule's own message asks for; one of the four was a duplicate of the line below
it and is gone.
The lint states the rule. It found these five pairings on its own, matching a
hand count made before it existed, and reports none after the variant fix.
`test_sso_variant_requires_tor` covers the complementary half, every variant of
a role that has an onion must pin it on, and exempts the clearnet-only role
itself; this rule covers the consumers that bind one. The suppression marker
belongs on the bound role when the reason is that role's own shape, because a
consumer-side marker would also silence every other clearnet role in the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PHP_MEMORY_LIMIT and PHP_OPCACHE_MEMORY_CONSUMPTION were both the host's
RAM divided by 30, read from two keys in meta/services.yml: 533M on a
16 GB host, 2133M on a 64 GB one, beside a container limited to 3g either
way. They are now half and an eighth of that limit through
lookup('service_memory_mb'), which renders 1536M and 384M on every host.
The lookup sits in env.j2 and the two meta keys are gone. It reads the
applications payload, and meta/services.yml is part of that payload, so
a call from there would run back into the render that asked for it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The role restricted the WOPI hosts with `domain` and with
`--o:net.wopi_allowed_hosts`. The distroless image that #775 brought in
applies neither. `domain` was translated by the start script of the older
image, which this one does not have, and coolwsd knows no setting called
`net.wopi_allowed_hosts`. Its startup log listed that key among the
overrides and no trusted WOPI host, so the server ran with the policy the
image ships instead of the one the role declares.
The allowlist now goes through `aliasgroup1`, the variable coolwsd reads
itself. It carries the origin of the Nextcloud domain as the deployment
resolves it, with scheme and port, so it also holds in a Tor variant. The
old expression was built from DOMAIN_PRIMARY and would not have matched an
onion address.
No spec opened a document, which is why this went unnoticed. The
richdocuments spec now uploads a document, opens it and waits for the
`status:` frame on the editor websocket.
Its first run on the Tor variant found a second defect. `ssl.termination`
was fixed to true, so coolwsd advertised https URLs in its discovery while
an onion vhost only serves http, and the browser never reached the editor.
The setting now follows the TLS state of the Collabora vhost.
Closes#802
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>
Connector v10.1.2 declares Nextcloud 33 to 34 as its supported range, so
occ refuses to enable it on Nextcloud 35 and the ONLYOFFICE variant of the
Nextcloud 35 bump fails in the onlyoffice addon spec. v10.2.1 declares 33
to 35 and therefore serves the pinned Nextcloud 34 as well as the pending
bump.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The validation job asserts that main is fully merged into the PR head,
so the branch has to carry the current main before CI can pass.
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>
ONLYOFFICE listed onlyoffice 10.2.0 in the Nextcloud appstore on 2026-09-18 at
10:16:31Z and never published the matching GitHub artifact; the tag itself does
not exist. `occ app:install` takes the newest compatible release, so it dies on
a 404 that no retry can heal. CI proved that twice independently: upstream run
35329488812 and fork run 35330645276 both fail Nextcloud#4 at that task after
six attempts, on the same commit, for the same reason.
The fault had three layers, each visible only once the one above it was gone.
Install. meta/addons/onlyoffice.yml pins `config.archive` to v10.1.2, whose
artifact answers 200 and which the appstore also lists for this server major.
01_install.yml stages that archive on the node and unpacks it into custom_apps
through a stdin pipe: the webroot is a named volume, so the node can only reach
the app directory through the container, and piping avoids both `container cp`
and a cleanup step that would have to run as a user which does not own the file.
Enable and configure needed no change at all. With the release already on disk
`occ app:install` reports "already installed", which nextcloud_install_status
has always mapped to runnable, so 02_enable.yml and 03_configure.yml proceed as
they always did. That is why this commit adds no branch to the plugin pipeline.
Update. `occ app:update --all` then dragged the pinned app back to the broken
10.2.0 and killed the deploy. occ offers no exclusion flag, but it does offer
`--showonly` and single app ids, so a pinned app is simply not updated. The old
`--all` call stays byte-identical and now carries a `when`: with nothing pinned,
thirteen of the fourteen variants take exactly the path they took before. This
layer cannot appear in CI at all, because it needs a second deploy with the app
already installed, and the install always died first.
The classifier test is inverted rather than deleted:
test_an_advertised_release_whose_artifact_is_gone_stays_fatal pins the decision
that a dead artifact fails loudly instead of degrading to a silent skip.
Verified by a local compose deploy of variant 4: ok=2508 changed=149 failed=0
and `✓ 36 tests/addons/onlyoffice.spec.js:12:1`, which drives the whole
JWT-authenticated round trip to the document server over its onion address, not
merely an enabled plugin. In the live container beforehand: `occ app:enable`
returned "onlyoffice 10.1.2 enabled", `occ app:install` returned "already
installed", and `occ app:update onlyoffice` reproduced the 404 that the pin now
avoids.
The loop expression was wrong before this commit and is fixed here: Jinja reads
'\1' as an octal escape, so the backreference produced chr(1) instead of an app
id. Evaluated against ansible-core's own filters over four shapes, the tail is
now cut instead of captured, which removes the escaping hazard entirely.
occ output is not localised: with default_language=de and a de_DE.UTF-8 locale
the strings are unchanged, so parsing them is as safe as the five markers the
role already relied on.
Residual: the per-app update branch has never run with a real item inside a
deploy. Its chain and `occ app:update <id>` are each verified separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ranking the last commits built toward is now enforced: a semver tag,
else a digest, and nothing else without a written reason. Two states fail.
A pin that is neither names something the registry can move underneath us,
and nothing here can tell whether it still points where it did when it was
written. A digest on an upstream that does publish semver takes the
fallback for no gain and hides the entry from the update job.
Thirteen entries stood in the way when the check was first run. Four were
resolved by the channel grammar, which made the Jitsi components orderable
without touching their pins. Three needed nothing once a moving tag could
declare itself with 'docker-version'. Two are fixed here and four are
declared.
LiteLLM leaves 'main-v1.77.3.dynamic_rates'. That tag exists exactly once
among 2609, so it is a dead end no updater can follow, and it misreports
itself: the image inside answers 1.77.2. The pin moves to
'main-v1.77.7-stable', five patches along the same minor and onto a channel
with 134 builds. Started against this repository's config shape, 1.77.7
registers both model entry forms and accepts drop_params and the
os.environ master key.
Pixelfed leaves 'latest' for '20260917'. The upstream stamps its dates as
one eight-digit number, which is depth 1 and orderable with no tooling
change at all, and the two tags resolve to the same digest, so nothing
about the deployed image moves.
The four declarations carry the count that justifies them rather than a
claim: LM Studio publishes two tags and neither is a version, the MIG
registry carries one, and the two Nextcloud Talk images publish 174 and 104
AIO build stamps between them.
Both branches of the gate were negative-controlled. The digest branch
skips in this repository because no digest pin exists at all, so it was
exercised by introducing one and watching it fail.
Co-Authored-By: Claude Opus 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>
API is a plain mapping, so an inventory that configures three providers replaces the whole declaration from group_vars/all/18_api.yml instead of merging into it. inventories/development/default.yml declares openai, anthropic and openrouter, so github, google and jira did not exist in CI at all and lookup('api', 'github.client_id') raised.
A raising lookup inside a value the applications renderer is templating does not fail the render: _templar_render_best_effort returns the string untouched and, for a value carrying a non-env lookup(, refuses the fallback as well. The raw template text reached plugin_value.enabled | bool, which coerced it to False and emitted the deprecation warning that fails the deploy gate. Run 34893363518, job 104153194109 lost web-app-nextcloud to exactly that, for three integrations at once. The configvalue entries would have stored template text as the client id.
utils/api.py merges the scope's override over the declaration, so an override narrows values and never the provider set, and holds the enable rule: every credential a provider declares must carry a value. The api lookup reads through it; the new api_enabled lookup gates on it and returns a real boolean instead of a string a filter has to coerce.
The same class sat in group_vars/all/16_ai.yml and roles/svc-ai-litellm/meta/secrets.yml as bare API.<provider>.<key> dereferences; both now go through the lookups.
Two lints keep it shut. test_addon_enabled_renders_to_bool renders the whole applications map against a scope with API blanked, the most hostile override an inventory may legally write, and requires every addon gate to come out a boolean. test_api_accessed_via_lookup forbids a bare API dereference in Jinja under roles/, group_vars/ and inventories/.
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>
Three Playwright workers run three spec files at once, and on a fresh instance the first OIDC callback is what provisions the Nextcloud account. Concurrent callbacks then race on INSERT INTO oc_users for the same uid: one wins, the losers answer HTTP 500. The exception is SQLSTATE[23000] 1062 Duplicate entry 'keycloak-<sub>' for key 'PRIMARY', raised from the sociallogin app's ProviderService through OC\User\Manager::createUser, and it is visible both in the CI rescue bundle of run 34653754175 and in the container log of a local variant-1 deploy.
The race is self-closing: once any insert wins, the window is shut for the rest of the run. It has never turned a job red, because Playwright's retries absorb it and a flaky-only run exits 0. What it costs is a rerun of the whole spec file, 1.8 minutes for fileslibreofficeedit against a 42 second first attempt, plus a flaky line in every report.
Retrying inside the helper keeps that cost to one login. The base entry point now makes a second attempt after a five second pause, which resolveTimeout stretches to twenty five over Tor, and the observed failures abort after thirty to forty two seconds against a nine hundred second budget, so the second attempt has room. A persistent failure still fails, and the first attempt's message is appended to the second rather than swallowed, which the old wrapper's bare catch did not do.
loginToStandaloneNextcloudWithRetry is gone: a helper of that name beside a base that retries is a trap, and its five callers now reach the same two attempts through the base. The other forty eight addon specs gain the retry without being touched.
Rejected alternatives, each against a written constraint: a serial globalSetup would have to live in the playwright.config.js that 87 roles share and would force ignoreHTTPSErrors into the central use block; PLAYWRIGHT_WORKERS=1 reverses the measured Tor tuning in playwright_workers.py and leaks through an unnamed include_vars into every later role; pre-creating the account collides with prevent_create_email_exists and turns a one-off flake into a permanent login refusal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ansible renders the values of a structure loaded through include_vars and leaves its keys as written, so the mapping key a564385068 wrote as a Jinja expression reached Nextcloud as its own source text. occ config:app:get sociallogin custom_providers on a live variant-1 instance returns "groupMapping": {"/{{ lookup('rbac_group_path', application_id=application_id, role='administrator') }}": "admin"} while every neighbouring value of the same provider is rendered, scope among them.
Keycloak emits /roles/web-app-nextcloud/administrator, so the mapping matched nothing and the account sociallogin provisions on first login stayed out of admin. occ group:list on that instance shows admin holding only the local administrator while keycloak-ac165525-e9e8-4230-9381-13b1acd83fc0 exists and belongs to no group, and every Playwright spec that needs an admin settings section answered 'Access forbidden'.
An independent reproduction outside this repo showed the same split on ansible-core 2.21.4 and 2.20.6: in a payload of the same shape the sibling value rendered and the key did not, so it is not a version artifact. Nextcloud 34.0.3 settles the other half: Manager.php hands an admin the unfiltered settings of a section and ai is a core section of the always-enabled settings app, so an admin cannot receive 403 there whatever is installed.
Writing the whole mapping as one expression makes jinja2_native, which this deployment has on, return a real mapping with the key computed. The lint takes the class: no meta payload may carry Jinja in a mapping key. It found exactly this one case in the whole meta tree, and it ignores a line that itself opens with the expression so the corrected form does not trip it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sociallogin creates a Nextcloud user as "<provider name>-<keycloak sub>",
and the provider name was the Keycloak domain. On an onion variant that
domain alone is 67 chars, so every OIDC callback died with
InvalidArgumentException "Username is too long" and HTTP 500 at
/apps/sociallogin/custom_oidc/auth.<onion> (run 34615516009,
Nextcloud#1, 204 occurrences). The whiteboard and admin OIDC specs failed
on the same login.
This is the second derived identifier an onion domain overflows; the
kernel hostname is the first, clamped to 63 bytes by container_hostname
since the pretix and bookwyrm fixes. The domain-or-entity rule now lives in
one lookup, bounded_name(application_id, max_length): the primary domain
when it fits, otherwise the entity name. container_hostname delegates to it
at 63 and keeps its eight callers and its behaviour.
The provider name is bounded_name('web-app-keycloak', 64 - 1 - 36), the uid
limit minus the separator and the UUID sub. A short clearnet domain stays
the name, so existing users keep their uids; an onion deploy gets
"keycloak", which, unlike a hash of the domain, survives a rotated onion
address.
Tests cover the lookup's fit, boundary and fallback cases and its argument
validation, container_hostname's delegation at 63, and the provider name's
room for the sub.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run 34421944328 Nextcloud#1 timed out "waiting for a signed-in Nextcloud
shell after the login redirect". 4164f37981 turned sso on in variant 1
while ldap stays off there, so that round logs in through sociallogin
instead of oidc_login - the first variant, on this branch or on main,
that runs sociallogin against an onion issuer. oidc_login goes through
Nextcloud's HTTP client, which honours the proxy in proxy.config.php;
sociallogin builds its own client and reads only
social_login_http_client['proxy'], so its token exchange never reached
the onion Keycloak. The template now sets the same tor SOCKS proxy for
it, inside the existing "sso on and the OIDC host is an onion" branch.
The spec only treated 5xx documents as a page failure, so a refused
callback surfaced as a 60s (x5 over tor) wait for a shell that could not
come. A 4xx on /apps/sociallogin/ or /apps/oidc_login/ now counts as
the page itself failing, reported without the query so no code leaks.
The plugin loop printed every item, including the OIDC client secret,
into the include line of a public CI log; it now labels by plugin key and
honours MASK_CREDENTIALS_IN_LOGS.
_shared.js grew past 250 lines; its page and modal helpers move to
_page.js and _shared.js re-exports them, with an identical export
surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
start_interval is the cadence while start_period runs, and only while it runs.
Once the window closes the probe falls back to interval, so a service that
becomes ready just after it pays a whole tick anyway and the cadence never
reached it. Six of the seven flavors were shaped that way, Curl worst: a thirty
second window against a one minute interval.
Measured across 151 converge waits in four job logs, 63 sit exactly on a tick
boundary, 36 in the thirty second step and 27 in the sixty second one.
Attributed by application, web-app-seaweedfs is the largest at 1501s over
sixteen waits with a median of 57.7s: it probes with curl and fell straight into
the gap. web-app-zammad probes with connect, whose window is fifteen minutes,
and its 154s median is real boot time with nothing to recover.
So the window moves to ten minutes and the steady state interval to two, and the
flavor overrides that sat below them go: Curl, Wget and Connect lose interval
1m, Http its twenty second window, Nc its ten. Connect keeps fifteen minutes.
Neither number costs anything while a container works. start_period delays
nothing, it only stops failures counting, and interval no longer touches
convergence at all since that hangs on the window alone; it is steady state
load, which drops from 120 probes an hour per service to 30.
Forty six role files follow. Twenty seven declarations go that repeat the new
default or undercut it, an undercut window being the defect itself. Eight
windows longer than ten minutes keep their value and now say why. The literal
blocks and the three Dockerfiles this repository builds spell every timing out,
because outside the lookup an omission takes docker's value and not the
platform's.
A fourth lint rule keeps the shape: start_period must outlast one interval,
checked against the effective values, so raising interval without the window
cannot quietly bring the cliff back.
Two faults in the lint surfaced while building it and are fixed here. It
compared timings as strings, so 600s and 10m read as different values, and it
matched the healthcheck key exactly, so a line carrying a trailing nocheck was
skipped whole, which is how the two swarm-init partials went unexamined.
One regression, bounded. A container that starts and never turns healthy is
neither rejected nor errored, so stack_ready.sh only calls it fatal after three
killed tasks, and each cycle now spans the longer window. That reaches twenty
minutes rather than six, capped there by swarm_stack_converge_retries at 600
with a two second delay, and it lands only on runs that were failing anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A healthcheck written outside the container_healthcheck lookup inherits nothing
from utils/docker/healthcheck, and docker offers nowhere to put a replacement.
daemon.json carries storage, logging, DNS, registries and runtimes, and no
health key at all, so an omitted timing lands on docker's own value rather than
the platform's. The two disagree where it matters most: docker starts a
container with no start period, which leaves start_interval no window to act in.
That is why the hand-written blocks never gained the cadence.
The three images this repository builds itself now carry it. openldap and
postgres also drop to the platform interval, and bluesky gains the start window
it never had:
svc-db-openldap/files/Dockerfile --interval=30s ... --start-interval=5s
svc-db-postgres/templates/Dockerfile.j2 --interval=30s ... --start-interval=5s
web-app-bluesky/.../Dockerfile --start-period=30s --start-interval=5s
Six literal blocks move to the platform interval as well: magento, redis,
elasticsearch, matrix and both sys-svc-rdbms partials. Since start_interval
exists, interval no longer touches convergence and is steady state load alone.
Sixteen values that stay where they are now name the mechanism that keeps them
there. Two are worth reading. web-app-kix overrides start_period and nothing
else on purpose, letting the image's own HEALTHCHECK supply test, interval,
timeout and retries; filling those in would have overwritten what upstream tuned.
The memcached and redis swarm-init partials probe every second and deliberately
run with no start window, which is why start_interval is absent rather than
forgotten.
The rest name their probe: occ boots the whole Nextcloud stack, tor re-parses
its torrc instead of asking a running service, opensearch is still forming a
cluster, matrix waits on a file another step writes to a share.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
42ebd9ea71 gave the healthcheck SPOT start_interval, the cadence docker uses
while start_period is still running. A block a template writes out itself
bypasses the SPOT and never gained it. Measured across the repository: 46 such
blocks, 40 of them already carrying start_period, not one carrying
start_interval.
Without it a probe fires only every interval, so a service ready after two
seconds is reported healthy at the next tick and a stack converges no faster
than its coarsest quantisation. That lands in sys-svc-compose's swarm converge
wait, 1551.7s per CI job over 38 stack deploys.
Twenty five of the blocks move into meta/services.yml and render through
container_healthcheck. Each keeps an explicit test rather than picking a flavor:
a flavor would replace the command instead of carrying it across.
The move is lossless by construction. probes.py block() writes
overrides.get(key, default) for interval, timeout, retries, start_period and
start_interval, so a declaration repeating the literal values renders the same
block plus the missing cadence. Checked per block against the old values: the
only difference is start_interval 5s, nothing changed, nothing lost.
Twenty one stay literal. Seventeen interpolate a template variable, whose value
resolves in a scope meta/services.yml does not share, so moving them would be a
rewrite rather than a transcription. Four interpolate nothing and still cannot
move: svc-db-memcached and svc-db-redis probe every second in their swarm
service partial, where a 5s start cadence would report healthy later than the
block already does, and the sys-svc-rdbms and keycloak sso_proxy partials render
for the consuming role's application_id, so their declaration has no single
meta/services.yml to live in. Each carries a nocheck naming its reason.
All twenty one gain start_interval. Three also gain start_period, without which
the key does nothing at all, since it only applies while the start period runs.
That widens their failure tolerance rather than narrowing it: bluesky from 90 to
120 seconds, magento from 50 to 80, the mariadb partial from 180 to 210.
pixelfed spelled its probe as a bare string and now spells it CMD-SHELL. Docker
reads both as a shell command; only the YAML shape changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
confluence 10.2.16 to 10.2.18, erpnext v16.34.1 to v16.34.2, espocrm 10.0.7 to 10.0.8, hermes v2026.7.20 to v2026.9.7, mattermost 11.10.1 to 11.11.0, nextcloud context_agent 2.7.0 to 2.8.0, seaweedfs 4.45 to 4.46, coturn 4.17.2 to 4.18.0. The automated updater opens one PR per role and had not reached these eight; playwright and elasticsearch were already current in the tree.
audit.md follows rather than leads: the MCP report carries a version column and test_mcp_audit_report compares the committed file against a fresh generation, so hermes and mattermost move there too. Regenerated with audit.gen.py as that test instructs. Note that make docs does not run this generator, so the report only tracks reality when somebody calls it by hand.
Verified by the docker-version check itself: eleven Outdated warnings before, zero after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nextcloud#2 of run 33923190220 probes the MCP surface three times and gets
"could not reach http://contextagentmcp:8080/mcp: <urlopen error [Errno 111]
Connection refused>" before failing on both workers. Connection refused, not
an unresolved name: the sidecar's address existed and nothing served it.
meta/services.yml derives both halves of that surface from the same switch,
context_agent and contextagentmcp each carrying
enabled: "{{ lookup('config', 'web-app-nextcloud', 'mcp.enabled') | bool }}".
mcp.enabled is itself derived, from peer presence rather than by hand:
utils/roles/applications/mcp.py renders "'peer' in group_names or ..." over
the consumers the role admits, and for nextcloud that is hermes, openclaw and
openwebui. Variant 2 is the openwebui variant, so the switch is true, the
sidecar comes up, and only the app behind it stayed down.
It stayed down because the variant pinned it off by hand. That pin is an
orphan. a2be4f081a introduced context_agent paired with an mcp pin in each
variant and kept the two in step; 06bc856c32 lifted mcp out of the services
map when it became a role topic; ffdef28da3 then dropped the hand-maintained
mcp values entirely, calling them "wrong in both directions", and replaced
them with the peer derivation. The mirrors it left behind have contradicted
that derivation ever since.
Removing them changes exactly one variant. Evaluated across all fourteen:
variants 0 and 1 keep their explicit enabled: true and are untouched, variant
2 gains the agent its sidecar needs, and variants 3 through 13 admit none of
the three peers, so the derivation yields false and they behave as before.
Eleven of the twelve removals were writing a value the derivation already
produced.
The budget objection does not hold either. collect_role_resources counts any
enabled that is not literally false, so a rendered template counts as on;
test_resource_budget therefore charges the agent's 512m to every variant that
no longer pins it off, and still passes.
merge/svc-net-tor still carries all fourteen declarations and will reintroduce
the orphans if it lands.
Nextcloud#1 of run 33923190220 fails two admin surfaces:
addons/whiteboard.spec.js:28 waiting for the settings/admin/whiteboard shell
and addons/integration_openai.spec.js:32 waiting for #openai_prefs on
settings/admin/ai. Both time out with "element(s) not found" after two
retries.
The cause is not the apps. In the sociallogin flavor the Keycloak login
provisions a second Nextcloud account whose uid is <provider>-<keycloak sub>,
while the role only ever puts the local administrator account into the admin
group, so the browser session that the specs drive is an ordinary user and
every /settings/admin/* route renders nothing. sso_oidc_plugin.py returns
"oidc_login" if ldap_enabled else "sociallogin", so this only bites where LDAP
is off: variants on oidc_login with oidc_login_proxy_ldap land on the
LDAP-backed account that 07_admin_identities.yml adds to admin instead.
The provider asked for scope "openid" with an empty groupsClaim, so no group
information reached the plugin at all and no mapping could have worked. It now
requests the RBAC scope, reads the claim, and maps the application's
administrator group onto Nextcloud's built-in admin group.
Every link is in place already. RBAC.GROUP.CLAIM is "groups", the Keycloak
client scope carries the same name and sits in defaultClientScopes, and its
mapper sets full.path true, so the claim carries /roles/<app>/<role> and the
leading slash in the mapping key matches. rbac_group_path accepts
'administrator' without a declaration because it is the implicit admin role,
and build_realm_rbac_groups adds that group to every RBAC application, so the
group the key names is really provisioned. Upstream reads both keys:
ProviderService maps groups_claim to groupsClaim and group_mapping to
groupMapping, and applies the mapping by group id.
Adminship is necessary for those two specs but may not be sufficient: both
assertions also claim their app is installed and enabled.
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>
plugins/lookup/tls.py:102 builds url.base as "{protocol}://{domain}/", pinned by test_tls.py:59. meta/addons/onlyoffice.yml appended another one, so DocumentServerInternalUrl was stored as https://host//.
The connector treats its two URLs differently: getDocumentServerUrl normalises with rtrim, getDocumentServerInternalUrl returns the stored string raw and concatenates the endpoint onto it. Every server-side call therefore went to //healthcheck and //converter. A swarm vhost forwards $request_uri verbatim, so the document server answered 404; a compose vhost uses proxy_pass with a URI part and nginx collapses the double slash. The 404 makes the connector cron write settings_error, and FilesListener then stops adding the onlyoffice script to the Files view, which is why iframe#onlyofficeFrame never appears and the addon spec times out. Observed in run 33828309745 job 100890535389: "GET //healthcheck HTTP/2.0" 404 in the openresty log, and the docx row offering Download as its default action.
The lint generalises it. This class has been fixed one site at a time at least five times before (efee415b21, 035c1efbf3, b0c7c9601e, a96bdbba38, 83636408c9), so the rule now holds repo-wide: a variable carrying an unstripped tls url.base may never be followed by a slash. Definitions that strip it again are excluded, which is what keeps KEYCLOAK_URL and OPENTALK_KEYCLOAK_BASE_URL out of the finding set; measured, no name is defined both ways.
Not fixed here: settings_error is sticky, so an instance already poisoned stays broken after a redeploy. The obvious remedy, gating the deploy on occ onlyoffice:documentserver --check, is a four-step round trip whose steps never succeeded in either artifact, so it needs its own design.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hub serves 94 tools across 23 categories, write tools included, and enabling the ExApp switches every category on. That surface was published on the public vhost and every discovered client was handed an app password for it. svc-ai-mcp-adapter now sits in front in mcp_passthrough mode: clients present credentials.mcp_bearer to contextagentmcp and reach the nine read tools of calendar, contacts and files, which files/mcp/tools.json names and the sidecar enforces per call. Those three categories are the ones whose upstream is_available is unconditional, so the served set does not vary with which optional apps a deployment installs.
Enforcing it in the sidecar rather than in each client is what makes it hold: web-app-openwebui renders no per-tool filter at all, so a client-side include list would have bounded two consumers out of three.
06c13e7f3e withdrew the declared path from the edge for exactly this reason - "that surface is what the adapter exists to narrow". Nextcloud answers the same route under a second spelling, without the /index.php front controller, which try_files rewrites back onto the route after the edge has already declined to block it. The withdrawal becomes a regex covering both, and only where stripping the front controller changes the path, so the four adapter providers without one render unchanged.
The app password is minted after the stack renders, so it reaches the sidecar through upstream.env and rebuild.yml rather than the compose environment: compose up resolves env_file while creating the container, a restart does not. The sidecar is the last service in the template because a Jinja set has no block scope and its env_file would otherwise displace the one talk, whiteboard and talk_recording each carry.
Two suites cover what nothing measured: the vhost template rendered without a container, asserting both spellings are withdrawn, and every shipped tools.json loaded through the adapter own loader, so a bad contract fails in a second instead of as a probe timing out an hour into a deploy. The guest spec probed mcp.endpoint.path, which this change makes the proxy answer 404 for its own reasons; it now probes all three public paths and requires no MCP body.
Not yet deployed: the sidecar build, the upstream.env delivery and the hub answering exactly nine tools are unverified against a running stack.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>