A node serving several networks runs one universal-logout conductor per network, and a page served over HTTPS cannot fetch the HTTP onion hosts, so one conductor cannot clear the other network's sessions. compose.yml.j2 now gives every conductor the url.origin of the other networks' conductors in LOGOUT_SIBLINGS (empty on a single-network node, which disables the hand-off), and the Keycloak logout panel that frames the conductor follows its handoff report to the next conductor once the sweep is done, accepting only an http or https URL.
The pinned image v1.4.2 ignores LOGOUT_SIBLINGS and never reports handoff, so behaviour changes only with the bump to the universal-logout release that carries the reachability probe. test_compose_siblings.py renders single- and multi-network nodes; logout-panel.test.js covers the handoff branch and rejects non-web URLs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI run 40979252521, Baserow#1 (swarm, variant 1): the PASS 2 MCP probe got a 500 and the deploy refused the endpoint. In replica 3, worker 244 served MCP at 11:20:40 and then sat idle for 3824 s; the next request at 12:24:24 failed with "OperationalError: server closed the connection unexpectedly", and every later one, including the 13:09:10 probe, with "InterfaceError: connection already closed". Postgres stayed up throughout. The swarm service VIP (IPVS) drops TCP idle for 900 s.
BASEROW_DATABASE_URL now carries libpq keepalives (keepalives_idle=60, interval 10, count 6, tcp_user_timeout 120000), the same set as the OpenProject swarm fix. test_database_keepalive.py parses the rendered URL with libpq's parse_dsn and fails on the old URL.
Baserow 2.4.1 mounts its MCP Starlette app before Django's handler, so close_old_connections never runs there and a dropped connection stays dead until the worker restarts; that half is upstream. A server-side keepalive on svc-db-postgres that replaces the per-role options is tracked in #1298.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A logo whose solid pixels share one colour (alpha >= 128, every channel within 24) is detected once per file and tinted to the text colour of each slot: frame_fill on frame slots, fill elsewhere, plus a prefers-color-scheme: dark variant in the SVG of page slots and the page text colour for the favicon. Edge pixels below half opacity are ignored so antialiasing does not count as a second colour. Multi-colour logos render byte-identical to before.
make design-logos (alias i8delo) renders every role with a design entry from one logo and base colour into an HTML page with day and night tiles, without a deploy. Slots render in four threads and roles in one process per CPU: the full preview went from 287.6s to 33.2s with identical output (diff -rq over 1229 PNGs). A GPU does not pay off for these PIL resizes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Swarm run 2 of the 061 Nextcloud step failed on every worker with "Assert Talk has the TURN server its calls relay through". The TURN server comes from web-svc-coturn's group membership (meta/services.yml standalone_enabled), which only the stack host carries; workers never run Talk, so the assert judged hosts it has nothing to say about. It came in with 5728502a19 and exists only on this line, not on main.
The assert now runs where IS_STACK_HOST holds: every compose host and the swarm manager, unchanged. test_talk_turn_assert.py evaluates the block and task guards plus the assertion with Jinja; against the unfixed file only the worker case fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gate pass 5 flagged test_gpu_detection.py:31 in test_file_spot: the
`ROLE / "vars" / "main.yml"` chain hard-codes a role-file path the
utils.roles.mapping SPOT already names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to ae259a9f58 after an independent review:
- The 136-character task name broke the 80-character name lint
(tests/lint/repository/test_name_emoji_and_length.py). Names are short
again; the device/driver/host matrix lives in the role README.
- Both stats now always run and lookup('gpu') requires both registers,
instead of reading a skipped device stat as "no device".
- A bare host that shows /dev/nvidia0 without nvidia-smi stops the
deploy with a message naming the fix. It used to die on the missing
binary anyway; ae259a9f58 would have run its GPU services on the CPU
silently. A container in that state (a swarm lab node, the dev
container without its GPU override) still runs on the CPU.
- svc-ai-s1 decided its CUDA build from its own stat of /dev/nvidia0 and
nvidia-container-runtime while its runtime came from lookup('gpu'), so
a host with the toolkit but no nvidia-smi would have built a CUDA image
that gets no GPU. S1_GPU now reads lookup('gpu'), the one detection.
- The libretranslate-engine README and the s1 TODO describe the new
detection.
test_gpu_detection walks the real tasks over device, driver and
container; against the original 00_core.yml it fails in the
driverless-container, driverless-bare and device-and-driver cells.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local 061 Nextcloud swarm step failed on every lab node in
"Ask the driver which CUDA devices this host carries" with
"[Errno 2] No such file or directory: b'nvidia-smi'". The lab nodes are
privileged containers (compose/swarm/compose.yml), so on a workstation
with an NVIDIA card they see the host's /dev/nvidia0, but they carry no
driver. The device node alone made them GPU hosts, for the GPU include
and for lookup('gpu') alike, which would also have picked a -cuda image
and runtime: nvidia for a GPU service there.
The role now stats SYS_SVC_CONTAINER_GPU_DRIVER (/usr/bin/nvidia-smi)
first and stats the device node only where the driver exists; the
skipped register reads as "no device" in lookup('gpu'), and the include
requires a device stat that ran and found the node. The query runs the
same binary the detection probed. Hosts with driver and device (bare
metal with the driver, the compose dev container through the GPU
override) and hosts without a device (CI) behave as before.
test_gpu_detection walks the real tasks over the four driver/device
cells and fails on the old 00_core.yml in exactly the device-without-
driver cell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in feature/updater 8c14873225: lockdown-probe with the Bluesky
broker key-rotation fix (0a6b6f9130) and the occ-only trusted_proxies
(406998f43a), the ERPNext server_scripts switch, and main with the
hotfixes #1290 (BBB apps-akka READ_COMMITTED), #1291 (FusionDirectory
PHP_MEMORY_LIMIT) and #1293 (Matrix variant 1 force_verification false).
Conflict resolutions:
- web-app-erpnext: create_site.yml takes updater's include of
restart_frappe.yml; that file's compose/swarm gates use
IS_COMPOSE_MODE / IS_SWARM_MODE instead of DEPLOYMENT_MODE literals.
- web-app-mailu, web-app-stalwart vars: store-i18n's RUF from
users.platform_administrator plus the new RUA vars; dmarc.rua_user is
platform_administrator (on store-i18n `administrator` is the host
account), so RUA and RUF share the app mailbox. READMEs follow.
- web-app-stalwart 09_dns-records.yml: store-i18n's block form with the
alias-domain MX task, plus rua=mailto in the DMARC record.
- web-app-pretix: store-i18n's source build stays; the version bump
lands as source_version v2026.9.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_no_uncached_file_reads flagged four direct reads in test_server_scripts.py. The script comes from the project tree and is now read through utils.cache.files.read_text. The three reads of common_site_config.json stay direct and carry `# nocheck: cache-read tempdir fixture`: the file lives in a temporary directory and the script under test rewrites it within the same test, so a cached read would return stale content. Each read sits on its own line so ruff format keeps the marker beside it. Quality-high pass 16 ran green with this change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CI run 40979252521 SuiteCRM#0 (swarm) picked the onion sibling and every
signed-in design spec failed on "the administrator owns a SuiteCRM
session" (100 times in the job log). The SAML ACS is
%env(SITE_URL)%/saml/acs and SITE_URL is the single canonical URL, so the
trace shows Keycloak posting the assertion to
https://suite.crm.main.infinito.test/saml/acs; the session landed on
clearnet and the onion host bounced back to /saml/login. Keycloak would
also have refused an onion ACS, because the SAML client listed only the
clearnet one.
- SuiteCRM: an auto_prepend_file (site_url.php, config-mounted) sets
$_ENV['SITE_URL'] to the network sibling the request arrived on,
matched only against the sibling URLs rendered at deploy time;
Kernel::setSiteURLEnv keeps an existing value. The canonical host and
CLI runs keep the installer's site_url. The mount target is read from
the volume registry.
- Keycloak: the SAML client's redirectUris come from
lookup('network_urls', <app>/saml/acs). Additive: clearnet stays first
and a single-network node renders the same one-entry list.
Executing the rendered site_url.php in PHP sets the onion URL for the
onion host (with and without :80) and leaves SITE_URL unset for the
clearnet host, a forged host and the CLI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI run 40979252521 EspoCRM#0 (swarm) picked the onion sibling and failed
the OIDC addon spec plus every signed-in design spec: EspoCRM builds its
redirect_uri as siteUrl + /oauth-callback.php from the single canonical
URL, so the onion login popup returned to
https://espo.crm.main.infinito.test/oauth-callback.php (12 times in the
job log), oidc.js could not read the code across origins and the onion
host never got a session.
A data/config-override.php, which Config::load reads on every request,
now answers siteUrl with the network sibling the request arrived on,
matched against the sibling URLs rendered at deploy time and nothing
else. The canonical host and CLI runs see no override. The manager ops
write it through container exec with a compare-and-rename that only
reports a change when the content moved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI run 40979252521 Weblate#1 (swarm) never converged in its first pass:
every weblate, worker and beat task died on collectstatic with
"PermissionError: [Errno 13] Permission denied:
'/app/cache/static/weblate_fonts'". The per-container tmpfs from
a0bcd59150 sits on the weblate_cache volume, whose static mountpoint is
root-owned 0755 (nfs-volume-contents.txt: drwxr-xr-x 0:0), and runc gives
a tmpfs on an existing mountpoint that directory's mode, while Weblate
runs as uid 1000.
The three static tmpfs mounts now declare mode 1777, rendered as
tmpfs.mode 1023; test_static_root_per_container.py asserts it per
service.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI run 40979252521 Matrix#3 (ansible flavor) failed the MDAD bootstrap
in the pass after the credential rotation at "Ensure Synapse user
registered - baibot". store-i18n runs the playbook with
setup-all,ensure-matrix-users-created,start, so the user creator runs
before start. After the reconfigure restart systemd had already booted
matrix-synapse on the previous pass's homeserver.yaml, which failed
against the rotated database password ("password authentication failed
for user matrix" in the nested journal) and crash-looped. The user
creator's `state: started` returned ok for the still-activating unit,
skipped its pause, and the registration found no container.
bootstrap.sh now stops an installed matrix-synapse.service right before
the playbook, so the user creator starts it on the configuration the
playbook has just rendered and waits for it. A first install has no unit
yet and is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
applications.web-app-erpnext.services.erpnext.server_scripts (bool, default false) now sets server_script_enabled on every deploy, so an inventory can turn ERPNext Server Scripts on and off again. The security check app needs them, and the validator aborted on its inventory with "Missing default for web-app-erpnext: services.erpnext.server_scripts" while core declared no such key.
Frappe v16 reads the key only from common_site_config.json, through a cached lookup (frappe/utils/safe_exec.py:86-88), and tests it with bool(). files/python/apply/server_scripts.py therefore writes the global file, never the site config, and stores the integer 1 or 0: a plain `bench set-config -g server_script_enabled 0` stores the string "0", which bool() reads as enabled. A value already stored as a string is normalised. The script reports changed only when the value differs, and only then are the Frappe processes restarted, so they drop the cached config.
The restart, the backend re-resolve and the gunicorn wait move from create_site.yml into 02_bench_bootstrap/restart_frappe.yml, unchanged apart from the task names, so the new site and the switch share one restart path for compose and swarm. Six unit tests cover enabling, disabling a string value, the string "0", an aligned value left untouched, the capitalised bool Ansible renders and a rejected value; storing strings instead fails three of them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CI run 40979252521 homeassistant#0 (swarm) failed in the pass after the
credential rotation: the owner realign rewrote the auth store and flushed
the recreate handler, which in swarm runs `docker service update
--force` and starts a new task container. The following owner check
still exec'd into the container id resolved before the flush and hit
"container eb0523a644de... is not running" 30 times, while the rescue
dump lists eb0523a644de exited and bbec666459ae up and healthy.
The resolve_host_cid include moves into tasks/utils/resolve.yml; 00_core
uses it as before and owner/realign.yml runs it again right after the
flush, so the check and the MCP tasks after it talk to the new
container. Compose keeps its stable container name, so nothing changes
there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings 20 commits from main: the DMARC aggregate report address (rua) for Mailu and Stalwart with its hardened checks, the Stalwart production facts and version pin update, the Pretix and Shopware image updates, and ruff 0.17.3. The merge was clean; the auto-merged YAML files carry no duplicate keys and the merged Python files are ruff-format clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The workspace swarm roundtrip for svc-db-postgres failed on every host
with "The lookup plugin 'tls' failed: NETWORK_MODE 'multi' needs
svc-net-tor with a node onion on this node" (CI run 40979252521, job
Workspace debian:13). roundtrip.sh (b2469ff948) pinned the swarm step to
multi whenever the validator accepts it and never looked at disable=,
while 03_roundtrip.sh passes a disable list that removes tor. The
hard-fail is the documented contract (network_modes.md: tor or multi on
a node without the provider fails the deploy); the request was wrong.
roundtrip.sh now pins clearnet when disable= holds the Tor key (read
from utils.github.variant.network.TOR_SERVICE), else multi when the role
takes it, else the rotation. 02_zombie.sh, which also disables tor,
passes network=clearnet instead of relying on the rotation drawing it.
TestRoundtripSwarmNetwork runs the real script against stubs; the old
pin logic fails three of its cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI run 40979252521 Taiga#0 picked the onion sibling and failed five specs
(test-login-oidc, test-themed-routes, network-leak, design:242/263).
- Login: TAIGA_COOKIE_SECURE renders True once clearnet has TLS, so
taiga-back marks sessionid and csrftoken Secure. Chromium drops them on
the plaintext onion, the /oidc/callback/ arrives without a session and
mozilla-django-oidc falls into login_failure() (gateway log: the 302 to
/login?type=oidc&error=None six times, no sessionid on the callback in
the trace). The plaintext sibling vhost now strips Secure from both
cookies via proxy_cookie_flags, wired as webserver_location_extra_
configuration like Semaphore; TAIGA_COOKIE_SECURE is pinned to the
canonical domain so the per-vhost tls lookup cannot flip it.
- Logo: the design sheet embedded absolute clearnet CDN URLs, rendered
once and served to both siblings. It now uses design.relative_urls,
served same-origin under /_infinito/cdn/; the spec resolves the logo
URLs against page.url().
A local compose deploy that picked the onion passed all five specs (19
passed); the onion vhost carries both proxy_cookie_flags lines, the
clearnet vhost none. test_onion_sibling.py renders the real vars through
Ansible's Templar; reverting TAIGA_COOKIE_SECURE fails two of its cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A custom role with a local source (`source.url: /opt/src/custom`) is copied with shutil.copytree, which skipped only .git and __pycache__. A dev checkout that Claude Code has worked in carries an empty `.claude/.cc-writes` (mode 0700, ignored by the global gitignore), unreadable for the runner uid, so `infinito administration deploy dedicated` aborted before the play with "Custom roles refused: ... [Errno 13] Permission denied". A git source never carries that directory, because it is staged through `git archive`.
The copy now skips `.claude` as well. The ignore list has one consumer, the local branch of stage(); git sources are unchanged. A new test creates `.claude/.cc-writes` at mode 000: before the fix it fails in shutil with the reported Permission denied, after it all 102 custom-role tests pass. Reported by the GUI lane on efd72474a6.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Fresh compose deploy (compose-deploy-req061-4.log) spent 764.9 s in the plugin phase. Two parts of it did no work:
Enable stage after a fresh install: occ app:install runs enableApp and prints "<app> enabled" (core/Command/App/Install.php, stable34), so the app:enable that 03_enable.yml ran next only answered "<app> already enabled". The include, the exec, the classify, the skip notice and the queue cost 90.9 s for the 26 plugins installed fresh. nextcloud_install_status now reports enabled when occ exited 0 and printed an "<app> enabled" line; 02_install.yml then queues the plugin for the configure stage directly and runs 03_enable.yml only for an install that left the plugin off ("already installed" from a pinned archive or the recognize background job, or a future --keep-disabled). Queue order is unchanged.
Configure stage: 49 pending config:app:set calls ran as 49 occ processes in one sh -s and took 62.8 s, about 1.28 s of occ boot each. files/php/utils/occ_batch.php boots the console the way Nextcloud's console.php does (lib/base.php, OC\Console\Application::loadCommands, setAutoExit(false)) and runs Application::run once per call, so every call goes through the same command object, app loading, ConsoleEvent and exit code as occ, with one boot. The filter nextcloud_occ_config_set_batch builds the argv per entry; a unit test proves it equals shlex.split of the former shell line for typed, quoted, multi-line, empty and non-ASCII values. The calls travel as base64 JSON assigned in front of the script on stdin, so no value reaches a host or container argv; the first failing call stops the run and names appid/configkey on stderr, the task still reports changed on "set to".
Expected saving per fresh deploy: about 74 s (enable chain, net of the skipped include and the new set_fact, 0.64 s per plugin) plus about 60 s (configure exec down to one boot), about 134 s of 764.9 s. A converged mode=update deploy installs nothing and writes about 3 values, so it saves about 2.5 s; only a live deploy can confirm both numbers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The test from feature/updater renders the real daemon.json.j2, which on
store-i18n also reads IS_SWARM_MODE, the NVIDIA runtime shim and the
daemon MTU. Its context lacked them, so both cases died with
"'IS_SWARM_MODE' is undefined". The context now carries them and derives
the mode booleans from DEPLOYMENT_MODE like test_sandbox_runtime does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
make autoformat output for three lines the feature/updater merge brought
in over 88 characters; matches feature/updater-lockdown-probe 88d138ba1c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
make autoformat rewraps the firewall-backend entry in test_userland_proxy.py, the _direct_media call in test_talk_direct_media.py and the _git listing context in test_files.py, which ran past 88 characters. The text is ruff's own output, the same store-i18n commits, so the next merge between the lines resolves cleanly.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in feature/updater accb22da75, which carries main 5995766f58.
Conflict resolutions:
- web-app-fider: main's tenant bootstrap (01_tenant.yml) runs first on
the stack host, OIDC setup moves to 02_setup_oidc.yml, design to
03_design.yml; flat includes so main's gate test reads them.
- sys-front-tls test_nginx_ssl_header: the tls fake maps the domain term
store-i18n passes back to its app.
- group_vars 04_webserver.yml, sys-svc-proxy CSP header: store-i18n's
webserver_resource lookup plus #1256's WEBSERVER_SECURITY_HEADERS and
the security_headers.lua include after the CSP block.
- web-app-nextcloud: updater's decoupled Talk stack (Janus, signaling,
NATS, recording builds) replaces aio-talk; store-i18n's per-network
Talk backends move from the wrapper script into signaling.conf.j2 urls
(NEXTCLOUD_HPB_SIGNALING_BACKEND_URLS from network_urls, canonical
first); IS_COMPOSE_MODE/IS_SWARM_MODE booleans kept; the integration
OpenProject digest stays delegated to STACK_HOST.
- users lookup docs, token-store write_many, cache README, meta info
lint: both sides combined.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a clearnet deploy with self-signed TLS, test-talk-recording.js failed three times: the call recording stayed at state 4 (starting audio) because the recording server could not notify Nextcloud. Its log showed CERTIFICATE_VERIFY_FAILED on POST /ocs/v2.php/apps/spreed/api/v1/recording/backend, although compose-ca sets REQUESTS_CA_BUNDLE to the mounted bundle. nextcloud-talk-recording's BackendNotifier sends through `session.send(prepared, verify=True)`, and Session.send() skips the environment merge that only Session.request() does, so requests falls back to certifi's bundle, which does not know the injected root CA. A probe in the recording container's network namespace verified Nextcloud with the mounted bundle (200) and failed without it.
When the CA is injected, the recording image now points certifi's cacert.pem at the mounted CA bundle (the system CAs plus the injected root), in the same build step as the Firefox certificate policy. Verification stays on. Without an injected CA, as with real certificates, the build arg is empty and the image is unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflict in roles/web-app-gitea/tasks/01_manager_ops.yml: main (c59e2838d6, c2577560f8) moved the readiness probes inside the Gitea container on its hosting node, because a host port lands on the swarm ingress mesh; this line (381c3cd5bc) had reduced them to one wait for /api/v1/version = 200 and dropped the explicit `gitea migrate`, which raced Gitea's own migration on galaxyserver. The resolution keeps main's in-container probe as that single wait with `curl -fS` (200 required), after the resolve and with this line's 600 s budget scaled by lookup('timeout'), and drops main's app.ini wait, its migrate task and its post-admin readiness wait.
main's new tests/unit/python/roles/sys-front-tls/templates/test_nginx_ssl_header.py renders nginx_ssl_header.j2 without `domain`, which ssl_credentials.j2 needs on this line since 47160d755e; its render() now passes the vhost's domain.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
00_core.yml included 01_setup_oidc.yml only when services.sso.enabled
was true, but that file carried the whole tenant bootstrap: wait for
HTTP, count tenants, POST /_api/tenants, create the admin user and
activate the tenant. Only its last task configured the Keycloak
provider. With SSO off no tenant was ever created.
Fider's RequireTenant middleware then answers every tenant route,
/api/v1/tags included, with 307 to /signup, and /signup is open to any
visitor, who can claim the fresh installation and become its
administrator. The MCP read fider_list_tags got that 307 in the swarm
SSO-off matrix row.
The tenant bootstrap moves unchanged into tasks/01_tenant.yml, which
00_core.yml includes on the stack host regardless of SSO. The OIDC file
becomes tasks/02_setup_oidc.yml and keeps only the provider upsert,
still gated on services.sso.enabled. The README states which bootstrap
steps run without SSO.
A unit test evaluates the include and task guards for every SSO,
stack-host and deploy-mode state: a fresh stack host posts exactly one
tenant with and without SSO, an existing tenant is never posted again,
the Keycloak provider is written only with SSO, and a non-stack host
runs none of it. The compose matrix row with SSO off stayed green
because nothing there read a tenant route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nextcloud/aio-talk bundled signaling, Janus, NATS and an onboard TURN server behind a start script that rewrote their configs, and AIO publishes only build stamps, so the updaters could not raise it and the role had to guard Janus against config drift. The backend now runs as talk (Janus built from a meetecho/janus-gateway tag, files/Dockerfiles/Janus), talk_signaling (upstream strukturag/nextcloud-spreed-signaling image), talk_nats (official nats image) and talk_recording (built from a nextcloud-talk-recording release tag, files/Dockerfiles/Recording), each semver-pinned, single-replica, healthchecked and resource-limited. The Dockerfiles take every value as a build arg from compose, and Janus and the recording server get their config path through `command:`.
The role renders janus.jcfg, the signaling server.conf and the recording server.conf; they match what AIO stamp 20260409_094910 generated apart from the service addresses and the session keys, which are now derived from the signaling secret. Secrets reach the services only through those files. Janus may join the host network in compose mode, which keeps direct media available there, and an assert refuses host mode in swarm. talk and talk_recording keep their keys, so one deploy replaces the AIO containers in place.
The onboard TURN server is gone with its ports, vars and credential, so Talk uses web-svc-coturn only, and the deploy now stops when Talk is enabled without it instead of handing out no STUN or TURN server. The recording image installs the injected root CA as a Firefox policy when the CA is mounted, so recordings work against self-signed TLS, and the recording scratch volume stays off NFS. The Janus drift guard and the janus_config_drift filter are removed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
services.talk.direct_media was an expression in meta/services.yml that read ansible_facts['all_ipv4_addresses'] and nested config lookups. lookup('config') renders it where the applications are merged without host facts; the render fails there, the lookup hands back the raw expression and `| bool` coerces it to False with a deprecation warning. Direct media therefore never switched on: both the first requirement-061 deploy and CI run 40877408319 logged the coercion, and a render in the development container returned the raw string without facts and true with them.
meta/services.yml keeps only the inventory switch, now `direct_media: false`, so every Talk stream goes through web-svc-coturn unless an operator opts a host in. That is the behaviour the bug produced everywhere, so no host changes on deploy, and it keeps Janus off the public network by default. vars/main.yml decides in task context from the switch, Talk, standalone TURN, the bridged network, compose mode and NEXTCLOUD_HPB_HOST_ADDRESSES, which reads the facts the way web-svc-coturn does. The flag tests render that chain against the merged applications: off by default, on for an opted-in compose host with coturn and an owned address, off for host networking, a foreign or loopback address, swarm, onboard TURN and missing facts. Administration.md documents the opt-in.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the userland proxy on, Docker starts one docker-proxy process for every published port. A published range pays that per port: Janus's direct-media range showed 74 proxies for 64 UDP ports plus the other published ports in the development stack, at about 1.7 MB unique memory (PSS) each, which projects to roughly 1.7 GB for the default 1000-port range and the same again for OpenTalk's.
daemon.json now sets "userland-proxy": false, so iptables/nftables forwards every published port, which the CIS Docker Benchmark (2.16) also asks for. Traffic from outside already took that path, so client addresses are unchanged. Host-local traffic to ports published on 127.0.0.1 now runs through route_localnet and the loopback DNAT Docker installs. From Docker 28.0 on, Docker keeps hosts on the same L2 segment off those loopback ports; every install path of this role ships 29.x.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
repository_files() listed every path `git ls-files` returns, including tracked files the worktree has already deleted. Every content scan that reads the listing then reported "[Errno 2] No such file or directory" as a finding: in a worktree that removed roles/web-app-nextcloud/filter_plugins/nextcloud_janus.py and its test but had not committed the removal yet, test_no_rsync_backup_flag failed on exactly those two paths and turned the whole lint target red. No scan can read such a file until the deletion is committed.
The listing now subtracts `git ls-files --deleted`. A clean checkout, as in CI, has no deleted entries, so the listing there is unchanged. The rsync lint is green again on the same worktree. The unit tests stub the extra git call and cover a file deleted in the worktree.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Validation step "Assert the base branch is fully merged into the PR
head" ran main_merged.sh, which fetches the live tip of main when the job
starts. PR runs wait in the queue for hours while image-update PRs land
on main several times an hour, so nearly every queued run failed
Validation on start and had to be re-pushed after another main merge
(PR #1265: behind_by=0 at push, behind_by=9 while its run still queued).
main_merged.sh takes a new INFINITO_MAIN_COMMIT override: when set, it
fetches that full commit SHA from the remote and requires HEAD to
contain it instead of the live tip; a set but malformed or empty value
exits 2. The Validation step passes github.event.pull_request.base.sha,
the main tip at event time, which the step's pull_request-only guard
always provides. The run tests the merge against that base, so the gate
still guarantees the tested tree contains it.
The local pre-push hook (make test-main-merged) leaves the override
unset and keeps checking the live tip: a push happens right now, so
lagging the current main is the real defect there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
services.dashboard.enabled already says whether a role takes part in the dashboard: roles_with_service lists only roles with enabled and shared as tile-reachability targets, the CSP filter adds the dashboard to frame-ancestors only when it is set, and docker_cards uses it as the card's iframe flag. A separate display key duplicated that switch and left a role with enabled false still rendering a card. docker_cards now skips the card when services.dashboard.enabled is false (a role without a dashboard binding keeps its card, and a value that is not a boolean fails loudly), so display, its validator exception and its roles_with_service filter go again; base.md documents enabled as the per-host switch.
An inventory hides a tile with applications.<role>.services.dashboard.enabled: false, which also drops the dashboard framing of that role. Unit tests cover false, true, a missing binding, a rendered string and a non-boolean; forcing the helper to always show the card fails four of them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The WebUI OAuth client Stalwart serves itself registered `<origin>/<prefix>/oauth/callback` only for the canonical WebUI URL. A browser that opens the WebUI on the onion sibling sends `redirect_uri=http://mail.<onion>/account/oauth/callback`, which Stalwart rejects with "The redirect_uri parameter is missing or does not match the client registration". CI run 40877408319 failed test-login-native.js:36 that way in Meta#0, Nextcloud#0, Shopware#0, Bluesky#0 and Moodle#0, every row whose Playwright pass picked the onion sibling.
The client now takes its origins from `lookup('network_urls', STALWART_WEBUI_BASE_URL)` and registers the callback and post-logout URIs for every origin and URL prefix. A host without a sibling still yields the single canonical origin, so clearnet-only deployments register the same client as before. The Align task updates an already registered client once.
The README states the per-network origins and that Stalwart asks for consent after the password form, as the local WebUI v2.0.2 run showed.
Verified with the unit test (20 passed; registering only the first origin fails the new two-sibling test) and a local update deploy whose Playwright pass picked the onion sibling: test-login-native.js:35 passed, recap failed=0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dashboard read `display` from the role's meta/info.yml, which no inventory can override, so a host could not hide a tile. docker_cards now skips a card when `services.dashboard.display` in the merged applications is false, and absent still means shown; a non-bool value fails loudly. roles_with_service drops the same role from the dashboard tile-reachability targets so the Playwright spec does not wait for a hidden tile, and the inventory validator accepts `services.dashboard.display` for every role that declares `services.dashboard`, since no role default carries it. `display` leaves the meta/info.yml allowlist, web-app-jellyfin drops its redundant `display: true`, and base.md and layout.md document the new switch.
Real lookups with web-app-gitea set to display false render the Dashboard and Jellyfin cards and only jellyfin as a tile target, and all three without the override; the nine tests for the new behaviour fail against the old code.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pinned onlyoffice archive was unpacked with tar over custom_apps/onlyoffice on every deploy, so files that a newer release no longer ships stayed behind. On cloud.veen.world that left the 52 files of onlyoffice 9.15.1 (NC32 line) inside the 10.2.1 tree, and the integrity check reported them as EXTRA_FILE; the 52 equal exactly the 9.15.1 files absent from 10.2.1. replace_pinned_app.sh now unpacks the release beside the app, keeps the app when diff -rq finds it identical, and otherwise swaps the whole directory, so the next deploy heals the drifted tree and later deploys report ok. A broken archive fails before the live app is touched.
The real install task, run through ansible-playbook against a reproduced 9.15.1 plus 10.2.1 tree, changed it once to a tree identical to a clean 10.2.1 and stayed unchanged on the rerun; eleven unit cases cover identical, drifted, missing and broken inputs under sh and dash.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The admin overview reported that no server identifier is configured. Nextcloud 34 accepts 1 to 511, treats values below 1 as unset and asks for one NC_serverid per server when config.php is shared, which it is between nextcloud, cron, the task processing worker and the swarm replicas. A single serverid in config.php would let them mint identical Snowflake IDs in the same millisecond. The nextcloud service now carries NC_serverid 1 in compose and its task slot in swarm, so the check passes and replicas never share an id; the other services keep the hostname fallback.
A unit test renders the service environment in both modes and fails when the compose id leaves the accepted range.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nextcloud 34 flags "Task Processing worker status: never run" because only occ taskprocessing:worker records core/ai.taskprocessing_worker_last_iteration, and the role ran background jobs through the cron container alone; since 32.0.7 the worker command is taskprocessing:worker, so background-job:worker would never clear the check. Add a taskprocessing service that reuses the cron image, mounts, healthcheck, restart policy and networks and runs occ taskprocessing:worker -t 60 in an in-container restart loop that backs off while Nextcloud is not yet installed, so swarm neither replaces the task every minute nor marks the stack fatal on a fresh install. It scales with the swarm topology, since task claims use FOR UPDATE SKIP LOCKED.
Rendered compose and swarm stacks only gain the taskprocessing service and validate against the compose schema; the new unit tests fail when the worker mount is dropped or background-job:worker is used instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
trusted_proxies held the host's public addresses, but OpenResty reaches Nextcloud through the published port, which Docker forwards from the gateway of one of the container's networks (192.168.201.1, the central MariaDB network, on production). Nextcloud therefore ignored X-Forwarded-For, attributed every request to that gateway and brute-force throttled it. The TRUSTED_PROXIES env var was joined with commas while the image's reverse-proxy.config.php splits it on spaces, which produced the single malformed entry the admin overview rejects, and the occ value reached config:system:set as a Python repr instead of JSON, so config.php got null.
Add the proxy_hops lookup next to resolve_upstream in utils/networks/proxy.py: under compose it returns the gateway of every network the application joins, under swarm the subnets of the overlays it shares with svc-prx-openresty, both derived from the service registry and role_subnets. NEXTCLOUD_TRUSTED_PROXIES now uses it, env.j2 joins the list with spaces and system.yml passes it through to_json.
Rendered with the real roles and group_vars for the galaxyserver layout: compose trusts 192.168.41.1, 192.168.201.1 and 192.168.202.1, swarm 192.168.107.0/24; unit and integration tests cover both modes, and two mutations of proxy_hops fail them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A token store write re-rendered the whole applications SPOT: merged_applications_cache_key carried the token store digest, so the applications_carrier park in sys-token-store/tasks/write_many.yml missed its key and rendered all 269 applications again (stalwart compose roundtrip 18.3 s and 10.2 s per pass, swarm pass 1 12.2 s and 12.6 s).
Rendered from the real roles tree under an empty, a realistic and an exhaustive token store (298 users x 269 applications), the applications tree differs only under <app>.users.<key>.tokens (28783 leaves in 46 applications, 0 elsewhere); the 69 password leaves that also differ change between two renders of the same store. No code reads a token through the applications tree: every reader goes through lookup('users'), lookup('config', <app>, 'users.<key>...') (served from get_merged_users), lookup('email') or the sys_token_store_users fact, and the users carrier keys on the store.
The token digest therefore moves from merged_applications_cache_key into merged_users_cache_key, whose value stays the same. A store write keeps the applications carrier and the write_many park re-renders the users alone; the task is renamed to match. applications.md now says that user records inside the payload can predate every store write and that application config must not derive a value from a token.
Real ansible-playbook harness on 2.20.6 with the real roles, group_vars and write_many.yml: the re-park after a first mint and after a rotation drops from 4.0 s to 0.55 s on one host (applications renders 1 -> 0), and every token probe (users, config users path, email password, stalwart_users) equals the old code in compose and swarm mode. Dropping the digest from the users key as well leaves those probes stale, which the harness and two unit tests catch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ansible forks a fresh worker per task, so every lookup('users') in a forked worker re-rendered the whole user catalogue (298 users, 0.3 to 0.5 s locally and about 1 s on the runner, 1 s locally once a users password is vaulted): _MERGED_USERS_CACHE lives in the process and had no carrier fact.
applications_carrier now also parks lookup('users', carrier=True) in _INFINITO_USERS_RENDERED at the same park points, and clear drops both facts. get_merged_users serves the fact to templated reads when its key matches: the applications carrier key (roles dir, applications, users, DOMAIN_PRIMARY, SYSTEM_EMAIL_DOMAIN, group_names, token store digest) plus inventory_hostname, so a carrier a run_once park broadcasts never answers another host with its become password. A park whose users carrier still matches leaves it in place instead of returning 119 KB again, so parks cost what they cost before.
Real ansible-playbook harness on 2.20.6 and 2.21.5 with the real roles tree: users renders per users-reading task after a park 1 -> 0, task 0.56 -> 0.08 s (vaulted 1.21 -> 0.17 s); a rotated token, a rotated inventory password, a cleared carrier and another host's carrier all re-render; 32 of 32 probe digests equal HEAD's. Removing the carrier check fails three unit tests. The stalwart swarm roundtrip pass 1 and its DR drill ran green with it on all nodes, with no 401 from JMAP, msmtp or LDAP.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Dockerfile change notifies compose-up and compose-build in one flush. The compose pull handler (pull.py) builds every buildable service with `compose build --pull`, and the Build compose handler right after it built again with plain `compose build`. BuildKit keys a --pull build and a plain build differently, so the second build missed the first build's cache and ran every RUN layer again; in the stalwart compose roundtrip that was 233 s for tor, 129 s for openldap and 62 s for simpleicons, about 420 s per fresh compose deploy. It also overwrote pull.py's image, built on the freshly resolved base, with one from the plain-build cache: in pass 2 the --pull build of openldap ran its RUN layer again (34.5 s) while the plain build right after it was fully CACHED.
Both build handlers now run `compose build --pull` and fall back to a plain `compose build` when that fails, as pull.py does, so the second build of a flush is a cache hit and a lone-service rebuild keys like the flush builds. Without a reachable registry the fallback builds from the local base exactly as before.
A scratch compose project in the stack's daemon showed the cache behaviour: --pull after --pull CACHED, plain after --pull ran the RUN layer again. The new handler test fails on the old plain `compose build`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
test_sso.py, test_sso_oidc_plugin.py and test_sso_proxy_consumers.py evict utils.cache.applications, utils.cache and the roles packages from sys.modules to import their plugin against fresh modules, and never put the originals back. A later test module in the same worker then held two copies of utils.cache.applications: plugins.lookup.applications, imported earlier, kept calling the old one, while test_email patched and reset the new one, so its carrier tests counted 0 renders and failed depending only on which modules xdist placed in one worker.
_load_module now runs inside mock.patch.dict(sys.modules): the plugin keeps the fresh modules it was built against, and sys.modules returns to its prior state afterwards. `pytest test_sso.py test_email.py` failed test_worker_reads_the_provider_view_from_the_provider_carrier and test_worker_renders_when_the_provider_carrier_key_differs before; the three SSO modules followed by test_email.py now pass 66/66. Same patch-id as feature/store-i18n ec0fde6561.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>