The users cache no longer demands a mailbox on every host (a workstation
inventory without mail broke every users lookup) and skips forwards that
are still unrendered Jinja; a non-mapping target names itself. Stalwart now
enforces the mailbox where it consumes the forward. Under SSO 16_forwards
skips ReloadSettings, which would open the OIDC issuer before Keycloak runs;
the OIDC restart applies the limits. The routing probe treats transient
IMAP/SMTP errors as not yet within the same budget and only checks forwards
in Stalwart's own rounds, so the 67 roles that deploy it as mail provider
keep skipping it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
System mail now goes to platformadmin@, a mailbox nobody reads. The users
cache already carries a per-user forward and Stalwart already writes a
redirect :copy Sieve script per forwarding account; stalwart_user_forwards
feeds the first into the second for every user Stalwart has an account
for, merged with inventory forwards. platform_administrator forwards to
administrator, keeping its own copy. The users cache now rejects forwards
to unknown users, to users without a mailbox, and forward loops.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The svc-cache-package CLI probe GETs every declared route path through the frontend and fails on 502/503/504. registry.ollama.ai declared no paths, so the probe asked for /, which upstream now answers with 503; Nexus relays an upstream 5xx as its own 503, so package#0 (CI run 40979252521) failed with 'registry.ollama.ai/ on port 443 answered 503'. The host now declares /v2/, the registry API root every ollama pull goes through (manifests and blobs); upstream answers it with 404, which Nexus relays as 404 and the probe accepts. On a TLS host the frontend still proxies the whole host through location /, because upstreams.conf.j2 reads routes only for plain-HTTP listeners: the rendered upstreams.conf is byte-identical before and after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
make autoformat output for three lines the feature/updater merge brought
in over 88 characters; matches feature/updater-lockdown-probe 88d138ba1c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in feature/updater accb22da75, which carries main 5995766f58.
Conflict resolutions:
- web-app-fider: main's tenant bootstrap (01_tenant.yml) runs first on
the stack host, OIDC setup moves to 02_setup_oidc.yml, design to
03_design.yml; flat includes so main's gate test reads them.
- sys-front-tls test_nginx_ssl_header: the tls fake maps the domain term
store-i18n passes back to its app.
- group_vars 04_webserver.yml, sys-svc-proxy CSP header: store-i18n's
webserver_resource lookup plus #1256's WEBSERVER_SECURITY_HEADERS and
the security_headers.lua include after the CSP block.
- web-app-nextcloud: updater's decoupled Talk stack (Janus, signaling,
NATS, recording builds) replaces aio-talk; store-i18n's per-network
Talk backends move from the wrapper script into signaling.conf.j2 urls
(NEXTCLOUD_HPB_SIGNALING_BACKEND_URLS from network_urls, canonical
first); IS_COMPOSE_MODE/IS_SWARM_MODE booleans kept; the integration
OpenProject digest stays delegated to STACK_HOST.
- users lookup docs, token-store write_many, cache README, meta info
lint: both sides combined.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
repository_files() listed every path `git ls-files` returns, including tracked files the worktree has already deleted. Every content scan that reads the listing then reported "[Errno 2] No such file or directory" as a finding: in a worktree that removed roles/web-app-nextcloud/filter_plugins/nextcloud_janus.py and its test but had not committed the removal yet, test_no_rsync_backup_flag failed on exactly those two paths and turned the whole lint target red. No scan can read such a file until the deletion is committed.
The listing now subtracts `git ls-files --deleted`. A clean checkout, as in CI, has no deleted entries, so the listing there is unchanged. The rsync lint is green again on the same worktree. The unit tests stub the extra git call and cover a file deleted in the worktree.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A token store write re-rendered the whole applications SPOT: merged_applications_cache_key carried the token store digest, so the applications_carrier park in sys-token-store/tasks/write_many.yml missed its key and rendered all 269 applications again (stalwart compose roundtrip 18.3 s and 10.2 s per pass, swarm pass 1 12.2 s and 12.6 s).
Rendered from the real roles tree under an empty, a realistic and an exhaustive token store (298 users x 269 applications), the applications tree differs only under <app>.users.<key>.tokens (28783 leaves in 46 applications, 0 elsewhere); the 69 password leaves that also differ change between two renders of the same store. No code reads a token through the applications tree: every reader goes through lookup('users'), lookup('config', <app>, 'users.<key>...') (served from get_merged_users), lookup('email') or the sys_token_store_users fact, and the users carrier keys on the store.
The token digest therefore moves from merged_applications_cache_key into merged_users_cache_key, whose value stays the same. A store write keeps the applications carrier and the write_many park re-renders the users alone; the task is renamed to match. applications.md now says that user records inside the payload can predate every store write and that application config must not derive a value from a token.
Real ansible-playbook harness on 2.20.6 with the real roles, group_vars and write_many.yml: the re-park after a first mint and after a rotation drops from 4.0 s to 0.55 s on one host (applications renders 1 -> 0), and every token probe (users, config users path, email password, stalwart_users) equals the old code in compose and swarm mode. Dropping the digest from the users key as well leaves those probes stale, which the harness and two unit tests catch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ansible forks a fresh worker per task, so every lookup('users') in a forked worker re-rendered the whole user catalogue (298 users, 0.3 to 0.5 s locally and about 1 s on the runner, 1 s locally once a users password is vaulted): _MERGED_USERS_CACHE lives in the process and had no carrier fact.
applications_carrier now also parks lookup('users', carrier=True) in _INFINITO_USERS_RENDERED at the same park points, and clear drops both facts. get_merged_users serves the fact to templated reads when its key matches: the applications carrier key (roles dir, applications, users, DOMAIN_PRIMARY, SYSTEM_EMAIL_DOMAIN, group_names, token store digest) plus inventory_hostname, so a carrier a run_once park broadcasts never answers another host with its become password. A park whose users carrier still matches leaves it in place instead of returning 119 KB again, so parks cost what they cost before.
Real ansible-playbook harness on 2.20.6 and 2.21.5 with the real roles tree: users renders per users-reading task after a park 1 -> 0, task 0.56 -> 0.08 s (vaulted 1.21 -> 0.17 s); a rotated token, a rotated inventory password, a cleared carrier and another host's carrier all re-render; 32 of 32 probe digests equal HEAD's. Removing the carrier check fails three unit tests. The stalwart swarm roundtrip pass 1 and its DR drill ran green with it on all nodes, with no 401 from JMAP, msmtp or LDAP.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
68 conflicts, resolved per hunk; resolutions agreed with the Updater
session were applied as agreed:
- svc-db-postgres (superuser password script and core) and
sys-svc-rdbms realign_dedicated_db: updater's argv/stdin redesign with
expand_argument_vars: false, nested into store-i18n's block structure.
- svc-db-redis 01_init: store-i18n's ACL password on stdin.
- sys-svc-proxy forwarded.conf.j2: store-i18n's handover-aware file,
without the forwarded_base include (Host and X-Forwarded-Proto would
be sent twice); forwarded_base serves the inline consumers, e.g.
web-app-seaweedfs nginx.conf.j2.
- basic.conf.j2 and web-app-openproject proxy.conf.j2: issuer redirect,
then handover, else updater's location/sso_acl.conf.j2; the
server-level webserver_extra_configuration hook stays.
- web-app-keycloak: updater's admin-REST settings step and renumbered
update steps; update/01_client.yml removed; store-i18n's design step
and LDAP block kept; a73435cc9d arrived through rename detection.
- web-app-nextcloud OpenProject provisioning: updater's rewrite, all
credentials on the Ruby runner's stdin.
- web-app-semaphore: store-i18n's canonical web root (a1d7835863).
- Role READMEs: no generated Cosmos/Architecture/Quick Setup/Credits
sections; desk-* paths resolved to the dsk-* renames.
Integration fixes for what merged cleanly but did not fit together:
- web-app-matrix ansible vars: drop updater's second
matrix_client_element_configuration_extension_json, which silently
overrode store-i18n's design-aware value.
- url.origin instead of a stripped url.base in 19 places store-i18n
added since 44aaa68924, same lookup targets (the double-slash lint).
- Package batch (18ea75049e, cb4d340de9): installs the deploy-start
batch covers are removed (caffeine, cursor, blender, npm in dsk-gnt-*,
with shared references), the rest carry batch: false and a nocheck
with the verified reason (PyPI fallback, before the batch, vendor
repository, configured flavor only, addon gate); dev-npm references
the root npm id.
- sys-token-store write_many.yml: store-i18n's change-gated block and
IS_SWARM_MODE.
- web-app-openproject: IS_COMPOSE_MODE in 02_migrate/run.yml; the
seeded-admin lock (ccbf8f168d) ported into 04_admin/provision.yml and
covered by the admin-state digest.
- Bucket policy (3b4ac40df5 and follow-ups) moved to web-svc-seaweedfs,
where store-i18n's object store lives; the S3 admin secret travels on
stdin instead of a task-level environment.
- act workflow.sh: no Exception-labelled comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An inventory group web-app-cst-<name> with applications.<id>.custom: true and
a pinned source {url, version, path} is pulled into roles/ before the play,
read raw (Jinja, Ansible tags and credential URLs refused), placed through a
staging directory under an exclusive lock, and owned by a marker that names
its own directory, so a pull or roles-clean never replaces or removes a
directory it did not place and a failed pull leaves roles/ as it was.
Directory inventories, host lists and non-UTF-8 files behave as Ansible
reads them; generated test deploys pass --skip-pull. make roles-pull,
roles-clean and roles list, INFINITO_EXTRA_MOUNTS for local sources, the
web-app-cst category, the reserved prefix lint and a central
repository_files() that also yields the gitignored custom roles let every
lint scan them like in-tree roles. Supersedes the uncommitted 045 External
Roles draft and reuses its fetch, sync and mount design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The carrier key switched from the tokens file's path and hash to the
sys_token_store_users fingerprint once the token store loaded, so the
stage-wide park re-rendered the applications SPOT (about 11 s) even when the
tokens were identical. Both sources now sign as a digest over exactly the
tokens the users hydration reads; the store helpers move to users/store.py
to keep the package under the file length cap.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
- 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>
Every cold render of the applications SPOT walks the templates of all
269 roles, and those templates call lookup('config'), lookup('tls'),
lookup('domain') and others. Each of these re-enters
get_merged_applications while the render guard is set, and each one
rebuilt the unrendered view from scratch: a deepcopy of all role
defaults plus the override merge, about 8 ms, never cached. A local
cold render of the development inventory made 351 such calls, 2.82 s
of a 6.86 s render.
The outer render now keeps a copy of its unrendered view and its cache
key on the render guard, taken before the inventory volumes are
popped, so it is the same view a nested call used to build. A nested
call with the same key returns it; any other key still builds its own.
The guard drops it when the render ends.
Measured on the same inventory: the nested calls took 0.07 s instead
of 2.82 s and the cold render 3.60 s instead of 6.86 s. The rendered
SPOT is unchanged: the old and new dumps differ in 69 user passwords,
which the development inventory regenerates on every read (two runs of
the old code differ in 68), and in one absolute checkout path, the only
other difference.
In CI each cold render took 40 to 77 s and the heaviest swarm cell of
#950 missed its budget by a few minutes (#984).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Since 778c18fb23 the carrier key holds the tokens file's mtime and size.
sys-token-store changed that stat on every inclusion (00_core.yml
touched the file) and on every persisted token, and nothing re-parked a
carrier afterwards. Every later task's fresh worker then rendered the
applications SPOT cold, 30 to 80 s each. On main fea6f31d9f (run
38812964308) seven heavy jobs logged 194 to 287 such tasks and ran out
of the 20400 s budget; the same jobs before 778c18fb23 logged 65 to 83,
all of them real waits.
- 00_core.yml touches the store with modification_time and access_time
preserved, so it only creates a missing file. In swarm the controller
sees the store over NFS with attribute caching, and the touch alone
made the second pass go cold without any write.
- write.yml re-parks the carrier when the persist or the controller
mirror changed the store. Both run once, so every host that ran the
include gets the same verdict.
- tasks/utils/setup/role.yml re-parks after every role on every host,
so hosts that did not run the writing role recover too. Where the
key still matches this serves the carried payload; measured 0.1 s per
role in a local compose deploy.
- The tokens file enters the key as the SHA-256 of its content,
memoised per inode, mtime and size, so a touch no longer changes the
key. A stat that still matches serves the memo (1.67 us against
1.41 us for the old stat signature), and a stale stat keeps key and
YAML cache on the same old content instead of pairing new content
with an old key.
- applications_carrier opens the tokens file before it computes the
key, so on the NFS-mounted controller the parked key reflects the
revalidated attributes rather than a cached stat that changes a
minute later.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Six conflicts, all from git pairing store-i18n's renamed roles with their designer originals.
web-app-meta/meta/services.yml and web-app-sphinx/vars/main.yml: store-i18n renamed web-app-sphinx to web-app-meta and dropped its custom build, so the role keeps the prebuilt ghcr.io/infinito-nexus/meta image and the old sphinx role stays deleted.
svc-ai-tika/templates/compose.yml.j2: a bogus rename pairing dropped sphinx build lines into the tika service. Rejected; the template is byte-identical to its pre-merge state again.
web-app-docs/templates/Dockerfile.j2: the designer file renders sphinx under the design title and reads SPHINX_IMAGE, SPHINX_OUTPUT_DIR and SPHINX_EXEC_DIR, none of which exist in web-app-docs, which builds from files/Dockerfile. Rejected.
web-app-docs/templates/playwright.env.j2 and files/playwright/test-design.js: both sides add independently, so both are kept. Of the designer's three new env reads only designTitle survives; iconUrl and faviconUrl are shadowed inside the test by stricter requireDotenvValue reads and were dead at module scope.
plugins/lookup/email.py merges clean toward designer, which is intended rather than a loss: 08ef66d8de (2026-10-08) replaces the submission_via_relay port-25 rule of 233f15f3e0 (2026-07-01) with per-bot app passwords under SSO, and carries the matching 04_manage_user/app_password tasks.
Checked after resolving: no duplicate YAML keys across the 17 merged YAML files, and no nocheck line dropped above a renamed key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the node that runs svc-net-tor had it in group_names, so every other swarm node resolved no node onion, served no onion siblings and scheduled no onion apex redirect. tor_serves_host also accepts a provider anywhere in a cluster of more than one svc-swarm-node; a single compose host keeps the old answer.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The constructor parked no applications carrier between the merge block
and the token store, so every lookup in the svc-swarm-node, svc-storage,
svc-registry and svc-virt bootstrap rendered the SPOT cold, 25 to 30 s
each on a loaded host.
A carrier there was unsafe: in swarm, svc-storage-nfs-server mounts the
NFS export over the controller's DIR_VAR_LIB, so the tokens file the
render reads can appear in the middle of that section, and the cache key
did not notice. merged_applications_cache_key now includes the tokens
file's path, mtime and size, as the users cache key already did, so a
carrier rejects itself once the tokens change. A carrier parked after
the add_host tasks then serves the bootstrap roles. It is parked only in
swarm mode: in compose the section runs in about a second, so a carrier
there would add a render without saving one.
In a swarm drill of web-app-prometheus up to the known NFS probe-write
failure, "Resolve the NFS server backend flavor" went from 29.6 s to
0.07 s, "Probe-mount the NFS export" from 26.4 s to 0.4 s, "Mount
cluster-shared infinito state on controller" from 23.7 s to 0.5 s and
"Ensure Docker-In-Docker DNS works" from 30.0 s to 3.6 s; the run to
that point took 768 s instead of 1735 s.
Closes#925
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
get_merged_domains gated the onion injection on node_onion being set AND
svc-net-tor being in group_names. When the node was empty it answered the
clearnet map with no error and no log line, and because group_names is
part of the cache key, a clearnet map and an onion map coexist in one
process under separate keys. One render then serves onion while another
freezes a clearnet asset URL into a stylesheet.
That URL is unrecoverable at request time: body_filter.lua returns a CSS
response before onionize(), so the <link href> in the head is rewritten
while the url(...) inside the stylesheet body is not. The symptom is a
single toHaveCSS mismatch and nothing else.
An empty node under a deployed svc-net-tor now raises. A scope carrying no
group_names at all stays silent, because that is indistinguishable from a
legitimate non-tor deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflicts resolved by keeping both sides' intent:
- call-test-deploy.yml: main's re-enabled deploy concurrency group, keyed
on the network axis instead of the removed tor axis.
- sys-svc-proxy: main's shared headers/forwarded.conf.j2 now carries the
branch's per-vhost semantics (X-Forwarded-Host, TLS port and state read
from the vhost domain), so html, ws, upload and the Prometheus probes
all answer per network sibling.
- plugins/lookup/email.py: main's provider-agnostic mail lookup asks the
network registry instead of testing the .onion suffix, as the branch
does everywhere; container_extra_hosts.py likewise. The provider host's
NETWORK_MODE now decides whether it is served onion-first, since a Tor
node defaults to multi and keeps clearnet canonical.
- workspace common.sh: main's INFINITO_DOMAIN fallback without the
duplicated no-op line.
- README.md and requirement 035: both sides' rows (Wazuh plus main's
Pi-hole and Stalwart entries); adapter_candidate count 39.
Adapted to main where the branch's rules now reach main's code:
- six tests from main read the primary domain from default.env instead
of writing it out, as the hardcoded-domain lint requires;
- the chutney worker-check test passes ONION_SUFFIX from the network
registry, which the branch's svc-net-tor test script requires;
- web-app-wazuh moves its local http ports to 8096/8097 (8094/8095 now
belong to web-app-stalwart) and sizes the indexer heap through
service_memory_mb, the same fixes#414 carries;
- web-app-wazuh's README cosmos follows Stalwart as the mail provider.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A gate run died two seconds in with PermissionError on
compose/package-cache/pip.conf. The worktree is bind-mounted into the
container, so a container that rendered these files owns them afterwards
while the directory stays the host's. Overwriting needs the file's
permission and unlinking the directory's, which is why the host could no
longer write a file in a folder it owns.
_write now catches the refusal and replaces the inode instead. Only after a
refusal, never by default: compose/swarm/cache.override.yml bind-mounts an
apt list into a container by inode, and swapping one that was writable
anyway would leave that mount reading a file nobody updates any more. The
new test pins that half as well, so the guard cannot be simplified away.
Shown failing first: the previous _write raises PermissionError on the same
input the test feeds the new one. Shown working in place: lint-ansible came
back in 3m40s through a run during which make dotenv-force re-rendered these
very files, where the same sequence had aborted the target after two seconds
before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourteen real conflicts plus the 66 generated READMEs.
utils/cache/files.py and its test: both sides added a skip-dir rule, resolved
as a union so the root `build/` prune and the per-root walk key coexist.
plugins/lookup/database.py and postgres_libraries.py: main's added lookups kept,
with get_entity_name renamed to entity_name as this branch renamed it.
roles/web-app-{mailu,matrix}/meta/services.yml: main's `covers: [email]` and its
MAIL_PROVIDER-gated email entry, plus this branch's gitlab/jira/n8n/jitsi
consumers, which the merge base carried on neither side.
group_vars/all/17_resource.yml: this branch's lookup('resource', ...) refactor;
main's single added literal is already covered by _POSTGRES_MAX_LOCKS_PER_TRANSACTION.
docs/requirements/035-mcp-proxy-expansion.md: this branch moved the design into
ADR 0003, so the requirement keeps only the user story and the criteria. Main's
delta inside the moved block was two new roles, carried into the ADR audit:
web-app-stalwart as adapter_candidate, svc-net-tor-smtp as privileged_control_plane.
The no_surface total said 99 while its own subcategories summed to 112.
inventories/development/default.yml: biber keeps no explicit email. Both sides fixed
the same CI failure; this branch fixed it in _apply_account_defaults, which renders
biber@{{ DOMAIN_PRIMARY }} (utils/cache/users/__init__.py:68) — byte-identical to
the address main hardcoded, so the inventory line is a second copy of one fact.
roles/web-app-{gitlab,keycloak}/files/playwright/playwright.spec.js and
roles/web-app-gitlab/templates/playwright.env.j2: union of both import sets, with
requireDotenvValue kept over decodeDotenvQuotedValue for CANONICAL_DOMAIN because
an empty value must fail rather than match every substring.
The 66 role READMEs take this side: aceb5295ef moved Cosmos, Quick Setup and
Credits to the documentation build, and test_readme.py now rejects them in a
committed README. Main's prose edits inside them are carried over by hand (the
Mailu deprecation banner, Mailu -> Stalwart in erpnext and zammad), and the two
READMEs main added fresh are stripped of the three generated sections.
roles/web-app-sphinx/README.md stays deleted; 6eb9a59d14 merged that role into
web-app-docs. The root README's generated role table was regenerated from the
merged metadata.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`roles/user/meta/users.yml` reserved two names against the identity directory by
indexing the primary domain from the left: `sld` took label 0 and `tld` took
label 1. That is only the SLD and the TLD when the domain has exactly two
labels. On `label-a.tld.test` it reserved `label-a` and `tld` and let the real
TLD through, and on any deeper domain it drifts further.
The count is a runtime fact, so a static YAML file cannot express it. The two
entries are gone and `utils/cache/users` now emits one `domain_label_<n>` per
label:
infinito.nexus -> infinito, nexus (unchanged)
main.infinito.test -> main, infinito, test (was: main, infinito)
label-a.tld.test -> label-a, tld, test (was: label-a, tld)
Nothing read the `sld` or `tld` keys: a grep over roles, plugins, utils and cli
finds only the generator itself. The entries exist to carry `accounts: []` into
`reserved_usernames`, which `web-app-keycloak/tasks/02_init.yml:6` joins into the
username deny pattern, so the shape of the keys is free and only the set of
usernames matters.
Worth stating so the next reader does not overestimate it: the pattern at
`UserProfileProvider.json.j2:31` allows `[a-z0-9]+`, so a label carrying a hyphen
could never have been registered anyway. What this actually wins is the third
label. What it mainly wins is that the rule no longer has to be revisited when
the domain depth changes.
The `KEYCLOAK_DOMAIN` and `domain` fallbacks go with it. Both ran through a
helper that recovered the primary domain by keeping the last two labels of a
host like `auth.<DOMAIN_PRIMARY>`, which cannot work without already knowing the
depth it is trying to find. `DOMAIN_PRIMARY` and `SYSTEM_EMAIL_DOMAIN` remain,
and every deploy path supplies one of them. The test that pinned the removed
fallback is inverted rather than deleted, so the removal stays visible.
The rest is the refactor pass the change pulls in. The function no longer
materialises aliases and is renamed to `_materialize_domain_label_reservations`;
its candidate tuple carried one extractor after the fallbacks left and is a plain
key loop now; an `or [primary_domain]` guard that would have reserved a nonsense
username for an all-dots domain is dropped. `roles/user/vars/main.yml` loses four
comments that restated the line below them, and the README documents the
reservation registry, which no longer shows its full content on disk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`records()` is reached from two directions. The `.env` generator hands the
mirror list in explicitly, so `make dotenv` always resolved it. The
`cache_records` Ansible lookup calls `records()` with no argument, and the
fallback read `os.environ` alone. The deploy container is handed a curated
env and never sources `.env`, so `INFINITO_APT_UBUNTU_MIRRORS` is absent
there: `mirrors()` returned None, `${UBUNTU_PRIMARY}` never resolved, and
`records()` returned the empty string, which failed the svc-cache-package
bootstrap task with the message censored by `no_log`.
`env_setting()` is the repository's existing accessor for exactly this
shape - process environment first, `default.env` behind it - and is already
used for this key in the apt-mirror repository test. The explicit-argument
path is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_the_guarded_payload_never_reaches_the_main_cache compared
_MERGED_APPLICATIONS_CACHE against {}, which couples it to everything that
ran before it in the same xdist worker even though setUp resets the cache.
It failed once inside `make quality`, whose four test targets run in
parallel, and passed in four runs since: isolated, whole file, `make
test-unit` and a full unit-plus-lint sweep.
Recording the key set before the call and comparing it afterwards states
what the test name claims and stays true whatever else populated the cache.
A real write by the guarded path still fails it.
Why the cache held an entry in that one run is unexplained. utils/cache has
no on-disk layer and utils/cache/applications.py starts no thread the
thread-local render guard could miss, so neither cross-process nor
cross-thread leakage accounts for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_merged_applications writes its cache in the last line of the function,
and the re-entrancy guard returns above it. Every lookup('applications') a
template makes while the payload is being rendered therefore rebuilt it:
get_application_defaults deep-copies roughly two hundred roles, then the
merge runs again.
Profiled over one render of tests/integration/roles/test-e2e-cli: 382 calls,
18.765s of 29s, 13.7 million deepcopy calls. That test spent 208s in setUp
for twenty renders and now spends about half.
The guarded payload cannot go into _MERGED_APPLICATIONS_CACHE, because it
carries no users and still holds its volumes. It is memoised on the render
guard instead, which is thread-local, and the finally that releases the guard
drops it, so it lives exactly one render.
Three probes established the preconditions before the change: one cache key
across all 381 nested calls, re-entrancy depth never above 2, and not one of
the 381 payloads mutated after it was handed out. That last one was then
re-measured across the whole suite with the memo made self-checking: 6861
tests and 5133 subtests, no caller wrote to what it was given.
113113e553 removed the same cost between tasks through the carrier fact.
This removes what remains inside a single render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hardcoded-primary-domain lint exempted tests/ and scripts/tests/, so test
fixtures kept their own copy of the domain and would drift from
INFINITO_DOMAIN the moment it changes. The lint now scans both trees, and a
unit test pins its scope: only default.env, Markdown, inventories/, .claude/
and build/ may name the domain.
Python tests take the value from default_domain_primary(), and the workspace
helper builds its dashboard and Matomo URLs from INFINITO_DOMAIN once the repo
.env is loaded.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The one-shot walk behind every project-tree test descended into build/, where the make targets keep their caches. Nothing under it is version controlled (.gitignore carries build/*, git ls-files reports none), so the 579 files it contributed were never project files in the sense any caller means.
Two lints already carried build/ in a skip list of their own, which is what a cause treated at each symptom looks like. The prune is anchored at the repository root because the set is matched per directory name and cli/build holds the documentation generators, which are source. A test pins both halves and was watched failing without the prune.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A node runs in NETWORK_MODE clearnet, tor or multi; empty derives multi when svc-net-tor is deployed. meta/networks.yml reachability narrows a role (modes, single_mode, each justified by a network-reachability nocheck), and utils/networks/reachability.py is the one registry every network decision goes through. services.tor keeps only the dependency bond; exclusive and primary are gone and Matrix is single_mode.
Every canonical domain gains one sibling per effective network, clearnet first. sys-stk-front-proxy renders one vhost per sibling, and TLS, CSP, injected snippets, redirects, the web health probe and the CSP crawler are decided per vhost. Cross-app URLs follow the network of the vhost being rendered, the SSO issuer stays single, clearnet vhosts of multi roles send Onion-Location, and the shared location forwards X-Forwarded-Host, which lets Wazuh derive its OIDC redirect per request again. The to_onion_url filter is removed; its two consumers render per vhost instead.
Measured before commit: on a clearnet node the domain map of all 108 apps and every CSP header are identical to HEAD; on a multi node every role keeps its clearnet domains first and only gains onion siblings, and no CSP source crosses networks except the SSO issuer.
Refs: docs/requirements/037-multi-network-serving.md
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
oauth2-proxy rejected biber in CI run 35674334334 ("biber@infinito.test
[AuthFailure] unauthorized") after the CI domain moved to
main.infinito.test: the development inventory hardcoded biber's address to
infinito.test while email_domains follows DOMAIN_PRIMARY. The checkmk,
funkwhale, openproject and jitsi jobs failed on it.
Dropping the hardcoded address alone left biber without any email, since
only users declared in a role's meta/users.yml receive the
<username>@{{ DOMAIN_PRIMARY }} default; the OpenLDAP users task then
failed on "object of type 'dict' has no attribute 'email'".
_apply_account_defaults now gives inventory-only users the same default,
and an explicit email still wins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_all_project_files carried @lru_cache(maxsize=1) and read PROJECT_ROOT from the
module global inside its body, so the root was not part of the cache key. The
first call decided the answer for the rest of the process: point PROJECT_ROOT at
a fixture afterwards and the walk still reports the real repository, or, in the
other order, a sweep after the fixture is torn down enumerates a directory that
no longer exists.
Both failure modes are silent. iter_project_files, iter_project_files_with_content
and iter_non_ignored_files all sit on this one walk, 202 files in the tree use
them, and a lint whose corpus collapses to zero paths reports zero offenders and
passes. Nothing raises.
The root is now a parameter and therefore the key, with room for four of them.
_reset() still clears it through cache_clear(), and the only other caller is
iter_project_files, which passes str(PROJECT_ROOT) explicitly.
Live impact today is none, and that is worth stating: exactly one test patches
PROJECT_ROOT in this module, and it calls _reset() in both setUp and
addCleanup, so the defect was fully masked. Every other patch site in the repo
targets PROJECT_ROOT in a different module. This is about the next test that
forgets, not about a failure anyone has seen.
A regression test covers it rather than a comment. Negative control: with the
key removed the new test fails on the second root with
Items in the first set but not the second: '/tmp/.../first.txt'
and passes once the key is back; the file is byte-identical to its pre-probe
state afterwards.
The lint docstring that quoted the old cache shape is deleted rather than
updated, because a cross-module implementation reference goes stale the moment
that module changes, which is exactly what happened here.
Same class as c4556fe72d, which keyed the applications SPOT by the groups its
payload reads. That one was a live defect in utils/cache/base.py; this is the
latent twin one module over.
Residual: maxsize=4 is a choice, not a measurement. More than four roots alive
at once evicts the oldest, which re-walks rather than answers wrongly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A service flag may be written as `{{ '<role>' in group_names }}`; 114
meta/services.yml files do exactly that, and it is the only host-dependent
variable any of them uses. The rendered payload therefore differs per host
while the cache key did not: it was built from applications, users,
DOMAIN_PRIMARY and SYSTEM_EMAIL_DOMAIN alone, and in the failing job all
five host_vars files were byte-identical.
113113e553 moved the render out of every task on the premise that Ansible
forks a fresh worker per task, so the in-process cache is always cold. That
holds for workers and not for the strategy's main process, which renders a
task's name once for the first host of a batch. Anything cached there is
inherited by every worker forked afterwards, and applications.py consults
that process cache before the host's own carrier fact, so an inherited
entry outranks the correct answer.
Pretix#2 of run 35152776835 shows both sides in one task: `skipping:
[web-app-pretix-swarm-mgr-01]` against `included: dedicated.yml for
wrk-01, wrk-02`, eleven seconds before mgr-01 resolved its database to
`pretix_database`. The swarm was running the central `postgres_postgres`,
so the wait never converged and the job died after 600 attempts. Variants 0
and 1 pin the flag to a literal and both pass; only variant 2 inherits the
Jinja.
The groups join the key as a sorted comma-joined string rather than a
tuple, because carrier.py stringifies only one side of its key comparison.
A non-scalar component would make the carrier silently stop matching and
every lookup re-render.
domains derives from the applications view and inherits the same
dependency, so this closes a second instance that had not surfaced yet.
users reads group_names nowhere. No extra rendering results: group_names is
constant inside a worker, and the one process that sees several hosts is
the one the split is for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
services.tor.enabled is Jinja in 99 of the 105 roles that declare it. A lookup('tls') or lookup('domains') inside a meta/*.yml value runs while the applications render is in flight, the re-entry guard hands it the unrendered tree, and _as_bool read the flag's own source text as False. The onion injection then did nothing for any app and the clearnet domain was returned.
fa3797540a already described this and quarantined it: the render state joined the domains cache key so the poisoned map answers no later read. The value stayed wrong, which is enough until a call site bakes it into applications. roles/web-svc-asset/meta/services.yml:15 is the only one in the repo that does, and in run 34922994823 it froze https://file.infinito.test/assets into the payload while every vhost of that round was published under the node onion. web-app-dashboard then waited 60 times on a host openresty answers with ssl_reject_handshake, and the deploy died on TLSV1_UNRECOGNIZED_NAME.
_resolved_flag renders enabled, exclusive and primary and raises when template text survives, the shape plugins/lookup/proxy_pass.py already uses. plugins/lookup/config.py and plugins/lookup/email.py render before coercing for the same reason; this module was the one that did not.
Measured across the whole map: 44 of 12819 leaves change, none added, none removed, 17 roles. 42 are clearnet to onion. The two others are repairs as well: keycloak's csp frame-ancestors moves from https to http, which is what an onion node serves, and the nextcloud sociallogin provider name stops being a hostname and becomes the bounded name that test_sociallogin_provider_name.py documents.
The new tests drive the same function with Jinja instead of the literal booleans the existing ones pass, which is why those never saw this. Three of the four fail without the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
26 roles resolve a domain in their metadata, so lookup('tls') and lookup('domains') run inside the applications render. Under the re-entry guard those calls receive the unrendered tree, whose service flags are still Jinja strings, and _as_bool reads "{{ 'svc-net-tor' in group_names }}" as false. The resulting map skipped onion injection for every app that does not enable tor literally, and it was cached, so it answered every later read in that process.
Before the applications carrier each worker rendered and poisoned its own cache the same way, which is why the shared Keycloak client registered 5 onion origins then and 65 now. The render state joins the cache key, so the render's own call sites still share one map while a later read builds the finished one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ansible forks a fresh worker process for every task, so the in-process
cache behind lookup('applications') was cold in every task. Each task that
touched the merged applications tree (config, domains, nginx, tls, cdn and
their callers) paid a full render: about 16 s on main and 21 to 23 s on this
branch, where ten more application roles carry MCP and AI Jinja. Measured on
the workspace jobs of run 33574056480 against the main run: 331 such tasks
(93 min, 39 % of the job) versus 391 (141 min, 43 %); the same unit is 9 to
43 % of every compose and swarm deploy job on disk. The parent process
rendered once per play, late, while templating a task name, and only the
tasks after that point ran warm.
The constructor now renders once through the new applications_carrier
action and parks the payload with its cache key in the host fact
_INFINITO_APPLICATIONS_RENDERED. Every later worker inherits the fact at
fork time and get_merged_applications serves it while the key (roles dir,
applications, users, DOMAIN_PRIMARY, SYSTEM_EMAIL_DOMAIN) still matches; a
mismatch falls back to the old render. The action masks its own result: the
payload is the whole tree with every credential, and CI runs with
MASK_CREDENTIALS_IN_LOGS false.
The task sits after the networks, design and service_provider merges, after
the add_host placements and after the token store. An independent review
showed that an earlier position bakes stale values into the play: the
web-app-wordpress title rendered to 'Blog by ' because service_provider did
not exist yet. User records inside the payload are the token store's state
at render time; readers of tokens captured later keep using lookup('users')
or lookup('config', app, 'users.…'), which read the users cache fresh.
Expected savings per job: about 130 min on a fork workspace run, 16 min on
an average compose deploy and 28 min on an average swarm deploy. The
constructor's own lookups before the carrier stay cold.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RFC 6761 reserves .test for testing and .example for documentation, and the
project's own domain was doing both jobs under the documentation name. It is
what default.env hands every local deploy and what the test fixtures assert
against, so it is a testing name.
Rename it wherever it stands for the running machine: the environment
defaults, the development inventory, the workspace and trust scripts, the test
suites, and the four documents that describe real behaviour - the DNS mapping
to loopback, the browser CA trust, WSL2 resolution. Those documents would have
started lying the moment default.env changed.
The sandbox allowlist moves with them, since egress is matched per pattern and
nothing resolves the old name any more, and the sandbox document that mirrors
that list follows it.
Two places keep .example on purpose: the link-style sample in the
documentation guide, which is documentation about documentation, and every
generic fixture name elsewhere in the suite - a.example, foo.example and their
kin. Those are a larger sweep of their own and are not touched here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>