Commit Graph
597 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5.5 8135cd29ee fix(sys-svc-compose): generate the CA override after compose build
Handlers run in definition order, and "Generate CA trust override" was
defined before "Build compose". On a first deploy the override read the
Entrypoint and Cmd of every service image with docker image inspect
before compose build had created the locally built tags, found none,
and left those services without the with-ca-trust wrapper; on later
deploys it read the previous build's Entrypoint and Cmd instead of the
new one. The override and the compose config validation now run after
the build and rebuild handlers and before compose up; pull stays first.

The swarm handlers already build before they generate their override
and use no wrapper, so they are unchanged.

TestFirstDeployFlush replays the shell handlers of one compose-build
plus compose-up flush in the order handlers/compose.yml defines them
and asserts that compose up starts the freshly built image behind the
wrapper.

Local deploy: all six stacks with a built image carry the wrapper in
compose.ca.override.yml, nextcloud for 8 of 8 services.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 12:28:40 +01:00
kevinveenbirkenbachandClaude Opus 5.5 caabc61841 perf(web-app-nextcloud): install recognize beside the plugin install loop
recognize's app:install downloads about 700 MB of models and held the
plugin phase for minutes. An addon may now set
config.background_install; the plan files such a plugin under the new
background list unless it is pinned through archive or mandatory.

01_background.yml starts occ app:install as an async job and holds
every later occ call at a gate until the app's schema is applied:
Nextcloud migrations diff a schema snapshot, so DDL running beside
another occ call can be reverted. The gate reads the async job file
and files/php/utils/migration_state.php, which prints the versions
oc_migrations records and the versions the app's migration folder
holds. occ migrations:status is not usable, Nextcloud registers it only
with debug on. While the app directory does not exist yet the
MigrationService constructor throws and lib/base.php's ErrorHandler
only logs it, so the probe exits 0 with nothing on stdout and the gate
waits.

The install loop then runs while the job finishes; the always section
joins every job with async_status and fails on an exit code that a
timeout or signal produced, because the php process may then still run
inside the container. 02_install.yml afterwards answers "already
installed" and enables and configures the plugin, or installs it with
its own retries when the job failed before placing the app.

The stage files are renumbered (01_install -> 02_install, 02_enable ->
03_enable, 03_configure -> 04_configure) so the numbers follow the
include order the task-numbering lint checks.

Local perf run: plugin phase 538 s -> 282 s, gate open after 30.2 s,
recognize 12.0.2 enabled with 8/8 migrations, failed=0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 09:52:10 +01:00
kevinveenbirkenbachandClaude Opus 5.5 e2a49f89b5 test: settle the CodeQL notes on the unit test imports
CodeQL flagged five notes on PR #1215: four test modules imported unittest both as a module and through from-import, and sys-front-inj-matomo's __init__ exported PROJECT_ROOT without declaring it, so the import from test_site_cache.py read as unused. The modules now bind mock through import unittest.mock as mock, and the package declares __all__, the same fixes main carries from 3610e7b9ef and 6e776a5780. The same pattern is fixed in the new Talk direct-media test and the decommission integration package, and the coturn test package drops an __init__ export nothing imports.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 03:29:23 +01:00
kevinveenbirkenbachandClaude Opus 5.5 6e7956798d fix(web-app-nextcloud): drop the swarm-only mode from the Janus config mount
janus_conf is mounted only when direct media is on, which requires compose mode, and compose ignores a config mode outside swarm and warns about it on every deploy. The file keeps the 0644 it is rendered with on the host.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 03:25:21 +01:00
kevinveenbirkenbachandClaude Opus 5.5 9165bde600 feat(sys-svc-compose): decommission the compose stacks of roles no longer active
Removing a role from the inventory left its compose stack running under /opt/compose, holding ports, memory and a stale instance directory, until someone stopped it by hand.

On a full compose deploy with MODE_CLEANUP (no --id), the destructor now takes every directory below DIR_COMPOSITIONS that is named after a known role, carries the full instance layout and is owned by no active role (inventory-wide groups, group_names, the dependency closure and the instances this play prepared), stops it with compose down --remove-orphans without -v, verifies through the project and working_dir labels that no container is left, and only then deletes the directory; named volumes stay. Hand-made, partial or backup directories and instances a non-compose container is named after are left in place and listed. The step is the last block of the destructor, with the timer unlock in its always section, so a failure cannot leave timers locked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 03:11:10 +01:00
kevinveenbirkenbachandClaude Opus 5.5 1352de5422 feat(web-app-nextcloud): let Talk media reach Janus directly instead of only through TURN
The aio-talk image runs Janus with its stock janus.jcfg: no rtp_port_range and no nat_1_1_mapping, so Janus offered only its bridge address and every stream of a call went through coturn as relay. Calls above about six participants stuttered and failed.

When talk runs bridged next to standalone coturn in compose mode on a host that owns networks.internet.ip4, a rendered janus.jcfg is mounted over the stock one with rtp_port_range 61000-61999, nat_1_1_mapping = ip4 and keep_private_host, and that UDP range is published on ip4. TURN stays the fallback. Before rendering, the deploy reads the image's launcher and stock janus.jcfg and stops when Janus no longer reads the mounted path or the stock settings drifted from the template. Variant 0 publishes 64 ports so dev and CI exercise the path; the call capacity spec asserts a non-relay selected pair on ip4 in that range. With the flag off every rendered file is byte-identical to before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 03:08:06 +01:00
kevinveenbirkenbachandClaude Opus 5.5 cac25a8b54 fix(web-svc-coturn): listen and relay only on the host's own public addresses
With host networking and no --relay-ip, coturn put every non-loopback IPv4 of the host into its relay list, Docker bridge gateways included, and handed IPv6 and bridge clients relays on those addresses while advertising the public ip4, so their media went nowhere. It also listened on every bridge address.

In compose mode with host networking, coturn now listens on networks.internet.ip4, 127.0.0.1 for the healthcheck and networks.internet.ip6 when set, and relays on ip4. It pins only when the host owns ip4, and owns ip6 too or leaves it unset; behind 1:1 NAT, without the ip binary or with a foreign ip6 it renders the previous command unchanged, because coturn exits when it cannot bind a listening address. A unit test renders the real template and vars for every cell.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 03:04:18 +01:00
kevinveenbirkenbach 03d2fb100a Merge remote-tracking branch 'origin/main' into feature/updater-lockdown-probe 2026-10-27 16:37:54 +01:00
kevinveenbirkenbachandClaude Opus 5.5 ec7d4c9042 fix(web-app-seaweedfs): grant consumers WriteAcp on their bucket
SeaweedFS 4.49 (seaweedfs/seaweedfs#11592) authorizes a PutObject that
carries x-amz-acl as s3:PutObjectAcl as well. The consumer identities
only held Read/Write/List/Tagging, so every app that sends an ACL on
upload (Nextcloud, Fider and Pixelfed with private, Mastodon, PeerTube,
BookWyrm and Baserow with public-read) got AccessDenied after the 4.48 -> 4.51
bump in #1226. Main run 40401200083 failed Fider#0, BookWyrm#0,
Baserow#0, Pixelfed#0 and Nextcloud#0/#4/#8 on it.

Add WriteAcp to the s3.json identities and to the sidecar s3.configure
grant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:28:11 +01:00
kevinveenbirkenbachandClaude Opus 5.5 0359d04c82 perf(sys-ctl-hlth-csp): probe four vhosts in parallel with csp-checker 3.2.0
csp-checker 3.2.0 checks every URL in its own browser context and takes --concurrency for a worker pool. The role pins 3.2.0, sets HEALTH_CSP_CONCURRENCY to 4, and the systemd unit hands it through script.py to the container command ahead of the -- URL separator, for the clearnet and the onion batch alike. TimeoutStartSec keeps its sequential sizing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 22:21:12 +01:00
kevinveenbirkenbachandClaude Opus 5.5 f3e9f3de17 fix(web-app-discourse): set become once through apply on the legacy-checkout include
test_include_uniform_apply_keyword flagged legacy_checkout.yml after 11c2d8dcd3 moved its guard onto the include: both tasks repeated become: true. The include in 03_launcher.yml now applies become to the file and keeps the guard; the order test reads include_tasks in its mapping form too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 22:11:54 +01:00
kevinveenbirkenbachandClaude Opus 5.5 2244cc691b perf(svc-bkp-volume-2-local): skip baudolo-seed when databases.csv already holds the consumer's row
Every database consumer called baudolo-seed through the seed task, 37 times and always 'changed' in a full production run (54.4 s), although databases.csv rarely changes. The first seed of a play now reads the file once (05_read-seeded-databases.yml) into svc_bkp_volume_2_local_seeded; baudolo_seed_current compares the consumer's row with it and baudolo_seed_apply advances the cached state the way baudolo-seed writes the file, so the command runs only for a missing or differing row. A file baudolo did not write byte for byte (hand-edited, CRLF, short rows) falls back to one call per consumer; 02_reset forgets the cache with the file.

Replay harness old vs new: databases.csv byte-identical in fresh, empty, converged, drift and reset-mid-play scenarios; converged seeds 37 -> 0, drift 38 -> 2; the negative control without reset invalidation diverges. About 44 s per unchanged redeploy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:35:14 +01:00
kevinveenbirkenbachandClaude Opus 5.5 333b09cdca perf(web-app-nextcloud): configure all queued plugins in one exec, writing only the values Nextcloud lacks
Each plugin ran its own occ config:app:set per configuration value right after its install or enable, and a redeploy rewrote every value. Install and enable now queue the plugin; the configure stage runs once for the queue: it loads every queued addon spec, reads occ config:list once, keeps only the values Nextcloud does not hold exactly (nextcloud_plugin_config_pending; typed, masked and structured values are always kept), and runs the hooks in queue order afterwards.

Combined with store-i18n's 7f72e53a58: the pending occ config:app:set calls go on the stdin of one container exec -i ... sh -s, so no value (Talk, OnlyOffice, whiteboard, BBB secrets) reaches a host argv; a failing call names its appid/configkey.

Replay harness old vs new: identical final state, updated keys and hook order in first-deploy, converged, drift and failure scenarios (failure stops at the same key); converged config:app:set calls 19 -> 3. Merge note: resolve 03_configure.yml with this side against 7f72e53a58.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:29:48 +01:00
kevinveenbirkenbachandClaude Opus 5.5 73a16bef09 perf(sys-svc-compose): prepare the compose instance in one module call
Every app's compose pass ran three remote tasks before compose up: create the directories, compose ps for running containers, and stat the pending marker (53 apps x ~2.7 s in a production run). compose_instance_prepare does all three in one call from 02_stack_host_prep.yml, sharing the directory creation with directory_batch through utils/directory_batch.py; compose ps runs only in compose mode. up.yml evaluates the registered result, named compose_instance_before_files because it predates 04_files: the running check is skipped whenever the compose, env or override file changed in this run (those changes notify compose-up themselves).

Replay harness old vs new: rendered files and the compose-up, marker and swarm handlers identical in every scenario; the only difference is the skipped running check when only the override changed, where compose-up still runs. About 85 s per full production run.

Merge note: store-i18n's a4c7fda974 hoists the removed marker stat into a block; resolve up.yml with this side.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:22:32 +01:00
kevinveenbirkenbachandClaude Opus 5.5 d45d1f07af fix(web-app-discourse): clone the launcher into services/discourse_docker and retire the URL-named checkout
DISCOURSE_REPOSITORY_DIR appended the repository URL to the services directory, so the launcher lived under services/https:/github.com/discourse/discourse_docker.git/. It now lives under services/discourse_docker. Before the clone, launcher/legacy_checkout.yml removes the URL-named tree (computed with relpath against the services directory) and prunes its emptied parents; a guard skips the cleanup when that path would escape the services directory or equal the new checkout. The cleanup must precede the clone: git otherwise resolves the old relative path as a URL and aborts with 'protocol ... is not supported'. Discourse data stays in the launcher's named volume and /var/discourse/shared; the first deploy rebuilds once because the bootstrap digest lived in the old containers/ directory.

relpath joins the builtin filters test_defined knows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:12:53 +01:00
kevinveenbirkenbachandClaude Opus 5.5 afb82f57a1 feat(web-app-dashboard): let a role name the canonical domain its card opens
The dashboard card of every app opened its primary domain, so Matrix led to the Synapse status page and Stalwart to its admin UI. A role can now declare services.dashboard.card: <key of meta/domains.yml canonical>; docker_cards builds the card URL with lookup('canonical_url', app, key), the Apps menu reuses it, and roles_with_service('dashboard') hands the same domain to the tile e2e spec. Matrix sets card: element, Stalwart card: webmail. Without card nothing changes, and the prometheus/matomo target lists are untouched. A lint requires the key to resolve in the role's own domains.canonical.

The Matrix ansible flavor also sets Synapse's web_client_location to Element through matrix_synapse_configuration_extension_yaml, as the compose flavor already does, so matrix.<domain>/ redirects there. Merge note: feature/iam-profile-rbac defines the same extension variable inside the OIDC block; the next merge must combine both into one block (agreed with that session).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:01:06 +01:00
kevinveenbirkenbachandClaude Opus 5.5 acad754cda fix(group_vars): evaluate the CAPTCHA flags only for a configured provider
A host that defines CAPTCHA with one provider replaces the group_vars mapping (no hash_behaviour merge), so galaxyserver, which configures only hCaptcha, left CAPTCHA.RECAPTCHA undefined. RECAPTCHA_ENABLED then failed to render, the applications render fallback returned the expression as text, and Keycloak's '| bool' coerced it to False with a deprecation warning that ansible-core 2.23 turns into an error. Both flags now check that their provider mapping is defined first; the effective values stay the same for every existing inventory.

test_captcha_enabled renders every role's services.recaptcha/hcaptcha.enabled through the real templar for defaults, both providers, only the other provider and only this provider, and fails on the raw text (negative control with the old expression: 4 subtests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 20:58:06 +01:00
kevinveenbirkenbachandClaude Opus 5.5 d6ebec8488 perf(web-app-nextcloud): run the post-upgrade pass only after a version change, take app updates from the daily update check otherwise
Every redeploy ran occ maintenance:repair --include-expensive (43.5 s, maintenance mode on) and listed the appstore updates through Tor (49.7 s) although the version had not changed. 03_upgrade.yml now reads occ status and files/mimetype_version after occ upgrade; nextcloud_needs_post_upgrade decides whether the pass is due (version changed in this deploy, or the expensive repair has not run for the running version). A due deploy includes 03_upgrade/01_post_upgrade.yml with today's app update and repair; an unchanged one includes 03_upgrade/02_cached_updates.yml, which updates only the unpinned enabled apps whose release stored by the updatenotification background job is newer (nextcloud_cached_app_updates, PHP version_compare semantics). NEXTCLOUD_PLUGINS_PINNED is the single source of the pinned check.

Verified against echoserver's live occ output (status, marker, updatenotification cache, app:list, with debug log lines): unchanged redeploy is not due and finds no update. Scenario harness 24/24 incl. negative controls on the old file. About 86 s per unchanged redeploy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 20:52:11 +01:00
kevinveenbirkenbachandClaude Opus 5.5 afaade9bbd perf(sys-stk-front-proxy): probe every queued vhost in one parallel batch per stage
Each vhost used to run its own sequential reachability probe. The role now queues the vhost and deferred_probe.yml checks the whole queue with one uri_probe_batch call after the shared-service preload and after the server roles. A vhost answering like a stale config restarts OpenResty once; one still unserved afterwards fails the deploy by name. uri_probe_batch takes follow_redirects explicitly so the probe sees the redirect status itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 18:16:04 +01:00
kevinveenbirkenbachandClaude Opus 5.5 35722aa3bc fix(lint): shorten the Matomo cache reset name, read the Keycloak vars path from the SPOT
5b7284c4a4 named its reset task with 81 characters, one over the limit of
test_name_emoji_and_length, and 754d35c4de built the path to
roles/web-app-keycloak/vars/main.yml with a pathlib chain that test_file_spot
requires to come from ROLE_FILE_VARS_MAIN. Both commits went in without the
lint suite. The task keeps its behaviour under a 70-character name, the test
reads the same file through the constant. make test-lint: 577 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 13:39:42 +01:00
kevinveenbirkenbachandClaude Opus 5.5 754d35c4de fix(web-app-keycloak): bring SAML clients to existing realms too
SAML clients were created only by the realm import, and the import runs only
for a missing realm. echoserver keeps its legacy realm cymais.cloud and ran
SuiteCRM behind oauth2-proxy before v16, so the SuiteCRM SAML client
https://<suitecrm>/saml/metadata never existed there: the SAML hop showed
Keycloak's Invalid Request page and Keycloak logged LOGIN_ERROR
error=client_not_found reason=Cannot_match_source_hash.

The existing-realm reconcile now also loops over the clientId of every SAML
client in the rendered realm dictionary, the same source the import uses,
and creates or updates it through kcadm like the application-declared OIDC
clients. The same path already converged the stalwart-webmail and
stalwart-webui clients in echoserver full-10.

Same change as 2b4db90576 on feature/store-i18n, so a later merge of the two
lines resolves without a conflict, plus a unit test that pins the SAML client
selection and its place in the reconcile loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 12:30:09 +01:00
kevinveenbirkenbachandClaude Opus 5.5 5b7284c4a4 perf(sys-front-inj-matomo): cache a failed site list load so later vhosts skip the dead API
On a host whose Matomo was not deployed yet, every vhost injected before
web-app-matomo asked SitesManager.getSitesWithAtLeastViewAccess again and
waited out uri_retry (30 retries x 2 s): 6 vhosts, 84 to 98 s each, 8.7
minutes in galaxyserver full-2. matomo_site_cache only kept a loaded list.

inject.yml now caches every executed load: the list on success, none on a
failed request or an error mapping. A cache of none skips the cached-id step
and getSitesIdFromSiteUrl, so the existing skip task turns the injection off
for the vhost as before, without the retry wait. web-app-matomo resets the
cache of every play host (delegate_facts, swarm workers included) after
07_config and before 08_reinject_consumers, so the re-injection and every
later vhost load the list from the now running Matomo.

Evidence: the real inject.yml with the real uri_retry against a closed port
and a stub Matomo, 11 vhosts in 4 scenarios: the first probe took 68.6 s, the
next two 0.4 and 0.3 s with matomo off; after the reset a known site came
from the cache, an unknown one went getSitesIdFromSiteUrl -> addSite and was
appended, a later vhost of that domain hit the cache. HEAD inject.yml took
68 s per vhost with the same outcome. The swarm reset reached a second host.
ansible-lint passes; tests/unit/python/roles/sys-front-inj-matomo pins the
gates and the reset order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 12:26:05 +01:00
kevinveenbirkenbachandClaude Opus 5.5 7b2dfcfac7 fix(tls): serve a redirect to another host through the redirect role
resolve_term follows a redirect source to its target so an alias inherits the TLS settings of the application it points at. A redirect to a domain another host serves ends that chain on a domain this host does not know, and the tls and cert lookups failed: galaxyserver full-1 stopped in web-opt-rdr-domains on 'tls: domain project.infinito.nexus not found' for its project.veen.world -> project.infinito.nexus redirect.

When the chain ends outside the host's domains and web-opt-rdr-domains is deployed, the source now resolves to web-opt-rdr-domains with the source itself as primary domain, which is the role that serves its vhost and whose certificate bundle names it. Only the outermost call falls back, so the source asked for stays the primary domain across a longer chain; without the redirect role, or for a target this host serves, nothing changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 11:04:28 +01:00
kevinveenbirkenbachandClaude Opus 5.5 69871cbfad fix(web-app-flowise): stop retrying the MCP reconcile when Flowise refuses the key
The reconcile task retried every failure thirty times ten seconds apart, so a permission the key lacks surfaced only after five minutes: rdr-3 spent 331 s on a 403 that no retry could change. The reconciler now ends with exit code 3 when Flowise answers 401 or 403, and the task stops retrying on that code while its failed_when still fails the run under MCP_RECONCILE_STRICT.

Only 401 and 403 count as final: authorize may answer a provider that is still starting with another 4xx, and that wait is what the retries are for. Every exit that reports an HTTP status goes through fail(), which keeps the message on the exception, so the existing tests read it unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 09:29:43 +01:00
kevinveenbirkenbachandClaude Opus 5.5 ca4f53d0e4 fix(mcp): keep a persistent host's providers through a partial deploy
mcp_servers narrowed the MCP providers to application_closure(deployment.whitelist), which on a persistent host drops every provider an earlier run deployed and a partial run does not touch. sys-svc-mcp-reconcile then converges each client present on the host against that narrowed set: on echoserver the --id web-opt-rdr-domains run rdr-3 removed the Prometheus registration from Flowise.

deployment now reports ephemeral (runtime act or github), and the closure narrows only there, where a whitelist is all a run deploys; a persistent host narrows nothing, as a full deploy already did with its empty whitelist, so its clients keep every provider it serves. CI keeps its narrowing unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 09:24:28 +01:00
kevinveenbirkenbachandClaude Opus 5.5 f8385c1cca test(web-app-flowise): import PROJECT_ROOT in the mint key test
test_mint_mcp_api_key.py computed PROJECT_ROOT itself from Path(__file__).parents[6], which test_project_root_import forbids outside an __init__.py. The files package now exports PROJECT_ROOT the way its reconcile subpackage does, and the test imports it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 09:20:43 +01:00
kevinveenbirkenbachandClaude Opus 5.5 a6bb6c5240 fix(web-app-flowise): let the MCP registry key delete its stale probe flows
The reconciler prunes the managed fixture chatflows of providers it no longer probes, but the scoped API key it gets was minted without chatflows:delete, so the first prune ended in 403 Forbidden after thirty retries; on echoserver rdr-3 stopped on 'FAILED deleting stale infinito:fixture:web-app-prometheus'. Adding the permission alone would not have reached that host, because api_key() reused an existing key unchanged.

KEY_PERMISSIONS now carries chatflows:delete, and api_key() reuses the managed key only while its permissions equal KEY_PERMISSIONS; otherwise it deletes the key through DELETE /api/v1/apikey/<id> and mints a new one, which the reconciler receives in the same run. The script gains its mirrored unit test, so its mirrored-unit-test exemption goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 09:08:15 +01:00
kevinveenbirkenbachandClaude Opus 5.5 6bb1dafcc5 refactor(web-opt-rdr-domains): read host-wide redirects from the role config
Host-wide redirects such as a legacy zone came from the top-level inventory variable redirect_domain_mappings, which only the role default declared. Once the role input moved to web_opt_rdr_domains_mappings nothing declared it any more and the inventory validation refused the deploy, because it does not see the lookup's vars_ read. The redirects now live where the role's other settings are: services.rdr-domains.redirects in meta/services.yml, empty by default and set per inventory under applications.web-opt-rdr-domains, and current_play_redirect_domains reads them through the config lookup. They still win over the application aliases, and a caller that includes the role without its own web_opt_rdr_domains_mappings still renders the merged CURRENT_PLAY_REDIRECT_DOMAINS.

Inventories move their redirect_domain_mappings list unchanged to applications.web-opt-rdr-domains.services.rdr-domains.redirects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 08:11:33 +01:00
kevinveenbirkenbachandClaude Opus 5.5 3044ae7a2e perf(web-app-nextcloud): plan the plugin pipeline from one app:list
Every addon ran through the install, enable and configure stages on its own, and each stage asked occ for the plugin's state again. The role now reads occ app:list --output=json once, and the new nextcloud_plugin_plan filter replays the addon order (an enabled entry switches its incompatible_plugins off and itself on, a disabled entry switches itself off, the later entry wins) and splits the app ids into disable, install, enable and configure. One app:disable call takes every id that has to go off; the install, enable and configure includes loop only over the ids that need that stage, so an already enabled plugin skips straight to its configuration. A plugin shipped as a pinned archive still goes through install on every run.

On echoserver the plugin phase took 608 s in full-9 for 51 addons; a repeat deploy is derived at about 195 s instead of about 550 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 07:39:16 +01:00
kevinveenbirkenbachandClaude Opus 5.5 28ce14ffe7 style(web-app-keycloak,web-app-openproject): apply autoformat to the no-verify commits
quality-high's autoformat pass rewrote three files that 9301220d05,
49aa920034 and c78d5f906c committed without the hooks. ruff drops three
`noqa: E402` markers as unused (RUF100): E402 does not fire on an import
that follows sys.path.insert. The formatter wraps the long
KEYCLOAK_RBAC_GROUP_LOGIC_DIGEST line and the OpenProject admin runner's
`sh -c` line; the double-quoted scalar and the `>-` block both fold the
break back into a space, and a parse against HEAD gives equal values.

On merge: feature/store-i18n 2bcf59109e carries the same four Keycloak
hunks byte for byte; they merge without a conflict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 23:02:56 +02:00
kevinveenbirkenbachandClaude Opus 5.5 49aa920034 perf(web-app-keycloak): mark user emails verified in one REST call
The emailVerified update looped over every non-reserved user, uploaded a
shell script per user and ran kcadm with a JVM login each time, and it
always reported changed: 7 users took 23.8 s on echoserver.

email_verified.py uses the shared keycloak_admin client through
tasks/utils/rest_script.yml: it looks each user up by exact username,
skips users already verified, and writes emailVerified with a GET plus a
full-representation PUT, as kcadm update did. The task reports changed
only when a user was updated, and a failed lookup now fails the task
instead of being treated as a missing user. The old lookup piped
kcadm -q username=X into head -n1, which could have hit a longer username
sharing the prefix.

Evidence (local compose, slot 104): summary updated=0 unchanged=8
missing=0 failed=0 in 0.60 s, task ok, staging dir removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 19:39:53 +02:00
kevinveenbirkenbachandClaude Opus 5.5 9301220d05 perf(web-app-keycloak): reconcile per-app RBAC over the admin REST API
The per-app RBAC groups and LDAP group mappers were reconciled by four
kcadm shell scripts that started a JVM per call and re-synced every app
on every deploy: 582 s for the mappers plus 324 s for the orphan purge on
echoserver (39 apps).

rbac_reconcile.py now talks to the admin REST API from inside the
Keycloak network namespace (nsenter --net) with the admin credentials
read from /proc/<pid>/environ, so nothing secret reaches argv or output.
It drops the shared ldap-roles mapper, purges root groups of undeployed
apps, ensures each app group and mapper, and syncs a mapper only when the
group is new, the mapper config drifted, or the fingerprint stored on the
group (mapper payload, app rbac and domains, group-logic digest) differs.

The per-app payload no longer carries groups.path.group: Keycloak 26.8.1
does not store that key, so a payload with it never matched the stored
mapper and every deploy re-synced every app.

The admin client lives in keycloak_admin.py; tasks/utils/rest_script.yml
stages it with the script in a 0700 temp dir on KEYCLOAK_HOST_NODE, runs
it and removes the dir in an always block.

Evidence (local compose, slot 104): matrix iteration green in both
variants and passes; update runs show synced=1 in 1.1 s, then
synced=0 unchanged=1 in 0.81 s through the final delivery path, no
staging dir left behind.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 19:28:31 +02:00
kevinveenbirkenbachandClaude Opus 5.5 372142848b test(web-app-stalwart): decide the IMAP parallelism instead of racing it
The parallel-sessions test asserted that more than one of four sessions
appended something, which a loaded host where one worker drains the
shared iterator fails with nothing broken. The sessions now meet at a
barrier before their first append, so a serialised pool still fails the
assertion while a concurrent one always passes.

(cherry picked from commit 657015852a)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 18:01:19 +02:00
kevinveenbirkenbach 9d08fda117 Merge remote-tracking branch 'origin/main' into feature/updater-lockdown-probe 2026-10-24 15:09:52 +02:00
kevinveenbirkenbachandClaude Opus 5.5 d3b9ae59a9 fix(sys-token-store): let the users lookup read the store of the stack host
Lookups render on the controller and read FILE_TOKENS there, while sys-token-store writes the store on STACK_HOST and mirrors it to the controller only in swarm. A compose deploy run from another machine therefore resolved every token empty: on echoserver Friendica rendered an empty SMTP_AUTH_PASS (msmtp: authentication method PLAIN needs a password) and Stalwart minted new bot app passwords on every run.

get_merged_users and the applications carrier key now take the sys_token_store_users fact, which the play reads from the stack host, over the controller file; write.yml refreshes the fact after each write, and 01_load.yml slurps from STACK_HOST so every host reads the store that is written. Without the fact, as in tests and ad-hoc calls, the controller file still applies, and where controller and stack host share the file the merged users are identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 15:01:05 +02:00
kevinveenbirkenbach 62164178a2 Merge pull request #1204 from infinito-nexus/fix/web-app-matrix/probe-and-federation-delegation
fix(web-app-matrix): probe only served domains and serve the federation delegation
2026-10-24 13:04:32 +02:00
kevinveenbirkenbachandClaude Opus 5.5 bfd6f7c9b8 fix(install-lint): give chrome-headless-shell a font on dnf, yum and pacman
On a fresh Fedora dev container lint-mermaid failed most files with
ConnectionClosedError: the browser aborted with
`FATAL:third_party/skia/src/ports/SkFontMgr_FontConfigInterface.cpp:163]
Not implemented.` in its font fallback, because the host had no font at
all. install-lint provisions the browser's libraries by soname on dnf,
yum and pacman, and a font is not a soname; apt-get gets fonts through
puppeteer's --install-deps.

It looked load-dependent because lint-packages installs rpmlint in the
same parallel phase, which pulls fontconfig and Noto Sans: renders
crashed until that transaction landed, and every later run was green.
With every font removed, lint-mermaid alone fails 274 of 289 files the
same way.

ensure() now installs the distribution's font provide when
/usr/share/fonts holds no file (`font(:lang=en)` on dnf and yum,
`ttf-font` on pacman), and browser_installed() counts the font, so the
install-lint stamp is not fresh on a font-less host.

Closes #1154

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 12:59:42 +02:00
kevinveenbirkenbachandClaude Opus 5.5 3d1480b3f7 fix(web-app-stalwart): resolve account ids when SSO owns the directory
With the Keycloak OIDC directory active, Stalwart refuses every account create with 'forbidden: Cannot set credentials for accounts in an external directory', and full-4 failed the new batch assert on all accounts. The create request now lists the accounts in the same call and takes each id from the listing of the provisioned domain. Under SSO a forbidden create is tolerated for people, who may not exist before their first login; every bot still needs an id because its app password is addressed by account.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 12:04:29 +02:00
kevinveenbirkenbachandClaude Opus 5.5 7a82a92e84 fix(web-app-prometheus): probe only the domains an app serves itself
#1180 gave web-app-matrix a canonical `jitsi` domain for its own Jitsi
and listed it under `services.jitsi.domains`. When Jitsi is shared or
disabled nothing serves that domain, and the blackbox healthz job of
web-app-prometheus probed every canonical domain, so the probe of
https://jitsi.matrix.<domain>/healthz/ready failed with "tls:
unrecognized name" and `prometheus scrape: every consumer role reports
up=1 and passes its probe` went red (main run 40017166046: Matrix#2,
Nextcloud#12).

web_health_expectations already skipped such keys. The rule now lives
in utils/domains/unserved.py: a key claimed by a service entity that is
disabled, or enabled but shared, is not served by that app. The health
filter uses it unchanged, and prometheus.yml.j2 filters its targets
through the new `served_domains` filter, which also drops the
`<key>_onion` mirror of a skipped key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 11:44:14 +02:00
kevinveenbirkenbachandClaude Opus 5.5 f37baef29e test(library): keep the project-walk marker on the rglob line
The reason appended to the nocheck marker pushed the line past ruff's limit, so ruff format wrapped the call and left the marker on the closing-parenthesis line, away from the rglob the lint matches. Keep the bare marker, the form every other project-walk opt-out in the repository uses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 11:06:38 +02:00
kevinveenbirkenbachandClaude Opus 5.5 eca6dc9e50 test(library): mark the directory_batch fixture walk as outside the project tree
test_no_raw_project_walk flagged the rglob in DirectoryBatchTests.tearDown. It walks the test's own TemporaryDirectory to make every directory writable again before cleanup, after tests that leave an unwritable parent behind, so the cached project-file helpers do not apply.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:56:05 +02:00
kevinveenbirkenbachandClaude Opus 5.5 b796903d21 perf(svc-db-openldap): set the user passwords over one connection
Three per-user loops ran on every deploy: an ldap_search for whether the user existed (with failed_when: false, so an LDAP error read as 'missing'), a set_fact building a map from it, and community.general.ldap_passwd per user, 18 s for 7 users on echoserver. ldap_entries state=create now registers the DNs it created, and one ldap_passwords call sets the passwords over a single admin bind: a probe bind as the user decides, INVALID_CREDENTIALS leads to the password modify operation, check mode reports without writing.

openldap_user_passwords keeps the old policy: a user whose record leaves password_update on, or whom this run created, gets its password. Errors on single entries are collected so the others still converge, and the task then fails naming every DN.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:54:16 +02:00
kevinveenbirkenbachandClaude Opus 5.5 76b16a8abb perf(library): create a whole directory list in one module call
Directory loops over ansible.builtin.file cost one SSH round trip per path: the openresty config tree (20 paths) took up to 12.9 s, the webserver core directories (16) up to 13.2 s, every app's compose directories (5) 2.5-5 s per app, the shared CDN roots 5.8 s and the OpenLDAP LDIF dirs 2.2 s on echoserver.

directory_batch takes entries (path plus its own mode, owner, group) or paths with one shared set, and handles each path like file with state: directory: symlinks are followed, missing parents get the same attributes, an attribute left out stays as found, and it never recurses. It reports changed_paths, supports check and diff mode, and fails on a relative path, an existing non-directory or a failed mkdir. It replaces the five loops and the earlier install -d of the CDN role directories, which now report changed honestly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:51:18 +02:00
kevinveenbirkenbachandClaude Opus 5.5 1fbd4758e3 perf(web-app-stalwart): provision accounts and bot app passwords in batched JMAP calls
Every account ran about six JMAP calls of three Ansible tasks each through jmap_call.yml, 80 s for 7 accounts on echoserver. Each phase now sends one request for all accounts: one x:Account/set creates them all (ids from created or, on alreadyExists, from notCreated.objectId), one updates the passwords of the accounts that already existed, and the app-password phases (list, mint, retire, drop) carry one method call per bot, each addressing its bot through accountId, in chunks of STALWART_JMAP_MAX_METHOD_CALLS (Stalwart's default maxMethodCalls, 16).

jmap_call.yml retries and asserts on every method response of a batch, and a refusal names its owner through the call id. A create refused for a reason other than alreadyExists now fails the deploy instead of being skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:37:01 +02:00
kevinveenbirkenbachandClaude Opus 5.5 3b4727fcfe fix(web-app-stalwart): rate-limit and ban HTTP clients by their own address
With the role network's gateway on Stalwart's allowlist, every request OpenResty proxies arrives from an address that is never throttled or banned, so the anonymous HTTP limit and the scan and auth bans no longer applied to web clients at all. Stalwart can take the client address from a Forwarded header, but OpenResty passed a client-supplied one through, so trusting it would have let anyone pick the address that gets banned.

forwarded.conf.j2 gains an opt-in proxy_forwarded_client_address switch that sets Forwarded: for=$remote_addr on every location including the healthz ones, replacing any value the client sent; other vhosts render unchanged. The Stalwart vhost opts in, and the role sets useXForwarded through x:Http. The settings apply moved into security/apply.yml behind one include so the guarded block stays within the three-task limit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:17:42 +02:00
kevinveenbirkenbachandClaude Opus 5.5 57eb64e0af fix(cache): render a templated DOMAIN_PRIMARY against the play variables
get_merged_domains rendered DOMAIN_PRIMARY with no templar and an empty variable scope. A primary declared as a template, such as echoserver's "{{ SOFTWARE_NAME | lower }}", therefore came out as 'none' inside the applications SPOT, while direct lookups saw the domain Ansible had already resolved. Every SPOT value built through it named file.none, and the dashboard waited for https://file.none/assets/img/logo.png until full-2 failed. The render now gets the templar and the play variables, which also covers services.tor.node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 00:40:32 +02:00
kevinveenbirkenbachandClaude Opus 5.5 47160d755e fix(tls): serve every vhost the certificate of its own domain
ssl_credentials.j2 resolved the certificate by application_id, so every vhost of an application got the lineage of its primary domain. echoserver serves webmail.infinito.nexus the CN=academy.infinito.nexus certificate that names mail.infinito.nexus but not webmail, and browsers refuse it with a hostname mismatch. The template and the letsencrypt existence check now resolve by the vhost's domain, so a secondary vhost points at its own live/<domain> link and a missing link triggers the issuance instead of a mismatch. Primary vhosts resolve to the same files as before in every TLS mode and flavor.

The LDAP stream passes its primary domain explicitly. Onion domains stay on the application lineage, and the app-scoped self-signed path always names the application leaf, because neither has a per-domain certificate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 23:41:33 +02:00
kevinveenbirkenbachandClaude Opus 5.5 ef246a6e03 refactor(unit_name): build unit names from SYS_SERVICE_VERSION_SUFFIX
The lookup assembled '.<version>.<software domain>' itself, a second copy of the infix the unlock and prune phases now read from the SPOT. It reads SYS_SERVICE_VERSION_SUFFIX instead and refuses one that does not start with a dot or carries an empty segment. The direct version-plugin fallback goes with it: it existed because the plugin rendered the untrusted literal "{{ lookup('version') }}", while the SPOT arrives as a trusted group_vars string. Every unit name rendered from group_vars is byte-identical before and after.

The timer description names the versioned service it starts instead of the legacy unversioned name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 23:34:13 +02:00
kevinveenbirkenbachandClaude Opus 5.5 694007c58a feat(sys-svc-cln-domains): remove the nginx configs of domains the host no longer serves
Retired apps, aliases and legacy zones left their vhosts behind, so 37 legacy cymais.cloud subdomains kept serving a certificate that does not name them after the zone moved to one wildcard redirect. With MODE_CLEANUP the role now deletes every <domain>.conf below the servers directory whose domain is not in CURRENT_PLAY_DOMAINS_ALL, the domains of every application in group_names and its dependencies including redirect sources; files not named after a domain and onion services stay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 21:25:50 +02:00
kevinveenbirkenbach d0eaca8292 Merge remote-tracking branch 'origin/main' into feature/updater 2026-10-23 20:28:25 +02:00