The role's compose template names its service after the entity, which the rename turned into seaweedfs-console, while meta/volumes.yml still pointed the config mount at a role id that no longer exists and at the service key the engine consumer occupies. A mount whose service matches nothing is dropped without an error, so the console would have come up without its vhost config and still answered the HTTP probe from the nginx default page.
The remaining files are line joins the formatter produced once the shorter symbol name let the calls fit on one line; their syntax trees are identical to the previous revision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entity name keys a role's instance directory, its compose project and its container name, and was derived only by stripping the longest matching category prefix. Two roles under different categories whose names share a suffix therefore collapse onto one entity and overwrite each other's rendered stack, which no provides: can repair.
A literal entity_name in vars/main.yml now wins over the derivation. web-app-seaweedfs-console takes its shorter name back and declares the entity seaweedfs-console, which frees the seaweedfs key in its own services map for the engine consumer and removes the provides:, covers: and alias entries that stood in for it. A new lint pairs the declaration with the primary key it has to match, because a drift between them would drop the role from the provider set in silence.
Compared across all 288 roles, exactly one entity changes, and it restores what main already keeps on disk, so no instance directory moves.
The function and the filter lose their get_ prefix. Two lints read that prefix as the discriminator between the variable and the filter that fills it; both discriminate on the pipe now. Ansible's templar resolves a filter and a same-named variable independently, verified against ansible-core 2.20.2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two concerns, staged together on request.
Variant pins
------------
A matrix round hands every role the round index and clamps to variant 0 when
the role has fewer entries, so a cross-role pairing holds only while two
independently maintained variant lists agree on an index, and it breaks
silently once the partner runs out of variants.
'# variant-pin: <role>#<index|reciprocal>' above a service entry states the
dependency instead of inheriting it. It is a comment, so it never reaches the
merged services map or host_vars the way a YAML key under the service would.
'#reciprocal' resolves to the partner's variant that switches this role back
on, so neither side tracks the other's index. Two roles pinning one partner to
different variants raises VariantPinConflictError before an inventory is
written.
web-app-openproject is the first user: a fourth variant carries the nextcloud
round, pinned reciprocally. The fourth round exists because folding it into
variant 2 next to gitlab was measured at 35.58 GB against the 32 GB
reservation cap, and it has to be the last round because a role with fewer
variants falls back to variant 0 in a later one.
Measured: the pin changes no role's index in any of the 14 rounds today, so
the mechanism sits in the deploy path without moving a single inventory.
OpenProject addons
------------------
c83c00da7d gave the role meta/addons/ and three task hooks, but the role never
included tasks/addons/ at all, so all three hooks were dead files. 05_addons.yml
now resolves the web host and container id and runs the hook of every addon
whose gate resolves true.
gitlab_integration is provisioned: a bot account, its API token and the plugin
setting carrying gitlab_user_id and webhook_secret. The GitLab-side webhook is
not created here and the hook prints the URL to register it against.
storages keeps an empty hook deliberately. web-app-nextcloud owns both halves
of that integration and runs eleven positions later in the deploy order, so at
this hook's dispatch point the storage it would touch does not exist yet.
openid_connect stays off: OIDC is Enterprise-gated at this version.
Probing the pinned openproject/openproject:17 image corrected two defects
before they could ship: the API token was minted without the name the script
fetched but never used, and the Playwright webhook probe omitted the
X-Gitlab-Event header that HookHandler dereferences before any auth check, so
the smoke request answered 500 where the spec demanded less than 500.
The addon specs now assert the integration rather than the route: storages
requires a registered storage addressing the partner Nextcloud, and
gitlab_integration requires the bot account the webhook authenticates as.
Deploying this needs a regenerated inventory: gitlab-web-app-openproject is a
new user key and users_generator pins it at inventory build time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lookup('i18n_content', …) already returns every language in one dict, and the
role took it apart again: one stack_host_template action per language, a file
task for the directory, a find, and a second loop dropping the catalog of a
language core no longer translates. Four tasks and two loops for what is one
mapping.
The template they looped over was not a template. i18n-content.yaml.j2 held a
single line, `{{ dashboard_i18n_content[item] | to_nice_yaml(width=100000) }}`
— serialisation wearing a costume — so it is deleted.
library/stack_host_files.py takes the whole mapping, serialises each entry,
writes only what differs, drops what the caller no longer wants and creates the
directory, in one module run. The IS_STACK_HOST gate moves to the task's `when`
because a module runs on the target rather than on the controller, where the
sibling stack_host_template and stack_host_copy action plugins gate it.
The per-language file layout stays: files/Dockerfile copies i18n-content/ into
the image, and port-ui reads one yaml per language from /app/i18n/content.
main() stays thin so render, survey, plan and apply can be unit-tested without
mocking AnsibleModule, the way library/database_query.py is tested. The cycle
test asserts the second pass finds nothing to do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cli/build/docs/integration_matrix was 1189 lines a human ran through
'make integration-matrix', and its output sat frozen as a 125-line grid in
requirement 027. Its ENTITIES and EDGES lists were hand-maintained, so the
grid could only drift: it covered 84 entities while the tree holds 98 roles,
named two roles that no longer exist, and omitted sixteen current ones -
api, checkmk, docs, hermes, homeassistant, jellyfin, meta, mirror, openbao,
openclaw, penpot, pihole, s1, seaweedfs, seaweedfs-console and semaphore.
infinito_docs/generators/integrations.py replaces it and joins the nine
sibling generators in that package, so the overview, services and addons
pages are written into generated/integrations/ on every docs build rather
than when someone remembers the target. index.rst takes the overview and the
new docs/architecture/integrations.rst references the other two.
Requirement 027 becomes prose. The make target and its i8inma alias are
removed as a pair, which keeps the alias lints satisfied.
Coverage is not strictly larger. The removed EDGES list carried a curated
upstream project URL per integration, 96 of them, and the new edges have no
such field; only 48 of 159 addon declarations hold update.upstream_id or
config.repository, so about a third is re-derivable and the rest existed only
in that list. Its fourth field is no real loss - 57 entries read 'check', so
it was a near-constant rather than an axis. In exchange the old module had
zero tests, not one file under tests/ referenced it, and the generator ships
310 lines including generate().
Residuals: the HTML build runs without -W, but i18n.py:57 runs the gettext
builder with -W and check=True, and that build has never seen
integrations.rst - no catalog mentions generated/integrations. And
docs/requirements/036:72 still carries a checked criterion asserting a table
in 027 that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
required is the install-failure switch, not an enablement switch:
docs/requirements/026-unified-addon-syntax.md:33 and :111 define it that way,
it is implemented only as failed_when guards in web-app-wordpress and
web-app-mattermost, and no variant and no inventory pins it anywhere. ANDing
it into the gate therefore held the flag of every 'required: false' addon at
false in every variant and every mode since 70786aa126 created the suite, so
those Playwright specs reported as skipped with the plausible reason 'addon
disabled or not deployed' while asserting nothing.
Measured against this commit's parent: 32 addon specs declare
'required: false', 24 of them in the 11 roles that call the lookup, out of the
89 addon specs those roles ship. Those 24 move from always-skipped to
executing. The flag reaches no deploy configuration, so the worst case is a red
Playwright job rather than an open surface - the old behaviour was the silent
one.
Both test files stated the old contract and are corrected with it.
Residual: the repairs for several of the specs this wakes - discourse's akismet
assertions and its missing sso gates, moodle's two addons that no install path
covers - are not in this commit. A round over those roles is red until they
land.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a cold container the docs Playwright suite triggers four serial Sphinx builds and waits for the last one. test-languages.js polls /deployed/de/ for 200 and got 202 for 55.6 minutes in job 110003713825 of run 36745666606, after which the suite budget expired and five further specs never ran. The suite has never passed on a first attempt; it used to go green only on the harness retry, which 7127c6b96b removed for budget timeouts, so a structural overrun became a red job.
Measured cost per build, from run 36558500327 where the whole queue drained on a kept stack: container up 12:15:52, the spec green at 13:21:41 answering on its first poll, next container start only at 13:30:45. Four builds in 65 minutes 49 seconds, so at most 16.5 minutes each - not the sixty to ninety minutes 40688e4ded estimated.
Three of the four builds were none of the test's business. fetch() queued latest in the foreground although nothing waits on it at startup, and _refresh_stale_sites did the same for every servable version; both now use the background lane, which is what its own docstring describes. test-front-page.js reached GET /latest/, and reaching an unbuilt version queues its build - it now asserts the redirect from the Location header without following it, and checks the same page shape against /deployed/, which is built anyway. The release tag that test-on-demand-build.js clicks stays, and now runs behind the awaited work instead of in front of it.
The translation could only be queued after its base finished, because the 202 branch returns before _translation is reachable, so it always sorted behind whatever the sibling specs had queued meanwhile. The 202 branch now carries the language segment along, admitted by translatable(), which answers from the snapshot catalog on disk before any build; build_language still re-checks the written index.
Moving a version into the background lane exposed that status() read only the foreground one, so a waiting version would have been reported as missing rather than queued to the versions overview and to /api/versions. is_queued() now covers both lanes, mirroring _dequeue, which always removed from both. Two of the five unit failures this change first produced disappeared on that fix alone, which is what shows they pinned real behaviour; the three that pinned the lane itself were rewritten to assert the behaviour instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7b5833b37a assembled it in zammad's env template: urlencode both halves of the
credential by hand, patch them into the URL the lookup had already returned,
then append bucket and query. Three set-statements of string surgery on a value
the lookup builds from host and port in the first place.
The lookup now exposes credentialed_url next to url, built from the same
endpoint it already has, with urllib.parse.quote(safe='') on each half. The
template keeps only what is actually zammad's: the bucket path and the region /
force_path_style query.
This also removes the manual scheme concatenation that
test_onion_url_central_lookup refuses. That rule guards against a hand-built
app URL repeating the clearnet-vs-onion decision; the template was not doing
that, but it did name '://' and could therefore touch the scheme. Now it
cannot.
quote(x, safe='') is equivalent to the old
chain: checked over slashes, plus, equals, percent and space, no divergence.
Both branches of the lookup carry the key, the disabled one as the empty
string, so a consumer cannot hit a missing key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three failures of the same deploy, each uncovered by fixing the one before it.
web-app-seaweedfs-console read its two vhost domains through
lookup('config', ..., 'domains.canonical.*'), which resolves against the raw
applications tree. The onion substitution happens elsewhere -- in
get_merged_domains, where svc-net-tor's exclusive flag REPLACES the clearnet
value. On an onion node the role therefore rendered vhosts for clearnet names
while Keycloak's redirectUris, the CSP header and LOGOUT_DOMAINS all carried
the onion names, so the Filer and Master surfaces had no vhost at all and the
default server answered. Reading through lookup('domains', app).<key> is the
shape matrix and bluesky already use for named canonicals.
With the onion names in place the console's own nginx refused to start:
a server_name of ~81 characters overflows the stock bucket size of 64 and
nginx aborts with 'could not build server_names_hash'. The central openresty
has carried server_names_hash_bucket_size 128 since forever
(sys-svc-webserver-core/templates/nginx.conf.j2); the second nginx needs the
same.
With both surfaces served, an administrator still had to re-authenticate on
the Master host: cookie_domains was emitted only when the app had clearnet
domains, so an onion-exclusive node got no cookie scope and oauth2-proxy fell
back to a host-only cookie. whitelist_domains on the very next line already
handled both address families; cookie_domains now does too.
Both lists move out of the template into lookups of their own. The template
held six set-statements and two nested inline conditionals to build two JSON
arrays; the lookups take an application_id and return the list, which also
makes the behaviour testable without rendering the template. The template
structure assertion in test_cookie_scope.py went with them -- it measured
Jinja text, not behaviour -- replaced by thirteen cases over the two lookups:
single host, multi-host collapse, onion-only, dual-stack, no domains, no tor
node, and a missing DOMAIN_PRIMARY that now raises instead of silently
scoping nothing.
Evidence: make compose-playwright role=web-app-seaweedfs-console goes from
3 failed to 3 passed against the live stack. For the single-domain case the
change newly affects, web-app-prometheus keeps all four of its SSO specs
green with the scope applied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine has no browser surface, so it is verified through the repository's
CLI harness rather than Playwright: test-e2e-cli discovers a role by its
templates/test.env.j2 and runs files/test/test.sh on the node that holds the
container.
probe.py asserts five things against the running store: an unsigned request is
refused with an S3 error, the admin identity completes a signed write, read
back and delete, a consumer identity reaches its own bucket and is refused
another's, the anonymous grant matches the declared public flag, and the filer
and master ports stayed unpublished after the split.
It creates no bucket. A bucket is a SeaweedFS collection and the engine's
-volume.max is budgeted for the declared consumers only, so a probe that
creates one can exhaust the volume slots its own consumers write through;
measured as a 500 on PUT once the slots ran out. Every write now lands under
one reserved key prefix in a bucket a consumer already owns.
SigV4 is hand-rolled because the CLI runner ships no boto3. Its vectors were
generated with botocore and are frozen in a unit test, which also pins the
Node signer the consumer specs use, so the two cannot drift apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
web-app-seaweedfs becomes two roles. web-svc-seaweedfs keeps the storage
engine: the container, the seaweedfs_data volume, the per-consumer S3
identities and the single canonical domain api. web-app-seaweedfs-console
takes the filer and master web UIs into its own compose stack behind the
admin-only oauth2 proxy, reaching the engine over the shared object-store
network. A node can now run the store without the consoles.
The console needs a distinct role name because get_entity_name strips the
category prefix: both halves would have resolved to the entity seaweedfs and
collided on the compose project, the instance directory and the container
name.
The filer and master ports move into the engine's services.seaweedfs.ports
.internal as their single owner; 01_init_consumer.yml consumes the master
port and would otherwise have resolved it only by role-var leakage from the
console, breaking bucket provisioning on exactly the console-less node the
split makes possible.
The engine's api vhost gains the prometheus locations. It never had them: the
override satisfied the cross-cutting-include lint only because the strings sat
in the SSO branch that the api domain never took.
Consumers, the objstore lookup and the catalogs follow the role id.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`roles/user/meta/users.yml` reserved two names against the identity directory by
indexing the primary domain from the left: `sld` took label 0 and `tld` took
label 1. That is only the SLD and the TLD when the domain has exactly two
labels. On `label-a.tld.test` it reserved `label-a` and `tld` and let the real
TLD through, and on any deeper domain it drifts further.
The count is a runtime fact, so a static YAML file cannot express it. The two
entries are gone and `utils/cache/users` now emits one `domain_label_<n>` per
label:
infinito.nexus -> infinito, nexus (unchanged)
main.infinito.test -> main, infinito, test (was: main, infinito)
label-a.tld.test -> label-a, tld, test (was: label-a, tld)
Nothing read the `sld` or `tld` keys: a grep over roles, plugins, utils and cli
finds only the generator itself. The entries exist to carry `accounts: []` into
`reserved_usernames`, which `web-app-keycloak/tasks/02_init.yml:6` joins into the
username deny pattern, so the shape of the keys is free and only the set of
usernames matters.
Worth stating so the next reader does not overestimate it: the pattern at
`UserProfileProvider.json.j2:31` allows `[a-z0-9]+`, so a label carrying a hyphen
could never have been registered anyway. What this actually wins is the third
label. What it mainly wins is that the rule no longer has to be revisited when
the domain depth changes.
The `KEYCLOAK_DOMAIN` and `domain` fallbacks go with it. Both ran through a
helper that recovered the primary domain by keeping the last two labels of a
host like `auth.<DOMAIN_PRIMARY>`, which cannot work without already knowing the
depth it is trying to find. `DOMAIN_PRIMARY` and `SYSTEM_EMAIL_DOMAIN` remain,
and every deploy path supplies one of them. The test that pinned the removed
fallback is inverted rather than deleted, so the removal stays visible.
The rest is the refactor pass the change pulls in. The function no longer
materialises aliases and is renamed to `_materialize_domain_label_reservations`;
its candidate tuple carried one extractor after the fallbacks left and is a plain
key loop now; an `or [primary_domain]` guard that would have reserved a nonsense
username for an all-dots domain is dropped. `roles/user/vars/main.yml` loses four
comments that restated the line below them, and the README documents the
reservation registry, which no longer shows its full content on disk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`config/main.yml` exists in no role: f65f4d60fb migrated the last one. A guard
against the string already exists, `TestNoLegacyPathReferences`, and it names
`config/main.yml` among its LEGACY_TOKENS. It scanned plugins, utils, cli,
roles, scripts, filter_plugins and lookup_plugins, which is every tree except
the two the stale references actually lived in.
So nine of them survived in `tests/`, and the guard reported green the whole
time. The code underneath was already correct everywhere: `test_csp_consistency`
and `test_role_wiring` iterate ROLE_FILE_META_SERVICES, and only their docstrings
and one assertion message still said `config/main.yml`. Had any of them globbed
the old path the test would have gone vacuous rather than red, which is the
failure mode worth naming here.
`tests` and `tasks` are now scanned. The targets come from
`utils/roles/mapping.py`, not from guesswork: `config/main.yml` is
`meta/services.yml`, `schema/main.yml` is `meta/secrets.yml` (confirmed at
`cli/administration/inventory/provision/reset.py:67`, which reads exactly that
file), and `users/main.yml` is `meta/users.yml`.
Scanning `tasks` exposed a second dead pointer. The migration tool became a
package on 2026-09-14, but EXEMPT_FILES still named `migrate_meta_layout.py`, a
file that no longer exists, so the exemption was inert and only invisible
because `tasks` went unscanned. Two dead pointers covering for each other. The
exemption is now a directory, so a new module in that package cannot fall out of
it. `test_no_direct_calls.py` named the same dead path in prose; that lint
allows through nocheck markers at the call site, so nothing was broken there.
`docs/administration/configuration/email.md` was the worst of them, because it
instructed operators rather than describing tests: it told them to write
`compose.services.email` into `roles/<application_id>/config/main.yml`. The
consumer, `plugins/lookup/email.py:255`, reads `entry["services"]["email"]` and
says in its own comment that there is no `compose.services` envelope. The role
snippet, the inventory snippet and the precedence list now match the reader.
`group_vars/all/TODO.md` pointed at the same dead file.
Verified by watching the guard fail, twice. Re-introducing `config/main.yml` in
a test file reports it; pointing the new directory exemption at a wrong path
reports `tasks/utils/migrate_meta_layout/credentials.py`, which proves both that
`tasks` is scanned and that the exemption is the only thing letting the
migration tool through. Restored, 11 passed.
The changelogs under `docs/changelog/` keep their mentions, and `.md` stays out
of the scanned suffixes for that reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`make quality-high` runs autoformat before the gate, and this pass rewrote three
files the same session had just committed. Nothing here changes behaviour: two
over-long call sites in `native_metrics_target.py` wrap, the `scraped()` return
in `test_vhost_cross_cutting_includes.py` gets its parentheses, and `import ast`
moves into the stdlib block it belongs to in
`test_budget_timeout_not_retried.py`.
They ride in their own commit rather than in the changes that produced them, the
way 4c66220c50, 1e083c4963, 3dd4f0abb1 and 2ba37b55b2 did, so that a reflow never
has to be read as part of a fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The span check compared multisets, so a translation was refused whenever its
set of protected spans differed from the source's in either direction. But
`PROTECTED` ends in two very low-precision alternatives, `\d+(?:[.,]\d+)*` and
the bare markup characters, which claim every digit run and every parenthesis.
Over-claiming is right when masking, because nothing may leak to the engine
unprotected. As a comparison basis it is wrong: the translation is another
language whose ordinary prose trips those same rules, and a gained parenthesis
destroys nothing the source carried. The module's own contract says "whether a
translation broke something its source carried", which is a subset relation.
So the loss half keeps the name and the surplus half gets its own. `added_spans`
refuses only a surplus whose presence is itself damage: an injected `%s` that
`msgfmt` rejects, a decoder artefact like `♪`, a leaked `<unk>`, an invented
`{placeholder}`. Measured over representative shapes, all four of those stay
refused, a gained parenthesis stays refused through the existing `structure`
check, and what becomes admitted is a surplus numeral or an abbreviation like
the Danish `f.eks`.
The gate moves in the same commit, and it has to. fe80a23725 introduced one
predicate for both ends precisely because a writer and a gate that disagree
never converge: the run writes, the gate condemns, `i18n-prune` empties, the
next run writes the same entries back. That commit did not quite finish the job
- `test_i18n_catalogs.py` still duplicates the comparison rather than calling
the shared predicate - so the duplicate is mirrored here. Making it call
`reason()` outright would be better and is left for its own change.
No catalog entry changes here. The refusal markers are all still in place, so
the entries this releases stay empty until a translate run fills them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`truncated` compared character counts against a flat 0.5 for every language. A
logographic script carries a morpheme per character, so a complete Chinese
rendering of an English passage runs at roughly a third of its length and the
predicate read it as a dropped clause.
Measured against the deployed LibreTranslate, re-translating 15 refused sources
per language: zh 15 of 15 refused, ja 11 of 15, ko 10 of 15, while 44 of those
45 ended in terminal punctuation, so they were complete sentences. The ratios
clustered between 0.28 and 0.55; a real truncation scatters and stops
mid-sentence. 0.25 sits below every complete rendering measured and still
catches a passage cut to a quarter.
The language set is measured, not guessed: zh, ko and ja account for 5553 of
the 6135 truncation refusals, 90.5%. The next language down is at 116, small
enough to be genuine truncation, so it keeps the flat ratio.
`collapsed` already reasons this way for the same reason, its docstring naming
Chinese explicitly; `truncated` never got the same treatment. `reason` already
carried the language for `untranslated` and now passes it on.
Effect on the catalogs, from the run rather than a test: zh 4153 truncation
refusals to 20, ko 816 to 0, ja 584 to 0, and 5590 entries that had been stuck
are now translated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects where a suite-budget kill is handled.
`SUITE_RESERVE_MS` exists so a waiter that runs out reports which predicate it
was waiting on, instead of letting the global timeout fire first and count the
in-flight test and everything behind it as "did not run". a490b28af6 set it to
60 s, but the cap it feeds is measured from the waiter's own start while the
global timeout runs from the suite's, so the reserve must also absorb every
test that ran before the waiter plus the post-failure trace and report write.
Measured prefixes in run 36651124458 were 3.5 s, 32.4 s and 86.2 s; at 86.2 s
the cap landed 26 s past the global timeout and OpenProject#1 kept only a
screenshot, no error-context. 180 s leaves roughly double the largest prefix
seen.
No waiter changes category: the newly cut band, between the old cap and the
new one, is empty on both budgets. The already-capped ones lose 120 s of about
5.28 M, and the only one that reached its cap was failing at the old value too.
The retry at orchestrate.yml exists for a Tor circuit flake. A run killed by
the global timeout fails the same way every time, so it burned a second full
budget for an identical result, up to 80% of some jobs' wall clock. Such a role
is now recorded and excluded from the retry loop.
The exclusion is only safe because the stash keeps it. That task reset
`_playwright_failures` to an empty list and only a retried role re-adds itself,
so dropping a role from the retry while leaving that reset would have removed
it from the final fail task and turned a red job green. The intersect keeps
exactly the budget timeouts in, and a test renders the task's own expressions to
hold that: the regression it guards against is a wrong set operation, not wrong
wording. Reverting the reset to `[]` makes it report `[] != ['web-app-docs']`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/snapshot` is a tar of `DOCS_SNAPSHOT_PATHS` and the list carried 32 entries,
neither of them `Makefile` or `aliases`. Both are tracked at the repository
root and `commands.py:80-90` hands them to two generators,
`make_targets --makefile {src}/Makefile` and `aliases --aliases-file
{src}/aliases`. `generators/make_targets.py:45` reads with no existence guard,
so the build dies with FileNotFoundError before sphinx is reached.
It dies without saying so anywhere. `_failed` wrote the exception into
`states/deployed.json` and printed nothing, and `library.status` iterates
`[LATEST, *tags]`, so `deployed` never appears in `/api/versions`. No log, no
API and no test carried the failure. `/deployed/de/` therefore answered 202 for
as long as the container lived and the Playwright suite polled a dead build for
its entire budget - eight jobs of run 36651124458, three hours each.
The failing test's trace settles which build it was: the 202 body carries
`<title>Building deployed</title>`, not `deployed/de`. The English `deployed`
version is the one that never lands, so the German build is never even queued.
That also gives the discriminator: `latest` and the release tags build from a
`git archive` of the mirror, where both files are present, and they reach
`ready` in the same run.
Three parts. The list gains the two entries, in the file's ASCII order - that
is the fix. `_failed` also prints to stderr, because without it the next such
gap is just as invisible; the moodle job's docs container log was 484 access
log lines and nothing else. And a test derives the requirement instead of
restating it: it calls `generate_commands()` and requires every first path
segment under `src` to be in the snapshot, `generated` excepted, since that one
the build creates. Adding two entries by hand would have broken the same way on
the next generator.
Main is unaffected: the role does not exist there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`native_metrics_target` was a Jinja filter, so it could not read
DEPLOYMENT_MODE: filters see no Ansible variables. It returned
`<services.<key>.name>:<port>` unconditionally, which is `matrix-synapse:9000`
for matrix. Compose serves that name; swarm does not, because `docker stack
deploy` ignores `container_name` and the alias never exists. This repository
says as much itself, through `lookup('compose_only', 'container_name', ...)`.
The target was therefore scraped and refused, and `playwright.spec.js:274`
reported `web-app-matrix: 1 matching target(s) reporting up != 1`, meaning one
matching target whose `up` value is not 1.
It becomes a lookup that delegates host resolution to `scrape_target`, which
was already mode-aware, so exactly one plugin knows the difference between the
two modes. The port has to be handed over explicitly: matrix exposes metrics on
9000 while `services.synapse.ports.internal.http` is 8008, so the existing
internal-port lookup cannot serve this case on its own.
`scrape_target` now refuses `port` together with an explicit `port_kind`.
Before, `port` won silently and the error text still named the
`ports.internal.<port_kind>` path that the call had never consulted.
`native_metrics_apps` drops an app that pins itself to compose through
`compose_mode_force` while the cluster runs swarm, because the scrape target
would then name the wrong object. Only the literal case: matrix declares that
override as a Jinja expression over its flavour, which this lookup cannot
resolve from the scraping role's variable scope, so variant 0 on swarm stays a
latent mismatch.
The two comment lines claiming `container_name:port` went with the change;
after it they state the opposite of what the code does.
Verified by mutation: with the swarm branch of `scrape_target` disabled, the
new test emits `matrix-synapse:9000`, the exact string CI reported.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_no_uncached_file_reads` requires every read under tests/, utils/, cli/,
plugins/ and library/ to go through `utils.cache.files.read_text`, which keeps
one decoded read per file for the whole pytest session. The test added in
3b85e4e1e1 called `Path.read_text` directly and so read init.py and
deploy/cli.py past that cache.
The rule allows `# nocheck: cache-read` only for a read outside the project
tree, which is how bfdf3dee79 handled a tempdir. These are real source files,
so the reader is the answer and the marker would have been the convenient
wrong one; e5430ad4ca is the matching precedent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`request()` ended in `touch(exist_ok=True)`, and on an existing file that
refreshes the mtime. `next_queued()` sorts by mtime and serves the oldest
marker, so re-requesting a build that is already queued moved it behind
everything else. The server calls `request()` on every visit, and a visitor
waiting for a page polls every thirty seconds, so waiting for a build was
what prevented it:
queued de, then a tag : ['deployed:de', 'v14.2.0'] -> builds deployed:de
after one poll on de : ['v14.2.0', 'deployed:de'] -> builds v14.2.0
One poll is enough. In the docs suite the spec before it deliberately clicks
an unbuilt release tag, so another hour-long build is always sitting there to
be overtaken by.
Observed in run 36596159856: `/deployed/de/` answered 202 and never 200 for
two hours and four minutes in one deployment and two hours fifty-seven in
another, from those jobs' own rescue artifacts, while the suite budget is
fifty-nine minutes on clearnet and eighty-nine on tor.
This removes the starvation, not the arithmetic: a full Sphinx build costs
sixty to ninety minutes here and the suite needs two of them in sequence,
English then German. Whether one round now fits is for a run to say.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adb53392bd taught the deploy planner to pin the instructions host to its
smallest variant and to leave a pinned app out of the round total. It taught
only the deploy side. `init` writes the inventory and `deploy` reads it back,
and the directory carries a round suffix only when the plan holds more than
one round, so the two sides named different directories:
>>> Inventory initialized at /home/runner/inventories/github-0
=== matrix-deploy: round 1/1 inv=/home/runner/inventories/github
[ERROR] Some application_ids are invalid for this inventory
[DEBUG] inventory file does not exist
Nine jobs of run 36596159856 died there, before ansible-playbook started. The
trigger is a subject with a single variant: init counted the host's two
variants and suffixed, deploy pinned the host out and did not. svc-ai-s1
declares two variants of its own and was the only instructions row to survive,
which is the shape of the bug read backwards.
`variant_select` opens by stating the invariant this broke - "keeps init and
deploy walking the exact same selection (SPOT)" - so the fix is to honour it
rather than to special-case the suffix.
The accompanying test asserts both entry points forward `pinned_variants`.
The five tests that shipped with adb53392bd all exercised the pin helper on
its own, which is why nothing caught a caller that never asked it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A row marked for the instructions replay gets `web-app-docs` appended to its
apps, and the planner hands every primary app the round's index. The host
therefore took the subject's index, which for a passenger is an arbitrary
shape. Measured in run 36558500327: the same appended role deployed
dashboard, matomo, prometheus and css in 141 tasks on a round-0 row and in
none at all on a round-1 row, purely because of whose round it was riding.
The README's Production block describes the bare role, so the host now takes
its smallest variant in every round. The index comes from `smallest_variant`
rather than a literal, so it follows `meta/variants.yml` instead of drifting
from it.
The row names the host in an environment variable. `--apps app#variant` would
have read better at the call site but needs a new matrix column in both
`axes.py` and `cli/meta/ci/plan.py`, while the deploy CLI already reads
`disable` from the environment and the four shell layers between the workflow
and the CLI pass it through untouched.
A pinned app no longer counts toward the round total: a passenger should not
lengthen the subject's sweep. That removes an unreachable round rather than
coverage, because `deployed_rounds` plans one app at a time and a role with
one variant therefore only ever yields `variant: 0` rows, which the workflow
pins on every deploy.
The host is left alone when it is also the subject. `web-app-docs` carries the
replay on its own rows too, where the apps list reads `web-app-docs,
web-app-docs` and the round index is the entire point of the sweep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`MODE_CLEANUP` says what to tidy on the deployed system: prune unused Docker
resources, clear Mastodon caches, switch an LDAP user list from `present` to
`exact`. Eight roles read it that way. The dedicated runner read the same flag
and ran `make clean` in the control repository instead, which is
`git clean -fdX` over every ignored path there.
`inventories/production/` is one of those paths. The published host install
guide provisions into it and then deploys against `$INVENTORY/.password`, so
the deploy deleted the vault password it was about to use:
Removing inventories/production/
[ERROR] The vault password file .../inventories/production/.password was
not found
Reproduced in CI on two unrelated rows, arm64/zfs/debian and
amd64/btrfs/centos, so it is neither architecture nor filesystem specific.
The coupling was a name collision, not a decision. 7e5990aa16 generated the
MODE_* flags from `01_modes.yml` and removed the legacy skip flags, and the
repository clean - which had had a flag of its own - was attached to the
generated `MODE_CLEANUP` because one now existed by that name. No other deploy
path does this; `container`, `development` and `swarm` never call `make` at
all.
`make clean` keeps its own entry points, `make setup-clean`, `make clean-sudo`
and `infinito --git-clean`, where a caller asks for it deliberately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both roles mount their model cache as a `type: bind` volume, and swarm
rejects a task whose bind source is missing on the node it lands on. Neither
declares a `placement`, so the scheduler may put the container on any node,
while the directory was created by a single task that reached one host. CI
showed the consequence directly:
s1_s1.1 desired=Ready current=Rejected error="invalid mount config for
type "bind": bind source path does not exist:
/var/cache/infinito/core/cache/models/s1"
with the creating task logging exactly one result line, for the manager.
`nfs_prep.yml` already fans directories out across the cluster but gates on
`driver_opts is defined`, which a bind volume never carries, so these two
volumes were never covered by it.
The host list moves into a `node_hosts` lookup rather than an inline
expression, because two roles already spelled the same idea by hand and a
third and fourth were about to. It answers one question - the hosts a
node-local path must exist on - and keeps the swarm group name in one place
per plugin. The call sites use `query()`, since `lookup()` joins several
results into one string and a loop needs the list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every commit here went in with `--no-verify`, so the pre-commit `make test`
never ran and neither the formatter nor the linter saw the change sets. The
quality gate collects that debt now.
`test_aliases.py` built a lookup table by handing a generator to `dict()`,
which ruff C402 rejects and offers no autofix for; a dict comprehension does
the same work. `selections.py` and `test_translate.py` each wrapped a call
across three lines that fits on one, which is the formatter's own rewrite
from the run that then aborted on the C402 above.
All three edits preserve behaviour: the comprehension is equivalent to the
generator it replaces, and the rest is whitespace.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`records()` is reached from two directions. The `.env` generator hands the
mirror list in explicitly, so `make dotenv` always resolved it. The
`cache_records` Ansible lookup calls `records()` with no argument, and the
fallback read `os.environ` alone. The deploy container is handed a curated
env and never sources `.env`, so `INFINITO_APT_UBUNTU_MIRRORS` is absent
there: `mirrors()` returned None, `${UBUNTU_PRIMARY}` never resolved, and
`records()` returned the empty string, which failed the svc-cache-package
bootstrap task with the message censored by `no_log`.
`env_setting()` is the repository's existing accessor for exactly this
shape - process environment first, `default.env` behind it - and is already
used for this key in the apt-mirror repository test. The explicit-argument
path is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
make.md names 'make help' as the authoritative target list and the site never carried one, so a reader had to run the repository to see what it offers. The same held for the i8 aliases and for the infinito command tree.
Three generators join the ones the build already runs for the YAML index and the role glossary. make_targets reads the comment block above each target, the same block make help prints, and turns 'Param x:' into a field. aliases reads the trailing comment, falling back to the target an alias wraps, because a plain make wrapper carries no comment by repository rule; an unparseable line raises instead of vanishing from the page. cli_commands asks cli.core.discovery, the rule the dispatcher itself applies, so the page cannot promise a command the CLI rejects.
cli_commands parses each __main__.py with ast rather than importing it: rendering a page would otherwise run 65 modules' import side effects, and a broken one degrades to 'Undocumented.' instead of losing the page.
literal() escapes the inline markup starters in prose taken from those files. 3b81b4de2e fixed this class once already, where a role description reading (*.parent) opened an emphasis that never closed; a Makefile comment naming two globs does the same. Measured: the unescaped form reports three docutils warnings, the escaped form none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--roles-only collapsed the entire failed set, so a job that never reached a verdict came back as a bare role name and the rotation re-picked axes the run had already proved green, dropping the one combination it never got to. Nothing distinguished a verdict from an interruption.
collapse_failures() takes the failed set and the hard-failure subset: a role that failed outright collapses to its name, everything else keeps the exact role#variant@mode+tor it was given. A role with a hard failure absorbs its own unfinished rows, since the collapsed name already covers every variant of it.
With --strict the two arguments are the same list, so i8ciall, i8ciallon and i8cifo are byte-identical to before; a test asserts that agreement rather than leaving it to inspection. The new combination is exposed as i8civar, which is i8ciallon without --strict.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
apply() already refuses a translation another entry holds, on the same floor the release gate reads, so within one process the second sharer is discarded. Four entries of docs/vi nevertheless shared one msgstr: one_catalog reads the catalog once, keeps what it has spent in memory and writes the whole file back after every chunk, so two concurrent runs each hold a private view and overwrite each other.
exclusive() takes an flock on build/i18n/<domain>-<code>.lock around the whole read-modify-write. It tries the lock non-blocking first so a second run can say it is waiting instead of looking hung. The lock files sit under build/, which .gitignore already excludes.
The damage could not be repaired either: prune ran damaged(), which reads one entry at a time, and a collapse is only visible across entries - each one is individually sound. over_shared() groups the machine translations by what they say and returns every sharer beyond MAX_SHARING_SOURCES. prune clears those first, so damaged() no longer sees them and the printed counts cannot overlap. Measured on the corpus: 10 in docs/vi, 132 overall.
MAX_SHARING_SOURCES lands in damage.py next to COLLAPSE_FLOOR and the gate imports both from there. fe80a23725 split a criterion across the two ends once already and the run never converged: every pass wrote what the gate condemned and prune emptied it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What is wrong with the answers the collapse check reports is not their
shape. A label is caught by its length, but `sq` shares a translation with
four sources at 0.84 of their length, `ga` at 0.73 and `he` at 0.62: full
renderings, spent twice. No length rule separates those from a language
that is simply terse, because the fault is that another entry already says
it, which a predicate reading one entry cannot see.
The writer now carries the translations a catalog already spends on a
source long enough to deserve its own, and turns down a repeat. It lives
where the whole catalog is in hand; `apply` alone sees one chunk. Both
arguments default to None, so a caller without the catalog behaves as
before.
The floor is now inclusive, matching the check it mirrors. `az` collapsed
on sources of exactly forty characters, which the check counts and the
first version of this guard skipped.
`criteria` is threaded through so the run names what it refused. A
duplicate is turned down here rather than in the client, and the summary
reported the discard with no reason for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A server that gives up on a passage returns a word: four distinct sources
of forty characters and more all came back as `Name`, and the catalogs kept
it. Every predicate passed, because a label carries no protected span, no
name, no markup and no stutter, and `truncated` does not look below 120
characters, which is where these sources end.
`collapsed` covers that band. Its ratio is a third of the truncation one so
a language that genuinely says the same in fewer characters survives:
Chinese renders a 58-character source in 17, a fifth of it, while the label
that prompted the rule is a fifteenth. It joins `reason`, so the client
drops such an answer and the release gate reports one that got in anyway.
This does not close the whole gap the collapse check polices. An answer
that is short but not a fragment, `E-poçt ünvanı` for several distinct
sources, sits between this ratio and a legitimate Chinese rendering; no
length rule separates them, because what is wrong with it is that another
entry already has it. That needs the writer to carry per-catalog state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lane is no longer LibreTranslate-only, so `i18n` is the wrong name for
the runner, its compose override, its inventory and its env keys. Renames
to `tools` throughout and points the lane at
`svc-ai-libretranslate-engine`, which is the role that actually owns the
`libretranslate-engine` service key.
The six cache host paths were each written out in full. They now derive
from `DIR_CACHE`, one plain string in `group_vars/all/05_paths.yml`, via a
`cache_paths` handler; the generated values are byte-identical. The handler
is registered directly after `cache_stack` rather than late, because
`registry_cache_max_size` reads `INFINITO_CACHE_REGISTRY_HOST_PATH` and
returns early when it is empty, which silently dropped
`INFINITO_CACHE_REGISTRY_MAX_SIZE` from the generated `.env` and broke
every `docker compose` invocation that layers `cache.override.yml`.
`INFINITO_TOOLS_MODELS_HOST_PATH` derives from the same base, so the model
bind the lane mounts follows the cache layout instead of repeating it.
The inventory pins `DOCKER_BIND_HOST=0.0.0.0`: the group_vars default keys
off `DOCKER_IN_CONTAINER`, which evaluates false for the inner deploy and
would leave the lane's services on the runner's loopback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a headless LibreTranslate role for callers that reach the engine over
the container network: no vhost, no reverse proxy, no MCP adapter.
The role is named `-engine` rather than `svc-ai-libretranslate` because
`get_entity_name` strips the category prefix, so that name and
`web-svc-libretranslate` both reduce to `libretranslate`. The entity name
keys the instance directory, the compose project and the container name, so
the two roles overwrote each other's rendered stack and `sys-service-loader`
resolved the engine's own `libretranslate` service key to the web role.
`provides:` cannot fix this: it renames the registry key, not the entity.
`loading_model` selects when models arrive: `lazy` (default) on first
request, `build` into the image, `task` by a post-deploy install. Under
`lazy` and `task` the models sit on a node-local bind so a rebuild reuses
them; `build` is mounted without it, because a bind is never seeded from an
image and would hide what the build baked.
Two lints guard the class:
- entity names must be distinct among roles that render a compose stack,
which `provides:` cannot satisfy and only a rename can
- a role that reaches a cached upstream another role declares must declare
it too, so the entry is discoverable from the fetch
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lookup takes the role from its term and falls back to reading
application_id out of the play's variables. That read was raw, which is the
trap that took down three jobs from webserver_resource, so it now goes through
resolve_var like every other lookup.
Prevention rather than a fix. The fallback is the else of a term that the only
caller always passes, and where it would fire web-app-docs sets application_id
literally; the roles carrying a templated id reach it through sys-stk-full,
which web-app-docs does not. The consequence would also have differed: an
unrendered string never matches a peer, so the replay would have asked the
site for a role named after the template and failed on the page rather than in
the lookup.
Measured: with the resolver's body stripped back to the raw read this test and
the webserver_resource one both fail, and both pass with it restored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven lookups read application_id out of the play's variables and every one of
them has to template it, because sys-stk-full defines application_id from
sys_stk_full_application_id and a dict read returns that unrendered. They did
it in five spellings: a public resolve_var, two byte-identical private copies,
and inline variants. Nothing named the rule, so it survived as folklore, and a
lookup written without knowing it took down three jobs of one run.
resolve_var now lives in utils/templating, which is where the six lookups that
import it can reach it without pulling in a networking module for a concern
that is not networking. utils/networks/lookup_context re-exports it under the
alias form, so its own importers are untouched and autodoc still resolves one
target. The two private copies go, and so does the fourth copy this branch
added to webserver_resource a few commits ago.
The private copies guarded value is not None and the public one did not. The
canonical keeps the guard: it is narrower than every live caller, each of
which passes a defaulted value, so no existing call can reach the branch.
The helper had no test at all. Its six behaviours are characterised first,
including that a templar raising on an undefined variable leaves the value
alone rather than failing the play, which is what the suppression is for.
Four inline copies stay. compose_volumes lets a template error propagate where
this suppresses it, and mcp_servers only templates a str containing a template
marker. Folding those in would change behaviour, which is not a refactor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving the effective-cpus expression into a lookup changed where the role id
is read from. Inside a Jinja expression the templar resolves a variable
recursively, so a value that is itself a template arrives fully rendered.
Reading it out of the play's variables does not. sys-stk-full sets
application_id from sys_stk_full_application_id, so the lookup asked the
applications dict for a role literally named
{{ sys_stk_full_application_id }}
and three jobs of one run died on it: dsk-gnt-claude, svc-ai-agent-broker and
svc-ai-searxng, every one of them a role that goes through that stack.
The lookup now templates application_id and service_name when they still
carry a template, and returns them untouched otherwise, so the ordinary role
never reaches the templar at all.
Measured: with the resolution removed the new test fails with the run's
message character for character, path_trace and config_path included, and
passes with it restored.
The same raw read survives as a fallback in guide_role, unreachable while its
only caller passes the id as a term.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A container cap had to be an absolute number, and docker refuses one above
the host's core count, so "use this machine" could only be written as a
number that breaks on a smaller machine. cpus now also takes a percentage,
read against the host's raw count: 100% is every CPU and lands exactly on the
ceiling docker accepts, 50% is half of it.
The share is read against that raw count rather than against the fair-share
default, because the two CPUs the resource lookup reserves exist to bound
what a service gets when it declares nothing. A service writing a percentage
overrides that default on purpose, and subtracting the reserve a second time
would count it twice and leave no way to name the whole machine.
A bare cpus: 0 is refused from here on. Docker reads a zero as uncapped
rather than as none, so the value most likely to be typed by mistake was also
the one that removed the limit; the error names the fix. No role declared
one: all 315 cpus values in the tree are plain numbers, the smallest 0.01.
Two lookups carry the resolution so that no caller repeats it or has to know
what a percentage is read against. lookup('cpus', app, service) replaces six
copies of lookup('config', ...) piped through the resolver; those six size
worker pools and thread counts from their own cpus, and left alone a
percentage would have given libretranslate a single thread and handed the
agent broker an uncapped container. lookup('webserver_resource', key)
replaces the two blocks that took the smaller of the proxy's value and the
app's, which were the same eight lines written twice for cpus and for
pids_limit.
resolve_cpus is no longer registered as a jinja filter. Nothing referenced it
from a template once the lookups existed, and keeping it would have left two
public ways to resolve one value.
Measured: 96 tests across the filter, both lookups and the container render
templates, including that a percentage resolves before the smaller of the two
is chosen, and that pids_limit stays an integer because nginx renders it
straight into worker_connections.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The instructions test brings up a machine per role, so it can only ever
replay one guide. Which one it picked was a side effect of ordering: the
lookup returned the first invokable peer the round listed, and on a CI row
that is the role under test only because the row's apps happen to read
<role>,web-app-docs.
It now reads APP_ID, which the deploy workflow already sets to the row's app
and which the swarm routine already documents as the app under test. A round
that carries no APP_ID, or one whose app publishes no Production block, draws
an invokable peer at random instead, so a local round covers a different
guide each time rather than always the first one the inventory lists.
Ansible runs inside the infinito container rather than on the runner, and
that exec passes an explicit allowlist, so the name arrives only where it is
forwarded. Two seams carry it: _FORWARDED_ENV_KEYS, which covers CI and the
local initialize and reinstall verbs, and the update verb's own docker exec,
which assembles its arguments without that CLI.
The pin is tolerant rather than strict. APP_ID is set on every deploy row and
not only on documented ones, so refusing an app that publishes no guide would
let a generic variable abort a deploy.
Measured: the draw is observed over 200 rounds rather than assumed, and the
forwarding is negative-controlled, removing the key and watching the new test
fail on that key alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b64e4a0558 gave web-svc-mirror the shared monitoring include so it would
appear in the scrape set. That include ends in two locations proxying to
the role's own backend, and this role runs no container: it renders a
vhost and nothing else. Every deploy that reached its vhost then died in
resolve_upstream, twenty-one jobs of one CI run, in both the compose and
the swarm phrasing of the same message.
What followed was worse than the original gap. 514a1fe74b guarded the
probe inside healthz.conf.j2, which ninety-six roles render, so a
one-role want became a change to shared infrastructure. Three further
attempts sharpened that guard, none of them fixed the deploy, and the
renders I offered as proof were vacuous: the outer integration guard
short-circuited in the context I tested, so the block I was measuring had
never rendered.
main does not carry the include, cannot reach that code path, and does
not fail. The complaint that started this, that mirror carries no
app="web-svc-mirror" label, does not appear in the run preceding the
include either. It was not a regression and not urgent, and it did not
justify touching a template every monitored vhost depends on.
So the tree returns to main for both files and the lookup written to
support them is deleted. Mirror is unscraped again, which is how it has
always been, and now a decision to take deliberately rather than a defect
invented from a passing remark in a job log.
Measured: both templates are byte-identical to main, nothing references
the deleted lookup, and the ansible lint suite passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The monitoring block a vhost includes ends in two locations that proxy to
the application's own backend, and resolve_upstream raises rather than
returning empty when no port exists to build that upstream from. A role
that renders a vhost without running a container of its own has none, so
including the block aborted its play. b64e4a0558 made web-svc-mirror the
first such role, four months after the assumption was written down.
The probe halves are now conditional. /healthz/live stays: it answers from
openresty, which is what liveness means for a role whose only artifact is
a vhost. /healthz/ready and /_live_probe go, because both proxy to a
backend that is not there. Guarding only the second one left the first
referencing a variable the guard no longer set, which is its own failure
and the reason this took two passes.
The condition asks a lookup rather than rebuilding the rule in the
template. Hand-derived, it tested the local port alone, which is correct
outside swarm and wrong inside it, where the upstream comes from the
internal port; web-app-matrix declares only that one and would have lost
its probe silently. The lookup reads both scopes and the port a role
resolved for itself, which is what resolve_upstream consumes.
No working endpoint is removed. A vhost rendering the block today must
already carry a port, or the lookup would be raising for it as well, so
the condition can only skip what could never have rendered. Not yet
rendered: the deploy that proves the template resolves has not run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entry carried two fields naming the same host by two routes.
canonical_url came from the tls lookup, which substitutes a role's onion
address on a Tor deployment; canonical_domain came straight from
domains.canonical, which does not. On clearnet the two agree and nobody
notices. Under Tor they part ways, and the field a consumer compares
against a rendered page is the one that stayed clearnet.
Every dashboard tile assertion failed on an onion swarm job for that
reason: the page offered
http://prometheus.toxlaup2sbprl6zklgcsofj7g4xim6uxjd4zjiiqtk3qzffykqwda2qd.onion
and the locator looked for a[href*="prometheus.main.infinito.test"]. The
matomo tracker spec failed the same way, comparing registered site URLs
against clearnet hosts. Deriving the domain from the URL leaves one
source instead of two that only agree off Tor.
The bug shipped with 6fa7bd2f57 in May, when canonical_url was added
beside the existing field without reconciling how each is resolved. It
stayed unreachable on swarm because the dashboard container failed to
start there at all, until 0e4ed57c3c let it start and the tiles rendered.
The stubbed tls in the tests answered with a host that no clearnet deploy
can produce, which is why the old expectations looked right. It now
answers with the role's own domain through the module's resolver, and
takes url_host to model a deployment whose URL host differs.
Consumers are the five playwright specs that read these fields; no task
reads them. Prometheus keeps matching on the role id, so its needle is
unaffected. css and logout carried the same latent failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
load_only reaches the image as a build ARG, and the RUN that fetches the
argos models is keyed on it. Pinning it to the languages a run happens to
translate therefore changed the ARG on every batch, invalidated that layer,
and refetched every model it names. Ten batches meant ten full rebuilds,
each one downloading what the previous had already downloaded.
Accumulating the languages instead would have been worse, not better: the
layer is all or nothing, so a growing set refetches the whole set each
time. What works is the opposite of following the batch. The lane now pins
load_only to every translatable code of both domains plus the pivot, 48 in
total, and asks for from_source, which drops the reverse pairs a lane that
only ever translates out of English never requests. The ARG is constant, so
the layer is built once and every later batch reuses it.
codes no longer reaches the rendered vars, which left it unused in deploy()
and server(); both lose it, along with the two call sites. That undoes
44260b8786, whose subject was passing it to a parameter this removes.
Correction to 4f2c9140fa: that message said an empty load_only makes the
image skip prefetch_models.py. It does not. The script always runs, and an
empty value only drops its --load_only_lang_codes filter, which is what
made it reach for every package. The consequence it described was real, the
mechanism was not.
Measured: after the one rebuild the server offers all 48 languages, and the
three batches since ran with no ansible task at all in their logs, going
straight to translating 12,518 entries across nineteen catalogs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The site answers 202 while it builds a version, and 200 once a build
exists. Whether that build matches what the version currently names was
never asked at the serving gate: servable() only checks that an index.html
is on disk. So the first request after a new snapshot got the previous
build with 200, and the rebuild it triggered finished into a directory
nobody was waiting for.
For a release tag that cannot happen, and for latest it is a reasonable
trade, since a visitor would rather read the previous page than a spinner.
For deployed it is wrong by definition: the name means the working tree
this run staged, so an earlier snapshot is not a stale answer but a
different question's answer. The post-deploy test that reads the published
page therefore validated the round before it, and reported a role broken
whose page had already been fixed.
The comparison already existed. _current() weighs built_ref against
_wanted_ref, which resolves deployed to the snapshot digest, and the
builder consults it to decide whether to rebuild. Only the server did not.
outdated() exposes it for deployed alone and the serving branch now treats
a mismatch as not yet servable, so the poll waits for the build it asked
for.
Measured on the stack while the previous round was live: the served page
was written 16:44:41 from the older snapshot, its own source carried the
newer content, and the correct build landed at 17:10:56, twenty-six
minutes after the test had already read the stale one and failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A batch that named its languages still deployed a server that knew none of
them. The role renders load_only from the inventory, the tracked i18n.yml
leaves it empty, and an empty load_only makes the image skip
prefetch_models.py. Nothing was baked behind the package cache, so the
server reached for the whole argos set at runtime over a path no cache
serves. The readiness wait then sat for its full hour and gave up with
"does not offer", which reads like a server that refuses the language
rather than one still downloading it. The lane now renders its own host
vars with load_only pinned to the pivot plus the batch, beside the tracked
file so the container sees it, and deploy and server carry the codes that
decide it.
The span guard could not see a hyphen. Its dotted-name alternative matched
[A-Za-z]\w*(\.[A-Za-z]\w+)+, and \w excludes -, so a host like argos-net.com
was never a protected span: a translation was free to mangle it and the
comparison still balanced. Widening the head to [\w-]* brings those names
under the same guard the undotted ones already had.
A refusal named its criterion and nothing else. "protected-span" says an
entry was turned down without saying which span moved, which is the one
fact that separates a bad translation from a criterion that is too strict,
so the marker now carries lost= and gained= and the per-catalog summary
counts the criteria it saw instead of reporting every rejection as a
damaged span.
Verified in the container against babel 2.18.0: 171 unit tests pass, and
the catalog lint holds across all 218 catalogs under the widened regex,
which is the gate that would have caught a span the old pattern let through.
The rendered vars file pins de, el, en, et, hi and lv, and those catalogs
carry their translations.
make i18n-tune still calls server with one argument and is fixed separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs round ended the same way twice: seven specs passed, four never ran,
and the suite was killed at its 89-minute cap after an hour and a half. Three
separate causes, each of which hid the next.
A waiter could outlive the suite that contained it. The specs budget a poll
against the role's run timeout, but Playwright is handed that timeout minus a
60-second buffer, so the global timeout fires first. When it does, Playwright
counts the in-flight test, and every test behind it, as "did not run", which
names nothing and reads like a suite that was merely slow. resolveTimeout now
caps what it returns at the budget the suite actually has, so an exhausted
waiter reports which predicate it was waiting on.
What it was waiting on could never arrive. 59e7b0a2a7 retargeted the yaml page
from /latest to /deployed, but /deployed is built from DOCS_SNAPSHOT_PATHS and
that list does not carry compose.yml, while /latest is built from a full git
archive and does. A servable version whose file does not resolve answers 302,
not 404 and not 202, and the poll rejects redirects, so it waited 88 minutes
for a page the version could not produce.
With the suite finishing, the CLI test's own failure became reachable: it reads
the published instructions from /latest, and /latest is stale. It still serves
web-app-sphinx and desk-zoom, names this repository renamed away, so ten roles
have no page there at all and web-app-docs is one of them. It now reads the
version the run just deployed, which is what a post-deploy test should replay.
Measured on the stack: /deployed/roles/web-app-docs/README.html answers 200
with the production block, /latest answers 302, and the published index lists
275 of 282 roles. The round now passes nine specs in 17.5 minutes.
Only five of the 1512 resolveTimeout call sites across 87 roles are large
enough for the cap to reach: three here and two in web-app-openwebui, whose
suite has not been re-run.
The package cache was configured in six places at once: the nginx upstream
map, the consumer compose override, pip.conf, .npmrc, the apt sources and the
twenty ensure_proxy_repo calls that bootstrap Nexus each spelled the same
hostnames, repository names and suites by hand. A new upstream meant editing
five files and hoping none was missed, and the docs told contributors to do
exactly that.
A role now declares what it needs under cache: in its meta/networks.yml, and
everything else is derived from it. svc-cache-package holds the general
upstreams; web-svc-libretranslate holds its own. All nine derived files are
generated and gitignored, so a second spelling of a hostname cannot exist.
utils.cache.hosts reads the declarations without PyYAML, because the .env
generator reaches them before any dependency is installed; it raises rather
than truncate when a comment sits at column 0 inside the block, which would
otherwise drop every declaration below it in silence. utils.cache.records
owns the Nexus record format and the Ubuntu mirror resolution, so the dev
stack and the role provision from one rendering.
A build may only resolve a cached host it can verify: the plain-HTTP hosts
are hijacked for every image, and the TLS hosts only for an image whose
Dockerfile installs the frontend CA. Hijacking all of them would fail every
pip and npm build with CERTIFICATE_VERIFY_FAILED.
svc-cache-package deploys the cache and its TLS frontend by calling the same
shell scripts the dev stack calls, rather than reimplementing them as tasks.
The role is invokable through the new svc-cache category and inert until a
host carries it.
Verified: the twenty Nexus payloads, the fifteen nginx server blocks and all
seven package-manager configs are byte-identical to the hand-written files
they replace, and the new category adds one role to the deploy order while
preserving the relative order of the other 180.
The role has not been deployed yet.
server() preferred the deployed service and fell back to a throwaway
container when it did not answer. That container ran the upstream image
directly, with LT_UPDATE_MODELS on and neither the package-cache CA nor
the argos-net.com hijack, so it refetched gigabytes from upstream on
every run while the deployed service already carries those models in its
image, installed through the cache at build time. The fallback is gone.
It was reached by accident rather than by failure. deploy() ended in
await_service(), which polled for 300 seconds; the first start of the
service installs its models and takes far longer, so the wait expired,
deploy() reported nothing running, and the caller spawned the throwaway
beside the service it had just deployed successfully. await_service and
SERVICE_TIMEOUT_SECONDS are removed, leaving the single readiness wait
the client already performs with the budget those models need.
Step 0 of the deploy also stopped forcing a recreate. It rebuilt a
healthy lane on every invocation and took whatever model install was in
flight with it; compose recreates on a changed configuration by itself.
The wait no longer looks like a hang: it reports how many of the wanted
languages the server offers, which ones are still missing, how long it
has waited against its budget, and what the lane has pulled. i18n-tune
waits the same way and gets the same report.
service_url() and lane_container() replace the two places that each
rebuilt one of those strings, and deploy() reads the environment once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The top-level i18n/ directory held a compose override and a development
inventory and nothing else, so neither sat with its siblings. The override
joins compose/, whose entries are already named <name>.override.yml, and
the inventory joins inventories/development/, which is what it is. The
directory is gone.
Both moves are pure renames; only the four sites naming the old paths
change, and the -f argument in compose_file_args is the one that decides
which override every docker compose call in the i18n lane layers on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every desktop job of run 36246124624 aborted before ansible started:
[ERROR] Some application_ids are invalid for this inventory:
- user-workstation: not present in inventory
The include list the planner returns becomes two things at once: the
inventory provisioner's --include, and the deploy ids validate_application_ids
checks against the resulting inventory. The inventory is built from
get_all_invokable_apps, and utils/roles/stage.py loops a stage only over
invokable category paths, so a non-invokable id in that list is one no group
can hold and no stage can dispatch. `user-workstation` landed there because
the desktop-base service edge pulls it into the closure.
Measured over all 179 invokable roles, old resolution against new:
rounds losing a member: 33
distinct dropped roles: ['dev-java', 'user-workstation']
`user-workstation` in all 33 dsk rounds, `dev-java` in dsk-bluray-player,
which carries it as a meta dependency and would have aborted the same way
on main the day a round picked that role.
Neither stops running. A meta dependency resolves at role-include time,
independent of the inventory. `user-workstation` reaches its host through
the service loader, whose preload closure is computed from the filtered
whitelist and still contains it, and through dsk-base's own include_role:
dsk-firefox: include=['dsk-base', 'dsk-firefox']
user-workstation in preload closure=True
workstation service required=True
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>