allowed_consumers was twenty hand-kept lists of which fourteen were identical, so every new client meant an edit in every provider. A client now declares services.<own-entity>.mcp_consumer: true once and a provider refuses one with mcp_consumer: false, which is also what tells a deliberate exclusion apart from a forgotten entry.
The group_names-gated consumer entries give the inventory resolver the co-deploy edge meta/mcp.yml never had: one of sixty-nine matrix rounds carried an MCP client before, twenty-three role/round pairs do now. bond 0.5 keeps the clients out of round 0 so the host memory budget still holds.
BREAKING CHANGE: meta/mcp.yml no longer accepts allowed_consumers; declare services.<entity>.mcp_consumer on the client instead.
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>
An engine of its own keeps its passwords in its data directory, written once
when that directory was initialised. When the deploy rotates the credential the
new value reaches the container's environment and never the engine, so the
health check authenticates with a password nothing has ever accepted. The
application user and root come from the same value, so nothing is left that
could log in and change it either.
mariadbd reads --init-file on every start, before it opens the socket, and runs
the statements with full privileges. That is the one point where a password can
be reset without knowing the previous one.
The SQL is rendered next to the env file that already holds the same secret in
plaintext, so it adds no new exposure. It reaches the container as a config
rather than a bind mount because a swarm task may be scheduled on a node the
rendered path does not exist on; the config name carries a hash of the file
content, so a rotated password produces a new name and swarm replaces the
immutable old one.
The guard is mariadb-and-not-shared, which matches the `local` guard the service
template already renders on, so the block and the top-level entry appear
together or not at all.
3831c8f804 fixed the other half of this failure: matomo, moodle and nextcloud
keep the same password in a config file on their data volume. Same rotation,
opposite end of the connection.
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.
The reference sits in prose inside a module docstring, not in a markdown
link or an import, so neither the link lint nor the import resolver saw
it when the rest of the paths were followed down. A grep for the old
names across md, py, yml and j2 is now empty.
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>
Both agents registered every tool every provider served. Hermes documents that as its own default in tools/mcp_tool.py, "Neither set -> register all tools", and the role never set tools.include; OpenClaw's toolFilter was never rendered at all. A provider that grew a mutating tool would have reached both without anything declaring it.
A serving role now names its mutating tools, the discovery snapshot carries that set beside the allowlist, and every client renders the allowlist minus it in whatever form its upstream enforces. The subtraction sits at the client and not in discovery because Flowise compares the declared set against what its registry actually discovered and needs the full served list. Where a provider permits mutations there is nothing to withhold, so the set renders empty.
This is a client-side boundary, not a server-side one: whoever holds the endpoint key can still call a write tool directly. Baserow is the case that made it necessary, since its MCPEndpoint model carries no scope and its owner is a workspace administrator, so the declared mutating_tools_enabled bounds nothing there.
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>
checkmk's entrypoint runs `omd create --no-tmpfs`, so the site's tmp directory
is never mounted and the site cannot come up cleanly. Declaring it needs a tmpfs
with sticky, world-writable permissions, which the volume layer could not
express.
container_volumes built the tmpfs options as a hard-coded single key:
if mount.get("size") is not None:
tmpfs_entry["tmpfs"] = {"size": mount["size"]}
so a `mode` in the declaration was accepted by the schema and silently dropped by
the renderer. It now accumulates size and mode and attaches the key only when
something is set, coercing mode to the int docker expects. mounts.py validates
both the bind and tmpfs spellings through _is_octal_mode and names the expected
shape in the error, so `1777` or `0o1777` fails at render with a readable message
instead of producing the wrong bits.
Capability and consumer land together deliberately. Split apart, checkmk's
mode: "01777" would pass validation, be dropped at render, and the tmp directory
would come up without the sticky bit while everything reported success.
checkmk declares site_tmp as a tmpfs at the site's tmp path with size 512m and
mode 01777. The site name moves into services.checkmk.site, and CHECKMK_SITE
reads it through lookup('config', ...) instead of hard-coding "cmk", so the mount
target and the variable cannot drift.
MERGE NOTE: fork/feature/svc-net-tor carries acc9e25c0f, which deletes
plugins/filter/container_volumes.py and moves it to plugins/lookup/. That
relocated copy has the identical defect at lines 141-142 -- the same hard-coded
{"size": ...} -- and its _coerce_mode_int is wired only to the config-ref path.
It is not a competing fix, but this change will disappear at merge unless it is
re-applied in the lookup.
Verified: the unit suite passes, 4448 tests, including new coverage of the mode
path in test_container_volumes.py.
Not verified: checkmk has not been watched starting with this mount. Docker's
tmpfs mode also interacts with the container umask, so the effective bits on the
created directory were not inspected live. The new validation is fail-closed: a
role carrying a non-octal mode would now error at render rather than be ignored,
and while the suite is green, I did not grep every CHECKMK_SITE consumer for one
still assuming the literal "cmk".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deletions only, no line added. The rationale each of these carried is already in
the commit message of the change that introduced it, which is where it belongs.
Three of the twenty Exception blocks this change set accumulated had already been
committed when the audit ran, so they were invisible to it: an audit that
enumerates git status stops seeing a file the moment it is committed. Two further
details are worth recording because they are what let these survive.
The purge pattern (^|\{)#\s*Exception: does not match an indented comment, so a
block nested inside YAML never matched while the report claimed none remained.
Anchoring it as ^\s*(#|//|--|\{#)\s*Exception: raised the count from 12 to 17.
One of the three was written as a paragraph inside a Python docstring rather than
as a comment. Docstrings are an allowed kind, so no comment grep matches them and
the repository's own comment lint passes over them; using the allowed kind to
carry the banned label is the same rule-gaming in a syntax the checks do not
cover.
Verified the deletion removed nothing else: the diff contains zero insertions,
every touched YAML file still parses, and the unit suite and comment lint are
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both halves of the Playwright harness used "ships a Playwright suite" as a
proxy for "is deployed". The proxy is structurally wrong in opposite
directions, and each error hid itself.
discover_playwright_roles.py finds roles by globbing
`templates/playwright.env.j2`, so a role without that template can never
appear in TEST_E2E_PLAYWRIGHT_APPS no matter how deployed it is.
addon_env_flags resolved its bridge-partner check against exactly that list,
so every addon declaring `bridges: [litellm]` (5) or `bridges: [ldap]` (4)
gated itself off: svc-ai-litellm and svc-db-openldap ship no suite. The
affected specs reported as skipped, which is indistinguishable from passing
in a `failed=0` PLAY RECAP -- discourse-ai, AIEditingAssistant and ai-engine
have never once executed. Measured: `ls roles/svc-ai-litellm/files/playwright`
does not exist and the role is absent from the runner's "Playwright-enabled
roles" line, while `grep svc-ai-litellm ~/inventories/localhost-2/devices.yml`
returns a group entry. Two sources, opposite verdicts.
The gate now reads `group_names`, the round's actual closure. It stays
fail-open when that cannot be resolved: `_resolve_deployed_roles` returns None
for an empty set, so contexts without an inventory skip bridge gating exactly
as before rather than flipping every bridged addon false. The change is
monotone -- TEST_E2E_PLAYWRIGHT_APPS is the discovered roles intersected with
`running`, and `running` is a subset of `groups`, so a flag can only move
false -> true and no spec that ran before is newly skipped.
The second half is the mirror image. test_e2e_playwright_only_roles defaulted
to `lookup('deployment').groups`, the whole round closure, so `apps=` deploying
three roles still ran every spec in the round; specs for roles left on stale
state then failed and masked the result of the deploy that actually ran. It is
`.running` now: the whitelist when a targeted deploy sets one, the inventory
groups otherwise, leaving full runs unchanged.
The two lookups deliberately pull in opposite directions. Addon gating asks
whether the partner exists anywhere in the deployment and needs the full
closure; run scoping asks what was just deployed and needs the subset.
Conflating them was the shared defect.
defaults/main.yml keeps the same knobs and values apart from only_roles; its
mid-file narration moved into a Param header so the comments policy passes on
a file that is now touched.
Verified: 23 unit tests including a guard that fails on the old path
(group_names unread -> gating skipped -> true, where the test demands false),
and `make quality` green on test-lint, test-unit, test-integration and
test-external, read from the per-target table rather than the exit code.
Not yet verified live: the three unskipped specs have still never run, and
`group_names` is per-host, so a swarm delegate whose membership differs from
the stack host is unexercised.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
89f53a942 landed the canonical mount surface without running autoformat,
so ruff reflowed six files the next time the quality gate ran. Pure
formatter output: expressions that fit on one line joined back up, one
import pair sorted, no executable change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bridge resolver comes from ansible_facts docker0, a fact that does not exist on a first deploy: the same role installs docker, and the daemon only starts on this very template's notify. Run 1 therefore renders without the bridge and run 2 with it. Where bridge and clearnet are the same address that is one entry against two - different bytes, so the template reports changed, the handler fires, and dockerd restarts underneath a live stack. In CI run 32119014832 that restart killed postgres on the manager at 12:42:26 and the deploy unravelled from there.
Deduplicating makes both renders collide on identical bytes. Order is preserved: the bridge must stay first or dnsmasq never sees the onion names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mount pipeline was split across two layers: compose_volumes and
container_volumes lived as filter modules that ansible's filter loader
rejected on every start ("has no attribute 'FilterModule'", two warnings
per run), while the same-named lookups wrapped them thinly. The filter
bodies now live inside their lookups (the tor_socks precedent), the
filter files are gone, and the render tests moved next to the lookup
tests.
As lookups they see the templating context, so the application_id term
every call site passed back to its own role is gone too: both lookups
read application_id from context and fail loudly when it is missing.
107 templates and nfs_prep.yml drop the term; the calls collapse to
lookup('compose_volumes') and lookup('container_volumes', service_name).
The container_volumes call is now canonical - four leading spaces,
service_name in the service position, | indent(4) - enforced by a new
lint (test_container_volumes_call_shape). Normalizing the 85 literal
call sites surfaced that erpnext keyed its meta/volumes.yml mounts by
container name instead of compose service key, the only role to do so;
its meta now uses the service keys. wordpress's column-0 call, which
rendered its first line at top level, is indented like every other.
Template logic that only restated meta/volumes.yml is deleted: mailu fed
compose_volumes an extra_volumes dict whose eight names were looked up
from the very meta file compose_volumes reads itself; zammad's init and
railsserver extra mounts were the volumes.yml entries minus the two
services, which the meta now lists (the when-gated OIDC binds included).
Conditionality that meta cannot express stays in code, but in role-local
lookup plugins instead of template loops: matrix_bridge_mounts renders
the per-bridge registration mounts, nextcloud_extra_volumes the
feature-gated whiteboard/recording volumes (type: volume entries are
declared unconditionally by compose_volumes, so gating must happen
before it).
Parity is proven, not assumed: every mechanically rewritten template is
byte-identical to HEAD after the transformation regexes (95/95), and the
semantic roles render identically through the real functions against
HEAD's meta - zammad across six sso/tls polarities, all seven erpnext
services, mailu's top level. make test-lint passes complete.
Also: suitecrm's compose template pins container_extra_hosts with
host_alias (the SOCKS proxy URL host.docker.internal never resolved in
compose+tor), and the five disabled mautrix bridge addons use the
empty-default config lookup so templating the applications dict stops
emitting 125 errors per batch in every CI job.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lookup read services.<type>.name only for the central provider and derived
the dedicated one from the entity, which holds as long as sys-svc-rdbms renders
the engine. bigbluebutton brings its own and declares bigbluebutton-postgres-1,
so the lookup answered bigbluebutton-database - a container that does not exist.
Nothing reads that field for bigbluebutton today, which is why the mismatch
survived: it was wrong and unused at the same time. The central branch is
untouched, where `container` is the compose service key rather than a container
name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sys-svc-mcp-reconcile sets application_id from a loop variable, so the
lookup received the literal {{ mcp_client.id }}. An unrendered id matches
no allowed_consumers, which rejected every provider as
consumer_not_allowed and emptied each client's registry without raising.
Pull the resolution into resolve_consumer_id() so the branch is reachable
from a test, and cover the three cases: a deferred id is rendered, a plain
id is kept, a missing id aborts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d130f5559 moved the swarm converge wait onto compose_mode_force-or-
DEPLOYMENT_MODE and left the compose health wait beside it reading
DEPLOYMENT_MODE raw. A role pinned with compose_mode_force: compose on a
swarm cluster then satisfied neither condition - the compose branch saw
'swarm' and the swarm branch saw 'compose' - so it got no post-deploy
readiness wait at all. web-app-matrix in its ansible flavour is the live
case.
The database lookup read DEPLOYMENT_MODE raw for the same reason, handing a
pinned role a swarm service name and a resolve-container-id address for a
stack that docker compose had created.
The override is scoped to the dedicated database only. A dedicated engine
runs inside the consumer's own stack, so the consumer's pin governs it; a
central engine is its own stack under the cluster mode, and letting a
consumer's pin reach it would rename a service swarm really did create.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Output of 'make autoformat': ruff collapses expressions that were wrapped across lines without need, shfmt and the yaml formatters drop surplus blank lines. No semantic change - the whole diff is 10 insertions against 39 deletions, and every hunk joins lines that already said the same thing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3529 invalid comments in 528 files, grandfathered in while the linter
only saw the working-tree diff. Removal ran through the linter's own
collectors, so detection and deletion cannot drift: a comment whose
opener line is blank before it and whose closer line is blank after it
loses all its lines, anything else loses only the comment region, and a
line left holding a bare delimiter goes with it.
Five files keep their comments behind a nocheck marker instead. Four are
upstream configs whose inline docs are upstream's own (mautrix, systemd
journald, nextcloud-oidc-login, MediaWiki). In littlejs the
commented-out entries ARE the deactivation mechanism for an example
tile, so deleting them would delete configuration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Outside compose TOR_CONTAINER_DNS_HOST was empty, so the drop-in rendered no
listen-address and lookup('container_dns') handed containers the clearnet
resolver alone. A swarm stack host ran the egress router without a listener its
containers could reach, and .onion stayed unresolvable for every one of them
while compose resolved it fine. Ownership already governs the listener, the
upstream policy and the resolv.conf pin; the deploy mode only ever stood in for
it. With the mode clause gone both modes render the drop-in compose has been
running in production, and compose keeps its exact behaviour: where matched == 0
the old and the new expression are the same boolean.
In CI run 31927586060 the swarm node dnsmasq logged "ignoring query from
non-local network 172.18.0.16" seconds after a veth appeared and vanished on
docker0, and every container lookup timed out from then on. The health service
retried for 77 minutes against a resolver that no longer answered.
Measured on dnsmasq 2.93 before landing this: a listen-address disables
--local-service even when that flag comes from the init script; without a bind
directive dnsmasq takes the 0.0.0.0 and [::] wildcard pair, which would collide
with a systemd-resolved stub and occupy port 53 on every address; bind-dynamic
and bind-interfaces both hold their per-address listeners across 2000 veth
cycles; and the stock dnsmasq.conf on arch, fedora and centos carries no
bind-interfaces, so the drop-in's bind-dynamic cannot abort startup there.
Two expectations in tests/unit/plugins/lookup/test_container_dns.py still encode
the compose-only asymmetry and fail with this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The new role-templates lookup read its paths with os.path, which the repo's
ruff config rejects (PTH113, PTH119). autoformat is the first step of the
quality gate, so those two lines stopped the gate before a single test ran --
the whole suite was unreachable over a filesystem call spelled the old way.
Behaviour is unchanged: Path(...).is_file() and Path(...).name answer exactly
what os.path.isfile and os.path.basename answered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
render_replicated_templates.yml built its expanded spec list in a 20-line Jinja set_fact, and both it and _render_one_template.yml carried their own copy of the same five defaults. The two drifted: a46348b6c changed the notify default to the compose-up listen topic in _render_one_template.yml and missed the identical line in its sibling, which kept notifying a handler named with a space. Ansible resolves handler names only when they fire, so nothing complained until a role took the src_glob path -- web-app-nextcloud variant 3 then died 49 minutes into a CI run with "The requested handler compose up was not found", after the rescue bundle had filled up with unrelated network diagnostics.
The expansion moves to plugins/lookup/role_templates_expanded.py, where DEFAULTS holds owner, group, mode, condition and notify once and applies them to both the glob-expanded and the passed-through branch. render_replicated_templates.yml stays as the wrapper every consumer includes by absolute path, so no call site changes; it shrinks to the include_tasks loop and no longer leaks _role_templates_expanded into the play namespace. The twelve now-dead defaults in _render_one_template.yml are removed, so a missing key fails loudly instead of falling through to a second definition.
The plugin lives in plugins/lookup rather than in the role, because role-local plugin directories are only on the search path while that role runs and the wrapper is included from magento, n8n, matrix, nextcloud and others; sys-stk-full does not include sys-svc-compose as a role and no consumer declares it as a dependency.
Verified through real Ansible, not only unit tests: the lookup resolves by name, returns a list of dicts that loop can iterate, builds dest from an actual templates directory, and keeps an explicit empty notify empty -- the shape magento relies on to suppress the handler. Twelve unit tests pin the contract, including both falsy cases and parity with fileglob, which drops directories and sorts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
roles/sys-svc-mail-msmtp/tasks/ca_bundle.yml stat'ed candidate bundle paths on
the Ansible target and wrote the winner into msmtprc. Moodle and WordSpress then
copied that file into a container, where the named path is a different file - or
none. Only update-ca-certificates populates a container's system bundle, and
that runs solely from the with-ca-trust.sh entrypoint, which compose_ca.py sets
only when 'docker image inspect' succeeded. On a fresh host the image is built
minutes later, so it never ran: job 94965457602 of run 31864448027 shows
Config.Entrypoint still moodle-entrypoint.sh while the CA sits mounted beside it,
and msmtp reporting 'The certificate issuer is unknown'.
An explicit tls_trust_file also outranks SSL_CERT_FILE, so the env injection the
override always performs could not compensate.
Name CA_TRUST.inject_cert_container when the CA is actually injected, and emit
no line at all otherwise - msmtp's own 'system' default is the right answer for
a letsencrypt relay, and it is what the container's stock bundle verifies. This
also removes a latent defect on letsencrypt: a RedHat host would have written
/etc/pki/... into a Debian container.
The removal of the probing apparatus is taken from 9a72d01eb on
fork/feature/ai-agent-employees, which diagnosed the wrong-machine measurement
correctly. Its own conclusion - drop the line entirely and rely on the trust
store - does not hold where the wrapper never runs, and in swarm it never does:
handlers/swarm.yml passes wrapper=False by design.
The three-term predicate behind all of this was hand-copied into four places.
lookup('ca_injected', application_id) is now its single source; the swarm and
compose gates and sys-svc-compose-ca's own var read it too.
compose_ca.py finally says when it degrades to mount-plus-env, naming the service
and the image it could not inspect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moodle renders the same msmtprc as WordPress and mounts it into two services,
but nothing ever proved a message left the container: its healthcheck opens the
FPM port and nothing else. Moodle 0 of run 31658241728 passed green while mail
could not have worked -- the defect 2da285067 fixed for WordPress was invisible
here, and would have stayed invisible until a user reset a password.
WordPress proves delivery with the msmtp_curl flavor: the probe sends one mail
and writes a marker, and a task after convergence waits for that marker. Moodle
cannot use it, because curl cannot judge a port that answers FastCGI. Rather
than add a second msmtp_* class per liveness shape, the declaration now composes:
healthcheck:
flavor:
- msmtp
- connect
port_key: fpm
msmtp stops being a flavor and becomes a prefix -- shell that runs on the way
past and never decides liveness. connect joins the probes as the one that only
asks whether the port accepts a TCP connection. A declaration names exactly one
probe and any number of prefixes, as a string or a list; msmtp_curl stays as an
alias for [msmtp, curl], and the lint that validates declarations now shares
resolve_flavors with the lookup instead of keeping its own idea of a flavor.
The module became five responsibilities in one file, so it is a package:
probes.py answers which probes exist, prefixes.py what runs before them,
compose.py how a declaration becomes a rendered block. The curl helpers sit with
the probes and mail_branch with the prefix -- each next to its only caller,
rather than together in a module header.
Two defects in this work were found by measuring, not reading:
- the timing merge took the maximum across prefixes only, which cut Moodle's
start period from 15m to 120s and would have marked a heavy PHP app dead
while it was still installing. A prefix may now raise a timing and never
lower one, pinned by test.
- composing moved msmtp_curl's interval from 30s to 1m, because the composed
class derives from Curl where MsmtpCurl derived from Probe. That is the
interval every other curl probe uses, but it was not intended; it now has
its own test rather than riding along unseen.
Regression measured across the whole repo rather than argued: every declared
healthcheck was rendered with the pre-refactor code and with this one. 58 render
byte-identically, 1 is Moodle's intentionally changed declaration, and 2 differ
-- friendica and wordpress, in that interval alone, with the probe command
itself unchanged.
Coverage was checked in both directions. No test was removed: the four deleted
lines are an import, the registry literal, one class name in a url test, and the
helper, which now goes through compose() instead of reaching into PROBES -- a
tightening. New code had two gaps, both closed: connect was the only probe whose
emitted command no test pinned, and compose() raised KeyError for an unknown
name unobserved.
Not verified: no deploy has run any of these probes. The composed class is built
dynamically, so what it emits is proven for all 59 services while docker
accepting it is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One template serves two consumers with opposite needs. sys-svc-mail-msmtp
renders it to /root/.msmtprc, which the host itself reads, and web-app-wordpress
and web-app-moodle render the same file into their config directory, from where
it is mounted at /etc/msmtprc inside a container. resolve_smtp_host returned the
loopback for both, which is right for the first and meaningless for the second:
inside a container 127.0.0.1 is that container's own loopback and nothing is
listening on it.
WordPress 0 of run 31658270172 died on it. The deploy converged and then
"⏳ Wait for the delivery marker the healthcheck writes" retried sixteen times
before `assertion: wordpress_smoke_mail.rc == 0` evaluated to false. The reason
is in the rescue bundle rather than the log, identically in all three replicas
(containers/wordpress_wordpress.{1,2,3}.*.inspect.json):
msmtp: cannot connect to 127.0.0.1, port 587: Connection refused
msmtp: could not send mail (account default from /etc/msmtprc)
Those containers report Status: healthy with FailingStreak: 0, which is by
design since ebdadd91b decoupled the smoke mail from liveness -- the probe ends
on curl and the delivery assertion carries detection. The assertion was right;
the address it complained about was ours.
smtp_host gains in_container=, and the template passes it from a variable each
of the three call sites sets explicitly. There is deliberately no default: a
fourth consumer must fail loudly rather than silently inherit a loopback that
does not apply to it, which is the very defect being fixed. grep across roles,
utils and plugins finds exactly those three consumers today.
The alias needs pinning to resolve, and container_extra_hosts is the only
emitter of extra_hosts. It gained host_alias=, which appends
host.docker.internal at whatever reaches the host in the effective mode by
reusing _gateway_address -- host-gateway under compose, the stack host's routable
address under swarm. Hardcoding host-gateway at the call site would repeat the
mistake 7b1839681 already paid for: that alias resolves per node and misses the
one that publishes.
Measured, not assumed:
- all four resolver branches: host 127.0.0.1, container host.docker.internal,
bare infra node the manager, outside the rig the configured relay unchanged
- both branches again through a real ansible lookup call, because run() at
first did not forward the kwarg and Python-level testing would have missed
a silently ignored parameter
- the pin emits `host.docker.internal:host-gateway` under compose and
`host.docker.internal:<node address>` under swarm
- the destination exists under swarm: sockets-tcp.txt of all three nodes in
the same rescue bundle shows `LISTEN 0 4096 *:587 users:(("dockerd",…))`, so
the submission port is published on every interface of the node -- which is
why the container's own loopback missed it and the host alias will not
- and under compose by construction: mailu publishes submission on
MAILU_IP4_PUBLIC (templates/compose.yml.j2:12), which is
DOCKER_PUBLIC_BIND_HOST, which is 0.0.0.0 inside the rig
(group_vars/all/00_general.yml:46)
- 11 existing smtp_host unit tests stay green; make test-lint stays at 328
The moodle half fixes nothing that is currently red, and that is the point:
moodle renders the same broken file into two services but its healthcheck only
opens a TCP connection to the FPM port, so Moodle 0 of run 31658241728 passed
with mail that could never leave the container. It breaks when Moodle actually
sends -- a password reset, a forum subscription -- and no job would say so.
Not verified: that the relay accepts the message once reached. The port is
proven reachable, delivery is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
meta/volumes.yml says where a volume lands inside its container. Wherever a
second file spelled the same path out, the two drifted independently: the mount
moves, the consumer keeps pointing at the old place, and nothing fails until the
process looks for content that is not there. e031b7f61 fixed exactly this for
web-app-moodle; this generalises it and makes the rule hold.
The lookup gains what a caller needs. `targets` maps each mounting service to
its container path, and `target` is the one path an entry mounts at -- present
only when every mount agrees. An entry that lands at different paths per service
omits `target` rather than picking one, and the caller names the service:
"{{ lookup('volume', application_id, 'prometheus_config').target }}"
"{{ lookup('volume', 'web-app-prometheus', 'host_sys').targets['node-exporter'] }}"
66 literals across 48 role files now derive. Replacement only happened where the
path stood alone -- /srv/app inside /srv/app/data is a different path -- and
every rewritten file was re-parsed, Jinja and YAML alike. Three sites the tool
refused rather than guess were done by hand: zammad's path sat in a Jinja list
literal, seaweedfs' inside a quoted string feeding a filter, and openresty's
/var/run/docker.sock is not a mount target at all but the socket on the host,
which only happens to spell the same string. That one carries the single
nocheck.
tests/lint/repository/spot/test_volumes_path_single_spot.py keeps it that way.
roles/*/files/ is out of scope by construction: it is copied verbatim into an
image and executed there, so a Dockerfile or entrypoint has no Ansible to
evaluate a lookup with.
One defect this found, and it was in the tooling rather than the roles: deciding
ambiguity AFTER filtering short paths made web-app-prometheus' host_sys look
single-valued -- it mounts /sys for cadvisor and /host/sys for node-exporter,
and /sys is below the length filter. The template would have rendered an
undefined `.target`. Both the converter and the rule now decide ambiguity over
every target and filter afterwards, and that call reads targets['node-exporter'].
Verified by resolving rather than by reading: all 70 accesses in the diff were
run against the real registry and every one yields a non-empty path equal to the
literal it replaced. make test-lint: 328 passed.
Not verified: no Ansible render, so a call site whose surrounding context cannot
build the applications registry would still fail at deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
extra_hosts is the one compose key a service may not carry twice. The onion SSO
back-channel pin was emitted by a Jinja partial that six roles included, while
six other services wrote the key by hand; nothing stopped a service from
receiving both. YAML then either rejects the document or keeps one list and
drops the other, silently.
The partial becomes plugins/lookup/container_extra_hosts.py, and every pin now
goes through it. Callers add their own entries with extra_hosts=[...], the way
container_volumes already takes extra_volumes=, and the lookup merges them after
the automatic pin and deduplicates in first-seen order. With nothing to emit it
returns an empty string.
The swarm address reasoning from 7b1839681 is preserved and now written down in
the plugin rather than only in that commit: the pin must NOT be host-gateway in
swarm, because that alias resolves per node while openresty publishes :80 with
mode: host under node.role == manager. It carries the stack host's address,
which reaches every task because the compose file is rendered once with the
manager as inventory host. The mode is read as compose_mode_force or
DEPLOYMENT_MODE, not the bare cluster mode, so a role with an override does not
lose its pin and its overlay at once.
Two roles kept a pin that now collides with the automatic one, and both are
resolved the same way -- the hand-written entry stays for clearnet and yields
the .onion case to the lookup, which picks the address per mode. In
web-app-mailu the three conditional entries move into a role-local
mailu_extra_hosts lookup exposed as MAILU_EXTRA_HOSTS, because an eight-line
conditional expression in the template argument is worse than the block it
replaced. It returns its entries as separate results, so vars/main.yml uses
query(); plain lookup() would join them into one comma-separated string.
tests/lint/ansible/jinja/test_compose_extra_hosts_use_lookup.py fails on any
literal extra_hosts: key in a role template, so the invariant survives the next
author who needs a pin. It follows test_compose_ports_use_container_ports_lookup:
the raw construct is forbidden, the lookup is not mandated per service -- a
service with no pins is correct without it, and the swarm branch raises when
facts are absent, which would turn a no-op into a failure surface for containers
that never speak OIDC.
Equivalence checked by rendering rather than by reading: under the trim_blocks +
lstrip_blocks the stack-host action forces for compose.yml.j2, both former call
site shapes (indented, and matrix's column 0) emit byte-identical text to the
new form when the block applies. The no-op case differs -- a whitespace-only
line where the include emitted none -- and yaml.safe_load returns the same
document for both.
31 unit tests; make test-lint 325 passed. Does not touch web-app-espocrm, whose
missing pin is a separate claim and gets its own commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
container_hostname is computed for sethostname(2) and clamped to 63 bytes, so a
v3 onion domain degrades it to the bare entity name. container_healthcheck
forwarded that value into the curl probe's Host header, and curl is the only
flavor that asserts a vhost identity on a loopback probe -- tcp and http_status
hardcode Host: localhost, wget and http send none. An app that validates Host
therefore rejected its own healthcheck: bookwyrm's Django answered 400 to
Host: bookwyrm and the container never turned healthy while serving fine over
its onion.
This was diagnosed once already. e9817f825 measured it A/B on pretix
(Host: pretix -> 400 x4 unhealthy, Host: localhost -> healthy), named reusing a
kernel hostname as an HTTP Host the underlying design bug, and deferred the
general fix because it would move six roles' probes at once. 9fb08b4ed then
pinned bookwyrm the same way. This is that deferred change, so there is no
third round of per-role pinning.
The header now comes from meta/services.yml and nowhere else. Of the eight
roles defining container_hostname, bookwyrm and pretix were already pinned and
bridgy-fed and postmarks use the tcp flavor, which never reads it. Of the
remaining four, chess never passed anything but its entity name and xwiki was
measured healthy while answering to Host: xwiki, a name in no vhost config; so
both are safe without a header. confluence and jira are pinned here rather than
argued about -- their Host is today the real domain on a clearnet deploy, and
Tomcat's ATL_PROXY_NAME being a proxyName and not an allow-list is reasoning,
not a measurement.
The templating contract moves from the play var to healthcheck.hostname, and a
new test fails if the fallback ever returns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f713ba835 moved credentials to applications.<app>.secrets.credentials and
updated every role that reads them, but four lookup plugins kept addressing
the bare credentials.<key> root. They call get() with strict=False and
default="", so the miss was silent rather than loud: MariaDB received
MYSQL_ROOT_PASSWORD= and refused to initialise, which took friendica-database
down as an unhealthy dependency and failed compose up. Object-store consumers
and shared redis auth resolved to empty secrets for the same reason.
The five call sites now build the path from OVERRIDE_SECTION, the single point
of truth that commit introduced, instead of spelling it beside it.
The two lookup unit tests pinned the pre-rename fixture shape, which is why
they stayed green straight through the regression; their fixtures move to
secrets.credentials so they fail against the old code.
Evidence: run 31649386343, job 94302347256, artifact
rescue-diagnostics-instructions-web-app-friendica,
containers/infinito_nexus_debian/nested/.../friendica-database.log:29
"Database is uninitialized and password option is not specified".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cli build include` generated one tasks/groups/<group>-roles.yml per invokable category during setup, and the stage plays included those files. They were written by the in-container UID, which is why a dedicated clean-container-owned helper had to delete them from inside the container.
The stage plays now loop over lookup('group_roles', <group>) and include tasks/utils/setup/{group,role}.yml directly. ordered_roles() in utils/roles/order.py holds the run_after topological sort the generator used to perform at build time.
utils/roles/stage.py:_group_names() no longer globs the generated files; it reads get_invokable_paths(), the same categories.yml source those files were derived from.
Measured: handlers still flush after every role (ansible-core 2.20.2, three loop items, three separate handler runs), and resolving all 18 groups costs 0.60s cold against 1.4s for the generator.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Friendica 0 and WordPress 0 of run 31543714113 never delivered their startup mail. An onion relay has no TLS certificate, so the resolver forces tls=false - and both port and auth hung off that flag: port collapsed to 25 and auth to off. Port 25 is the inbound MX and offers no SMTP AUTH at all, so the message reached Mailu unauthenticated with a local envelope sender and rspamd rejected it 554 5.7.1. Port and auth now hang off `external` instead: 465 with TLS, 587 without, and auth wherever a relay is external.
Moving the port alone would only relocate the failure. msmtp refuses to pick PLAIN or LOGIN itself on a cleartext link and aborts with EX_UNAVAILABLE, so the mechanism has to be named. The new `auth_mechanism` key resolves to off, on, or plain and sits in RESOLUTION_ORDER right after auth, whose result plus tls it reads.
It is one derived value rather than two template expressions because the two failing jobs run different msmtp instances: WordPress renders sys-svc-mail-msmtp/templates/msmtprc.conf.j2 through WORDPRESS_MSMTP_SRC and mounts it at /etc/msmtprc, while Friendica generates its own from SMTP_AUTH via the image script, whose line 32 writes `auth $smtp_auth` verbatim. Both now read the same key.
Verified against the shipped Mailu 2024.06 front rather than assumed. EHLO on 587 under TLS_FLAVOR=notls answers `250-AUTH PLAIN LOGIN`, so PLAIN is offered on the cleartext link; dovecot renders disable_plaintext_auth=no in that flavor. PORT_587 is set because socrate lists 587 outside PORTS_REQUIRING_TLS, unlike 465, which notls skips - so 587 is the only submission port available there. smtp and submission bind identically to MAILU_IP4_PUBLIC, so the path to 587 is the one that already reached 25.
This supersedes the premise of 563da9e5b, which disabled AUTH because the resolver had. That commit quoted a different msmtp error - "the server does not support authentication" - which was port 25 having none, not the relay refusing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
75 commits: the socialhome role reimplemented against the project it actually
deploys, native-login specs for funkwhale and shopware, a window.open IdP
handoff and a settings-link fallback in the shared persona helpers, a swarm
converge budget that gives up on a terminal task, and a long tail of role
fixes.
Eleven conflicts. The shared persona helpers (keycloak.js, logout.js) take the
incoming structure — they gained real behaviour this branch never had — with
this branch's resolveTimeout convention re-applied over it and
confirmKeycloakLogoutIfPrompted re-attached at every return point; without it
the Keycloak SSO session outlives the logout. playwright.config.js keeps this
branch's version: both sides set actionTimeout, but ours routes through an env
var and 15s is too short behind an onion. socialhome's spec takes the incoming
content — 2dbd6eb10 found the role described the wrong upstream project
entirely — and keeps gotoOnion and expectHstsWhenTls, since demanding HSTS over
a plaintext onion is a false failure. Both READMEs were regenerated rather than
merged by hand.
Three collisions arrived with no conflict marker at all, because each side
changed a different place in a different file:
- group_vars/all/18_swarm.yml declared swarm_stack_converge_retries twice.
Same value, so nothing breaks today; editing the documented one would have
silently changed nothing.
- svc-net-tor and web-app-socialhome both claimed 192.168.74.0/24. Socialhome,
the newer role, moves to 192.168.75.0/24.
- web-app-socialhome/meta/schema.yml arrived as a new file, so ef5ea7cb5's
rename never saw it. Its credential would not have resolved and the role
would have deployed with an unusable administrator account.
The incoming specs also needed this branch's conventions: raw timeouts wrapped,
four page.goto calls routed through gotoOnion, a request.get given an
onion-aware timeout, and 21 roles marked playwright-service-flag over the tor
block this branch added to them.
make quality green on the merge result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
meta/schema.yml held credential definitions and their runtime values, but the
name said 'schema' and read like structural metadata next to services.yml and
volumes.yml. Nothing in the file is a schema in the sense the other meta files
use the word, and a contributor had to open it to learn it holds secrets.
62 role files renamed. ROLE_FILE_META_SCHEMA becomes ROLE_FILE_META_SECRETS and
keeps its role as the SPOT the path is built from.
Two readers bypassed that constant and spelled the name themselves:
utils/cache/applications.py built meta_dir / 'schema.yml' twice, and
plugins/lookup/application_gid.py carries the filename in the marker list it
detects application roles by. Left behind, the rename would not have raised —
credentials would have resolved empty and the role would have stopped counting
as an application.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3a1ef70cb removed the rerun workflow's rows from the actions configuration
page and left two blank lines at the end of the file, which MD012 rejects.
That is what failed the Lint job on 777f7f0fa.
The two Python files carry the reflow ruff 0.16.2 produces; 6320da703
brought that version in with the main merge. Committing it keeps the tree
matching the formatter, since scripts/lint/python.sh runs ruff format in
write mode and would otherwise rewrite them on every run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
force_shared_db flipped every DB service to shared with no exemption, so
web-app-magento's deliberate embedded MariaDB 11.4 - declared shared:
false with a comment warning that no bot may move the major underneath
it - became dead config and setup:install hit the central engine's 12.3.2,
outside Magento's supported range. The run log names the override:
'swarm force-shared: web-app-magento.services.mariadb -> shared=true'.
Exempts a DB service whose role meta declares a literal shared: false AND
whose role is manager-pinned. The second condition is not decoration:
utils/storage/nfs.py keeps a manager-pinned role's volumes node-local, and
InnoDB over NFS is why the harness force-shares in the first place. Of the
two roles with a literal local DB, magento qualifies and bigbluebutton
(unpinned) does not, so its behaviour is unchanged.
derive_includes._force_shared_db_view documents itself as mirroring
force_shared_db, so it takes the same exemption - without it the dep walk
would compute magento's include closure against the central engine while
the deploy runs a local sidecar.
The sidecar carried no deploy block at all, so an exempted service would
have been free-floating with one task per node over a single-writer
datadir. The block now comes from a container_deploy lookup shared with
sys-svc-container/templates/deploy.yml.j2, which replaces the replicas
ternary and the duplicated placement constraint there. Its unit test
caught a real defect while being written: with compose_mode_force=swarm
over DEPLOYMENT_MODE=compose the delegated compose_replicas returned
empty, emitting a deploy: key with no child.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The msmtp_curl probe joined its mail branch to the curl probe with '&&', so a
relay that refuses the startup message short-circuited the whole command: curl
never ran, the container stayed unhealthy forever, and the swarm converge gate
burned all 150 retries before aborting the deploy. Friendica 0 and Fediwall 0
of run 31340965955 died that way, reporting only 'not converged:
friendica_friendica' with the real cause buried in a rescue artifact.
That contradicted the probe's own docstring, which promises an unrelated SMTP
outage cannot flap the container into unhealthy -- it only delivered that for
email_enabled=False. sys-ctl-hlth-msmtp/files/script.sh:23 has always tolerated
exactly these exit codes; the container probe was the outlier.
Liveness is now curl's verdict alone. The '&&' before touch stays, so a refused
mail leaves no marker and the next probe retries it rather than turning the
smoke test one-shot.
Detection does not disappear with the coupling: web-app-friendica now asserts
the delivery marker after convergence and fails with the relay's reason instead
of a timeout three layers away. sys-ctl-hlth-msmtp is a systemd timer and the
Mailu Playwright roundtrip submits through Roundcube, so neither covers a
container relaying over port 25 -- this assertion is what does.
The marker path moves into utils.docker.healthcheck.MAIL_MARKER and reaches
Ansible through lookup('container_mail_marker'), so the probe and the assertion
cannot drift apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>