Sociallogin creates a Nextcloud user as "<provider name>-<keycloak sub>",
and the provider name was the Keycloak domain. On an onion variant that
domain alone is 67 chars, so every OIDC callback died with
InvalidArgumentException "Username is too long" and HTTP 500 at
/apps/sociallogin/custom_oidc/auth.<onion> (run 34615516009,
Nextcloud#1, 204 occurrences). The whiteboard and admin OIDC specs failed
on the same login.
This is the second derived identifier an onion domain overflows; the
kernel hostname is the first, clamped to 63 bytes by container_hostname
since the pretix and bookwyrm fixes. The domain-or-entity rule now lives in
one lookup, bounded_name(application_id, max_length): the primary domain
when it fits, otherwise the entity name. container_hostname delegates to it
at 63 and keeps its eight callers and its behaviour.
The provider name is bounded_name('web-app-keycloak', 64 - 1 - 36), the uid
limit minus the separator and the UUID sub. A short clearnet domain stays
the name, so existing users keep their uids; an onion deploy gets
"keycloak", which, unlike a hash of the domain, survives a rotated onion
address.
Tests cover the lookup's fit, boundary and fallback cases and its argument
validation, container_hostname's delegation at 63, and the provider name's
room for the sub.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit wrote the same proxy-aware logout expression into
four javascript.js.j2 templates. The rule it encodes - sign out of
oauth2-proxy first when the app is proxy-gated, else go straight to the
provider - belongs where proxy-gating is already decided.
utils/roles/applications/services/sso.py gains logout_url(url,
is_proxy_gated) next to get_sso_config, with OAUTH2_SIGN_OUT_PATH as the
path constant; the rd value is quoted exactly as Jinja's urlencode
filter does it. The sso lookup exposes it as the logout_url term: it
renders OIDC through the templar, takes is_proxy_gated from the same
resolution, and refuses a context without OIDC. Prometheus, Checkmk,
Baserow and Jitsi now render lookup('sso', application_id, 'logout_url').
Behaviour is unchanged: the resolver tests pin the rd encoding against
jinja2's do_urlencode, and the lookup tests cover a proxy-gated app, an
OIDC-native app, a disabled SSO and a missing OIDC.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run 34421944328 odoo#0 loaded assets over the node onion that the page
could not use.
build_csp_header mirrors every clearnet token that carries the primary
domain onto the node onion, but kept its scheme. Onion vhosts are served
in plaintext, so a sibling like https://cdn.<onion> never matched the
http://cdn.<onion> request it was meant to allow. Onion siblings now map
https to http and wss to ws; the clearnet token is unchanged.
The body filter rewrote external asset origins to the onion mirror only
in HTML, so stylesheets kept pulling https://<origin> directly. When an
app has tor enabled and external asset origins, the header filter now
also flags text/css responses on .onion hosts (dropping Content-Length,
since the body changes), and the body filter runs those through the same
buffer and mirror rewrite and returns before the HTML injections. The
flag is set under the same Jinja condition that renders the mirror
block, so a flagged response always has a rewrite to run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
compose_volumes copied extra_configs and extra_secrets through verbatim.
A file-backed extra without a name therefore reached swarm under a fixed
name, and swarm secrets are immutable: a new content under the same name
is rejected on the next stack deploy. Such extras now get the same
{role}_{key}_{sha8(content)} name that _config_secret_name already gives
meta-declared entries. An explicit name is kept.
container_volumes appended extras as handed over. Ansible passes one
lookup's result to another as proxy containers, and a TaggedInt mode
inside a proxy mapping made PyYAML raise "cannot represent an object".
The private _to_plain of compose_volumes moves to utils/templating as
to_plain, now coercing bool, int and float to the exact builtin types
(bool before int, since bool subclasses int), and container_volumes runs
its extras through it.
Only web-app-matrix passes extra secrets or configs today; the
extra_volumes path that web-app-nextcloud uses is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LITELLM_BACKEND_ROLES read lookup('deployment').groups, which deployment.py
fills from group_names. That variable is host-scoped: it lists only the groups
the host currently being processed belongs to.
On a swarm round the manager carries svc-ai-ollama, svc-ai-lmstudio and
svc-ai-litellm while the workers carry three groups and none of them is an AI
backend. Both branches of LITELLM_CHAT_MODEL therefore missed on a worker and,
with OPENROUTER_API_KEY empty, the model resolved to the empty string.
Only one task made that visible. Every stateful task in the gateway include
carries delegate_to STACK_HOST plus run_once, so it templates in the manager's
scope and wrote the correct model into the database. The assert carries
neither, so it re-templated in each worker's own scope and failed there while
the manager passed.
The manager-only membership is not a placement decision. The swarm test
inventory is provisioned with a single --host MGR, so every role of the round's
closure gets that one host, and extend_inventory then widens only
svc-swarm-node, svc-storage-nfs-client, svc-storage-nfs-server and the round's
own application. The three AI roles declare no placement at all; they are
manager-only because nothing widened them.
Add an inventory selector next to the host-scoped one, built from the play-wide
groups magic var, skipping groups without hosts and Ansible's implicit all and
ungrouped, sorted so the cache key is stable. The key takes it too, or two
hosts with equal group_names but different inventories would share an entry.
The blast radius is two readers, both in 16_ai.yml, and both ask only whether
svc-ai-ollama or svc-ai-lmstudio is present. On the manager the new value gains
exactly svc-storage-nfs-server and svc-bkp-nfs-2-local, neither of which either
reader tests, so the manager's answer does not move and only the workers change,
to match it.
This is the third step on the same line. 46a16127fc set .running; b149ad0d22
corrected that to .groups because a targeted apps= round leaves only the
command-line roles in the whitelist. .groups solves the targeted round and not
the multi-host one, because group_names stays host-scoped either way.
Measured in a two-host play with a real group_vars file and the real lookup:
the worker resolves ollama false through group_names and true through the new
selector, the manager is true both ways, and the assert that failed in CI
passes on both hosts.
Requirement 036 named the host-scoped backend selection as the thing that has
to change and deferred it. That residual is closed, so the paragraph now
describes the selector in use and why the two rejected ones are wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_ram_gb read mem_total_mb while mem_available_mb sat next to it, added by
3e9deab6f7 for the drill abort and never carried over here. The ceiling exists
to stop the sidecar from competing with what already runs, so measuring the
machine instead of what is free made the term inert: on a 16 GB runner it
computed five workers while 5.5 GB was actually available, and the CI cap was
the only thing that ever bound.
Reading MemAvailable makes the guard real and self-limiting, which matters now
that the onion case raises the count. Against the memory the run itself reports:
PASS 1, 9113M free clearnet 1 onion 2
PASS 2, 5550M free clearnet 1 onion 1
Also withdraws a CI CPU divisor I had added on a false premise. It cited the
matrix telemetry stall60 as evidence that reserving three cores of four buys
nothing on a CI runner - but mem_stall_pct reads /proc/pressure/memory
(utils/env/runtime.py:84), so that figure proves memory headroom and says
nothing about CPU. Raising the clearnet count needs CPU evidence this run does
not carry, and a contention flake would cost coverage, so the divisor stays at
four until something measures the CPU side.
The onion lift keeps its own justification, which never rested on that number:
a browser waiting on Tor circuits does not consume CPU, measured at 4817s over
Tor against 911s on clearnet for the same specs on the same cores.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidecar is the dominant cost of an onion round and almost free without it.
Measured on one Nextcloud variant at equal mode and filesystem, differing only
in the onion axis, sync pass:
test-e2e-playwright 4817s over Tor 911s on clearnet +5.3x
sys-svc-compose 2294s 2174s +120s
everything else < +25s
The whole Tor surcharge sits in one role. Tor burns no browser CPU, so a 5.3x
wall clock against identical specs on identical cores is circuit latency: the
phase waits rather than computes. The CPU divisor that sizes the clearnet count
does not describe it, and on a 4-core runner it yields exactly one worker,
which leaves the CI cap of 2 unreachable.
compute_workers takes an onion flag and, when set, multiplies the count and
caps it separately. The CI ceiling is overridden rather than respected: it
describes CPU contention, which Tor does not create. ram_workers keeps its
veto, because circuits are cheap and browsers are not.
The lookup reads the transport itself, from the same source the role derives
its own flag from, lookup(tls, application_id, url.base) tested for .onion,
following the _swarm_prefix idiom in plugins/lookup/image.py: read from
variables, render through the templar, fall back to the safe answer. One truth
about the onion state, and a caller asking for a worker count cannot forget to
pass it.
Factor 2 and cap 4 rather than more: each worker holds its own circuits and the
Tor daemon is the next bound, which no measurement here reaches. Expect the
sidecar near 2.6ks per pass instead of 4.8ks, roughly 4.4ks per onion job
against a 20.4ks budget. It does not help a clearnet round, where the sidecar
costs 911s to begin with.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The base image postgis/postgis:17-3.5 is Debian 11, whose archive was decommissioned on 2026-08-31. The pgvector layer pulled 174 MB of build toolchain from deb.debian.org, and those pool files are now purged while the index still advertises them: linux-libc-dev 404'd on 44 of 44 attempts across four Fastly POPs. Since 2026-09-07 the suite's InRelease is also expired, so apt-get update itself exits 100.
PGDG ships postgresql-17-pgvector prebuilt and its bullseye-pgdg suite is alive, so reading only the pgdg.list the image writes itself removes deb.debian.org from the build entirely. Verified on the exact mirrored base image: the package installs, vector.so lands, zero bytes fetched from deb.debian.org. Signature verification is untouched - no Acquire::Check-Valid-Until.
Side effect worth naming: the old recipe cloned pgvector.git with no tag or ref, so every rebuild baked unpinned master into the central RDBMS. It is now pinned at 0.8.6-1.pgdg11+1. The base image is still EOL and its migration remains open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The front proxy hardcoded [200, 301, 302] and escalated to a full OpenResty redeploy on anything else. Apps that answer 403, 404 or 405 by design tripped it on every run: 19 escalations in one Nextcloud job at a measured median of 40.5s each.
roles/<app>/meta/server.yml already declares status_codes per canonical key, and sys-ctl-hlth-webserver already reads it. Move the resolution into utils/roles/applications/status_codes.py so both consumers share one definition of DEFAULT_OK, and expose it to templates as the accepted_status_codes filter.
The proxy gate takes the union of the default and the declaration rather than replacing it. Most declarations are narrower than the triple, so replacing would escalate vhosts that answer perfectly well. Contract checking stays with sys-ctl-hlth-webserver, which keeps replacement semantics.
_normalize_codes and _valid_http_code became dead in the health filter and are removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
65c72fc374 settled the convention: import unittest, and reach the submodule as import unittest.mock as mock, so a call site has one spelling of the name. test_onion.py and test_tor_socks_proxy.py still carried from unittest import mock; CodeQL reports them as py/import-and-import-from, the last two in the repository.
Same one-line change per file the sweep applied; mock.<attr> call sites untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hermes and OpenClaw wrapped the tool allowlist in a guard that dropped the key when the callable set was empty. Both upstreams read an absent filter as "register every tool the server serves", so a provider resolving to no tools got the widest surface instead of none. Nextcloud keeps its contract in tools.categories with no allowlist and admits both clients, so it was the live case.
The templates now render the filter unconditionally, the way the n8n client node already pins include to selected; the two tests that asserted the omission are inverted. mcp_servers additionally rejects such a provider with the fatal code tools_unenforceable: a client filters by tool name, so a runtime-composed surface reaches it as no surface at all, and a server the agent can never call is worse wired in than refused with a reason.
A deployment pairing Nextcloud with either agent now aborts naming the provider, where it previously handed the agent every Context Agent tool unfiltered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docker stack deploy --detach=false converges on a running replica. web-svc-cdn's build container and every migration job end Shutdown/Complete by design, so the CLI waited for a state those stacks cannot reach: nine jobs of run 33698844864 burned the runner's 20400s budget inside this one handler, six of them on web-svc-cdn. The gate that follows already knows better, is_completed_oneshot accepts exactly that task shape.
The timeout keyword could not bound it either. The stack host is reached over ansible_connection: docker, whose plugin runs the child inside a Popen context manager, and __exit__ waits the child out before the timeout exception escapes; a probe measured 90s of wall clock against a 6s timeout. The bound now sits in the shell, where it works.
Handing the wait back to the gate would reopen the race 87e4313e11 closed, so the deploy stamps its start and names the services it reported touching, and the gate treats their earlier completed update as still pending. An inspect format the daemon rejects falls back to the old one, which only disables that check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SSO gate handed X-Forwarded-Access-Token to every oauth2-fronted upstream, so each of the 28 gated roles received a full-scope bearer for the signed-in user while exactly one, n8n, decodes it. The token also grows with the realm's registered origins, and LAM's Apache rejects a header field above 8190 bytes (Moodle#0, run 33689701895).
Consumers now declare services.sso.oauth2.pass_access_token. Without it oauth2-proxy already answers the auth request without the token, and the gate blanks the upstream header rather than dropping the directive, because nginx inherits proxy_set_header as an array and an unset header would let a client-supplied one through. The new lint keeps declaration and use in step in both directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lookup('redirect_uris') walked every app in the domains map whose services.sso.enabled resolves truthy, which every role spells as "'web-app-keycloak' in group_names". A round deploying 21 apps therefore registered 69 redirect URIs and web origins on the shared client, 65 of them onion, and an app that is not running has no reason to be a redirect target. group_names is now required and scopes the result, matching KEYCLOAK_SAML_APPS which already selects on it.
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>
The contract check ran as an Ansible task in the middle of every provider deploy, and the two client checks ran the same way. A test that is part of the deploy cannot be run without deploying, cannot be re-run on its own, and reports its verdict as a deploy failure. Twenty-seven task files lose 491 lines to this.
It is now what it always was. Each provider ships files/test/test.sh, templates/test.env.j2 and meta/tests.yml, and test-e2e-cli stages one shared harness beside it: run.sh drives contract.sh in a throwaway container on the provider's own network, then isolation.sh for adapter-fronted providers. The read probe each provider offers moved from the calling task into meta/mcp.yml next to the allowlist it must satisfy, and the schema now rejects a read probe naming a tool outside that allowlist.
The bearer the test presents is the one a client resolves through mcp.credential, via the new mcp_client_bearer lookup over resolve_credential, which moved to utils.roles.applications.mcp so it is the same function mcp_servers renders every client connection with. The probe presented whatever the calling role passed, which for the fifteen adapter-fronted providers is a different secret than any client holds.
Sidecar isolation is read from the engine rather than measured inside the container, so it no longer needs a delegation to the node holding the task: attachment is compared by network name, which drops the gwbridge special case, and a swarm worker says it cannot measure instead of skipping silently.
Retrying is bounded by wall clock against MCP_DEADLINE_SECONDS, which scales through the same lookup('timeout') as the harness budget it has to stay under. A flag that is neither true nor false aborts rather than reading as off.
mcp-provider-probe becomes mcp-provider-cli-test: an enabled provider must ship the test rather than include the task. mcp-probe-bearer becomes mcp-adapter-bearer, tying ADAPTER_BEARER to the declared credential key now that no probe include carries it. A new integration test renders the shared env template for every provider through Ansible's own templar, so a later change to it breaks visibly instead of poisoning twenty deploys quietly.
Hermes and OpenClaw keep their client-side proof as CLI tests of their own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nextcloud#7 of run 33445831564 fails on three discourse specs, all on one
assertion, all for one reason. The received body is Discourse's own page:
"Sorry, there was an error while trying to authorize your account with oidc."
The browser leg works. What dies is Discourse's server-side back-channel:
"GET /auth/oidc/callback?...code=..." 302 2.125 2.125
"GET /auth/failure?message=openid_connect_request_failed&...&strategy=oidc" 200
Four of those, at 2.125 / 2.015 / 2.063 / 2.014 seconds. That is not the plugin's
10s HTTP timeout; it is Discourse's DNS budget, hard-coded at 2s in
lib/final_destination/resolver.rb (connect_timeout arrives nil, so the default
applies). openid_connect_request_failed has exactly one emitter --
omniauth_open_id_connect.rb:147-155, rescue Faraday::Error -- and the alternative,
SocketError -> LookupFailedError, is not a Faraday::Error and would have produced a
500 rather than the observed 302.
The correlation leaves no room: across 18 back-channel requests, all from
192.168.9.3 with UA Faraday v2.14.3, there are exactly two complete token 200 +
userinfo 200 pairs -- and exactly two specs passed. Thirteen truncated flows,
thirteen failures. No test that got a complete exchange failed; none that failed got
one.
Why discourse alone: discourse.inspect.json:72 reads "ExtraHosts": null. Grepping the
whole rescue bundle for the pin finds nextcloud, nextcloud-proxy, mailu-admin,
prometheus and prometheus-sso-proxy. Discourse could not have it: config.yml.j2's
docker_args: carried only --network= and --name=. It is built by discourse_docker's
launcher, not by a compose file, so the lookup's rendered block has nowhere to go --
hence entries_only, which returns the strings and lets the template write
--add-host= lines. The pin itself is NOT re-derived: _sso_pins() and
_gateway_address() stay the single place that knows compose from swarm.
Its existing allowed_internal_hosts cannot help, because ssrf_detector's
lookup_and_filter_ips resolves BEFORE host_bypasses_checks?; the setting only makes a
private answer acceptable once it arrives.
Rendered through a real Templar both ways: with SSO on, the two --add-host lines land
between the networks and --name; with SSO off the output is byte-identical to today,
so clearnet and variant-1 deployments render an unchanged app.yml and trigger no
launcher rebuild. 24 lookup tests green.
Not closed: the biber persona has never passed, here or in run 33317483326. Its
failure text and code path match administrator, which did pass, so it is explained --
but a persona-specific second defect is not positively excluded. Deploying discourse
variant 0 locally, which already has sso and tor enabled, settles it.
Separately worth queueing: discourse's own meta/variants.yml variant 1 sets
sso.enabled: false and only variant 1 is scheduled, so the role that owns these specs
never runs them. Only partner rounds like Nextcloud#7 catch this class.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f85996b43a turned the tmpfs options into a dict so a declaration could carry a
mode as well as a size, and coerced the mode to the int docker expects. The size
sitting next to it kept being copied through verbatim. The short form accepts
"512m" and so does a human writing the declaration; the long form this renders
takes an integer and refuses the string rather than parsing it:
services.checkmk.volumes.1.tmpfs.size must be a integer
That is the whole of Checkmk#0 in run 33317483326, and it stops the deploy before
the role does anything.
The suffix is resolved the way the mode already was, in a helper beside it, and
a value that is neither an int nor a byte count is refused instead of passed on
-- passing it on is what let "512m" reach compose in the first place.
The existing test asserted {"size": "64m"}, so it pinned the defect: the suite
stayed green while CI failed on it. It now asserts the byte count, every unit a
declaration may write resolves, and three malformed sizes raise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b067d21ef added the realignment helper as an argument to compose_volumes(),
so it now runs before that function reaches its own guarded call. A role with
both engines enabled therefore raised the bare ValueError through the lookup,
and the message that names the collision and says to pick one dbtype per role
had become unreachable. The helper catches it and defers.
No role can trigger this today: over all 265 meta/services.yml, none declares
both keys at all, and `enabled` has a single shape across the tree, the literal
True in 54 places. The guard protects the diagnostic for whoever adds the
second engine, which is exactly when a bare ValueError would be least useful.
The tests pin what the two startup failures cost to find. The SQL must stay one
ALTER USER IF EXISTS: root must not appear, because giving it a password during
the entrypoint's temporary server breaks a readiness probe that connects
without one; CREATE USER must not appear, because the entrypoint's own carries
no IF NOT EXISTS and the script runs under set -e; and the GRANT must not come
back, because rotating a credential changes passwords, not privileges.
Verified by rendering the version those tests were written against: it puts
root, CREATE USER and GRANT in the file and four statements in place of one, so
every assertion fails on it. The deferral was checked the same way, against the
module as it stands in HEAD.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whether a resolver answers on the docker bridge was inferred from the host's
own resolv.conf: all entries loopback was read as "a listener of ours is there,
and containers must reach it on the bridge". A systemd-resolved stub binds
127.0.0.53, NetworkManager's dnsmasq binds 127.0.0.1, and a node dnsmasq binds
both those and the bridge. All three look identical from resolv.conf and only
the third is reachable from a container.
So ordinary hosts were handed an address with nothing behind it. The
Instructions job died there: daemon.json carried "dns": ["10.208.0.1"] alone,
and the openresty build could not resolve dl-cdn.alpinelinux.org while the
daemon, resolving through the host, pulled the same base image fine. Two netns,
two outcomes.
e308e18923 had already fixed this once, with a reachability gate whose message
names the same URL. b09f2cbd82 dropped that gate and ce43263745 put the
resolv.conf inference in its place, so the guard was gone and only the missing
docker0 fact still hid it. 285ba1e76c removed that accident and the failure
surfaced.
svc-net-tor and NETWORK_CONTAINER_BRIDGE_RESOLVER now say it instead, the
latter false by default and true in the swarm lab, which provisions the dnsmasq
its nodes resolve through. Extra-vars are global, so the lab's NFS and backup
hosts carry it although no listener is installed there; the clearnet resolver
behind the bridge keeps them working.
The docker0 fact is gone from the address too. It is not merely late, it is
wrong while it is late: facts are read before the daemon config is applied, so
docker0 still holds docker's default and writing default-address-pools moves
the bridge out from under it. A swarm node shipped "dns": ["172.17.0.1", ...]
against a live 10.208.0.1, which is why its containers asked a dead address and
fell through to the public resolver, where infinito.example does not exist. The
pool answers before the daemon exists and answers the same afterwards, and it
is read per slice rather than per supernet, which differ once a pool does not
start on a slice boundary.
A list that would carry only the bridge now gets NETWORK_PUBLIC_DNS_RESOLVERS
behind it, so a declaration that turns out to be wrong costs a wasted first
query rather than all name resolution. An empty declaration raises.
Rendered from the repository's own variable files: the Instructions host yields
[] and the template omits the key, which is what de95a95c65 emitted while that
job was green; lab and tor nodes yield the live bridge ahead of the clearnet
resolver even with a stale docker0 fact present; an ordinary host yields only
its declared resolver.
The suite asserted the two behaviours this replaces, including one case added
with 285ba1e76c demanding the stale fact win. They are inverted, and a
cross-product over nameserver shapes, tor membership, clearnet and the docker0
fact now asserts that no shape ends up holding the bridge alone - the old suite
checked each shape by itself, which is how this passed through it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ce43263745 replaced the bridge literal with the clearnet resolver and left
the lookup to supply the bridge from ansible_facts.docker0. That fact
cannot be there on the run that needs it: facts are gathered at play
start, nothing in the role re-gathers them, and on a fresh node docker is
installed afterwards - so the render that writes daemon.json always sees
no docker0 and emits the clearnet resolver alone.
Measured on one node of the same job: docker0 is 10.208.0.1/24 while its
/etc/docker/daemon.json carries "dns": ["1.1.1.1"]. The bridge existed and
was missing from the config that had to name it.
The inner containers then asked a public resolver for a name only the node
dnsmasq serves, and the DinD check reported it accurately seven times:
Server: 1.1.1.1
** server can't find infinito.example: NXDOMAIN
(The '[container] CA trust certificate is not a regular file' line above it
is noise - container.py catches that and falls back to plain docker run.)
default-address-pools is where docker takes docker0 from, so the
declaration answers before the daemon exists and gives the same answer
after: subnet_gateway("10.208.0.0/12") is the 10.208.0.1 measured above. An
observed docker0 still wins, so a host whose bridge sits elsewhere keeps
its real address.
That also settles what the docstring flagged as unavoidable. The
fact-derived value appeared only from the second run on, so daemon.json
differed between passes and restarted the container runtime under a live
stack. Both passes now render the same bytes.
Only the swarm path showed it: 18 jobs died at the daemon start before
reaching this handler, and the six that got here in the next run are the
first to exercise it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Discovery resolved applications[role].credentials, the address from
before f713ba8350 moved every credential under secrets.credentials. The
run's own inventory shows which one is real: twelve applications carry
secrets.credentials, none carries the top-level key. So every provider
declaring credential.source: credentials - fifteen of the twenty - handed
back an empty mapping, and reconciliation aborted on a secret that had
been provisioned all along:
mcp_servers: 1 authorized MCP server(s) cannot be rendered.
web-app-prometheus: credential_missing: web-app-prometheus declares
owner 'mcp-web-app-prometheus' but no secret resolved
The five providers on token_store read through users and were never
affected, which is why only some clients broke.
Provider and client were reading the same secret at two addresses:
web-app-prometheus/vars/main.yml:32 renders its bearer from
secrets.credentials.mcp_bearer. Without the strict assert this would have
surfaced as a 401 at runtime rather than a failed deploy.
The lookup was correct when 6881ac5132 wrote it - the rename landed on a
branch it was not an ancestor of, and arrived here through the main
merges of 08-15. Its own message notes that no regex over lookup('config')
could see an access path like this one; a .get("credentials") split across
two lines in a Python plugin is equally invisible to it.
The address now comes from credential_key's constants, which is where the
module already claims a credential is addressed by exactly one string.
resolve_credential was always handed a fabricated mapping directly, so the
function was covered and its caller's address was not. The new tests close
that seam, one of them end to end over the shape provisioning produces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5345f1632 added the render step but not the re-trust, so it never worked.
ansible-core 2.19+ renders only strings carrying TrustedAsTemplate, and
the str() coercion on the line above drops the tag the extra-vars loader
attached. templar.template() then hands the expression back untouched,
the guard sees Jinja and raises:
The lookup plugin 'container_dns' failed: networks.internet.dns is
'{{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}' and did not resolve to an
address.
Which is worse than the ParseAddr the guard was written to prevent, and
on the same 18 jobs: before, one bad daemon.json; after, every run that
reaches the daemon config, compose included, since
inventories/development/default.yml carries a lookup('env') there.
Re-tagging first is what proxy_pass, container_ports, container_volumes
and compose_volumes already do. Through the real lookup loader the field
now renders to ["10.208.0.1", "1.1.1.1"].
The unit stub was the reason none of this showed: it rendered any string,
so twenty tests passed against code that could not work in a play. It now
refuses an untagged string the way the templar does, which makes the four
tests covering this fail without the re-trust. Stub and production key off
the same private ansible module, so a move would degrade both at once -
production to silent identity, the stub to rendering anything. The added
class asserts that shared premise rather than trusting it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ce43263745 replaced the literal 172.17.0.1 under networks.internet.dns
with {{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}, for a good reason: the
fixed address went stale whenever default-address-pools moved the
bridge. Nothing renders that field, though. The file arrives as
'-e @inventories/development/swarm.yml' and this lookup read it straight
out of variables, so the expression reached the daemon as its own source
text:
dockerd: unable to configure the Docker daemon with file
/etc/docker/daemon.json: invalid JSON:
ParseAddr("{{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}"):
unable to parse IP
Then 'Start request repeated too quickly', and the node has no container
engine, which is where eight swarm jobs died - across centos and debian,
so it was never distro-specific. While the field held an address the
missing render step was invisible.
It now renders against the lookup's own templar, and refuses to emit a
value that still carries Jinja. A string that is not an address has no
business in daemon.json, and failing loudly beats leaving a node without
docker.
Baserow was the only role declaring endpoint.key_credential, and putting
the adapter in front of it left the field with no consumer. Keeping it
would also contradict the decision that put the adapter there: a secret
in the URL path cannot be scoped per consumer and cannot be revoked on
its own, so a provider authenticating that way belongs behind an adapter
rather than in a client's rendered configuration. The segment now stays
between the adapter and its upstream.
Discovery loses a config lookup per provider per client along with the
branch. The test that proved the segment was appended becomes its
inverse: an endpoint still declaring the field gets a URL without one, so
the decision is held rather than merely unproven.
Open WebUI runs with persistent config off, so the environment is authoritative on every start and whatever the connection filter renders is the state the container comes back with, not only what a deploy produces. The filter already carried the group grant in that environment, but nothing asserted it: without the grant travelling there, a restart would restore a disabled connection that waits for the next deploy.
The README described exactly that outage as the current behaviour, which stopped being true once the grant moved into the environment, so it is corrected rather than left to mislead the next reader.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Qdrant ran with no authentication at all: its environment set the HTTP port and disabled telemetry and nothing else, so every container that reached its network held full read, write and delete access to every collection, and the MCP surface's read-only promise rested entirely on the adapter's contract naming only GET routes. Qdrant 1.19 forces authentication on every request once a read-only key is set, so the engine now declares both keys, the adapter presents the read-only one, and the full key goes only to consumers that own data here. A write route is now refused by the engine rather than only by the adapter.
Three defects in the Flowise registry path. Its tool-name check compared against a contract the role always passed as empty, so it could not fail; it now compares against the provider's declared allowlist. Its managed flow pointed a customMCP node at an input that exists on no node at this tag, while the node that does take a registry id is customMcpServerTool, and the one that takes an inline config would have embedded the bearer in the flow; the flow is now an Agentflow whose Tool node calls one named tool with fixed arguments and is executed on every deploy. Its module docstring claimed only SSE providers could be registered, while the pinned MCPToolkit connects Streamable HTTP first and falls back to SSE, so the docstring contradicted the role's own declaration.
Reaching a provider inside the container network needs HTTP_SECURITY_CHECK=false, which replaces the built-in deny list rather than merging into it, so a typo there disables every protection silently while the registry keeps working. The deploy now points a throwaway entry at loopback and at the metadata address and fails unless both are refused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The swarm inventory carried the manager's docker bridge gateway as a literal under networks.internet.dns, so one field meant two things at once and the value went stale the moment default-address-pools moved the bridge. The daemon kept handing out the new address while the inventory kept naming the old one, and the DinD diagnostic probed that same literal, so it failed in step with the defect instead of catching it.
The inventory now declares only the clearnet resolver. The lookup hands containers the bridge whenever the host resolves solely through a loopback listener, which is how the simulated cluster nodes are wired and which docker strips from a container's resolv.conf. The diagnostic reads the gateway from the running daemon instead of being told it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
834 commits from the Tor onion-service branch against 299 of ours, from merge
base 7b646bdece: 127 conflicted files, 3506 files changed.
The two sides are largely orthogonal. Theirs adds svc-net-tor and a `tor` service
across the role tree, plus two sweeping refactors: shipped scripts sorted by
language (files/ruby/, files/python/, files/shell/, scripts/lint/*.sh) and the
unit suite moved under tests/unit/python/. Ours is requirement 036, the native AI
gateway, plus the MCP role work. Most conflicts were both sides appending to the
same service map, resolved as unions.
Resolutions that were not unions:
Credentials moved namespace. Theirs renamed meta/schema.yml to meta/secrets.yml
and re-addressed credentials as secrets.credentials.<name>. Git carried our
litellm_api_key blocks through the rename, but every consumer we added still read
'credentials.<name>', which no longer resolves. All 45 call sites are migrated.
plugins/filter/container_volumes.py is deleted by theirs and relocated to
plugins/lookup/container_volumes.py, where it carries the same tmpfs defect our
f85996b43 had just fixed: a hard-coded {"size": ...} that silently drops a per
mount `mode`. The fix is re-applied in the relocated file, as that commit's merge
note anticipated.
compose.yml keeps only INFINITO_PLAYWRIGHT_REPORTS_BASE_DIR. Theirs also forwards
INFINITO_PLAYWRIGHT_STAGE_BASE_DIR; that line is deliberately dropped, per the
analysis recorded in 45804e9ad -- STAGE has a drift-proof role-vars fallback,
while forwarding it promotes a setdefault-pinnable .env value above that SPOT.
tasks/stages/01_constructor.yml takes their include, ./tasks/utils/setup/group.yml,
because their refactor removed tasks/groups/*-roles.yml, leaving our include
target nonexistent. Our task name is kept, since svc-virt is in the loop.
Makefile swarm-zombie is a union: their validate plus teardown, then our
lab_subnet cleanup, which their side dropped while leaving lab_subnet.sh as the
only orphaned reference in the file.
01_bootstrap.sh keeps our deterministic --ip pinning, which distinguishes an
already-attached node from a real failure, over their `|| true`.
Their language-sorted layout orphaned references our side still held: zammad's
two scripts/apply/*.rb, jenkins' java-with-ca-trust.sh, and the notify topics
their handlers renamed to hyphenated form. All corrected.
roles/sys-svc-compose/files/shell/swarm/stack_ready.sh gains our latched-update
block: paused, rollback_paused, rollback_started and rollback_completed exit 2
rather than polling a state that cannot resolve without another deploy.
config_from_run is unioned to (title, logged, jobs); the production caller in
trigger/__main__.py now passes jobs, without which our suite suppression would
have been inert.
READMEs are regenerated rather than hand-merged: 15 LiteLLM rows and 91 Tor rows
now derive from the merged metadata, which is itself evidence the meta merge is
coherent.
Verified: 5161 unit tests pass. The single failure, test_query.py TestSortSpec,
also fails on fork/feature/svc-net-tor at b51d2f9919, measured in a baseline
worktree -- so this merge introduces no new unit-test failure. Two modules are
excluded from that run because `baudolo`, a dependency their branch introduces,
is not installed on this host.
Not verified: no deploy has run against the merged tree, and make quality has not
been run end to end here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two ca_* handlers sat flat in utils/env/handlers/infinito, which the
prefix-grouping lint rejects, and the bundle-candidates comment exceeded
the .env comment length limit.
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>