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>
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>
Nextcloud seeds the administrator once, at install time, from NEXTCLOUD_ADMIN_USER
and NEXTCLOUD_ADMIN_PASSWORD in the environment file, and no later run touches the
account again. A rotated credential therefore leaves the instance authenticating
against the old secret while every task in the role still reports success -- which
is exactly what the CI proves against, rotating every inventory credential between
the sync and the async pass.
The reset runs only when the declared password differs from the one this script
last applied, compared through a sha256 marker kept in the config volume. An
unconditional reset would have worked too, but it reports changed on every single
run and turns a rotation check into noise. The marker's cost is stated in the
script header: a password changed outside this script is not noticed.
The comparison cannot be an authentication probe. Nextcloud has no occ command
that verifies a password, and the HTTP endpoints that would are not reachable from
inside an fpm image, where no web server listens in the same container.
OC_PASS reaches occ through the environment and never through an argument, so the
secret stays out of the process list on both the host and the container, the same
way dc32574873 took the redis password off the exec command line.
The account this touches is the local administrator. The LDAP resolution in
07_admin_identities.yml only collects additional identities for the same person,
and the MCP service account reset added in e3caa074b2 targets a different user.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
integration_mastodon's connect returned 401 with
'cURL error 60: unable to get local issuer certificate ... for
https://microblog.infinito.example/api/v1/apps', byte-identical across
attempt and retry. The CA plumbing the repo already injects - the CA
mount, SSL_CERT_FILE, CURL_CA_BUNDLE, php.ini curl.cainfo - cannot reach
that call: OC\Http\Client\Client pins Guzzle's VERIFY to the
certificate manager's own bundle, which defaults to the shipped
resources/config/ca-bundle.crt.
occ security:certificates:import is the only lever that reaches it.
Verified in nextcloud:34: the command is registered as
'security:certificates:import' taking a required path, and
addCertificate() calls createCertificateBundle(), which writes the merged
rootcerts.crt that getAbsoluteBundlePath() then returns. Public TLS keeps
working because the import appends to the shipped bundle. Every
integration_* app talking to an internal host over HTTPS was broken the
same way, not just Mastodon.
Registers the GitLab OAuth application with the scopes the client
actually requests: integration_gitlab/src/utils.js:26 sends
'read_user read_api read_repository', and Doorkeeper's ScopeChecker does
plain string-set containment, so 'api' does not subsume 'read_api' and
consent would have been refused. Also least privilege.
The gitlab spec sampled the URL the page settled on after GitLab's 302,
but GitLab keeps the return path in session[:user_return_to], so that URL
carries no query and the assertion looked for evidence GitLab structurally
does not provide. It now captures the authorize request itself and
additionally asserts redirect_uri points back at this Nextcloud and that
the partner answered below 400.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three occ calls take an exclusive lock on config.php and gave up on the first
failure. In run 30830377785 the config:system:set batch hit
In Config.php line 306:
Could not acquire an exclusive lock on the config file
/var/www/html/config/config.php
after writing seven of nine values, and failed the swarm job. The same task ran
13 times across that run's swarm jobs and passed 12 times, including a second
fedora job on the same commit and topology, so the condition is transient rather
than a defect in the call.
maintenance:repair and app:update in 03_upgrade.yml already carry
retries/delay/until for this reason. This aligns the three that did not:
occ upgrade, maintenance:mode --off, and the config:system:set batch. The batch
is safe to repeat because config:system:set is idempotent and its `set -euo
pipefail` aborts at the failing call rather than continuing half-applied.
This does not mask the failure. Verified with a probe playbook of the same shape
(failed_when plus an until that never becomes true): the play ends failed=1 and
the downstream task does not run, so an exhausted retry still fails the deploy -
two attempts later than before.
The mechanism behind the lock error stays unproven: Config.php calls
flock(LOCK_EX) without LOCK_NB, which blocks rather than returning false, and no
errno appears in any of the 596 rescue-diagnostic files. A retry helps if the
condition is transient and does not if the NFSv4 lock layer returns a hard
error. That is why this is a mitigation of an ~8% flake, not a fix for a proven
cause.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This reverts commit 41063206a.
It broke every swarm nextcloud deploy. system.config.php.j2 opened with
<?php
{%- macro php_str(value) -%}
and the leading dash strips the newline after `<?php`, so under Ansible's
trim_blocks the file renders as `<?phpreturn [`. Run 30848540165:
ParseError: syntax error, unexpected token "=>", expecting "]"
in /var/www/infinito/system.config.php:2
#0 /var/www/html/lib/private/Config.php(229): include()
Config->readData() includes the file, so occ upgrade, every later occ call and
every php-fpm request died on an uncatchable compile error. The template had
been verified by rendering it with plain Jinja defaults, which do not match the
ones Ansible uses - the check confirmed the escaping and missed the line above.
A one-character fix (`{% macro`) is correct and was verified end to end, but the
commit is reverted rather than repaired, because its premise did not survive
review:
- The failure it targeted is not deterministic. The same task ran 13 times
across the swarm jobs of run 30830377785 and passed 12 times, including a
second fedora job on the same commit and topology. It is an ~8% flake.
- It does not remove the mechanism. 03_upgrade.yml:8 runs
`occ maintenance:repair --include-expensive` unconditionally, and upstream
Repair.php sets the maintenance system value twice with no guard - two
exclusive locks on the same NFS config.php per deploy, unretried.
- The benefit is largely illusory. Config.php:279 var_exports the whole merged
cache back into config.php, so the nine values land in the locked file anyway
on the first maintenance:repair, with a larger payload.
- It traded away occ's exit-code confirmation that Nextcloud accepted the
values, and moved them from the shared NFS volume to a node-local bind.
The remaining exposure is the unretried occ calls in 03_upgrade.yml; a bounded
retry there is the smaller and better-targeted change and is left for its own
commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine sequential `occ config:system:set` calls each open config.php exclusively.
In swarm that file lives on the NFS share behind every replica, and the seventh
call could not take the lock:
In Config.php line 306:
Could not acquire an exclusive lock on the config file
/var/www/html/config/config.php
stdout shows six values written before the abort, so the run left a partial
configuration behind. That failed the swarm web-app-nextcloud 5 job in run
30830377785 while every PLAY RECAP on the other nodes reported failed=0.
The same nine values now come from templates/config/system.config.php.j2, which
rides the additional-config channel the role already uses for redis, ldap and
the rest: rendered to NEXTCLOUD_HOST_CONF_ADD_PATH, mounted read-only, merged by
zzz-infinito.config.php after config.php so its values win. That path never
takes an exclusive lock, and it runs before the stack starts, since all nine
values are statically derived and none needs a live container.
Two behaviours are deliberately not carried over. The --type flags become native
PHP types, which is stricter than the string coercion occ applied. And nothing
confirms the file was accepted any more, where occ's exit code did; a read-only
`config:system:get` would restore that assurance at the cost of an occ call, and
is left out until someone wants it.
php_str() escapes backslashes and single quotes, matching the idiom already used
for the redis password in memcache.config.php.j2. Without it a quote in any
value would be a PHP syntax error, and include_once would drop every additional
config file, not just this one.
vars/system.yml and 02_manager_ops/05_system_config.yml are gone; the later task
files shift down one number so test_task_file_numbering stays satisfied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`shell` populates stdout on every path where changed_when is evaluated,
so the default filter guarded nothing: a non-zero exit and even a chdir
failure both carry stdout, a skipped task never evaluates the condition,
and on a module failure Ansible records the conditional error in
changed_when_result while still reporting the real cause.
The async lint walked the tree itself instead of going through the
shared cache. Both selections cover the same 1079 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repo's async convention is fire and forget: ASYNC_POLL is 0 whenever
ASYNC_ENABLED is true, and the swarm test lane forces it true through
extra vars. A task dispatched that way returns a stub, so a changed_when
or failed_when reading its own register evaluates against nothing and can
never report changed and never fail. Several tasks carried a
"not ASYNC_ENABLED and" prefix in front of those conditions, added to
avoid touching undefined rc and stdout, which turned the guard off
entirely rather than adapting it.
That is how the nextcloud plugin configuration stayed broken unseen: the
batch reported ok after 0.8s while its delegated commands kept running,
the next task read the half written configuration, and no failure in any
of them could surface.
Every async in the repo was decided individually rather than swept. Where
nothing consumes the result, the async stays and a one line rationale now
records what does not read it. Where a later task, a handler or a browser
does consume it, either the async goes or the result is collected.
Collecting is what the new reaper does. It waits for a whole batch, then
reports every broken job at once instead of aborting on the first, so one
run shows whether a single item or the whole set is broken. Deciding what
counts as broken lives in a filter plugin, because the distinction is
subtle enough to deserve tests: a module reports failure in msg, a command
in rc and stdout, an idempotent API answers "already exists" and means
success, and a job that cannot be read at all comes back marked finished
with no outcome, which would otherwise read as green.
Timing cost is roughly one to two minutes on a deploy measured in hours.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The batched occ config:app:set ran fire-and-forget. It reported ok after
0.8s while 41 delegated docker execs kept running for roughly 40 more
seconds, and the very next tasks in the same file include the addon hook,
which activates the LDAP configuration and calls occ ldap:search.
A process snapshot taken 7 seconds after the failure shows the batch alive
and working on key 24 of 41. ldap_host is key 23, ldap_login_filter 24 and
ldap_port 32, so the search read a configuration whose mandatory fields were
still empty. ldap_configuration_active is key 11, which arms the config long
before those fields exist, so user_ldap's doCriticalValidation rejects it and
Connection.php:249 reports "Connection to LDAP server could not be
established" without ever opening a socket. That is why the LDAP server logs
show nothing: the swarm variants lose the race because every occ call crosses
a node boundary.
The guards hid it. failed_when and changed_when were both prefixed with
"not ASYNC_ENABLED", so with async on — everywhere except debug mode — no
failure in those 41 commands could ever surface and the task never reported
changed.
Waiting costs about 93 seconds across the 22 addons that carry configuration,
against a deploy measured in hours.
main.yml now reads as the chronological sequence it describes: it includes
01_fullstack.yml, then the single IS_STACK_HOST-gated 02_manager_ops.yml,
whose substeps live in the matching 02_manager_ops/ directory numbered in
execution order. The plugin pipeline moves under its parent step so its
include resolves against its own directory instead of an inherited search
root.
The flush that deploys the stack moves out of the config step to the top of
02_manager_ops.yml. It previously ran after the swarm resolver, which then
waited 600 times for a service that did not exist yet and killed two CI jobs
with "no such service: nextcloud_nextcloud". The second flush and the
re-resolve behind it are removed: neither included file notifies a handler,
so they could never fire.
A new lint pins what made this refactor risky. A relative include is resolved
at runtime, so a typo or a moved file survives ansible-lint and
--syntax-check and only fails mid-deploy; worse, a bare name that matches
under two search roots silently shadows one file with the other.
Comments that only narrated code are dropped from the addon hooks and the
plugin install step.