The min_storage gate measures every declared pin on each run, and every measurement was an anonymous manifest request to Docker Hub, which meters those per IP. A full run exhausted the pull budget, so the following local deploy could no longer pull at all and the gate itself stopped measuring: 96 of 165 images answered indeterminately and only warned.
Read the GHCR mirror first and fall back to the upstream registry only for images it does not carry. The copy is byte-identical, so the measured size is unchanged. Persist measured sizes under the temporary directory; store positive results only, because a cached indeterminate answer would be remembered as fact and the gate would stop measuring for good.
Lift the mirror path formula out of the provider into utils so the writer and the reader share one definition, resolving the namespace from GITHUB_REPOSITORY, then remote.pushDefault, then origin. pushDefault has to win: origin may point at an upstream that hosts no mirror. Parse .git/config by hand because configparser rejects the duplicate keys git writes and the test container ships no git binary.
Measured over all 165 declared pins: no requests to Docker Hub, 277 to the mirror, 9 to registries it does not carry, and 164 of 165 images measured.
Leave manifest_exists on the upstream registry. It answers whether the declared pin is still pullable from where the deploy pulls it, which a mirrored copy cannot attest; it takes the cache only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CI deploy bootstrap hand-listed two packages and let the ubuntu-latest runner image supply the rest, so a new host-side import surfaced as a red deploy job instead of a failed install. Size [project.dependencies] to what runs outside the project image and move the deployment stack into a deploy extra, then have every install site read that instead of naming packages itself: the image build and make install-python take the deploy extra, the CI host and the mirror and instructions workflows take the base.
Declare cryptography, which utils/manager/value_generator.py imports directly and which only reached the environment transitively through ansible. Drop numpy, which nothing in the repository imports. Guard the boundary with a lint test that blocks every deploy package and asserts the runner-host CLI still builds its parser.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three conflicts, none resolved by picking a side wholesale:
- Makefile: took the incoming ACT_INPUTS, which routes through SWARM_DISTROS
instead of inlining the same expression a second time.
- test-e2e-playwright/02_run_one.yml: took the incoming rc handling. An absent
rc now counts as failure; the previous default(0) let an aborted Playwright
run pass silently, contradicting the debug message right above it.
- web-app-peertube spec: combined both. Kept gotoOnion and resolveTimeout,
which this branch's lint enforces, and adopted the incoming retry-inside-poll
for the OIDC button plus its networkidle wait.
Formatter-only change; make autoformat rewrote the three is_constrained calls that exceeded the line limit when the file landed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An over-declared min_storage makes a host look too small: it feeds the storage guard, the runner packer and the sufficient-storage filter, so a deploy that would have fit prunes a cache it did not need to lose. Nothing checked the declared number against reality.
The new external test measures what a pull of the service's own image:version actually transfers - config blob plus every layer blob, resolved for linux/amd64 - and fails when min_storage exceeds it by more than 1GB. A service whose data volume genuinely dominates keeps its value by carrying '# nocheck: min-storage-headroom' together with a 'Reason:', the same two-marker shape meta/volumes.yml already uses for nfs: false.
Sizes come from the repo's own registry client rather than 'docker buildx imagetools': make test-external runs inside the compose container, which has no docker socket. manifest_transfer_size reuses the existing bearer-challenge flow, keeps the tri-state contract of manifest_exists (an indeterminate registry warns instead of failing), and resolves a multi-platform index to its amd64 manifest - the case a raw jq over '.layers' would miss entirely. Distinct image refs are measured concurrently through resolve_max_fetch_workers().
A min_storage that parses out of the YAML but cannot be located in the file text fails the test, so a declaration can never silently lose its marker resolution.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flag was decided by a fixed free-space threshold (100 GiB by default), which fired on roomy hosts and stayed silent on a 145 GiB CI runner that a single deploy round could fill. It now compares free space on Docker's data root against what the deploy declares it needs: the summed min_storage of the selected applications and their transitive dependencies.
The need is variant-aware. utils/storage/constrained.py overlays each app's meta/variants.yml entry before walking the service registry, so a round that trims services down is measured at its own size instead of the base configuration's.
Both entry paths pass their per-round variant map: cli/administration/deploy/development/init.py decides inside the round loop (compose and host share this via ci/one.sh -> dedicated.sh), and utils/tests/swarm/matrix.py feeds the decision into the swarm host_vars payload. --threshold-gib is gone; --force-storage-constrained still overrides.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep's own governor was sized against the 6h platform kill (19800s) while the deploy step was capped at 300 minutes, so it could never fire: GitHub hard-killed the step mid-distro and reported a run that simply did not fit all distros as a job failure.
Size the ladder instead, 10 and 5 minutes apart: sweep budget 340min < deploy step 350 < job 355 < platform kill 360. The swarm per-leg ceiling moves to 350 so it stops matching the job cap, where it was dead code. The rescue-state dump drops from 10 to 5 minutes so it cannot alone consume the window between the job kill and the platform kill; measured teardown is 10-38 seconds.
A new lint asserts the budget stays under the governing step cap, the lower half of the invariant whose upper half the headroom lint already carried; that lint's ceiling moves from 345 to 355.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TOR_CONTAINER_DNS_HOST is empty in swarm mode by design, so no dnsmasq
listener appears on the docker bridge there. The lookup gated on the tor
group alone and wrote 172.17.0.1 into daemon.json anyway, handing every
nested container a resolver nothing answers on — nested image builds then
died resolving dl-cdn.alpinelinux.org.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The endpoint port resolver preferred ports.local and only reached for ports.internal when the block declared exposure: internal. Its single consumer builds a container-network URL (http://<service_key>:<port><path>), where a host-published port is always the wrong one. A server role that omits exposure would therefore have silently received the host port, which is the same class of silent-wrong-value the exposure handling was added to remove.
The order is now internal first, unconditionally, and the exposure argument is gone. All three MCP roles declare exposure: internal, so nothing rendered changes today. exposure stays part of the MCP schema, where it documents who may reach the endpoint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workspace workflow asked the distro SPOT two questions through embedded python one-liners: the matrix image list, and the distro behind a matrix entry. Give both a name -- distro_of_dev_runtime_image() as the counterpart to dev_runtime_image(), and a cli.meta.ci.dev_runtime module shaped like its image_ref neighbour. The empty-SPOT guard that protected the matrix from silently skipping every job moves along with it. No caller yet; the workflow switches over in the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workspace workflow asked the distro SPOT two questions through embedded python one-liners: the matrix image list, and the distro behind a matrix entry. Give both a name -- distro_of_dev_runtime_image() as the counterpart to dev_runtime_image(), and a cli.meta.ci.dev_runtime module shaped like its image_ref neighbour. The empty-SPOT guard that protected the matrix from silently skipping every job moves along with it. No caller yet; the workflow switches over in the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Open WebUI talked to Ollama directly. It now speaks the OpenAI-compatible surface of svc-ai-litellm with a per-consumer virtual key, so a second backend or an external provider becomes a gateway config change instead of a role change. A lint keeps future roles off the model servers.
The gateway advertised a hardcoded model list (ollama/llama3, ollama/nomic-embed-text) while the matrix variant preloads smollm2:135m, so it offered only models the backend did not have and hid the one it did. The list is derived from the ollama preload SPOT now.
The chat spec followed the rename: it gates on litellm instead of the removed ollama service flag, and picks the first non-embedding model from /api/models rather than pinning a variant-specific name, so it exercises the chain rather than a fixture.
Routing openwebui through the gateway pulls both model backends into the closure of every role that consumes it, which pushed nextcloud variant 2 over the memory budget. The budget stays at 32/64 GB; instead the matrix variants of svc-ai-ollama and svc-ai-lmstudio now declare the size their test model needs (1g/2g) while the base keeps the 6g/8g that llama3 and mistral require. The budget guard only saw half of that: it replaced the evaluated role with its variant but counted every dependency from meta/services.yml, a combination no matrix round deploys. It now evaluates every variant-carrying role at the same round index, falling back to variant 0, the way the deploy planner does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge across every meta/users.yml compared a field as a whole. For tokens that field is a mapping whose keys are per-app entries, so two roles registering a token for the same user hit the conflict check even though the entries never overlapped: the first role to declare it won and the second aborted the deploy. sys-token-store is the sanctioned way to persist a runtime secret per app on a user, which made it usable by exactly one role.
Dict-valued fields now merge key by key. A real collision, the same sub-key with two different values, still raises, and the message names field.sub_key so a token store holding a dozen apps says which one collided.
Tests cover both paths: two roles registering distinct token keys for one user, and the same key with conflicting values.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
roles_with_service kept a private copy of the canonical-domain rule that only understood the list and string forms. A role declaring domains.canonical as a mapping resolved to an empty string and was dropped by the consumer filter, so it vanished from every consumer list without a trace: dashboard cards, matomo targets, prometheus scrape targets and litellm consumer keys. The failure surfaced three layers later as HTTP 401 at the model gateway. Delegate to get_primary_domain, the platform SPOT that already handles all three shapes and raises on a malformed value instead of silently dropping the role.
The endpoint port resolver always tried ports.local before ports.internal. A service declaring exposure internal is reached by a peer container over the shared network and therefore needs the container port, so the resolver now orders the namespaces by the declared exposure.
Tests pin the mapping form, both port directions, and the fail-loud behaviour for a canonical list whose first entry is empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test-environment.yml and test-guide.yml become test-workspace.yml and
test-instructions.yml. Orchestrator job ids, the distros-SPOT matrix
lint, the act example, the diagnostics slug and the rescue artefact
names follow.
Replace the roles_only input with per-job workspace/instructions
tri-state inputs (auto|true|false). 'auto' derives the former
roles_only skip straight from whitelist: both jobs run on a global
scope (whitelist empty or __ALL__) and are skipped on a role-scoped
run, which keeps the PR behaviour identical because the diff resolver
never emits an empty whitelist. entry-manual exposes both as choice
dropdowns defaulting to false, so a manual run opts in explicitly.
The now redundant roles_only output is dropped from
pr_affected_roles.sh and subset_roles.py.
Branch-protection required-status-check names change along with the
job renames.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six os.environ uses remain in the module, so without the import it dies
with NameError before collecting a single test. The import was dropped a
second time when the swarm branch was rewritten on push.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
package_install read task_vars['ansible_facts'], which under delegate_to still describes the INVENTORY host. svc-storage-nfs-server delegates its controller-mount step to localhost, so a fedora node resolved the RedHat mapping and then ran `dnf install` on the Debian-family runner. Debian-only runs never showed it because node and runner shared os_family and apt happened to exist on both; fedora, centos and arch all hit it.
The delegation target is now asked directly with a minimal setup call, so the facts always describe the host the module runs on, and a failing gather names that host instead of silently falling back. _fact() normalises the two shapes, since task_vars carries bare keys while the setup module returns them ansible_-prefixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
package_install read task_vars['ansible_facts'], which under delegate_to still describes the INVENTORY host. svc-storage-nfs-server delegates its controller-mount step to localhost, so a fedora node resolved the RedHat mapping and then ran `dnf install` on the Debian-family runner. Debian-only runs never showed it because node and runner shared os_family and apt happened to exist on both; fedora, centos and arch all hit it.
The delegation target is now asked directly with a minimal setup call, so the facts always describe the host the module runs on, and a failing gather names that host instead of silently falling back. _fact() normalises the two shapes, since task_vars carries bare keys while the setup module returns them ansible_-prefixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nested dockerd writes /mnt/docker as root, so shutil.rmtree from the runner user silently removed nothing and the previous distro's containers survived into the next distro run. They restarted under unless-stopped against a fresh container rootfs, failed with exit 127, and sys-ctl-rpr-container-soft exited 2 because their compose working_dir no longer existed, which failed the guide deploy on the second distro.
Escalate the wipe the same way dedicated.sh does and hard-fail when the root survives, so a silent no-op can no longer leak state across distros.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The loop may drop a distro when the budget runs out, and both skip paths fell through to an implicit exit 0, so a run that never touched two distros still reported success. That hid the coverage gap: arch, fedora and centos get no package cache (cache.override.yml is apt-only), are therefore the slowest, and are the first the remaining < max_seen heuristic drops.
Skipping stays non-fatal and the order stays randomised, but every distro now shows up in a GITHUB_STEP_SUMMARY table in execution order, as passed, failed with its rc, or skipped with the reason. The table is also rendered before an aborting exit, since that is the run whose table matters. Two contract tests cover it; the order assertion checks the logged shuf order rather than the input list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The serial distro loop (shuffle, time budget, fast-fail skip, exit on the first failing distro) lived inline in scripts/tests/deploy/ci/all.sh and was therefore reachable only from the compose and host deploys. Swarm and guide have to iterate distros with the same semantics, so the loop moves into scripts/tests/deploy/distros.sh and takes the per-distro command as "$@".
ci/all.sh keeps the app-level preamble and execs the loop with the new ci/one.sh, which resolves the CI image and dispatches to the per-app override or dedicated.sh.
Image resolution leaves the loop as well: scripts/meta/resolve/image/ci.sh renders the reference through cli.meta.ci.image_ref instead of respelling ghcr.io/... locally, which meta/distros.yml declares as the single source.
The two CI time knobs are declared in default.env instead of being repeated as literals, so no caller has to invent a budget.
Three paths are dropped as dead: the empty budget that disabled the limit, the failed counter that could only ever print 0, and the branch for an unset deadline.
Both new scripts are pinned by contract tests. Those tests clear BASH_ENV because the test container exports it as the env loader, which would otherwise re-inject every stripped key and make the assertions pass for the wrong reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The five images in test-environment.yml were a verbatim copy of the
dev_runtime_image field of every distro in meta/distros.yml. A prepare
job now reads them through utils.distros and publishes a JSON array that
the matrix consumes with fromJson, so adding or dropping a distro no
longer needs a workflow edit. The resolver runs on the bare interpreter
because utils/distros.py is stdlib-only.
An empty list aborts the resolver: GitHub skips a job with an empty
matrix silently, which would leave the run green with nothing tested.
The lint could no longer compare literals, so it walks the wiring
instead: axis expression to needs edge to job output to step id, then
runs that step body the way the runner does and compares what it writes
to GITHUB_OUTPUT against the SPOT. The Act example in the iteration docs
was the last remaining literal and now reads the SPOT too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
makepkg refuses to run as root, so the AUR plan marks its kewlfft.aur.aur
call with become_user=aur_builder. The action plugin honoured that by
mutating _play_context.become_user, which ansible-core never reads when
it escalates: the target user comes from the become plugin bound to the
connection, and _play_context mirrors that plugin rather than driving it.
The build therefore ran as root and makepkg aborted with rc 10.
Writing the plugin option instead flips only the AUR call to
-u aur_builder and restores the previous user afterwards, so the
bootstrap calls in the same plan keep running as root. Without a bound
become plugin the action now fails loud instead of silently building as
root.
The old test mocked _play_context and _execute_module, then asserted the
mocked attributes carried what the setter had just written, so it passed
against the broken code. It now builds a real sudo become plugin and
asserts on build_become_command output.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The env generator runs on the bare bootstrap python, before any
dependency is installed. utils/distros.py reached PyYAML through
utils.cache.yaml, so `python -m cli.meta.env` died with
ModuleNotFoundError on every dev-runtime image that ships python3
without PyYAML.
utils/yaml_bootstrap.load_block reads the block-YAML subset the SPOT
files use with stdlib only. It raises BootstrapYamlError on anything it
cannot parse identically to yaml.safe_load, so meta/distros.yml fails
loud instead of drifting.
The per-line regex lint meant to guard this is gone: one hop of
indirection defeated it and it stayed green while the invariant was
broken. It is replaced by a test that runs the real generator with
every non-stdlib import blocked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reset introduced in 30435a625 re-probed the live container for
config.ini.php and skipped itself whenever one was present. A failed first
attempt leaves exactly that file behind, so the reset vetoed itself in the very
case it exists for, and because both flavors share the task, the veto also
disabled the swarm reset that had been working since e46659ac4.
Decide reachability and prior installation separately. The install probe in
03_install_path.yml now echoes an explicit marker instead of relying on an exit
code, and no longer swallows failures, so an unreachable container fails the
play rather than reading as a fresh install. The reset goes back to being
ungated: between that snapshot and a retry, only our own bootstrap writes, so
whatever is there belongs to the attempt being discarded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compose flavor retried the one-shot installer up to eight times without
touching what a failed attempt left behind. Once an attempt had half-installed
Matomo, every retry met the existing-tables dialog, erased the tables and landed
on the CoreUpdater screen, which the bootstrap image cannot drive: 41 minutes of
retries against an application that had in fact been installed since attempt 2.
Move the swarm flavor's pre-retry cleanup into tasks/utils/reset_state.yml and
drive it from both flavors, so every retry starts from a clean slate and takes
the clean-install path instead of the erase-and-wedge one.
The reset re-probes the live container before it touches anything. The caller's
gate is a snapshot taken before the attempt loop and reads an unreachable
container as "not installed", which would otherwise put a DROP DATABASE within
reach of a live installation; a probe that cannot run now fails the play.
Collapse MATOMO_BOOTSTRAP_RETRIES and MATOMO_BOOTSTRAP_SWARM_ATTEMPTS into a
single MATOMO_BOOTSTRAP_ATTEMPTS, and surface the failed attempt's stderr in the
final failure message, which the attempt loop would otherwise swallow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parallel checkouts collided on the compose subnet, host ports and container
names, so a quality run in a worktree died at compose-up. Slot each checkout
via custom.env and layer two overrides on top: the shared cache network in
place of a second cache stack, and the shared .git directory without which
git aborts inside the container.
Both overlays gate on the resource they need, not on the slot number, so a
stray INFINITO_INSTANCE cannot make compose demand a value nobody set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The template carried a three-way conditional across three {% set %} lines,
which was already at the edge of readability. Move the decision into
lookup('smtp_host') as plain control flow and pin all six cases in tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
categories.yml moves from roles/ to the repository-root meta/, joining packages.yml. utils/roles/categories.py owns its location plus the tree-walking helpers that were stranded in entity/name.py, so no caller spells the path or duplicates the lookup.
meta/distros.yml becomes the SPOT for every distribution: os_family, Galaxy platform name, dev-environment base image, and the pkgmgr/pkgmgr-virgin/environment reference templates. It replaces seven scattered copies (DISTRO_FAMILY, VALID_DISTROS, _DISTROS, _CANONICAL_PLATFORMS, two shell arrays, a Makefile literal). INFINITO_DISTROS is no longer a static default but generated by utils/env/handlers/infinito/distros.py; the shell consumers read it through the env layer.
Drops dead CLI surface found along the way: --categories-file and --roles-dir on the provision CLI plus three unused parameters of generate_dynamic_inventory, and the positional roles_file of the invokable categories command.
tests/lint/repository/test_distros_spot.py pins the copies that cannot call the SPOT (workflow literals, docs table) and forbids composing a pkgmgr reference outside it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The daemon configuration named fuse-overlayfs as the storage driver for every
host it was rendered on, while the package that provides it is installed under
the opposite condition: only inside a nested container. A host that is not
docker-in-docker therefore received a daemon pointed at a graphdriver nothing
had installed, and dockerd does not start on one.
That path is reached by a bare host in the svc-virt-kata group, which is
exactly the shape the agent tier is meant to run on. It stayed invisible
because every deploy in this repository nests through the stack container, so
the condition that installs the package and the condition that renders the file
were never false at the same time.
fuse-overlayfs exists for the nested case, where overlay2 cannot stack on
overlay2. On a bare host the native default is both correct and faster, so the
key is now emitted only where it is needed and the JSON comma structure moves
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sandbox tier carried no check of its own. Three agent replicas once ran on
the shared kernel while every test stayed green, because nothing between the
runtime selection and the running container ever asked what the container
actually got. Each guard below sits where the promise first breaks.
A node is declared a sandbox target by a label that routes every sandboxed
service to it. It now has to prove it can carry them first: the node reads its
own default runtime and refuses the label while that is still runc. Doing it
before the label rather than after is the point, since the label is what makes
the promise.
Compose has no node-wide default to inspect, so the agent roles read the
runtime the container actually received once the stack is up. The check is
skipped in embodied mode, where the runtime is dropped on purpose because a
microVM between the agent and the hardware it drives defeats the point, and it
keys on the same services.kata switch that already drives the placement
constraint and the compose pin.
A runtime is only as good as the template around it. The new lint refuses a
privileged container, a host network, pid, ipc or user namespace, and a host
docker socket bind in any role that declares a kata service. It fails when it
finds no sandboxed role at all, so renaming the service cannot silently retire
the guard.
It caught its own subject on the first run: both agent templates emitted
privileged: true next to the device allowlist, which grants every device and
made the allowlist decorative. The requirement is explicit that no device
outside the allowlist reaches the agent, so the flag is gone and the role
README no longer claims a limit that was not enforced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge dropped the import while six os.environ uses remained, so the
module died with NameError before collecting any test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
categories.yml moves from roles/ to the repository-root meta/, joining packages.yml. utils/roles/categories.py owns its location plus the tree-walking helpers that were stranded in entity/name.py, so no caller spells the path or duplicates the lookup.
meta/distros.yml becomes the SPOT for every distribution: os_family, Galaxy platform name, dev-environment base image, and the pkgmgr/pkgmgr-virgin/environment reference templates. It replaces seven scattered copies (DISTRO_FAMILY, VALID_DISTROS, _DISTROS, _CANONICAL_PLATFORMS, two shell arrays, a Makefile literal). INFINITO_DISTROS is no longer a static default but generated by utils/env/handlers/infinito/distros.py; the shell consumers read it through the env layer.
Drops dead CLI surface found along the way: --categories-file and --roles-dir on the provision CLI plus three unused parameters of generate_dynamic_inventory, and the positional roles_file of the invokable categories command.
tests/lint/repository/test_distros_spot.py pins the copies that cannot call the SPOT (workflow literals, docs table) and forbids composing a pkgmgr reference outside it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
package_install resolves ids against the including role, not the role
owning the task file. web-app-mailu and sys-svc-mail-msmtp both include
sys-svc-mail-smtp's disable/enable tasks, so postfix has to live in the
shared root registry; only the owning role had declared it, which aborted
the mailu deploy.
The new lint walks cross-role task includes and reports every id the
including role cannot reach, so this fails in seconds instead of hours
into a deploy. It flags both call sites when the fix is reverted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop a return-only temporary and state autoescape explicitly — the test
renders a dnsmasq config, where html escaping would corrupt the output.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
package_install resolves ids against the including role, not the role
owning the task file. web-app-mailu and sys-svc-mail-msmtp both include
sys-svc-mail-smtp's disable/enable tasks, so postfix has to live in the
shared root registry; only the owning role had declared it, which aborted
the mailu deploy.
The new lint walks cross-role task includes and reports every id the
including role cannot reach, so this fails in seconds instead of hours
into a deploy. It flags both call sites when the fix is reverted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reaching an onion address required each role to point its own http client
at the tor socks proxy, which then carried clearnet traffic too — fatal
in the test network, which has no exit, and the reason nextcloud needed a
per-host exclusion list.
The egress router already redirects tor's virtual range to the TransPort
and dnsmasq already forwards .onion to tor's DNSPort; the piece missing
was reachability. dnsmasq listened on loopback only, which docker
discards, so containers never saw it. It now also answers on the docker
bridge, the daemon points there with the clearnet resolver kept as
fallback, and the role's own onion resolves to a container-reachable
backend instead of loopback.
Nextcloud drops its proxy block and the exclusion list; every other role
gets the same behaviour without configuring anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>