The lookup read DOMAIN_PRIMARY straight from the templar's available
variables, which hold it unrendered. The generated inventory defines it as
"{{ lookup('env', 'INFINITO_DOMAIN') }}", so whitelist_domains was written as
the literal ".{{ lookup(...) }}" and matched no host at all: oauth2-proxy
answered every absolute redirect with "domain / port not in whitelist", which
left pihole's sign_out unable to reach the Keycloak end-session endpoint.
utils/cache/domains.py already renders the raw value through
render_ansible_strict; do the same here, guarded on the value carrying a Jinja
marker so a literal domain is untouched. render_ansible_strict also refuses
output that still holds "{{", so an unresolvable primary domain now fails loud
instead of becoming a junk scope.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
58 commits, five conflicts. Four of them are the same disagreement: main
stops deriving memory parameters from the host's total RAM and takes them
from the container's own limit through the service_memory_mb lookup, while
this branch had routed the same values through its resource lookup. Main's
direction wins each time, because the new
tests/lint/ansible/services/test_memory_parameters_use_lookup.py requires it
and because a value read from the host drifts from the limit its service
declares.
group_vars/all/17_resource.yml keeps this branch's lookup('resource', ...)
form for the parameters that stay, and loses POSTGRES_SHARED_BUFFERS,
POSTGRES_WORK_MEM and POSTGRES_MAINTENANCE_WORK_MEM, which svc-db-postgres
now derives per container in its own vars. Main's side of the hunk also
redefined RESOURCE_CPUS, RESOURCE_MEM_RESERVATION, RESOURCE_MEM_LIMIT and
RESOURCE_PIDS_LIMIT, which already stand above the conflict as lookups;
taking it whole would have duplicated four keys.
web-app-nextcloud drops the two php values it derived from host_mem_mb.
templates/env.j2 already sets PHP_MEMORY_LIMIT and
PHP_OPCACHE_MEMORY_CONSUMPTION from the container limit.
web-app-wordpress is adjacency only: both sides added one line in the same
place, WORDPRESS_MULTISITE_ENABLED here and WORDPRESS_PHP_MEMORY_LIMIT on
main. Both stay.
web-app-zammad takes the updater's elasticsearch bump to 9.5.5, which
svc-db-elasticsearch already carries, and resolves its compose template the
way 774ff8c2d2 did: the search keys live in templates/env.j2, because an
inline environment: block is what compose-environment-from-env-file rejects.
Main's new path.data joins them there.
Six keys of plugins/lookup/resource.py lose their last caller with the
postgres parameters above and are now unreferenced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Redis sidecar whose consumer declares no services.redis.mem_limit
runs in a container that inherits the consumer's primary entity limit.
Since the lookup replaced the old filter, maxmemory followed that limit
and rose from 204 MiB to between 819 and 3276 MiB in 17 roles.
lookup('service_memory_mb', ..., provider=<role>) caps the result at
the same share of the provider's own limit when the application does
not declare one for that service. A declared limit is not capped, and a
container smaller than the provider keeps its own share. The Redis
sidecar passes provider='svc-db-redis': those 17 roles get 409 MiB, the
seven with a 256 MiB container stay at 204.
The lookup now rejects unknown options and a provider that is no role
or declares no limit for the service, so a misspelled option fails
instead of silently lifting the cap.
The memcached sidecar's literal -m 64 takes the same lookup and keeps
its value.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three conflicts, all from main sizing memory parameters off the container
limit while this branch moved container environments into one file per role.
plugins/filter/memory_filters.py and its test are deleted on main, replaced
by the service_memory_mb lookup. This branch only renamed the filter out of
its get_ prefix and fixed the docstrings' reStructuredText, so nothing of
substance is lost; the deletion is taken. Verified first that no consumer
survives: jvm_max_mb, jvm_min_mb and redis_maxmemory_mb appear nowhere in
roles/, group_vars/, tests/, plugins/, cli/ or utils/ once the merged tree
is in place.
web-app-zammad's search service is the only real conflict. 37e80b957e moved
discovery.type, xpack.security.enabled and ES_JAVA_OPTS from the compose
template into templates/env.j2, where an inline `environment:` block is what
the compose-environment-from-env-file lint rejects; a7db89ac0b changed the
heap from a literal 512m to half the container's limit. Both intents hold,
so the keys stay in env.j2 and the heap now reads ZAMMAD_SEARCH_HEAP_MB,
which the merged vars/main.yml already defines through the new lookup.
web-app-magento and web-app-shopware keep their inline environment blocks:
both carry an explicit nocheck naming the reason, that their single env.j2
is shared with containers the search daemon's keys must not reach.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lookup('service_memory_mb', application_id, service_name, share) returns
a share of a service's mem_limit in whole MiB. It resolves the limit the
way sys-svc-container resolves the container's own: the named service,
then the role's primary entity, then RESOURCE_MEM_LIMIT.
It replaces the three filters of memory_filters, each with its own unit
parser and its own idea of the limit: Redis maxmemory at 0.8 of the
limit, and the Confluence and Jira heap at 0.7 and 0.35.
The central Redis (409 MiB), Confluence (5734/2867) and Jira (4300/2150)
keep their values. A Redis sidecar without a mem_limit of its own does
not: the filter fell back to a fixed 256m, which gave 204 MiB, while the
lookup takes the limit that container really runs with, the primary
entity's. That moves 25 roles to between 409 and 3276 MiB.
The heap clamps of the old filters are gone: the 1024 MiB floor, limit
minus 1024, the 12288 cap and Xms bounded by mem_reservation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two lint suppressions shipped with the multisite OIDC work. Both are replaced
by the thing they were standing in for.
settings.php claimed an exemption from the mirrored-test rule because every
call it makes belongs to the WordPress runtime. The six functions stub in a
few lines, so the claim does not hold. SettingsTest.php declares WP_CLI and
the globals over a state holder and includes the real payload per case:
* a single site is written without switching blogs
* every site of a network is written, switch_to_blog paired with restore
* a site whose write does not persist is named by blog id
* every site is still visited before that failure is reported
* a payload that is not a map halts, and so does an absent one
The fourth case is the one worth having: a `break` on the first failure would
pass the other three and silently stop writing.
Neither existing house pattern fits. `eval` suits the tag-less tinker snippet
of web-app-akaunting, `proc_open` the standalone script of web-app-joomla;
this is a tagged file that needs a runtime, so it is stubs plus `include`,
which also avoids the `nocheck: eval` the akaunting test carries.
The installer test's cache-read exemption rested on a cached read serving a
stale call log. Measured, that risk is confined to the suite and sits around
3e-13: the log lives in a fresh TemporaryDirectory per run, mkdtemp verifies
non-existence, and each path is read once. What complying actually costs is
roughly 25 dead entries in an 8192-slot LRU, so the read goes through
utils.cache.files.read_text.
Proven by `make test-unit-php`: 32 tests, 68 assertions. The first version of
this test passed nothing at all -- it sits one directory deeper than the
other PHP tests, so dirname(__DIR__, 6) resolved to tests/ and the include
found no file. Without the test that would have stayed invisible.
The suite drives stubs, so it proves the payload's control flow and not that
WordPress behaves as stubbed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Multisite subsite served `/wp-login.php` with no SSO entry point even once
the OIDC settings reached every site (e1901fb88e), because the plugin was
never running there:
wp plugin activate <name> --path=<docroot>
Without `--network` that activates the main site only, so a subsite had the
settings and no plugin. `--network` appears on no branch of this repository,
so the gap was everywhere.
`services.wordpress.multisite.enabled` now resolves once, in vars/main.yml,
and feeds the activation, the multisite conversion gate and playwright.env.j2,
which until now recomputed the same lookup inline. The conversion gate also
drops a `| default(false)` on a key meta/services.yml always declares.
The installer moves out of Ansible into files/shell/install_plugin.sh. Eight
tasks interlocking through register/when/until/failed_when read as one linear
script, and the contract arrives as environment assignments. The container
address stays an inline shell assignment rather than an `environment:` entry
because in swarm it is the literal text `"$(resolve-container-id ...)"`, which
only the shell evaluates; the body streams over stdin because swarm workers
carry no role checkout (03ed52eea5).
Moving the logic across languages would have dropped the WP-CLI 255 contract
to shellcheck coverage, since the suite has no shell tests. The unit suite
therefore now drives the real script against a stubbed `container` wrapper
instead of evaluating the task file's `when:` expressions, which exercises the
behaviour rather than the text describing it.
A mutation pass over that suite -- eight mutations, each required to turn its
own guard red -- found two defects before any deploy:
* `set -e` is suspended inside an `if` condition, so a probe folded into
`if needs_install; then` turned an unreadable version into "nothing to
do". The version read is now a standalone call that aborts with 67.
* a bare `return` propagates the status of the preceding test, so
`[ -n "$WP_PINNED_VERSION" ] || return` aborted the script whenever an
unpinned plugin was already present -- the path every redeploy takes.
The second hid behind a vacuously passing check, which is why the suite gained
exit-code assertions where it previously only counted calls.
This commit carries a fix and a refactor together. The `--network` fix lives
inside the extracted script's activate(), so separating them would mean
reverting the extraction, committing the flag, and reapplying it.
No deploy has run any of this, in compose or in swarm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard required a register on every async kcadm job and nothing more.
A result that is registered but no longer handed to async_reap_jobs,
directly or through the list it is collected into, passed it, although
the reap then neither polls that job nor sees it fail.
The test now follows each registered result to an async_reap_jobs
expression, by variable name with word boundaries, so kc_declared_client
does not pass for kc_declared_client_jobs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The stub answered every absolute name, so a guard that asked for the
backend twice and never for the exporter passed both tests. The stub now
records the names it is asked for, and the tests require the backend and
the exporter.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
COLLABORA_WOPI_HOST read the scheme and the port with
lookup('tls', 'web-app-nextcloud', ...) and the host with
lookup('domain', 'web-app-nextcloud'). The tls lookup aligns an app term
to its consumer: for a clearnet Collabora it answers for the clearnet
sibling of an onion-primary Nextcloud, while the domain lookup keeps the
onion name. Such a deployment renders https://<onion>:443, an origin
Nextcloud does not use, so the allowlist does not match its WOPI calls.
Nextcloud pins overwritehost and overwriteprotocol to its primary domain,
so the allowlist now asks the tls lookup for that domain instead of for
the app.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The check listed the torrcs without the limit through "grep -L ...; true".
With no torrc under /opt/chutney/net/nodes, or with one that cannot be
read, grep printed nothing, the trailing true turned its error into
success, and the test reported that every node runs one worker.
The check now counts the limit per file with "grep -cH" and reads the exit
status. A status above 1 or an empty answer fails the test, and a file
with a count of 0 is named as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The variant-3 bootstrap aborts on an upstream guard:
TASK [custom/matrix-bot-baibot : Fail if admin patterns list is empty]
You need to define a required configuration setting
(`matrix_bot_baibot_config_access_admin_patterns`)
which takes the whole playbook down and surfaces only as the generic
"matrix-mdad bootstrap failed inside the container". Nothing asks for that
setting directly: at the pinned MDAD ref 40179e6d4a it is derived from
matrix_admin, which matrix-base defaults to the empty string. The template
rendered every other baibot setting and never set the one they hang off.
matrix_admin could not simply be written out, because "@localpart:server" was
assembled by hand in nine places and the two flavors had already drifted apart
over it: the compose flavor built it from MATRIX_SERVER_NAME while the MDAD
template computed its own onion conditional off DOMAIN_PRIMARY, skipping the
services.matrix.server_name key the role declares. group_vars/all/14_about.yml
had drifted further still and used the raw clearnet name, so on an onion
deployment it published a contact handle that resolves nowhere.
The shape now lives in the mxid filter, which rejects an empty part or a
localpart that already carries a sigil and lowercases the result because
Synapse refuses anything else. The homeserver name lives in the
matrix_server_name lookup, which answers the Tor node when the role is
published under .onion and the declared name otherwise. mxid joins the two, so
every caller names only an account, needs no role variable in scope, and gets
an id that matches whichever network is published. MATRIX_SERVER_NAME now reads
that lookup rather than repeating the conditional, which leaves MATRIX_ONION_NODE
without a consumer, and it goes.
Rendering the replaced Jinja against the new resolver over eighteen input
combinations disagrees three times, each on input no inventory produces - a
whitespace-only Tor node, an uppercase .ONION domain, one with a trailing space -
and each time the new answer is the correct one.
Separately, Nextcloud#12 dies in the same role with
/bin/sh: 1: sudo: not found
because matrix-synapse-usage-exporter pins self_build: true upstream and its
clone task becomes, which the runner image could not do. sudo joins its package
list; the container is already privileged, so this grants nothing new.
Static throughout: the baibot bootstrap had reached ok=196 of roughly 540 tasks
and the usage-exporter self-build never got to its docker build, so both fixes
clear the task that failed and nothing behind it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1d57e22926 raised web-app-docs' mermaid_version from 11.4.1 to 12.1.0 and left
mermaid_sha256 describing the 11.4.1 asset. The Dockerfile fetches that asset
with ADD --checksum, so every round that deploys docs died in the build with
#7 ERROR: digest mismatch sha256:6484afc3...: sha256:a43bc1af...
which names neither the pin nor the role. Seven of the eleven failures of run
37440146966 are this one line. The pin now carries the digest the CDN actually
serves, measured three ways that agree: a host curl, the new helper, and
buildkit's own computed value in those jobs.
The separation is the real defect, so the updater closes it. A version pin may
now declare checksum_key and checksum_url beside its type, and after writing a
new version apply_updates refetches that URL with {version} substituted, hashes
it, and replaces the value on the checksum_key line of the same entity block.
A pin that declares neither behaves as before and performs no request.
invalid_declarations rejects the two ways the declaration could silently do
nothing: a checksum_key naming a key the entity does not carry, and a
checksum_url without the placeholder.
source.py stood at 496 of the 500-line cap, so the three line locators move to
utils/update/pins.py and checksum.rewrite reuses them instead of repeating the
block-scoped search.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tor starts its worker threads by the number of CPU cores it detects, and
the cpus limit of the container does not change that number. On a host
with 20 cores every node of the private chutney network ran 21 threads and
grew to about 0.6 GB. The seven relays and authorities filled the 2g
limit and its swap within ten minutes, the kernel killed them one by one,
and no circuit could be built any more.
The entrypoint now writes NumCPUs 1 into the torrc of every chutney node
before it starts the network. Measured on that host over ten minutes: the
nodes run 3 threads and stay at 65 to 86 MB, the container at 0.47 GB
without swap, where it had filled 2 GB plus 2 GB of swap before.
The CLI test of the role fails when a chutney node runs without the limit.
Closes#824
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nobody could sign in to Odoo through Keycloak. Odoo sent an http redirect
URI, and Keycloak refused it as "Invalid parameter: redirect_uri".
The addon auth_oauth_https builds that URI from web.base.url. Two things
kept it from doing so after the image moved to Odoo 20.
Its manifest declared version 19.0.1.8.0, and Odoo marks a module whose
version names another series as not installable. It only warns about that,
so the install task stayed green and the addon was missing. The manifest
now declares 1.8.0, which Odoo prefixes with its own series.
Once installed, the addon read the parameter with get_param, which Odoo 20
replaced by typed getters. The call raised AttributeError, a broad except
logged it and fell back to the URL of the request, which is http behind
the proxy. The addon reads the parameter with get_str now and no longer
catches the error.
A deploy now fails when the addon is not installed afterwards. The check
reads ir_module_module, because the output of "odoo -i" does not tell an
installed module from a skipped one.
The spec logged out by navigating to /web/session/logout. Odoo 20 accepts
POST only on that route and answered 405, so both SSO tests failed after
a successful login. The spec logs out through the user menu now.
A host that runs the suite with several workers still failed it. Odoo
computes the session token of an account from its OAuth access token, so
every new sign-in ends the other sessions of that account, and the spec
files share the administrator. The role caps its suite at one worker
with PLAYWRIGHT_MAX_WORKERS, which the central config now honours, and
the spec asserts the cap.
Closes#822
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A gate run died two seconds in with PermissionError on
compose/package-cache/pip.conf. The worktree is bind-mounted into the
container, so a container that rendered these files owns them afterwards
while the directory stays the host's. Overwriting needs the file's
permission and unlinking the directory's, which is why the host could no
longer write a file in a folder it owns.
_write now catches the refusal and replaces the inode instead. Only after a
refusal, never by default: compose/swarm/cache.override.yml bind-mounts an
apt list into a container by inode, and swapping one that was writable
anyway would leave that mount reading a file nobody updates any more. The
new test pins that half as well, so the guard cannot be simplified away.
Shown failing first: the previous _write raises PermissionError on the same
input the test feeds the new one. Shown working in place: lint-ansible came
back in 3m40s through a run during which make dotenv-force re-rendered these
very files, where the same sequence had aborted the target after two seconds
before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In the async pass the role runs kcadm jobs side by side. Every kcadm call in
the Keycloak container shares one config file. kcadm rewrites that file when
it refreshes the access token, about every 55 seconds, takes the file lock
with tryLock() and gives up at once when another kcadm process holds it. One
of two overlapping declared-client jobs then failed with "Failed to get lock
on /opt/keycloak/.keycloak/kcadm.config", and the deploy with it.
keycloak_kcadm_update now repeats a call that lost the lock, up to ten times
one second apart. The call fails while it refreshes its token, before it
sends its own request, so repeating it is safe. Every other error still ends
the call at once.
That covers the module only, so the async jobs are reduced to the ones that
go through it:
- The default-scope assignment was an async shell task. It ran beside the
shell tasks that follow it, and neither side repeats a lost lock. It runs
in line now.
- The two mail-settings jobs were async without a register, so nothing
reaped them and a failure was never seen. They run in line now.
The client job and the declared-client jobs stay async. A test fails when an
async kcadm task does not use the module or is not registered for the reap.
A config file of its own per call was the other option. It does not work by
itself, because the admin session lives only in the shared file.
Closes#805
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nginx resolves its upstreams once, at start, so the frontend starts through
a guard that waits until `backend` and `exporter` resolve. A container
resolves with a search domain, and the zone of a deployment answers every
name with the address of the ingress. A service that Docker's DNS did not
know yet therefore resolved anyway, as `backend.<domain>`. The guard let
nginx start at once and nginx pinned the ingress as its backend. Every API
call then failed with 502 and the SPA showed "Bad Gateway" instead of the
login form, until the container was restarted.
The guard now asks for the absolute names, `backend.` and `exporter.`. The
search domain is not applied to those, so the lookup fails until the
services themselves resolve.
Closes#806
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The extension installer returned as soon as an add-on was installed, in
whatever version. A new pin in meta/addons/ therefore reached a fresh wiki
only, and an existing wiki kept the version it was first deployed with.
The installer now skips an add-on only when the installed version equals
the pin. Otherwise it calls install(id, version, namespace), which the
Extension Manager resolves as an upgrade, and it reports an error when the
add-on is not on the pin afterwards.
The check that follows the installer compared presence only. It takes the
pin as well, so a deploy fails when an add-on is installed in another
version.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The gateway answered two sources with "<source>\n---\n<rendering>" and the
whole answer was stored, so twenty languages shipped the English text as
their own translation and the release gate stayed green. Every criterion
passed it: echo compares for equality and the answer is longer, the markup
comparison counts only []{}`*() so a rule of hyphens is invisible to it, and
the rendering the server appended keeps the spans, the names and the length.
echoes() reports the same "echo" criterion when the translation contains its
whole source, under the floor the equality case already uses: a short source
may legitimately survive inside a longer rendering, a sentence may not.
reason() is the single source of truth for both ends, so the client now drops
such an answer on arrival and the gate reports one that got in earlier; make
i18n-prune clears those.
Swept every core catalog: exactly the 21 damaged entries are reported, all as
echo, and nothing else in the corpus. The old predicate returns no reason at
all for the same input.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The update bot opened a version PR for web-app-jira (#821) although the role is end of life: none of the updaters looked at the lifecycle. utils.update.base.is_maintained now answers that question once, and the collectors for image versions, declared version sources, pip pins, repository refs and the addon check skip a role whose lifecycle is eol.
The lint that requires every semver pin to be watched shares that scope, otherwise it would report the pins of the eol roles as unwatched. Every other stage, deprecated included, stays in the update scope.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
make autoformat reflowed a resource_filter call, a generator expression and five test files it touched along the way. The new architecture test also carried the two findings the first gate run stopped on: three RUF012, where a TestCase class attribute needs ClassVar, and two PERF401, where an append loop becomes list.extend over a generator.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prepared tree is reused through hardlinks, so its files share inodes and a build writing a source file in place would rewrite the cache for every later reuse. _seal drops their write bits to make that a loud failure, and the test proved it by expecting a PermissionError on a write.
pytest runs as root in the test container, and root writes through missing write bits, so the test failed there while passing on a host. It now asserts st_mode & 0o222 == 0, which is what _seal actually does and holds for any user, and keeps the write attempt as an enforcement probe that is skipped under root rather than reported as a defect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
For a very dark base color the light palette used the base itself as
link color, which is as dark as body text: #001f3f gave a link at
contrast 1.02 against the text, so links in tables read as plain text.
The dark palette did the same for a very light base (#FFFF00: 1.11).
The lightness the link search starts from is clamped to 0.45..0.55 in
light and 0.70..0.80 in dark mode. #001f3f now yields #37577c in light
mode (2.27 against the text, AA on every surface). Bases inside the
band, #FFA500 among them, produce the same palette as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing held a role's architectures to reality: test_image_reachable proves a tag exists, test_arm64_images proves four named roles publish arm64, and the closure lint only proves the declarations do not intersect to nothing. An amd64-only bump became a scheduling failure on an arm64 row rather than a red test.
A new external test sweeps every pinned image and judges a role by the intersection over its services, because a role runs only where all of its images run; judging per image would demand arm64 from an amd64-only role's nginx and call a narrowing stale because nginx publishes arm64 while the application does not. A role declaring nothing must cover every architecture the matrix rotates; a role that narrows must narrow to exactly its capability, which is what retires its TODO by itself. An unread image downgrades the stale verdict to a warning, since the image a probe could not read may be the one the narrowing exists for. The probe reads the mirror first and caches on disk, because the sweep is ~160 manifests and Docker Hub counts its allowance per IP, shared.
A new lint requires every architectures declaration to carry a nocheck marker and a Reason. bigbluebutton and opentalk keep their pins with measured reasons; decidim, pretix and taiga lose theirs, because each builds its own image and their upstream Dockerfiles are arch-neutral. Their source builds are not converted yet, so until that lands the matrix may hand them an arm64 row that fails at FROM.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The injector knows a third snippet kind, top, and places it right after
the opening head tag. The design role uses it to link layer.css, which
only declares the cascade layer infinito-design. Declared before the
stylesheets of the app, that layer loses to every rule of the app,
layered or not.
The shared Bootstrap component mapping is linked only for a role whose
design entry sets bootstrap: true.
A role stylesheet or design script that renders empty counts as absent,
and a deploy removes the stylesheet a role no longer ships from the CDN.
The two style.css guides describe the layer, the order of in-house
options before the role stylesheet, and the frame and focus tokens.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The palette derives the link color and the four status colors against
all five surfaces (the three surface levels plus their hover and active
states) instead of the two content surfaces, and a status color also
reaches AA on its own subtle tint. The subtle and border tints of the
status colors move from a CSS color-mix into the palette, so both modes
carry them as plain values.
New tokens --design-frame, --design-on-frame, --design-frame-hover and
--design-frame-active give large brand surfaces such as a sidebar a deep
tone with AAA text in light and dark mode. The default focus outline
reads --design-focus and falls back to --design-link, so a frame can
keep its focus indicator visible.
The design lookup exposes the generated icon under urls.favicon_ico,
because a role may declare a slot named favicon. Slot names must be
kebab-case; a violation fails the lookup. resolve_branding returns a
bootstrap flag whose default false lives in web-svc-design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
mautrix-telegram cannot register without an API id and hash, and the task that dropped it when they were missing ran only in the compose flavor. The ansible flavor renders its upstream flags from MATRIX_BRIDGE_ADDONS, which carried telegram regardless, so MDAD was told to enable a bridge it could not authenticate.
The gate moves onto that variable: MATRIX_BRIDGE_ADDONS_DECLARED keeps the unfiltered selection and MATRIX_BRIDGE_ADDONS drops telegram unless MATRIX_TELEGRAM_USABLE finds both credentials non-blank. The compose bridge list built addons itself rather than reading that variable, so removing the task alone would have taken the filter off the list that drives the docker loops, the homeserver registration and the compose services; it now derives from the gated addons, and a unit test fails if it stops doing so.
MATRIX_BRIDGES deliberately stays a compose set_fact. As a role variable it would also be defined in the ansible flavor, where the playwright env would start emitting MAUTRIX_<BRIDGE>_ADDON_ENABLED and run addon specs that flavor has always skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/lint/playwright.sh derives a stub value per env key by name suffix, and only *_JSON, *SLUGS and *OVERRIDE get []. Everything else gets the literal string stub, which decodeDotenvJsonList rejects at module scope, so the spec contributes no tests and playwright test --list reports none. The exported key becomes DOCS_I18N_PRELOAD_LANGUAGES_JSON on both sides; the Ansible variable keeps its own name, because only the wire name is what the stub matcher reads.
The prepared-tree fixture is walked with rglob, which the raw-walk guard flags so repo scans share one cached walk. A tempdir is the opt-out that rule names, and its marker has to sit on the call's own line, so the call moves onto one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collapsing the three GPU call sites into one lookup moved the whole decision behind lookup('gpu', ...), but both consumers' tests still stubbed lookup with a constant. One test per file went red; three more kept passing for the wrong reason, because the stub's {} is falsy and every negative case was therefore true no matter what the template did.
The stubs now branch on the lookup name and record what they were asked, so each file guards what it still owns: the answer reaches the compose key or the role variable, and the lookup is asked for this service. The declared-flag-times-device matrix stays with the lookup's own tests, which is also where the boolean() over bool() rationale now lives, checked rather than written beside the call it protects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine was a CPU-only Debian build, so a GPU host translated on the CPU. The runtime base is now nvidia/cuda and the builder its matching -devel tag, which carries no Rust, so rustup installs the pinned toolchain the rust:slim builder used to ship.
The cargo feature hangs off the gpu lookup rather than being unconditional: a CUDA runtime image ships cuBLAS but never libcuda.so.1, which the nvidia container runtime injects, so a CUDA-linked binary on a card-less host would fail to load. The -cuda suffix travels with the feature into the image tag and into cache_from, or a host that gains or loses a card would serve the image the other state built.
svc-ai-ltengine declares huggingface.co, whose weights its hf-hub dependency downloads on first start, and svc-ai-ollama declares registry.ollama.ai. The first charged svc-ai-s1 with fetching from a host it only names as its info.yml homepage, so the ownership lint now reads info.yml as prose the way it already reads a README.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A translated site repeated the whole checkout and generation of its version, and the builder ran one marker at a time. The prepared tree is now cached per (ref, tooling digest), hardlinked into each build's scratch and sealed read-only, and a bounded thread pool claims the markers a free slot can build now - a language only once its version is current, so a stale one is no longer claimed and bounced in a spin.
services.docs.build.jobs and services.docs.build.parallel replace build_jobs and default to half the host's CPUs, parallel capped at 2 so the two knobs cannot multiply past mem_limit. DOCS_I18N_PRELOAD_LANGUAGES replaces DOCS_I18N_LANGUAGE, defaults to the host language and warms [en, de] in both matrix variants, where docs_sites is backed up under swarm so the DR drill restores the built sites instead of rebuilding them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0a3da88912 closed the EOL contract at the declaration level: the four retired
roles carry `enabled: false`, an end-of-life README banner and three lints.
iter_role_images still enumerated their images, so the mirror job kept trying
to copy tags nobody can consume. MinIO's quay tags answer 401 to anonymous
pulls and exist nowhere else, which failed that job on every run while no
deploy could have reached the result: an eol role sits outside the lifecycle
envelope default.env tests.
role_lifecycle() reads the stage off the one entry that carries a `lifecycle`
field, which is the role's primary service, and iter_role_images skips the
retired ones. Measured against the tree: 202 images, with web-app-confluence,
web-app-jira, web-app-minio and web-app-phpldapadmin all absent.
test_version_pins_are_watched.py gains the same skip. utils/update/docker.py:271
builds its watched set from iter_role_images, so dropping the retired roles
there made their `version:` pins look unwatched and the lint went red on three
of them. The coherent reading is that a role no updater can reach cannot
declare a watcher either, and the lint docstring now says so rather than
claiming every pin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>