Commit Graph
234 Commits
Author SHA1 Message Date
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 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 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 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 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
kevinveenbirkenbachandClaude Opus 5.5 9875db5ff8 feat(web-app-stalwart): deliver mail of legacy alias domains to their target domain
services.stalwart.alias_domains maps a receive-only legacy domain onto the mail domain: the deploy sets the RCPT stage rewrite to one matches() rule per alias domain, reads it back, and with Cloudflare points the source's MX at the mail hostname. An empty list leaves Stalwart untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:59 +02:00
kevinveenbirkenbachandClaude Opus 5.5 8c8e6a1242 feat(web-opt-rdr-domains): redirect a whole legacy zone with one wildcard source
redirect_domain_mappings now renders its source and target, so an inventory can map *.{{ OLD_DOMAIN }} and the apex onto a target; keep_path: false drops the request URI from the 301. Wildcards get no www. variant, sys-dns-wildcards adds the *.<zone> A/AAAA records for every zone with a wildcard source next to DOMAIN_PRIMARY, and the CSP health check skips wildcard names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:17:19 +02:00
kevinveenbirkenbachandClaude Opus 5.5 27762376f0 perf(sys-ctl-hlth-webserver): probe the domains in parallel
The checker sent one HEAD request after the other, up to 10 s per clearnet and 30 s per onion domain. A pool of 16 threads now probes them; the waits are I/O, so 16 suit hosts with any core count, and pool.map keeps the output sorted by domain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:10:52 +02:00
kevinveenbirkenbachandClaude Opus 5.5 8e73c84b15 perf(svc-db-openldap): converge the user entries over one connection
Three loops started one async ldap_entry or ldap_attrs job per user and reaped them, about 2 minutes per deploy on echoserver. The ldap_entries module binds once and applies create, present or exact to every entry the new filters build from OPENLDAP_USERS, keeping the semantics of the community modules it replaces.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 19:59:37 +02:00
kevinveenbirkenbachandClaude Opus 5.5 5b6db5dc24 feat(web-app-stalwart): import Mailu only until one import has finished
The import script writes a done marker into the instance directory when it ends with rc 0, and the deploy skips the unit and the import block while the marker exists; deleting it imports again. The IMAP layout helpers move to imap_layout.py so the script stays under the 500-line limit, with mirrored tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 19:51:10 +02:00
kevinveenbirkenbach 0bca4bcd7f Merge remote-tracking branch 'origin/main' into fix/web-app-matrix/ansible-flavor-unwired-services/1165 2026-10-23 18:18:31 +02:00
kevinveenbirkenbachandClaude Opus 5.5 25ec8b0c59 fix(sys-ctl-hlth-webserver): skip a consumer's domains for a shared service
A service entity can list canonical domain keys under `domains`; the
web health expectations skipped them only while the service was
disabled. A consumer whose service is enabled but shared does not serve
its own domain for it either, since the provider serves the service on
its own domain, so the probe failed with an unrecognised TLS name.
Domains of an enabled and shared service are now skipped as well.

The only other entity with `domains` (web-app-seaweedfs frontend) is
never shared, so its behaviour is unchanged.

Refs #1165

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 17:16:19 +02:00
kevinveenbirkenbachandClaude Opus 5.5 462f30e46a perf(web-app-stalwart): import Mailu in parallel sessions as a background unit
On echoserver the import appended 23,045 messages one by one over a single
IMAP session, about six per second, and the deploy waited 62 minutes for
it inside the Keycloak stage, because Stalwart is a dependency of Keycloak.
galaxyserver holds 157,655 messages in one mailbox, which one session
would need 7 to 28 hours for, beyond the task's six-hour timeout.

migrate_from_mailu.py now opens several IMAP sessions per mailbox that
take messages from one queue in file name order, with the dedup set and
the counters under a lock, and logs [PROGRESS] every 1000 messages. A
session Stalwart refuses only lowers the parallelism; with one session
the import behaves exactly as before. The number of sessions is
services.stalwart.migration.workers, or one fewer than the host's vCPUs,
at least 1 and at most 8.

The deploy starts the import as the transient unit stalwart-mailu-import
and does not wait for it; a deploy that finds the unit running leaves it
alone. The unit removes the staged recovery administrator credentials when
it stops, and a failed start removes them in a justified rescue before it
re-raises. Progress and the closing [DONE] rc=<n> are in
journalctl -u stalwart-mailu-import.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 16:35:45 +02:00
kevinveenbirkenbach e4ac05dfb6 Merge remote-tracking branch 'origin/main' into feature/updater 2026-10-23 10:46:59 +02:00
kevinveenbirkenbach e53751402f Merge pull request #1114 from infinito-nexus/fix/security/swarm-ingress-guard/1089
fix(svc-swarm-node): drop outside traffic to non-public routing-mesh ports
2026-10-23 01:03:04 +02:00
kevinveenbirkenbachandClaude Opus 5.5 bfdf88fae0 fix(domains): read an application's domains where the cache puts them
meta/domains.yml lands on the top-level `domains` key of the merged
application, yet three readers still looked under `server.domains`,
which no role fills any more:

- current_play_redirect_domains built every mapping from an empty
  config, so each app redirected to itself and was dropped. Neither
  `domains.aliases` nor AUTO_BUILD_ALIASES produced a single redirect,
  and echoserver's update check listed nextcloud.infinito.nexus and the
  other entity domains as dropped by the upgrade.
- csp_skip_domains never found the canonical of a disabled service.
- the logout filter's fallback returned no domains.

The unit tests fed the old layout and passed regardless. They now use
the merged one, and a new test builds the redirect from keycloak's real
role defaults.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 22:18:07 +02:00
kevinveenbirkenbach 3cf840c72b Merge remote-tracking branch 'origin/main' into feature/updater 2026-10-22 21:14:49 +02:00
kevinveenbirkenbachandClaude Opus 5.5 8567cb6992 fix(svc-opt-ssd-hdd): recognise database images behind registry, namespace and custom builds
is_database() compared the whole repository part of the image reference
against postgres, mariadb, redis, memcached and mongo. Every reference
with a registry or namespace prefix, every derivative image and every
locally built `<engine>_custom` image therefore counted as application
data and went to the mass storage. On a local stack the central
databases report `postgres_custom:18` and `openldap_custom:13.7-slim`,
both classified False and moved to the HDD.

The check now takes the last path component of the reference, drops
digest, tag and a `_custom` suffix, and compares it against the engines
the svc-db-* providers and the app-local databases run: elasticsearch,
mariadb, memcached, mongo, mysql, openldap, opensearch, postgis,
postgres, qdrant, rabbitmq, redis, typesense, valkey.

Volumes the script already symlinked stay where they are; a database
volume misplaced on the HDD has to be moved back by hand.

Fixes #1163

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 21:02:06 +02:00
kevinveenbirkenbachandClaude Opus 5.5 558784e6ca feat(web-app-erpnext): set up the service provider's company and its accounting basics
The wizard bypass left ERPNext without a Company, so no app could create quotes, orders or invoices. The deploy now creates the Company from the service provider's name and country, with the currency from ERPNext's country data and an abbreviation that defaults to the name's initials, plus a fiscal year, five tax accounts, a cash account and the default company. It never rewrites an existing Company and fails loudly on a country ERPNext does not know. The README generator marks roles that read the service provider country, and the CI replay fills the placeholder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 20:52:30 +02:00
kevinveenbirkenbachandClaude Opus 5.5 286004349a fix(web-app-suitecrm): upgrade the code in the data volume to the pinned release
The entrypoint stamped an installed volume with the image's version and kept the old tree, so a newer pin never reached it and the CreateUser patch stayed out. On boot it now runs SuiteCRM's own suitecrm:app:upgrade and upgrade-finalize with the image's patched tree as the package, resumes an interrupted upgrade, stamps the version the tree really has, and refuses a tree newer than the pin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 20:44:13 +02:00
kevinveenbirkenbach eeb3305e52 Merge remote-tracking branch 'origin/main' into fix/security/swarm-ingress-guard/1089 2026-10-22 19:17:39 +02:00
kevinveenbirkenbach 1ad4f0ac39 Merge branch 'main' into feature/updater 2026-10-22 18:59:45 +02:00
kevinveenbirkenbachandClaude Opus 5.5 d2ef619faa fix(web-app-matomo): install the image's release into the data volume on upgrade
The upstream entrypoint copies the release only into an empty volume, so a newer pinned image kept running the old code from matomo_data. The deploy now copies the release over the volume, prunes the files the release dropped from its own trees, runs core:update, and recreates the security files after a code change. A volume newer than the image stops the deploy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 18:51:32 +02:00
kevinveenbirkenbachandClaude Opus 5.5 b83b361ea9 fix(web-app-bigbluebutton): run BBB v3.0.39 with the shared-notes server
The version-source update in #1146 moved the bigbluebutton/docker ref from
v3.0.23 to v3.0.39. That tag changes three things the role did not
handle, and it exposed a fourth defect in compose_file_args.

1. generate-compose renders etherpad and bbb-pads only behind
   USE_INSECURE_ETHERPAD and otherwise emits bbb-shared-notes-server with
   a `build:` block and no `image:` (upstream adds the image only after
   the tag, in 9a6a9a9a4c). compose_mods drops `build` only where an image
   is set, so the guard "Fail if any service still builds locally" stopped
   every BigBlueButton deploy (main run 39749899667, Nextcloud#11:
   `rc: 0`, `stdout: '    build:'`, bootstrap_compose_project.yml:35).
   meta/services.yml now pins alangecker/bbb-shared-notes-server:v3.0.39,
   compose_mods takes `pinned` (BBB_PINNED_SERVICES, every service the
   role config pins an image for) and drops their `build`, and the
   etherpad leftovers go (ep_redis_publisher patch, etherpad postgres
   entrypoint and initdb hook, etherpad database tasks, ETHERPAD_* env,
   etherpad_api_key secret).

2. The notes server connects to postgres once at start and exits with 2
   when it is not up yet; upstream gives it no restart policy. The
   override sets the role's restart policy, so it retries until postgres
   accepts connections.

3. Greenlight moves from v3.8.0 to v3.9.0.1, which sets
   `config.hosts = URL_HOST`, and the template passes
   `URL_HOST: https://${DOMAIN}`. Rails compares hostnames, so every
   request was blocked (`Blocked hosts: bbb.talk.infinito.test`, 403).
   Greenlight documents URL_HOST as the bare hostname; the override sets
   it to the role domain.

4. compose_file_args decided whether a role ships a compose override by
   templating "{{ application_id | abs_role_path_by_application_id }}"
   through the templar. ansible-core 2.21 does not render an untrusted
   string, so the check saw the literal and always returned False: the CA
   override generation and the pull handler never read BBB's
   compose.override.yml. That was harmless while compose.yml alone was
   valid; with the notes-server image living in the override, `compose
   config` failed ("has neither an image nor a build context"). The check
   now resolves the role path for the application id it is given. BBB is
   the only role that ships an override, so no other role's file list
   changes.

Existing installations drop the etherpad and bbb-pads containers on the
next `compose up --remove-orphans`; the old `etherpad` database stays in
PostgreSQL untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 16:56:48 +02:00
kevinveenbirkenbachandClaude Opus 5.5 77349c73e1 style(web-app-stalwart, web-app-joomla): apply the formatter and its lint rules
The upgrade work only ran make test, never make autoformat, so ruff now
reformats the new Stalwart and Joomla code and reports five rules it
cannot fix itself: Path.readlink instead of os.readlink (with Path moved
into the type-checking block), list.extend for a collecting loop, no
manual "Z" replacement before datetime.fromisoformat, and an S310 opt-out
for the two requests to the fixed internal JMAP origin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 11:40:43 +02:00
kevinveenbirkenbachandClaude Opus 5.5 05135a7cee feat(svc-net-wireguard): let a compose deployment name a hub with roaming spokes
Without a swarm manager the mesh planner only built full meshes, whose
handshake gate waits for every peer, so laptops behind NAT could not join.
An inventory may now name a hub in networks.hub: the planner builds every
declared mesh around it, the hub forwards between its spokes, a spoke marked
networks.roaming gets no endpoint in the hub's peer list and dials the hub
with PersistentKeepalive, and the hub's gate skips roaming spokes while a
roaming spoke gates only on the hub. The mesh CLI takes the hub's public
address per host (--endpoint) and a vault password per host
(--host-vault-password-file). Full meshes and the swarm manager hub plan
exactly as before; NETWORK_MESH_POOL keeps the operator's subnet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:56:50 +02:00
kevinveenbirkenbachandClaude Opus 5.5 8701ddb769 feat(web-app-stalwart): import every Mailu mailbox and provision aliases and forwards
The Mailu import covered only inventory users and logged in with their
passwords. It now reads every <address>/ maildir of the legacy volume
(files/python/mailu_maildir.py), logs in as the recovery administrator
acting for each account, creates missing accounts under the name the OIDC
directory resolves, keeps folders, flags, keywords and internal dates,
imports and activates each mailbox's Sieve script, merges mailboxes the
inventory maps onto another account (migration.mailbox_map), skips and
reports mailboxes of domains Stalwart does not serve, and imports nothing
twice on a re-run. The recovery credentials reach the script through a
0600 file, not argv. The import task allows 6 hours like the other long
data migrations, because a production mailbox of 157k messages does not
fit into one.

services.stalwart.aliases becomes Stalwart mailing lists, with @<domain>
as the domain's catch-all and destinations outside the served domains
allowed; services.stalwart.forwards becomes a redirect :copy Sieve script
chained with the user's own. Both are provisioned on every deploy and only
touch what the role created.

The test harness groups its probes under files/test/probe/ and its
scenarios under files/test/verify/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:53:11 +02:00
kevinveenbirkenbachandClaude Opus 5.5 d06fc7bd28 feat(web-app-mediawiki): refresh the extensions volume when MediaWiki changes version
Docker seeds mediawiki_extensions from the image only once, so after an
upgrade the bundled extensions of the old release kept running against the
new core. On every deploy a one-shot container of the pinned image runs
files/shell/refresh_extensions.sh against the volume: it adopts a volume
whose content matches the image, replaces the content with the image's
extensions when the recorded version differs, records the version in a
marker, and refuses when the volume records a newer version than the image.
After a refresh the role reinstalls its add-on extensions and runs
update.php.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:46:19 +02:00
kevinveenbirkenbachandClaude Opus 5.5 eddbccd63c feat(web-app-confluence, web-app-jira): refuse a pinned image older than the data
Atlassian applications refuse to start on data a newer build wrote, after
the old container is gone. Before the stack starts, each role compares
the build number of the data with the build of the pinned image
(files/python/build_guard.py) and stops the deploy with both builds named
when the data is newer. Confluence reads the build from the home in its
volume; Jira reads it from the database (files/sql/data_build.sql), so its
guard needs the database reachable before the stack starts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:38:11 +02:00
kevinveenbirkenbachandClaude Opus 5.5 93525d4f29 feat(web-app-joomla): update an older core in the code volume up to the image's core
The Joomla image never touches the core that sits in joomla_data, so an
old site kept its old core and the SSO task failed on a missing
extension:enable. The role now reads Version.php of the code volume and of
the image tree plus the version the database was finalised for, plans the
steps through the declared bridge releases (5.4.9, checksum pinned, with
its compat plugin states), lays each full package over the code volume
without its installation/ folder, runs the database and schema update with
files/php/core_update.php, and asserts one core at or above the image's.
A newer core than the image is left alone; a database that records another
version than the code volume stops the deploy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:27:23 +02:00
kevinveenbirkenbachandClaude Opus 5.5 8075c68c92 feat(web-app-moodle): upgrade an old code volume through the releases Moodle requires
Moodle 5.3 upgrades no site older than 4.4. The image now also ships the
4.5.15 transit release (tracked by the version updater) and records which
release each shipped tree accepts as its oldest source. On start, the
entrypoint reads the release in the code volume, migrates a Bitnami-layout
volume to this image's layout, runs admin/cli/upgrade.php with the transit
tree first when the site is older than the pinned release's minimum source,
then with the pinned tree, and refuses to start over an empty database.

The entrypoint and its release library move to files/build/moodle/, the
core installers to files/build/install/moodle/, next to the plugin
installers, and their unit tests follow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:24:09 +02:00
kevinveenbirkenbachandClaude Opus 5.5 cd28286ae6 feat(web-app-openproject): pass an older schema through every skipped major
OpenProject 17 refuses a 15.x database with IncompleteMigrationsError;
upstream requires each major in between. Before the pinned image is built
or started, the compose deploy reads schema_migrations, derives the major
that last migrated the schema from its aggregated migrations, and for each
major strictly between that and the pin stops the old containers, runs the
seeder of openproject/openproject:<major> once against the stack's
database and data volume, and works off the GoodJob queue in the same image
before GoodJob 4 takes over. A schema newer than the pin stops the deploy;
an empty database skips everything.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:20:22 +02:00
kevinveenbirkenbachandClaude Opus 5.5 678011cca0 feat(web-app-peertube): run the release migration scripts and refresh the client on upgrade
PeerTube 8.0, 8.1 and 8.3 ship scripts that must run after the server
migrations, and the image-seeded peertube_assets volume keeps the old
client forever. Before the pinned image starts, the role reads the release
the data was written by from a marker file and application.migrationVersion
(table of every release's last migration from 6.0.0 to 8.3.1) and plans the
upgrade; a downgrade or data older than the table stops the deploy. After
the server is healthy it asserts the pinned migration version, runs every
dist/scripts/migrations/peertube-X.Y.js above the data's release in order,
empties /app/client/dist so Docker reseeds it from the pinned image, and
records the pinned release in the marker. The health wait allows 30
minutes while existing data migrates.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:18:09 +02:00
kevinveenbirkenbachandClaude Opus 5.5 f6fffd13e1 feat(web-app-nextcloud): step existing data through every major up to the pin
Nextcloud refuses to skip a major. Before the pinned image starts, the
role reads $OC_Version from version.php in the code volume, plans one tag
per major between that version and the pin, and runs the image of each
step once against the data (occ upgrade through the entrypoint, with the
debug log lines filtered out of the output). Then the regular manager ops
run on the pinned image and assert that occ status reports the pinned
major. A fresh volume plans no step; swarm stops when data needs one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 09:52:36 +02:00
kevinveenbirkenbachandClaude Opus 5.5 849dd92afb feat(svc-db-postgres): upgrade an older cluster with pg_upgrade --link on deploy
Before the server starts, the role finds the cluster in postgres_data,
whether it sits in the volume root, in pgdata/ or in <major>/docker, and
upgrades an older one major by major into the PGDATA of the pinned image:

- builds an upgrade image from the pinned PostgreSQL image with the binaries
  of every older major on the path and the extension packages the
  consumers in the host's groups declare
- keeps a reflink copy of the whole volume next to it, or stops until
  services.postgres.upgrade.backup_confirmed is true on a filesystem
  without reflinks
- runs pg_upgrade --check, then --link, with the old cluster's superuser,
  encoding, locale and checksum setting, and carries pg_hba.conf,
  pg_ident.conf and postgresql.auto.conf over
- after the start, applies the extension updates pg_upgrade wrote,
  collects statistics, reports databases whose collation version changed
  and removes the retired clusters, never the reflink copy

A cluster newer than the pin, a pinned major outside PGDATA, or a retired
cluster without a newer one stops the deploy. Swarm mode only detects and
stops on an older cluster.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 09:46:24 +02:00
kevinveenbirkenbachandClaude Opus 5.5 788eabffbf fix(svc-swarm-node): drop outside traffic to non-public routing-mesh ports
In swarm mode `docker stack deploy` ignores the host IP of a short port
mapping, so every `127.0.0.1:<port>:<port>` that container_ports renders for
a `ports.local` entry is published by the ingress routing mesh on every
node, via docker-proxy on 0.0.0.0 and [::] plus DNAT in nat PREROUTING.
Databases, LDAP and application backends answered from outside the cluster.

svc-swarm-node now loads an nftables table through svc-net-firewall. Its
prerouting filter chain (priority mangle, ahead of Docker's dstnat) drops
packets addressed to the node for any ingress-published port that no
application declares under `ports.public`. Loopback, docker0,
docker_gwbridge, the user bridges and the swarm nodes' own addresses stay
admitted. Host-side provisioning over 127.0.0.1, containers that reach a
host port through host-gateway, and node-to-node consumers such as the
registry-cache mirror keep working.

Both sets are read live on the manager (`docker service inspect` for
`.Endpoint.Ports`, `docker node inspect` for `.Status.Addr`), so ephemeral
ingress ports and nodes outside `--limit` are covered. The table is loaded
on a node before it joins, reloaded on every node after the join, and
reloaded after each stack deploy by the sys-svc-compose swarm handler.

Refs #1089

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 20:56:29 +02:00
kevinveenbirkenbachandClaude Opus 5.5 6e776a5780 refactor(python): clear the open CodeQL code-quality alerts
- Declare `__all__` in every package `__init__.py` whose `PROJECT_ROOT`
  is imported by its modules (163 packages), so the constant counts as
  exported; list every public name where the package defines more.
- Empty the 15 package `__init__.py` files whose `PROJECT_ROOT` no module
  imports.
- Declare the public API of `utils.github.variant.axes` in `__all__`,
  re-exports included, instead of silencing them with `noqa: F401`; the
  list matches the one `feature/store-i18n` already carries.
- Use `import unittest.mock as mock` in `test_applications.py`, as the
  rest of the suite does.
- Spell `_NON_PRINTABLE` in `utils/yaml_bootstrap.py` as the equivalent
  BMP-only positive class; it matches exactly the same 2111 code points
  as the old negated class across U+0000..U+10FFFF.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 21:40:58 +02:00
kevinveenbirkenbachandClaude Opus 5.5 21eba1e88f fix(backup): carry hard links, ACLs and xattrs on every backup and recover hop
baudolo 7.1.x writes volume generations with `rsync -aHAX`, so the
producing host keeps `user.*` extended attributes (OpenCloud keeps its
storage metadata in `user.oc.*`). Every later copy dropped them again,
because none of the other rsync calls passed `-X` (nor `-A`/`-H`):

- NFS export snapshots (svc-bkp-nfs-2-local `script.sh`),
- the backup host's pull (svc-bkp-remote-2-local `pull_specific_host.py`),
- the device sync (svc-bkp-local-2-device `script.py`),
- every recover into a live target (`utils/recovery/base.py`, used by the
  volume, NFS and device recover scripts),
- the `recover full` and `recover remote` tree pulls.

A disaster recovery through the documented chain therefore restored
OpenCloud without `user.oc.*`, silently: the copies succeeded and the
dirval stamp does not cover attributes.

All of these now run `rsync -aHAX` (`-aHAXP` for the device sync). A
target filesystem without xattr support makes rsync exit non-zero as soon
as a source file carries attributes, so the hop fails loudly instead of
dropping them; there is no per-device opt-out. The single-image pull in
`recover remote` keeps `-a`. The provider's ssh-wrapper needs no change:
its glob accepts any server flags and its character guard allows them.

Two tests write a `user.oc.id` attribute and assert it survives the NFS
snapshot and the recover; with the old `-a` both fail with
`OSError: [Errno 61] No data available`.

Closes #1021

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 18:20:46 +02:00
kevinveenbirkenbach da449f8894 Merge pull request #323 from evangelostsak/feature/svc-net-wireguard
feat(svc-net-wireguard): Dockerized WireGuard role + test-e2e-cli DinD e2e
2026-10-18 19:50:42 +02:00
kevinveenbirkenbachandClaude Opus 5.5 2ceba492b8 fix(web-app-zammad): render the search precheck service in compose only
In swarm, docker stack deploy ignores depends_on, so the precheck
service and every init replica ran search_allocation_precheck.sh at the
same time. They shared one probe index: one invocation's PUT could fail
the shards_acknowledged check because another had created the index
first, and either one's cleanup could delete the other's probe. The
init service carries no replica override, so compose_replicas gives it
one task per host in the application group and the collision also
happens between init replicas.

A failing precheck task also never reached has_fatal_task in
stack_ready.sh: with restart_policy none it records a single error,
while has_fatal_task needs three. stack_ready.sh then kept answering
"not converged" until the handler ran out of retries. And a Running
init task counts as converged through running_current_spec, so the
swarm deploy never depended on init failing; the role's "wait for
Zammad init" task is what stops it.

The probe index now carries the container's hostname, which docker
sets to the container id, so concurrent invocations no longer share an
index. compose.yml.j2 renders the precheck service, and init's
service_completed_successfully dependency on it, only outside swarm.
Compose keeps its fast stop on a refusal; in swarm, init runs the same
check in front of zammad-init, a refusal leaves Zammad unseeded, the
wait for Zammad init fails the deploy, and the init service logs carry
the allocation explanation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 12:33:54 +02:00
kevinveenbirkenbachandClaude Opus 5.5 fcf1cca129 fix(web-app-zammad): stop the deploy when Elasticsearch allocates no shard
On a data disk with less free space than the high disk watermark asks
for, Elasticsearch creates indices but assigns none of their primary
shards. `zammad-init` then reports every search index update as failed
and never exits, so `compose up` waits on
`service_completed_successfully` until its 1800 s task timeout and the
deploy fails without naming the cause (#850). Elasticsearch's default
high watermark is an absolute 150 GB headroom on a large disk.

A new one-shot `precheck` service runs `search_allocation_precheck.sh`
with `restart: "no"`, and `init` depends on it with
`service_completed_successfully`:
- it waits a bounded time for `_cluster/health`;
- it creates a one-shard, zero-replica probe index with
  `wait_for_active_shards=1`;
- once Elasticsearch allocates it, it deletes the probe and exits 0;
- otherwise it prints the creation answer and the answer of
  `_cluster/allocation/explain` and exits 1.

The check sits in its own service because `init` keeps
`restart: on-failure`: wrapped into `init`, the failing check was
restarted seven times within eight minutes on a local deploy while
`compose up` kept waiting. Inside the stack, a `restart: "no"`
dependency that exits 1 made `compose up` fail after one second with
`service "pre" didn't complete successfully: exit 1`.

Every curl call carries --connect-timeout and --max-time, so a hanging
request cannot outlast the bounded wait. The image's entrypoint passes
any non-`zammad-*` command through `exec "$@"` (bin/docker-entrypoint of
7.1.2), and the image ships `curl`. The watermark values themselves come
with feature/store-i18n (b82591ba9d); this change covers inventories
without a variant cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 12:33:53 +02:00
evangelostsak 0c9ad653f7 Merge remote-tracking branch 'upstream/main' into feature/svc-net-wireguard 2026-10-17 22:19:57 +02:00