Commit Graph
344 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5.5 68b0dfd508 feat(ci): drop the dot between the category glyphs and the role title
Job and run titles read `🐳🧅…𓏴𓏛·Nextcloud#2`; the `·` between the
hieroglyph block and the title only costs width. RoleDisplayName.encode
now joins the two directly (`𓏴𓏛Nextcloud#2`). Spaces inside a README
heading still become `·`, which keeps a display name one token in the
space-separated whitelist and priority inputs.

decode and decode_list still map the dotted form of older runs back to
the role id, so report_failures, the run tooling and selection tokens
pasted from an earlier run keep working. Old and new names cannot
collide: a glyph block holds only hieroglyphs and a title starts with a
letter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:16:18 +01:00
kevinveenbirkenbachandClaude Opus 5.5 621a492ec3 feat(ci): name the deploy chunks as numbered batches and star the priority ones
The chunk jobs read `🧱 Chunk N` and every priority row carried its own
trailing ⭐, so a priority sweep repeated the star down the whole job
list while the chunk that held those rows looked like any other.

- The four chunk jobs are named `0️⃣ Batch` to `3️⃣ Batch`, with ` ⭐`
  when the batch holds priority rows, and their discover job reads
  `🔍 Batch N ⭐` through the existing `marker` input.
- call-validation counts those batches once per run
  (scripts/github/resolve/priority_batches.sh, which runs
  `cli.meta.ci.matrix --priority-chunks`) and exposes the count as the
  `starred` output. chunks.plan cuts the priority chunks from the
  priority rows alone, so offset and the regular line cannot move them;
  the orchestrator hands validation the same sweep and chunk size it
  hands the chunk jobs.
- The row label drops its own star. report_failures and the job-name
  parsers still accept a starred title from an older run.
- The glossary names the unit `batch` (`#️⃣`) instead of `chunk` (`📦`),
  `ci plan` prints the batch column as the keycap the job name leads
  with, and the name lint accepts a leading keycap emoji.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:46:38 +01:00
kevinveenbirkenbachandClaude Opus 5.5 260ebf7357 fix(backup): ask every rendered service for its stop decision
test_backup_stop_decision_declared looked a volume mount's `service` up
as a meta/services.yml key. A role whose compose service is renamed via
`proxy.service_key` slipped through: web-svc-xmpp mounts `ejabberd`, its
entry is `xmpp`, and the lint never asked it to decide.

- compose_service_names() in utils/roles/applications/mounts.py is the
  shared mapping from a meta key to the compose services it renders as
  (the key plus a literal proxy.service_key); the lint resolves each
  backed-up mount to its owning keys through it, and
  test_image_volumes_mounted uses it instead of its own copy.
- web-svc-xmpp stops before the copy (`/opt/ejabberd/database` is a
  Mnesia store spread over several files), which is what the missing
  marker already did. Its upload volume is no longer backed up: the
  ejabberd config enables no mod_http_upload, so nothing writes there.
- web-app-pretix deploys pretix_custom:3.13, the reference
  lookup('backup_image', 'stop_required', false) resolves, instead of
  overriding the tag with the source version. The override made the
  deployed image miss --images-no-stop-required, so baudolo stopped
  Pretix despite `stop_required: false`. The source version moves into
  the build args, which keeps a bump rebuilding through the build
  digest, and into an OCI version label on the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:41:45 +01:00
kevinveenbirkenbachandClaude Opus 5.5 91022ad37a fix(ci): let only the primary app's declared variant bring the guide host
Every role's base services carry `docs.enabled: "{{ 'web-app-docs' in
group_names }}"`, and the static closure counts any "in group_names" flag
as possibly true (is_explicit_truth). A round therefore pulled web-app-docs
in through any member whose active variant did not pin docs off, and a
round hands every role its index or clamps it to variant 0, where 84 roles
declare docs on. The README replay and everything it drags in (dashboard,
matomo, keycloak, prometheus with its MCP partners) landed in 365 of 469
rounds instead of one per role, which ran CI run 40625679270 out of
resources: Listmonk#0 memory, OpenProject#0 I/O in the DR drill,
Nextcloud#0/#2 budget (4310-4539 s in the replay each, 60 s over),
BookWyrm#0 drill disk, Matrix#1/#3 pids in the replay; none of those
primaries declares docs in the failing variant.

The guide host now stays on only where the primary app's own variant
declares it (declares_guide, the predicate instructions.py already used);
every other role has it switched off (guide_off), in the planner's round
overrides and in the swarm include set. The host_vars bake drops a docs
edge whenever the round does not deploy web-app-docs. Measured for
listmonk, openproject, translate and mariadb: web-app-docs is deployed in
exactly each primary's declared guide round, swarm and planner agree in
all 12 rounds, and rounds shed up to ten apps (mariadb#1 19 -> 9).

test_guide_rides_the_primary_only pins that on real role data (it fails
with the rule disabled); test_variant_pin_required leaves the guide host
out of its clone comparison and no longer counts the docs key as a
pairing edge, because the replay no longer follows a partner's index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:49:51 +01:00
kevinveenbirkenbachandClaude Opus 5.5 c323288170 fix(swarm): end the whole step when the watchdog aborts it
translate#1 (CI run 40625679270) hit the disk floor and the watchdog
returned rc 75, but its proc.terminate() reached only the step's python
wrapper, which dies on SIGTERM without passing it on. ansible-playbook was
re-parented to PID 1 (rescue processes.txt) and kept deploying and testing
for another eleven minutes, ate the rescue headroom, and was SIGKILLed by
the teardown, which surfaced as a misleading rc 137.

The step now runs in its own process group; an abort signals the group
(SIGTERM, 30 s, SIGKILL). SIGINT and SIGTERM sent to the watchdog itself
are forwarded to that group while the step runs, so the outer `timeout`
in 00_one.sh (no --foreground) still stops the step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:33:51 +01:00
kevinveenbirkenbachandClaude Opus 5.5 b87bf5f979 fix(ci): disable partners a tor-only node serves no network of
A variant that pairs a clearnet-only partner with the onion (Matrix
variant 2 with web-svc-coturn) could draw the tor-only network mode, and
the deploy's domain map then aborted with "web-svc-coturn: allows
['clearnet'] but the node runs in 'tor' mode". disabled_services() now
also emits every deployed partner with domains and a Tor bond whose
networks the node mode does not serve, mirroring utils/cache/domains.py,
so the inventory provision drops it the same way it drops Tor on
clearnet rows. Measured across every role: 8 tor-only rows change, all of
which failed at that point before; clearnet and multi rows are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:16:53 +01:00
kevinveenbirkenbachandClaude Opus 5.5 99123762df Merge branch 'feature/updater' into feature/store-i18n
16 conflicts, resolved per hunk:

- sys-stk-front-proxy: tasks/main.yml keeps the network-sibling loop;
  the single stack_host_contents write of http.conf plus vhost and the
  front_proxy_vhost_changed gate move into tasks/vhost.yml, with the
  http.conf decided per sibling domain so an onion vhost gets no HTTPS
  redirect.
- sys-front-tls: 01_http.yml is gone as the updater intends; the
  cert-renew include in main.yml gates on the vhost domain's TLS.
- sys-svc-compose: the updater's resolve_host_cid without the remote
  echo and the prepared pending flag in up.yml, written with
  IS_SWARM_MODE and the repeated-when block grouping.
- web-app-nextcloud: the updater's post-upgrade gate and pending-only
  plugin configuration, which already carry the pinned-plugin handling.
- web-app-xwiki: one XWIKI_CORE_VERSION for the readiness probe and
  style.css.j2; OIDC/LDAP follow xwiki_extensions_ready at bootstrap,
  the S3 blob store stays off there as TestBlobstoreRenderOrder demands.
- roles_with_service: onion swap and dashboard card key combined, the
  canonical domain is the canonical URL's host in both branches.
- sys-ctl-hlth-csp: --clearnet-timeout and --concurrency together;
  run_checker forwards its options to build_docker_cmd, keeping the
  script under the 500-line limit.
- web-app-matrix: Element branding and web_client_location side by side.
- web-app-stalwart/README.md: the generated Cosmos section is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:47:07 +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 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 bc14298f93 fix(i18n): remove the staging file when a catalogue write fails
write_catalog writes the rendered catalogue to .<name>.po.tmp and moves it
over the target. When the write fails, the staging file stayed behind: the
translation run that hit a full disk left eleven empty .docs.po.tmp files
untracked in locale/. Any failure between the write and the move now
deletes the staging file and re-raises, so the target keeps its old bytes
and nothing is left in the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:49:50 +01:00
kevinveenbirkenbach 1e4124b0d7 Merge branch 'feature/updater' into feature/store-i18n 2026-10-25 11:28:33 +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 dc26b941c1 fix(web-svc-translate): answer a batch per string instead of failing it whole
One string that every engine refused or mangled raised out of the batch route's gather, so the gateway answered the whole batch with a 422 and the client lost the other nineteen translations. Gateway.translate_each now owns the bounded fan-out; with partial=true it answers (None, None) for a string that ran into a GatewayError and keeps the rest. Non-gateway exceptions still raise, and a NoBackendError on every string still fails the batch as the outage it is.

The i18n client asks for partial batches and counts a null answer as a refused entry with its reason, so the re-ask and damage checks continue per string. A caller that does not send the flag gets the old behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:56:06 +01:00
kevinveenbirkenbachandClaude Opus 5.5 de29b99a89 fix(i18n): keep letter-digit names like S3 and OAuth2 whole
PROTECTED masked identifiers with an underscore and bare numbers, so S3, OAuth2, POP3 and HTML5 went to the engines as a word plus a separately protected digit. Engines tore them ('Amazon S ilə uyğun ...' for 'S3-compatible') or glued the digit onto another word ('Distribuid3-compatible'); the masking audit over core found 159 such entries.

A letter-digit token is now one protected span, ahead of the bare-number alternative. The client masks it whole, and damage.reason() condemns a translation that lost or moved it, so prune and retranslation pick those entries up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:49:19 +01:00
kevinveenbirkenbachandClaude Opus 5.5 c691abe8a8 fix(swarm-test): drop the backup host's mirrored network mode before it deploys
The VPN variants reset credentials with --mirror --backup in the mesh prologue, which copies the manager's host_vars onto the backup host again, NETWORK_MODE: multi included, after extend_inventory had stripped it. The backup deploy then failed in lookup('tls'): "NETWORK_MODE 'multi' needs svc-net-tor with a node onion on this node" (Meta#0, run 40131272595). The strip is now a helper that also runs right before the backup host deploys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 07:50:10 +01:00
kevinveenbirkenbachandClaude Opus 5.5 d30425c0ac fix(i18n): refuse a translation that adds lines or comes back in another script
A chat engine prefixed the Vietnamese catalog with its own preamble ("...Vietnamese:" followed by a blank line) and wrote Urdu in Devanagari. Neither tripped the damage predicate: vi is Latin script, so the script checks were blind, and untranslated only measures the Latin share, so a non-Latin language answered in another non-Latin script passed.

added_lines refuses a translation that breaks more lines than its source, and the gateway's _mangled carries the same check so such an answer falls through to the next engine instead of being cached. wrong_script refuses a non-Latin language when less than half of the letters it writes in another language's endonym script belong to its own; the script per language is derived from the endonym in meta/languages.yml, and letters are counted over runs of letters plus combining marks so Indic and vocalised Arabic words stay whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:09:00 +02:00
kevinveenbirkenbach 8f22a3d488 Merge branch 'feature/updater' into feature/store-i18n
# Conflicts:
#	README.md
#	roles/svc-bkp-local-2-device/tasks/00_core.yml
#	roles/svc-bkp-nfs-2-local/tasks/00_core.yml
#	roles/svc-bkp-remote-2-local/tasks/00_core.yml
#	roles/svc-bkp-secrets-2-local/tasks/00_core.yml
#	roles/svc-bkp-volume-2-local/tasks/00_core.yml
#	roles/sys-ctl-cln-bkps/tasks/00_core.yml
#	roles/sys-ctl-cln-certs/tasks/00_core.yml
#	roles/sys-ctl-cln-disc-space/tasks/00_core.yml
#	roles/sys-ctl-cln-faild-bkps/tasks/00_core.yml
#	roles/sys-ctl-hlth-webserver/filter_plugins/web_health_expectations.py
#	roles/sys-ctl-mtn-cert-renew/tasks/00_core.yml
#	roles/sys-svc-container/README.md
#	roles/sys-svc-container/vars/main.yml
#	roles/sys-svc-proxy/templates/headers/forwarded.conf.j2
#	roles/sys-svc-webserver-https/tasks/00_core.yml
#	roles/sys-token-store/tasks/write.yml
#	roles/web-app-discourse/templates/config.yml.j2
#	roles/web-app-keycloak/files/shell/per_app_mapper/ensure_app_group.sh
#	roles/web-app-keycloak/files/shell/per_app_mapper/purge/app_subgroups.sh
#	roles/web-app-keycloak/files/shell/per_app_mapper/sync_app_mapper.sh
#	roles/web-app-keycloak/files/shell/per_app_mapper/upsert_app_mapper.sh
#	roles/web-app-matomo/tasks/03_install_path.yml
#	roles/web-app-matomo/tasks/08_reinject_consumers.yml
#	roles/web-app-matrix/README.md
#	roles/web-app-matrix/tasks/flavor/compose/04_webserver.yml
#	roles/web-app-matrix/vars/main.yml
#	roles/web-app-moodle/tasks/03_ownership.yml
#	roles/web-app-seaweedfs/meta/info.yml
#	roles/web-app-stalwart/tasks/04_manage_user.yml
#	roles/web-opt-rdr-domains/README.md
#	roles/web-opt-rdr-domains/tasks/main.yml
#	roles/web-svc-file/tasks/00_core.yml
#	roles/web-svc-html/tasks/00_core.yml
#	tests/unit/python/roles/web-app-prometheus/test_blackbox_targets.py
2026-10-24 17:37:48 +02:00
kevinveenbirkenbachandClaude Opus 5.5 cdcb638deb feat(update): move a pinned commit SHA to its repository's default branch
The repository-ref updater only checked semver refs and skipped commit SHAs, so a role pinned to a commit never received a bump. Discourse pins its launcher and plugins to commits on feature/updater (297f3f4a67) to stop a rebuild on every upstream push, and those pins must keep moving after the merge.

A full 40-hex ref next to a repository, in meta/services.yml or an addon's config, is now compared with the tip of the repository's default branch (git ls-remote <repository> HEAD, one query per repository and run) and rewritten through the same path as a semver ref, so it lands in the usual update/repository-refs PR. Short SHAs and branch names stay skipped, '# nocheck: repository-version' still freezes a pin, and a failed lookup leaves the pin alone. test_version_pins_are_watched counts such pins as watched, and test_semver_pinning no longer warns about them.

The jeff pin of svc-ai-s1 drops its unread 'semver-ref' marker, and bridgy-fed and ltengine drop the repository-version markers that were written to silence the non-semver warning, so all three follow their upstream from now on, as does taiga's oidc plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:28:49 +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
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 0af8ee5992 Merge branch 'feature/updater' into feature/store-i18n
Five conflicts, resolved by what each side is for rather than by taking a
side:

- sys-service/01_prune.yml and sys-service-terminator/02_unlock.yml take
  the incoming single `systemctl` invocation over four module loops. The
  unlock side also drops an unmask step: the list it runs on is built with
  `reject('search', ' masked')`, so it never held a masked unit and the
  step did nothing.
- sys-stk-front-proxy/tasks/main.yml keeps this branch's version. The block
  the other side still carries there moved into vhost.yml, which runs it
  once per network sibling; taking theirs would have duplicated it.
  vhost.yml holds every task of that block, the global injection and the
  TLS layer included.
- web-app-matomo/08_reinject_consumers.yml likewise keeps the per-vhost
  loop, which re-renders the injection for every network sibling and passes
  matomo_token_override.
- utils/cache/domains.py combines both: the incoming side rendered
  DOMAIN_PRIMARY with the real templar and variables, which is right, but
  added a second renderer beside the module's own. _render_plain now takes
  that context optionally, so the other two call sites stay as they were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-24 01:57:56 +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
kevinveenbirkenbach 59c5667f1f Merge branch 'feature/updater' into feature/store-i18n 2026-10-23 22:24:51 +02:00
kevinveenbirkenbachandClaude Opus 5.5 9152c884d8 fix(tls): serve every redirect source the certificate that names it
The cert lookup handed an alias or redirect source the live directory of its app's canonical domain or of its target, so social.cymais.cloud got the infinito.nexus lineage and failed with 'Hostname mismatch'. With the san and dedicated flavors a domain term now resolves to its own live/<domain>, which cert_link_domains points at the lineage that covers it; application terms keep the canonical path. resolve_term follows redirect chains and fails on a cycle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:14:21 +02:00
kevinveenbirkenbachandClaude Opus 5.5 fb18077f57 perf(utils): key the domain index by the applications' domains only
build_domain_index fingerprinted the whole applications tree on every call, 77 % of a tls lookup. The index reads only each application's domains, so the cache key now covers exactly those; a tls lookup drops from 8.2 to 3.1 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:03:40 +02:00
kevinveenbirkenbach a04f2c9895 Merge branch 'feature/updater' into feature/store-i18n 2026-10-23 17:32:32 +02:00
kevinveenbirkenbachandClaude Opus 5.5 c728b4427b fix(tls): resolve a redirect source through its target
With AUTO_BUILD_ALIASES enabled, current_play_redirect_domains adds
<entity>.<DOMAIN_PRIMARY> as a redirect source for every application whose
canonical domain differs. web-opt-rdr-domains then asks the tls and cert
lookups about each source, but resolve_term only knew the domains mapping
and applications[*].domains, so the Keycloak stage on echoserver stopped
with "tls: domain 'funkwhale.infinito.nexus' not found".

redirect_targets() turns CURRENT_PLAY_REDIRECT_DOMAINS into a source to
target map, and resolve_term falls back to it, one hop, only where it
raised before, so every term that resolved keeps its answer. The tls and
cert lookups pass the map.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 16:08:18 +02:00
kevinveenbirkenbachandClaude Opus 5 dc1ba4144c fix(roles): name the redirect loop variable and drop a tmpdir default
A looped include that leaves its variable as item inherits whatever a
loop inside the included file binds, so the redirect loop now names
it and the include reads that name. Nothing in the role referred to
item, so the rename reaches every read.

The variants test asked the environment for TMPDIR and substituted
/tmp when it was unset, which is the silent fallback the project
forbids; tempfile.gettempdir() already knows the order to try.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 15:48:05 +02:00
kevinveenbirkenbachandClaude Opus 5 18e644e53d fix(packages): install the tool apt needs to read an xz control member
ansible.builtin.apt extracts a deb's control member through the xz
binary rather than through dpkg's own library, and Cursor's deb
compresses that member with xz. On a target without xz-utils the
install failed after the download and the checksum had already
passed, which is the most expensive place to discover a missing
tool.

The deb branch of the download plan now installs it first, the way
build_calls installs its declared depends. The rpm branch is
unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 15:01:37 +02:00
kevinveenbirkenbachandClaude Opus 5 66533e6b50 feat(packages): install a checksum-pinned vendor artefact where no repo exists
A vendor that ships a package file but runs no repository left the
registry nothing to resolve, so cursor declared an empty list on Debian
and RedHat and the role deployed while installing nothing.

The new 'download' source names the artefact directly and pins it by
digest: the URL alone would install whatever the vendor serves today.
get_url verifies before and after the transfer, the artefact is cached
root-owned so no other account can swap it in the window before the
install, and a closing package call fails loudly when the artefact
registered under a name other than the one declared.

Cursor's rpm carries no signature, which is why dnf runs with
disable_gpg_check; the digest is the integrity control. All 92 of its
requirements resolve against Fedora 44 and CentOS Stream 10 repodata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 14:08:16 +02:00
kevinveenbirkenbachandClaude Opus 5 677a63931c Merge branch 'feature/network/multi-network-serving' into feature/store-i18n
Resolves 68 conflicted files. The governing rule was our structure with their
network semantics: the tor boolean becomes a network mode (clearnet | tor |
multi) throughout the variant layer, while this branch's architecture and mesh
axes, its extracted modules and its i18n content stay.

Where the two sides covered different ground, both are kept rather than one
picked:

- ca_injected answers a ca_client on the deployment's TLS mode OR on an SSO
  issuer that resolves self-signed. 3a3e9ed2c9 records why: an issuer is not
  an object store, so keeping only one side drops the other's coverage.
- get_merged_domains keeps the loud refusal when svc-net-tor is deployed
  without a provisioned node, now gated on their tor_serves_host; their
  node_onion_of returns an empty string there, which would have served every
  onion-exclusive app on clearnet in silence.
- sso_cookie_domains stays the single source for oauth2-proxy's cookie scope.
  Their per-network branch omitted the issuer domain on the onion arm, which
  breaks the callback leg.
- The clearnet CSP batch carries --clearnet-timeout again; their batch dict
  hardcoded zero while the role still passes the flag.
- The dashboard keeps its i18n content dir, image, version and design
  resolution; their side reduced the logo sources to the bare service
  provider values.

Their side wins where it is measurably better: the onion mirror rewrite is
gated per vhost rather than per app, the Playwright onion decision reads the
pass's own domain instead of the role's canonical one, and the matomo
reinjection and dashboard config render per network sibling.

Eight files were broken by the merge without ever carrying a conflict marker,
because git marks only the lines both sides touched:

- utils/cache/domains.py lost the onionize block that sat between two hunks
- roles/sys-front-inj-all/templates/body_filter.lua.j2 called it
- web-app-bluesky, keycloak sso_proxy: the get_entity_name filter was renamed
- trigger/__main__.py, playwright_coverage.py and its test: the deleted tor
  module
- cli/administration/deploy/ci/timings.py read Label.tor
- plugins/filter/csp_filters.py called _aligned_url with four of six arguments
- tests/unit/python/utils/cache/test_domains.py referenced a renamed class
- tests/.../sys-ctl-hlth-csp/files/test_script.py mocked a function main no
  longer calls

Verified: the variant layer (155 tests, 65 subtests), ca_injected (18),
the CI layer (175), the domains cache including the restored guard (32),
retrigger inputs (9), timings and the CSP health script (53). make
quality-high clears autoformat; its remaining findings are tracked separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 09:53:41 +02:00
kevinveenbirkenbachandClaude Opus 5 e75ccc07cd perf(cache): let the sweep read the variants map without copying it
get_variants handed out a deep copy on every call so one caller's
mutation could not reach the next. services_overrides_for_round asks
for the map once per round, and a sweep resolves hundreds of rounds, so
the matrix planner paid that copy 460 times: 78 million deepcopy calls,
and get_variants alone accounting for 82 of the 163 seconds a profiled
build took.

The copy is now opt-out for that one caller. It reads the map and never
writes to it, resolve_pins takes it as a Mapping and has no mutation
site, and _deep_merge deep-copies both of its arguments, so nothing the
round produces keeps a reference into the cache. Fifteen other callers
keep the copy untouched.

Measured end to end on cli.meta.ci.playwright_coverage: 33.7s to 24.6s.
Equivalence is measured against the copying path for two fixed rounds,
291 roles each, rather than against a sweep -- two identical planner
runs assign different modes to some rows, so a plan diff cannot carry
that proof.

The test asserts identity rather than runtime, as the comparable
optimization in 0cb8bae88d did: a wall-clock bound on this machine is
the same flakiness the sweep already shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 02:54:26 +02:00
kevinveenbirkenbachandClaude Opus 5 74a91e7adf refactor(ci): declare the README replay as a service edge instead of a matrix flag
The replay used to be decided by the matrix: utils.roles.guide picked a
variant, instructions.mark wrote a flag, and the workflow appended
web-app-docs to the row's apps. Now each invokable role declares the
guide host in meta/services.yml with bond 0.5 behind the group gate and
switches it on in exactly one variant, and the deploy pulls it in
through that edge. The matrix only reads the declaration back.

web-app-docs was no provider at all: the registry requires shared,
provides or an alias on the role's own entity entry and it had none, so
the 188 declarations would have been decorative. Registering it brings
a structural contract with it -- a dynamically loaded service role must
set its run_once flag as its very first task or load/app.yml includes
it again -- so the role body moves into tasks/00_core.yml and main.yml
becomes the guard.

smallest_variant no longer counts the guide. It picks the variant that
then switches the guide on, which on the next read would make that
variant one service larger and no longer the smallest; without the
exclusion 30 of 188 roles disagreed with the file.

The job title loses the marker. The row is an ordinary deploy of its
variant, so nothing distinguishes it any more -- which is why the unit
test now asserts the label is left alone.

Measured against the previous tree, the sweep marks 68 rows where it
marked 88. Twenty roles declare the guide on a variant this sweep does
not run in the guide's mode, and by decision the declaration is
absolute: the file is the truth and those roles carry no replay in such
a run. The old code instead searched the deployed variants, which hid
the mismatch behind a fallback.

The same measurement found that the predicate first read the group gate
as a declaration -- the gate renders to a non-empty string, so every
variant looked like the declaring one. It now tests for a literal true.
Neither defect showed up in the unit tests, which check the new logic
rather than its difference to the old.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 02:30:56 +02:00
kevinveenbirkenbachandClaude Opus 5 75a2726389 feat(packages): install from Flathub where no distro package exists
An empty distro entry made package_install return skipped, so the role
deployed and the binary was simply missing. Blender#0 showed the shape:
'blender declare nothing to install on centos', then 'blender
--background' with rc 2 and no output at all.

A flatpak source closes the largest group of those gaps in one
mechanism. It takes application ids, installs the runtime and the
Flathub remote first, and links flatpak.command onto PATH -- without
that link the export is named after the application id, so a role
calling the plain binary still finds nothing and the fix would look
applied while changing nothing.

Roughly fifty entries move across, every id verified against Flathub's
appstream API rather than guessed; four candidates were wrong and did
not survive that check. The external probe now asks Flathub for a
flatpak entry instead of the distribution index, so the same
verification runs on every sweep.

jrnl and msi-perkeyrgb have no package anywhere and install from PyPI
instead: the task registers package_install and falls back to
sys-pip-install only 'when: ... is skipped', which keeps the registry
the single source of the per-distro decision rather than copying it
into the role.

The entries that legitimately install nothing keep their nocheck and
lose the deferral: the myhostname module ships inside systemd, the
kernel server inside nfs-utils, wine carries its own mono and gecko. A
new rule holds that line -- a justification may explain why nothing is
installed, but may no longer park the install behind a TODO. Twelve
gaps still fail it and stay red until each has a mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 02:09:32 +02:00
kevinveenbirkenbach 5c989668c7 Merge remote-tracking branch 'origin/main' into feature/network/multi-network-serving 2026-10-23 01:33:24 +02:00
kevinveenbirkenbachandClaude Opus 5 5b6666e70c Merge branch 'main' into feature/store-i18n
Conflicts resolved:
- Dockerfile: keep HEAD; filesystem.sh already runs at line 37 and pin_epel()
  arrived inside centos_mirror.sh, so main's hunk would have run it twice.
- peertube 02_fix-application-schema.yml: take main's task name, its
  expect_rows: true (matching the first query in the same file) and its when:
  without default(); keep HEAD's changed_when: true, which the notify:
  compose-up depends on.
- peertube check-application/defaults.sql: take main's information_schema
  query; HEAD's ::regclass cast raises instead of returning no rows when the
  table is absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-22 21:40:06 +02:00
kevinveenbirkenbachandClaude Opus 5.5 96fe8bd7e6 feat(playbooks/update): pull the images of the deploy before the upgrade window
pull.yml pulls, on the target host, every pinned image of the applications the host deploys, the same closure the update check judges, and lists the images a role builds itself instead. It never touches a container, retries each pull a bounded number of times and fails loudly with the image and the registry error. The update_check filter moves next to the new update_pull filter into plugins/filter/update/, and both read the code pins from one helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 21:30:18 +02:00
kevinveenbirkenbach 4a86376aeb Merge remote-tracking branch 'origin/main' into feature/updater 2026-10-22 21:08:26 +02:00
kevinveenbirkenbachandClaude Opus 5.5 091c58307d fix(install): drop the shfmt docstrings that misstate ensure()
Three docstrings from #1173 claimed that ensure() leaves only the pinned
release on PATH or replaces whatever version PATH finds. It does neither:
it installs the release into /usr/local/bin and fails unless PATH then
resolves to it, so a distribution copy such as /usr/bin/shfmt stays on
disk behind it.

The test module and ensure() lose their docstrings; the module docstring
of the installer returns to the form its siblings use ("Install hadolint
via GitHub-release prebuilt binary.").

Reported by CodeRabbit on #1173.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 20:37:01 +02:00
kevinveenbirkenbachandClaude Opus 5.5 a9a12da75c fix(install): install the pinned shfmt release on every distro
`shfmt.ensure()` returned as soon as any `shfmt` was on PATH and tried
the distribution package before the release pinned in
roles/sys-lint/meta/services.yml. The Debian dev container therefore
formats with its packaged 3.8.0 (/usr/bin/shfmt), Fedora with its packaged
3.7.0, the CentOS one with the pinned v3.14.1 (/usr/local/bin/shfmt), and
they disagree on
scripts/tests/deploy/swarm/utils/unmount/nfs_mounts.sh: every gate in a
CentOS stack moved the `then` of the heredoc `if` below the terminator.

ensure() now follows ruff's installer: it reads `shfmt --version`, keeps
the binary only when it reports the pinned release, and otherwise installs
the release binary into /usr/local/bin. It fails when PATH still resolves
another version afterwards instead of formatting with it. The system
package path is gone.

nfs_mounts.sh is committed in the pinned release's format (byte-identical
to the v3.14.1 output from the CentOS container), so the first gate on any
distro leaves the tree clean.

The install-lint stamp now also depends on roles/sys-lint/meta/services.yml,
the pin file of actionlint, hadolint and shfmt, so a bumped pin reaches
environments whose stamp predates it; until now only pyproject.toml and
scripts/install/lint.sh invalidated it.

Fixes #1155

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 19:16:22 +02:00
kevinveenbirkenbach 17918992c7 Merge branch 'main' into feature/network/multi-network-serving
# Conflicts:
#	plugins/lookup/compose_file_args.py
#	roles/web-app-dashboard/tasks/02_manager_ops.yml
#	roles/web-app-dashboard/vars/main.yml
2026-10-22 18:52:10 +02:00
kevinveenbirkenbachandClaude Opus 5.5 d080255ba1 fix(swarm-test): let the backup host derive its own network mode
02_provision_inventory.sh copies the manager's host_vars to every node,
the backup host included, so a row with network=multi handed bkp-01
NETWORK_MODE=multi as well. The backup host is deployed from its own
backup.yml, which has no svc-swarm-node group and no Tor provider, so
tor_serves_host is false there and resolve_node_mode raised at the first
tls lookup: "NETWORK_MODE 'multi' needs svc-net-tor with a node onion on
this node" (01_constructor.yml, pixelfed swarm drill on the multi axis,
after a green deploy round and Playwright pass).

extend_inventory already prepares the backup host's own inventory after
the copy; it now also drops NETWORK_MODE from that host's host_vars, so
the backup host derives clearnet as a host without Tor does. The
remote-2-local pull reaches the manager over SSH and needs no onion.
Rows on clearnet or without a mode resolve as before; only the tor and
multi rows, which raised, change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 18:33:56 +02:00
kevinveenbirkenbach 2d7f842613 Merge branch 'main' of github.com:infinito-nexus/core into feature/updater 2026-10-22 11:27:47 +02:00
kevinveenbirkenbach 4c4b00a0e9 Merge branch 'main' into feature/store-i18n
# Conflicts:
#	roles/update/README.md
#	roles/web-app-bluesky/templates/extra_locations.conf.j2
#	roles/web-app-opentalk/templates/nginx/extra.conf.j2
#	scripts/install/filesystem.sh
2026-10-22 10:57:53 +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 c65c5ce48c fix(update): pin only image tags the registry carries
7e82ba6d71 moved web-app-bigbluebutton's bbb-pads to v1.5.11, the tag
https://raw.githubusercontent.com/bigbluebutton/docker/develop/repos/tags
names for the bbb-pads source. alangecker/bbb-docker-pads has no such
image (Docker Hub: newest v1.5.8), so every BigBlueButton deploy pulls a
missing tag and test-external fails "These pinned Docker images do not
exist in their registry (HTTP 404): web-app-bigbluebutton/bbb-pads".

bbb-pads goes back to v1.5.8. The version-source updater now knows the
image a `version` pin tags (pulled directly or as the base of a custom
build) and withholds a bump when the registry answers 404 for the new
tag; an indeterminate answer does not block it, the same line
test-external draws. Run against main, the updater now reports no
update for bbb-pads instead of v1.5.11.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:30:10 +02:00
kevinveenbirkenbach b2ff696522 Merge remote-tracking branch 'origin/main' into feature/store-i18n
# Conflicts:
#	utils/install/README.md
2026-10-21 18:45:09 +02:00
kevinveenbirkenbachandClaude Opus 5 d9f8af9b16 fix(ci): refuse a mesh pin the row cannot take
A selection token may pin the mesh state as &vpn or &direct, but check_pins
had no arm for it. pinned_vpn_states intersects the pin with the states the
mode allows and only swarm meshes, so a token like @compose+clearnet&vpn left
an empty state list: the row produced no job at all and the run reported green
for a combination that never deployed. That is the outcome _reject exists to
prevent, and every sibling axis already refuses an impossible pin.

The new arm asks whether any candidate mode keeps a state rather than whether
the pin is reachable, so a held vpn axis still overrides the pin instead of
being refused. That behaviour has its own test and is unchanged.

An earlier check was removed with the compose-mesh revert of 2026-10-16
because it rested on meshable() and smallest_variant(), which that revert
deleted. This one reuses pinned_vpn_states, applies to the swarm-only axis the
revert restored, and covers both mesh states instead of only &vpn.

pin_vpn and vpn_mode now reach check_pins from all three callers. The run's
vpn axis reaches the validation job through call-orchestrator and
call-validation, which passed every other axis but this one. The priority
procedure and its skill state all eight axes a token carries, not six:
architecture and mesh were pinnable and, for architecture, already validated,
yet neither was documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-21 18:13:54 +02:00
kevinveenbirkenbachandClaude Opus 5.5 d4b3609488 fix(install): refuse the upstream Node.js install on non-Linux hosts
`tarball_name()` picked the archive from `platform.machine()` alone, so
an end-of-life Node.js on Intel macOS would have unpacked a Linux build
into /usr/local, Homebrew's prefix there. The upstream install now
supports Linux only and stops with a message to install a supported
Node.js through the package manager everywhere else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 16:52:56 +02:00