Commit Graph
497 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 4.8 6cc9228a10 fix(swarm-nfs): materialize export subdirs per node and honor volume nfs metadata
The export subdir was created run_once on the NFS server only; a worker
that had already readdir'd the export kept a negative dentry and swarm
rejected every replicas>1 task with 'readdirent .../_data: no such file
or directory' for the whole converge window. Materialize the bind-device
dir through each node's own mount, guarded by a per-node mountpoint
assert so an unmounted node fails the deploy loudly instead of receiving
a local mkdir that splits the brain and trips the nfs-client
has-local-data assert on the next full deploy.

compose_volumes popped the volume 'nfs' metadata before nfs_prep could
read it, so declared uid/gid/mode never took effect and every subdir
fell back to root 0755; keep the mapping under the compose-spec
extension key x-infinito-nfs (ignored by docker, verified against stack
config and compose config). KIX declares uid/gid 110010100 mode 0770
through it, fixing the backend_api_token EACCES crash on its shared
volume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:15:27 +02:00
kevinveenbirkenbachandClaude Opus 4.8 e77aae2afe perf(service-loader): single applications-load preload
Flatten a single list term in the service lookup so query(service, [ids]) does one applications load instead of one per id. Precompute the required service ids in sys-service-loader and gate the preload by set membership, collapsing about 30 applications loads into one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 02:47:40 +02:00
kevinveenbirkenbachandClaude Opus 4.8 c8a4320478 feat(lookup): container_hostname — kernel-safe container hostname
`lookup('container_hostname', app)` returns the app's canonical primary
domain when it fits sethostname(2)'s 63-byte cap, else the short entity
name. Onion subdomains (sub.<56-char>.onion) overflow that limit and make
`docker run` fail with "sethostname: invalid argument", killing container
init on onion nodes. Replaces the seven roles that hostname'd their
container on the raw domain (bookwyrm, bridgy-fed, confluence, jira,
postmarks, pretix, xwiki) with the encapsulated lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:34:00 +02:00
kevinveenbirkenbachandClaude Opus 4.8 32b2cf7e80 style: make autoformat pass (ruff line wraps, YAML comment spacing)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:33:59 +02:00
kevinveenbirkenbachandClaude Fable 5 808efad765 fix(test-e2e-cli): per-role CLI timeout via meta/tests.yml
svc-runner's CLI test runs a full nested reinstall deploy and needs
75+ minutes, so the uniform 3600s timeout killed it deterministically
in both modes. A new role_tests lookup reads roles/<id>/meta/tests.yml
(cli.timeout, scaled through the timeout lookup) with the existing
default as fallback, keeping the short hang-leash for every other CLI
test. svc-runner gets 4h and pins the nested deploy to variant 0; the
second matrix round only redeployed cdn+dashboard and added no
assertion value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:13:40 +02:00
kevinveenbirkenbachandClaude Fable 5 4a23e0cb09 feat(services): declare locally-built images via services.<key>.custom
The custom flag moves from 33 hardcoded template kwargs into the merged
service config: 'custom: true' names the shared <entity>_custom build,
a string names a second distinct '<string>_custom' image inside one app
(nextcloud whiteboard). The image lookup reads the declaration as a
fallback, so config consumers (e.g. backup image matching) resolve the
exact deployed reference from one declaration point. nextcloud cron now
runs the nextcloud_custom build like the app service instead of the
upstream image.

A new services lint couples the declaration to reality in both
directions: custom requires a role Dockerfile, and a role Dockerfile
requires a custom declaration. The eight legacy roles deploying local
builds under upstream tags or ad-hoc image= overrides are ratcheted via
'# nocheck: dockerfile-custom'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:48:53 +02:00
kevinveenbirkenbachandClaude Fable 5 e917f0860d refactor(lookups): split bare-ref image resolver out of container_image
New 'image' lookup is the SPOT for resolving <registry>/<image>:<version>
(swarm registry prefix in swarm mode, bare in compose; custom= accepts a
string base for a second distinct custom image inside one app).
container_image becomes a thin wrapper that forwards terms+kwargs and adds
the compose image: "<ref>" line, so all existing template call sites keep
their output byte-identical. The lookup-usage integration test now counts
lookup_loader.get() delegation as production usage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:48:53 +02:00
kevinveenbirkenbachandClaude Fable 5 781e787550 fix(opentalk): grant the embedded seaweedfs sidecar its consumer identity
The OpenTalk controller v0.34.1 verifies its bucket via S3 ListBuckets
membership, but SeaweedFS hides every bucket from an unconfigured
identity (confirmed identical on 4.38 and 4.39), and the embedded sidecar
started with no s3.config, so the controller's signed request got an
empty bucket list and crash-looped on 'bucket missing' although the
bucket existed. The sidecar now runs s3.configure to register the
consumer's access key with Read/Write/List/Tagging on its bucket, the
same grant the central provider gets from its mounted s3.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:48:53 +02:00
kevinveenbirkenbachandClaude Opus 4.8 be1a22661a feat(lookup): central onion-aware canonical_url resolver + enforcement
lookup('canonical_url', app_id[, key][, consumer=app]) resolves the web base
URL for an app or one of its named canonicals (element/synapse/filer),
following the onion-injected merged-domain map: .onion targets resolve to
http (Tor provides transport security), everything else follows TLS_ENABLED.
The consumer= kwarg returns '' unless the consuming app has the target's
service binding enabled, replacing the recurring
"(lookup(...) if lookup('config', app, 'services.X.enabled') else '')"
template branching.

- utils/cache/domains.py: inject the node onion into dict-form
  (named-canonical) domain declarations, not just list/str forms
- keycloak redirect_uris: onion redirect URIs are http
- 13 playwright.env.j2: manual scheme branching -> canonical_url(consumer=)
- lint: forbid manual '://' concatenation and to_onion_url in role templates
- unit tests: 10 cases (primary/named x clearnet/onion, consumer gating)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:56:13 +02:00
kevinveenbirkenbachandClaude Opus 4.8 bbb1220d7d feat(web-svc-mirror): first-party privacy proxy for third-party assets, coupled to tor
New web-svc-mirror role: an nginx vhost that proxies and caches exactly
the external asset hosts aggregated from all deployed applications' CSP
whitelists (external_asset_origins filter) under first-party paths
(/<origin-host>/...), refusing every other upstream. svc-net-tor
consumes the service, so any tor deployment automatically serves the
mirror on its onion.

For apps with services.tor.enabled the shared body filter rewrites
external asset URLs in HTML to the onion mirror (onion sessions only;
clearnet untouched) and the CSP builder allows the mirror origin in the
asset directives. The dashboard's keycloak-js vendoring is reverted:
onion sessions now load the adapter through the mirror, clearnet loads
jsdelivr directly as before.

Verified: variant=0 deploy green (failed=0), dashboard 14/14 over onion
with keycloak-js served through the mirror, mirror spec green, health
check accepts the 404 vhost root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:56:13 +02:00
kevinveenbirkenbachandClaude Fable 5 e2f8d4c304 refactor(container): drive exec timeout and CA paths from group_vars SPOTs
Two wrapper behaviours move onto single sources of truth:

Exec timeout: SYS_TIMEOUT_CONTAINER_EXEC (07_schedule.yml, default 7d so a
hard kill never beats a production migration; the dev/CI inventory
tightens it to 60min) reaches every task host via the play environment as
CONTAINER_EXEC_TIMEOUT. The wrapper prefixes only the exec subcommand with
coreutils timeout --kill-after=15. Ansible task timeouts are SIGALRM-based
and slept through the last wedged docker socket for 4.5 hours; the OS
level kill still fires there and returns 124, which until/failed_when and
the rescue diagnostics react to. Unset or 0 keeps todays behaviour.

CA container paths: the bind-mount targets for the injected root CA and
wrapper script lived as string literals in container.py, compose_ca.py
and the filter plugin. They now live once in the CA_TRUST group_vars SPOT
(inject_cert_container, inject_wrapper_container) and reach container.py
as env vars (play environment plus CA_TRUST_SYSTEMD_ENV) and compose_ca.py
as CLI arguments built by the compose_ca_inject_cmd lookup. A caller that
provides the host CA env but not the container paths is half-migrated and
fails loudly instead of silently dropping CA injection; a host without any
CA env keeps the soft fallback. All tests read the paths from the SPOT,
and a regression test asserts the runtime scripts carry no literal copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:58:28 +02:00
kevinveenbirkenbachandClaude Opus 4.8 8519642373 refactor(storage): SPOT the nfs export base and client version in their roles
storage.nfs shrinks to the deployment-specific server address: export_base moves into svc-storage-nfs-server's services.yml (the server dictates where exports root) and the mount protocol version into svc-storage-nfs-client's services.yml (the client owns how it mounts). Both are read through utils.storage.nfs loaders by the nfs lookups, the env handler and 00_topology, eliminating the unrendered-variables class for these values; the group_vars assert shrinks accordingly and the swarm test extras stop restating role defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:54:32 +02:00
kevinveenbirkenbachandClaude Opus 4.8 29184e38da fix(storage): resolve the nfs state subdir from the python SPOT
Lookup plugins receive ansible variables unrendered, so routing the state subdir through a group_vars jinja value handed state_path the literal '{{ lookup(...) }}' string and the controller mount asked the server for /srv/nfs/{{ lookup('nfs_state_subdir') }} (Connection refused, nextcloud swarm roundtrip). Both nfs lookups now import STATE_SUBDIR directly; the intermediate lookup, the group_vars indirection and the unit-test fixture var that masked the gap are gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:04:40 +02:00
kevinveenbirkenbachandClaude Opus 4.8 a7fbb94972 fix(swarm-test): bound diagnostics against wedged dockerd and SPOT the nfs state path
A wedged DinD dockerd hung a single diagnostics docker exec until the 360-minute job timeout cancelled the job together with its artifact uploads (job 86013153099 sat 5h in service logs). Route every diagnostics exec through a timeout wrapper, tail-limit the log dumps, bound showmount, cap the collect step at 30 minutes, and dump the pending services' log tails once on the final converge failure. The NFS state path moves into a single source of truth: STATE_SUBDIR in utils/storage/nfs.py feeds the nfs_state_subdir lookup (deploy side) and the derived INFINITO_SWARM_NFS_STATE_PATH env var (harness side); the diagnostics script requires both path vars instead of defaulting, and the swarm inventory drops its redundant export_base restatement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 17:30:36 +02:00
kevinveenbirkenbachandClaude Opus 4.8 cfa368ceff fix(opentalk): break the swarm bucket/client provisioning deadlock
The controller crash-looped on a missing S3 bucket and the recorder on an unprovisioned OIDC client while the swarm converge gate blocked before any post-flush task could create either. The embedded seaweedfs sidecar now bootstraps its consumer bucket itself at startup (new seaweedfs_sidecar_script filter: entrypoint-wrapped server, bounded readiness poll, idempotent weed shell bucket create, TERM/INT forwarded to the server), and the Keycloak clients are provisioned before the handler flush. Post-flush bucket tasks stay as an idempotent backstop; the obsolete restart tasks are dropped. Live-validated against chrislusf/seaweedfs:4.38: bucket created, stop behavior identical to the stock entrypoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 17:30:36 +02:00
kevinveenbirkenbachandClaude Opus 4.8 09a131d007 refactor(placement): rename meta key default_placement to placement
Rename the meta/services.yml key across all roles, the roles_with_default_placement lookup (now roles_with_placement), the get_role_default_placement / iter_roles_with_default_placement helpers, and every consumer (swarm closure, compose_volumes filter, nfs_prep, container deploy template, constructor stage, extend_inventory) plus their tests and docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:19:08 +02:00
kevinveenbirkenbachandClaude Opus 4.8 af1f915e70 fix(networks): honor compose_mode_force in network lookups
compose_networks/container_networks read DEPLOYMENT_MODE directly, so a node-local compose-in-swarm role (compose_mode_force=compose, e.g. the matrix ansible flavor) rendered a swarm overlay a standalone compose container cannot attach to ("network matrix not found"). Honor compose_mode_force like the other mode-aware lookups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 00:03:33 +02:00
kevinveenbirkenbachandClaude Opus 4.8 55ba9c0ffa feat(sys-front-inj-all): rewrite injected CDN origin to onion at request time
New to_onion_url filter swaps a clearnet host suffix for the tor_node and forces http (no-op without tor_node or off-primary hosts). body_filter.lua uses it in onionize() so that .onion requests receive the onion CDN origin for injected head/body snippets (e.g. universal-logout.js), which the Tor browser cannot reach on clearnet. Covered by 7 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:56:32 +02:00
Alejandro Roman 29f5e2cba1 Merge remote-tracking branch 'upstream/main' into feature/role-stalwart-mail 2026-07-08 10:31:42 +02:00
kevinveenbirkenbachandClaude Fable 5 15f95f0725 fix(curl): timeout-hardened curl SPOT filter, lint, and fleet-wide rollout
A curl against an accepted-but-silent peer hangs forever: task retries
never fire because the first attempt never returns, and the CI job dies
at the 6h runner cut (proven by the mailu admin-API hang that took the
suitecrm/peertube swarm legs down). The new `curl` filter renders the
hardened base command ("{{ 30 | curl }}" -> curl -s --connect-timeout 5
--max-time 30); scaling composes with lookup('timeout').

test_curl_via_filter enforces it: bare curl invocations in role tasks,
templates and shipped scripts fail the lint with a pointer to the
filter. Plain .sh files, which cannot render Jinja, pass by pinning
--max-time inline; Docker healthchecks are exempt (their timeout field
caps them); `# nocheck: curl-timeout` opts out (catalogued in
suppression.md).

Convert all 34 existing invocations: mailu (replacing the short-lived
MAILU_CURL var), elasticsearch, checkmk, yourls (300s for the
schema-creating installer POST), the telegram alert script, and the
plain scripts of jellyfin, svc-runner, apt_repo, moodle (600s tarball)
and the mattermost provisioner. Mailu's user/token tasks additionally
carry task-level timeouts so a wedged container exec cannot stall the
play.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:37:28 +02:00
kevinveenbirkenbachandClaude Fable 5 aedb26eacc fix(bigbluebutton): pull prebuilt images and align with house patterns
The freeswitch build fetched libopusenc from launchpad.net, whose
librarian backend rate-limits CI (429s across 11 attempts); three more
build-time fetches carried the same risk. The maintainer publishes an
alangecker/* image for every buildable service, so compose_mods drops
build: wherever the service carries an image and compose pulls instead;
a guard task fails loudly if a future upstream service still builds.

Pin ref to the v3.0.23 release branch (the packaging repo has no tags)
and clone it without submodules via the new compose_repository_recursive
toggle: submodules only feed build contexts and DEV_MODE mounts, and
generate-compose falls back to the committed repos/tags file.

Declare the pulled images in meta/services.yml (updater-managed SPOT,
mirror pre-pull) and the named data volumes in meta/volumes.yml; the
override renders both through the standard container_image /
container_volumes / compose_volumes lookups with the service_name
pattern. Compose merges volumes by target, so the override's named
volumes verifiably replace the clone-local bind mounts (docker compose
config proven). This also heals a split-brain mount: apps-akka,
periodic and prometheus-exporter previously kept bind mounts while
their peers used the named volumes.

Teach container_image and compose_volumes to honour compose_mode_force
(the SPOT the other mode-aware lookups already read): bbb deploys as a
node-local compose stack in swarm, so it must get hub image refs and
plain local volumes there, not registry-prefixed refs and NFS
driver_opts. The unpinned-engine-volume lint learns the same two safe
conditions. Read the embedded Collabora port from web-svc-collabora's
services SPOT instead of hardcoding 9980.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:37:27 +02:00
kevinveenbirkenbachandClaude Opus 4.8 0bf1e8ef8c refactor(gitlab): replace Omnibus with the Cloud Native GitLab composition
Deploy GitLab from the official CNG CE images as six services: webservice (puma) and sidekiq scale to the topology default, workhorse is the sole HTTP entry, gitaly and a one-shot migrations job are pinned singletons, shell runs gitlab-sshd. The Omnibus container, its Dockerfile and the GITLAB_OMNIBUS_* config groups are removed. All rails/workhorse/shell/gitaly configs and shared secrets are pre-rendered by Ansible and mounted read-only; eleven credentials land in meta/schema.yml including an rsa_pem_2048 OIDC signing key (new value_generator algorithm). Postgres, redis and S3 object storage ride the existing lookups; OIDC, SMTP, the public SSH port and the root password are preserved. compose_volumes gains a per-volume nfs:false opt-out so the gitaly repositories volume stays node-local in swarm (upstream forbids git repositories on NFS); nfs_prep skips volumes without driver_opts accordingly. The Nextcloud gitlab integration execs the CNG rails runner. ruby_single_line dies with its only consumer; ruby_dq keeps test coverage. Ports (8181/8080/2222/8075/3808/9122) and service names resolve from single SPOT declarations in meta/services.yml via vars constants; the version pin sits on the webservice image pair so the updater can bump it. Live-validated in compose up to all six services healthy and migrations completed; fresh-install-only semantics for existing Omnibus data are documented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 02:37:27 +02:00
kevinveenbirkenbachandClaude Opus 4.8 fbe50d10ed fix(svc-runner): make the runner role swarm-compatible
Workers failed 'docker build: no Dockerfile': the build context, compose file, .env and runner binary were staged on the stack host only while every node deploys its own node-local runner stack. Build and push the image once on the manager (RUNNER_IMAGE_REF via the swarm registry, 04_image.yml) and let workers pull it; render compose.yml (template with lstrip_blocks, matching the replaced stack_host_template action), .env and the extracted run.sh on every node. Add a node_local kwarg to the compose_networks/container_networks/compose_restart/compose_only lookups so this node-local compose stack renders a bridge default network (workers cannot create overlays), keeps restart: unless-stopped (ephemeral runners died after one job) and keeps container_name under swarm; default is unchanged for every other role. Treat build/install.stamp as stale when the venv is missing and exclude ./build from the nested-src copy, closing the nested-e2e ruamel failure class. Regression tests for the node_local render paths; the network include tests now accept lookup kwargs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 02:37:26 +02:00
kevinveenbirkenbachandClaude Opus 4.8 954da6d180 fix(tor): onion-aware CSP and oauth2-proxy (exclusive vs primary)
Over Tor a page served on an .onion still advertised a clearnet-only CSP and oauth2-proxy config, so onion origins were blocked (dashboard could not frame auth.<onion>; oauth2-proxy rejected the redirect back to the onion app). build_csp_header now mirrors the clearnet primary to the node onion per token; the keycloak oauth2-proxy config derives whitelist_domains and cookie_domains from the app resolved domains. Exclusive (onion-only) apps get onion tokens only, primary apps get both, non-tor apps stay clearnet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:25:44 +02:00
kevinveenbirkenbachandClaude Opus 4.8 c09eb61dc6 style: drop explanatory comments from the Tor onion changes
Remove the inline/trailing comments added across the Tor egress-router, onion-domain, mailu-notls and vault round-trip changes, per the repo no-comments convention. Comment-only: no code behaviour changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:25:44 +02:00
kevinveenbirkenbachandClaude Opus 4.8 e7d987b7c5 fix(email): relay plaintext to onion mail hosts (notls, no cert)
The email lookups tls default keyed on DOMAIN_PRIMARY, which can stay clearnet while Mailus canonical domain is turned into a .onion by the Tor onion injection. TLS therefore stayed on, but Mailu has no public certificate for the .onion (LE cannot sign it) and disabled TLS, so the SMTP handshake to the local front failed and the msmtp health check errored. Key the .onion detection on the resolved mail host instead (host is now resolved before tls in RESOLUTION_ORDER); an onion mail host relays in plaintext, which Tor already authenticates and encrypts end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:24:56 +02:00
kevinveenbirkenbachandClaude Opus 4.8 456ff59ca6 feat(lookups): tor_socks endpoint SPOT + tor_ports exposed-port forwarding
tor_socks returns <host>:<port> from svc-net-tor services.tor.ports.local.socks (single spot; default host 127.0.0.1, host overridable for 0.0.0.0/host.docker.internal). tor_ports gains variant-aware collect_exposed_ports: the loopback ports.local of exposed:true services become per-variant onion HiddenServicePort targets (public/TLS ports excluded).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:53:18 +02:00
kevinveenbirkenbachandClaude Opus 4.8 cbe3555d94 feat(svc-net-tor): expose deployed roles' public TCP ports over the node onion
Add a `tor_ports` lookup that collects every single-int `ports.public` entry
from the deployed roles' meta/services.yml and returns HiddenServicePort
mappings (`127.0.0.1:<port>`). UDP-only categories (relay, media) are excluded
since Tor forwards TCP streams only. Wire it into TOR_ONION_EXTRA_PORTS
(19_tor.yml) alongside the SSH port so mail (25/143/465/587/993), gitea/gitlab
SSH, ldaps, matrix federation and STUN/TURN control ports are reachable at
<node>.onion. Register Mailu's mail endpoints under ports.public and add the
matching PORT_BANDS entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 03:26:03 +02:00
kevinveenbirkenbachandClaude Opus 4.8 266a37d965 feat(svc-net-tor): onion mail/SSH ports + TLS-off compat (CA CN, HSTS, msmtp)
- svc-net-tor: expose SSH (22) + Mailu SMTP/submission/IMAP (25/465/587/143/993)
  as HiddenServicePorts on the node onion (TOR_ONION_SSH_ENABLED, 19_tor.yml).
- email lookup: relay plaintext (tls off, port 25) for .onion mail domains —
  no public TLS cert for a .onion; onion transport is already authenticated.
- CA root CN: drop the " Root CA" suffix when a 62-char v3 onion + suffix would
  overflow the X.509 commonName 64-char cap (02_tls.yml).
- Playwright onion compat: HSTS is HTTPS-only (RFC 6797), absent on the http
  onion vhost. New shared helper expectHstsWhenTls() gates the assertion on the
  reached scheme; wired into web-svc-cdn spec.
- rerun-spec.sh: route the inner-loop browser through the Tor SOCKS proxy when
  the staged env targets a .onion, mirroring the deploy-time runner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:39:40 +02:00
kevinveenbirkenbachandClaude Fable 5 97b1ffddb4 fix(dotenv): quote whitespace values in the swarm env passthrough
The swarm branch passed values through raw, so a value with interior
whitespace (MAIL_FROM_NAME='Infinito.Nexus by ...') landed unquoted in
the rendered .env and broke the env parser: the snipe-it service
crashlooped at 0/3 until the converge wait failed in every variant.

Quote (and escape backslash/double-quote) only when the value contains
space/tab/newline; everything else stays raw, so the swarm invariants
hold: no compose $$-doubling, raw $ and double quotes preserved,
and the playwright --env-file path (docker CLI keeps quotes literal)
is unaffected because no playwright env value can contain whitespace.
Compose quoting is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:34:28 +02:00
kevinveenbirkenbachandClaude Opus 4.8 e6538e4f61 feat(svc-net-tor): Tor onion-service provider (full onion node)
Turn a NAT/edge node into a full Tor v3 onion node: DOMAIN_PRIMARY
becomes the node's .onion, so the whole stack (web, SSO, LDAP, CA)
resolves onion domains and is reachable over Tor without a public IP,
port forwarding, VPS or DynDNS.

- utils/tor_onion.py: offline v3 key mint (address + HiddenServiceDir
  key files), verified against a known key->address test vector.
- cli .../inventory/onion: `init-env` pre-mints the node onion, writes
  INFINITO_DOMAIN(+_RE) into .env and authoritative key files into
  .onion-identity/hs (idempotent). The onion is a deploy-time input;
  no in-deploy random minting (that would not match the pre-set domain).
- roles/svc-net-tor: Tor sidecar (Debian-built, network_mode: host),
  installs the minted keys, torrc with one node HiddenService + a SOCKS
  proxy restricted to loopback/DiD ranges. files/test.sh probes every
  served onion domain over the Tor SOCKS.
- tls: .onion -> TLS off (onion v3 crypto is the transport auth; LE
  cannot sign .onion) + app-level flavor override.
- harness/DNS: coredns resolves the onion zone via INFINITO_DOMAIN;
  skip the clearnet DNS sanity check for onion; raise nginx
  server_names_hash_bucket_size to 128 for long onion server_names.
- test-e2e-playwright: gated browser SOCKS proxy (PLAYWRIGHT_PROXY) +
  navigation/action timeouts so specs reach .onion over Tor and fail
  fast; the onion node points it at the Tor SOCKS.
- docs/requirements/031: full design.

Verified: dashboard + SSO (Keycloak) + 9 apps reachable over Tor
(HTTP 200 through the node hidden service). make test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:38:11 +02:00
kevinveenbirkenbachandClaude Fable 5 1371d01a4f fix(service-lookup): resolve out-of-play keys via the static role registry
The sys-service-loader iterates the role-wide static service order, so it
asks the service lookup about keys (e.g. the css alias of cdn) that the
play-local registry built from applications does not contain. The lookup
raised and killed the bootstrap play on every distro.

Fall back to the registry read from roles_dir, the same source the
loader iterates, so every loader-visible key resolves with its provider
role and play-computed flags (false when no consumer is deployed).
Canonical aliases aggregate their primary key's consumer flags. A term
unknown to both registries still raises.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:00:49 +02:00
kevinveenbirkenbachandClaude Fable 5 9c84ae2240 feat(engine): shared-aware container field + SPOT container names via services.<x>.name
Container-name vars derived their value inconsistently (hard-coded literals, entity_name composites) instead of the role-owned SPOT. Add a lint requiring every *_CONTAINER var to resolve through lookup('config', app, 'services.<x>.name'), the bare entity_name form, or the engine lookup; give each multi-container worker a name: in meta/services.yml (with realistic resource limits) and switch its var to the lookup. Extend the engine lookup with a shared-aware 'container' field (central provider container when shared, embedded <entity>-<engine> otherwise) and gate zammad's embedded memcached sidecar on not-shared so its container_name never collides with the central memcached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:00:49 +02:00
kevinveenbirkenbach 9ee4cd0fb9 Merge branch 'main' of github.com:infinito-nexus/core into feature/swarm-nfs
# Conflicts:
#	docs/agents/action/iteration/compose.md
#	roles/svc-db-openldap/vars/main.yml
#	roles/svc-runner/defaults/main.yml
#	roles/svc-runner/tasks/02_cleanup_instance.yml
#	roles/web-app-discourse/meta/server.yml
#	roles/web-app-mailu/tasks/01_core.yml
#	roles/web-app-odoo/tasks/main.yml
2026-07-04 18:16:12 +02:00
kevinveenbirkenbachandClaude Fable 5 f18d909727 fix(service-registry): build the ordered loader list from static role metadata
Per-host rendered applications flip dynamic shared flags with
group_names, so hosts computed divergent ordered lists and Ansible's
result-arrival include registration could deploy consumers before
their engines. The ordered mode now reads the static roles dir; the
per-host when gate in load_service.yml keeps deciding what loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:05:57 +02:00
kevinveenbirkenbachandClaude Fable 5 7d5775ff96 fix(matrix): force compose rendering for the node-local ansible flavor
The ansible flavor deploys matrix as node-local compose even inside a
swarm cluster, but compose_only stripped container_name and friends
because DEPLOYMENT_MODE said swarm, so follow-up exec tasks could not
find the containers. compose_mode_force pins the rendering to compose;
the lookup honors it ahead of the cluster mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:05:57 +02:00
kevinveenbirkenbachandClaude Fable 5 30afef6bbe fix(resources): entity fallback, short compose service names + SPOT vars
resource_filter now falls back service_name -> entity_name -> hard
default, so entity-level limits keep applying to per-service compose
entries. Compose service names drop the entity prefix (kix, jitsi,
zammad, bluesky); the docker container_name keeps it via compose_only.
A new lint forbids '<entity>-'-prefixed service names.

Service names and internal ports are declared once: vars/main.yml
holds <ROLE>_<SERVICE>_SERVICE constants (container names derived via
entity_name), meta/services.yml declares ports.internal consumed
through config lookups. sso-proxy upstreams stack-qualify the origin
host in swarm because short aliases are ambiguous on shared overlays.
Stale proxy.service_key entries fixed for erpnext/shopware/nextcloud.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:05:56 +02:00
kevinveenbirkenbachandClaude Opus 4.8 d4ee9a85ba refactor(compose): align templates to the service_name skeleton
Every compose.yml.j2 now declares services as '{{ service_name }}:' with
its '{% set %}' knobs on two-space lines above the key, includes at four
spaces and if/for tags aligned to the content they wrap. The layout
renders correctly because stack_host_template enables lstrip_blocks for
compose.yml.j2; indent(4, True) in the container base and '{%+' guards
in whitespace-sensitive scalars keep the shared fragments identical
under both render modes. A new lint test enforces the skeleton.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 02:36:58 +02:00
kevinveenbirkenbachandClaude Opus 4.8 11efe2cfdf feat(sys-ca-selfsigned): shared java-with-ca-trust wrapper for JVM roles
One entrypoint wrapper imports the project CA into the JVM truststore for penpot and jenkins: env-driven (CA_TRUST_CERT from sys-svc-compose-ca, set in both modes), fail-open without a cert (production), fail-loud with one; writable cacerts are patched in place, read-only layouts fall back to /tmp + JAVA_TOOL_OPTIONS. Replaces penpot's inline compose command and jenkins' role-local entrypoint, which silently skipped the import when cacerts were not writable. ca_cert_container becomes a plain constant since its only Jinja consumer moved into the wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:18:21 +02:00
kevinveenbirkenbachandClaude Opus 4.8 af6f2bdcdf feat(filter): ca_trust_paths as the SPOT for the root-CA cert locations
ca_cert_host derives the host cert path from SOFTWARE_DOMAIN; ca_cert_container carries the fixed in-container bind-mount target. CA_ROOT.cert_host and the test fixtures consume the filter; the standalone runtime scripts (compose_ca.py, container.py) cannot import it and are pinned to the SPOT by the unit test instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:18:20 +02:00
kevinveenbirkenbachandClaude Opus 4.8 3f60a0422f feat(web-app-gitea): classify auth-source idempotency errors via gitea_auth_noop
'login source already exists' was matched inline in both the LDAP and the OIDC setup; the filter is the single marker source and normalizes case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:18:20 +02:00
kevinveenbirkenbachandClaude Opus 4.8 ea33483b15 feat(svc-db-openldap): classify ldapmodify idempotency errors via ldap_modify_noop
The two handler tasks carried the identical four-marker stderr chain inline and matched case-sensitively; the filter is the single marker source and normalizes case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:18:20 +02:00
kevinveenbirkenbachandClaude Opus 4.8 61b75f7505 feat(web-app-discourse): classify idempotent network errors via docker_network_noop
The five docker network connect/disconnect tolerances were inline stderr substring chains, inconsistent between sites (the handler variants matched case-sensitively without a default). The docker_network_noop filter is the single marker source per operation; disconnect additionally tolerates absent networks/containers, which is the proven swarm failure ('No such container: postgres' when the central DB is a swarm service). Drop the now-orphaned DISCOURSE_ERROR_NOT_CONNECTED.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:18:20 +02:00
kevinveenbirkenbachandClaude Opus 4.8 b1b24b9014 feat(networks): external-network lookup + probe-before-create with driver-drift fail
compute_external_network_roles() + compose_external_networks lookup expose which foreign provider networks a compose project references as external, so the swarm deploy path can pre-create them. network/create.yml probes for an existing network instead of unconditional state:present (the module's delete+recreate on attached networks crashed redeploys) and hard-fails on driver drift instead of silently skipping it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:18:20 +02:00
kevinveenbirkenbachandClaude Opus 4.8 8332357377 fix(email): resolve host to localhost when not external, even under TLS
email.py's environment folds TLS_ENABLED into its "external" base, so a non-external relay (no mailu deployed) under TLS resolved host to the mailu domain while auth/tls/from stayed in localhost mode: an unreachable, inconsistent msmtprc that aborted the sys-ctl-hlth-msmtp unit and the deploy (chess/bridgy-fed/hugo/legal). Gate host on external directly, like tls/auth/from, so a mailu-less deploy targets the loopback postfix that sys-svc-mail-smtp deploys under the same email.external gate. Adds a guarding unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:57:30 +02:00
kevinveenbirkenbachandClaude Opus 4.8 96aa7784b6 feat(sys-svc-compose): scale all task timeouts via TIMEOUT_FACTOR lookup
Add a 'timeout' lookup plugin that returns round(base * TIMEOUT_FACTOR)
(group_vars default 1, so value-identical to the old literals). One knob
in group_vars stretches every wait in the stack for a slow uplink:
TIMEOUT_FACTOR: 2 doubles them all.

- plugins/lookup/timeout.py + unit tests (identity, factor scaling,
  string coercion, error paths).
- group_vars/all/17_resource.yml: TIMEOUT_FACTOR: 1.
- Convert all 34 literal 'timeout:' task keywords / module args across 25
  task+handler files to "{{ lookup('timeout', <base>) | int }}".
- tests/lint/repository/yaml/test_timeout_uses_lookup.py enforces the
  lookup in every task/handler timeout (opt-out: # nocheck: timeout-lookup).
- swarm.yml pre-deploy build handler: add the missing timeout (same
  network-build hang vector as compose.yml's Build compose).
- Drop 13 pre-existing narration comments in the touched files (comment
  lint enforces whole-file on any edited file).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:57:30 +02:00
kevinveenbirkenbachandClaude Opus 4.8 9bf35d672a fix(xwiki): rotate the swarm config name so the final deploy converges
In the swarm-DinD test the ansible controller (ansible_connection: docker) cannot read the STACK_HOST-rendered xwiki.properties, so compose_volumes' content hash falls back to the source path and the config name is constant. xwiki deploys twice (bootstrap with the OIDC block off, final with it on after the extension installs), so the config content changes but the name does not, and the final docker stack deploy fails to update the immutable config with 'only updates to Labels are allowed'.

Honor an explicit name: on config/secret entries in compose_volumes (volumes already did), and give xwiki's properties config a name that rotates with the OIDC phase, so the final deploy creates a new config instead of updating in place. Backward-compatible: no other role sets name: on a config. make test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:11:19 +02:00
kevinveenbirkenbachandClaude Opus 4.8 7fce11867c fix(compose_volumes): drop orphan top-level config/secret when all mounts are gated off
compose_volumes emitted a top-level configs:/secrets: entry for every declared
config/secret, but container_volumes already gates each per-service reference on
its mount's when:. So a config whose only mount was gated off (e.g. LDAP
disabled) became an orphan: a top-level object no service references, whose file:
source points at a file the role never renders (same flag). Under docker stack
deploy that source is read at deploy time and the deploy fails; under compose it
was a harmless unused entry.

Gate the top-level emission on the same mount_when_passes container_volumes uses,
so a top-level config/secret exists iff a service actually references it. To share
the predicate, promote the private _mount_when_passes from container_volumes into
utils/roles/applications/mounts and import it in both filters; the bare except
moves with it (inline noqa), so container_volumes no longer needs its BLE001
per-file-ignore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:26:06 +02:00
kevinveenbirkenbachandClaude Opus 4.8 8c4d71f81e fix(bigbluebutton): route the swarm proxy to the bbb-net gateway
In swarm the shared openresty runs as an overlay container, so 127.0.0.1 is its own loopback, not the node's. bbb deploys node-local (host network) and its nginx binds the bbb-net gateway 10.7.7.1, a host IP the overlay reaches via inter-bridge routing. resolve_upstream now honors an opt-in services.<entity>.upstream_host in the compose branch (default 127.0.0.1) and bbb sets 10.7.7.1; proxy_pass collapses compose_mode_force apps to the static compose form. Compose keeps working because host-network openresty reaches 10.7.7.1 too. Validated live: curl bbb.talk/b/ returned 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:47:33 +02:00
kevinveenbirkenbachandClaude Fable 5 cc7662fe3a refactor(users): replace reserved flag with accounts + forward
Model change: per-user 'reserved: true|false' becomes 'accounts', a list of
host/mailbox/identity account types. Default when absent derives from roles
(bot/administrator -> [mailbox, identity], else [identity]) both for role-file
users and — via _apply_account_defaults after the inventory merge — for
inventory-only users, so users like biber keep their LDAP/Keycloak accounts.
Auto-generated role-suffix reservations get accounts: [].

New 'forward' field: mailu creates <username>@domain -> forward user's email
via the new 04b_manage_alias.yml (create-only; no_log on the loop so user
dicts never hit the log). Mailbox creation and password rotation now gate on
'mailbox' in accounts instead of role names; the mailu rbac role is renamed
mail-bot -> bot (token gate included).

svc-db-openldap drops the provision.reserved flag; OPENLDAP_USERS is always
the identity subset. reserved_usernames/non_reserved_users now classify by
'identity' in accounts, keeping the Keycloak deny-list semantics. root/backup
pin accounts: [host], administrator [host, mailbox, identity], helpdesk
[mailbox].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:51:31 +02:00