OpenProject's /login renders a header Sign-in toggle plus a duplicate pulldown form, so a generic Sign-in button click hits the toggle and never submits the credentials. Press Enter on the password field to post the enclosing form (the button click stays as a fallback). Also drop the bare account token from the admin auth marker so an unauthenticated Create-a-new-account link no longer masks a failed login as no-logout-control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Postmarks admin auth is the SSO trusted-header bridge only; with SSO off there is no native admin login for the persona to drive, so the no-SSO variant failed to reach an authenticated surface. Gate PERSONA_ADMINISTRATOR_BLOCKED on services.sso.enabled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the no-SSO variant the admin persona drives native login, but it was rejected ('Invalid user or password') even though the seeded credentials match. Set the user status to active in ensure_admin_user.rb so a freshly-created admin can authenticate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Synapse S3 media offload (synapse-s3-storage-provider) is wired only in the ansible flavor; the compose flavor stores media on local disk, so the matrix bucket never grows and the avatar seaweedfs check failed on variant-2 (compose). Expose MATRIX_FLAVOR and skip the test unless the flavor is ansible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Baserow's admin is SSO-gated; with SSO off there is no provisioned native frontend admin (first run forces /signup), so the persona can never authenticate and the no-SSO variant timed out. Gate PERSONA_ADMINISTRATOR_BLOCKED on services.sso.enabled, mirroring the biber gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On busy runners the mailu admin/MariaDB container can be OOM-killed or briefly refuse connections mid user-loop (flask mailu user -> ConnectionRefused rc=1 / SIGKILL rc=137), which failed co-deployed roles (opentalk, fusiondirectory). Add retries/delay/until to the user-create and password-change commands so a transient outage is waited out; 'already exists' still short-circuits as success.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`resolve-container-id` previously used the first running task from the
cluster-wide `container service ps` listing and emitted that task's
container ID. When the picked task lived on a worker but the caller
ran on the manager (every `delegate_to: STACK_HOST` exec path), the
follow-up `docker exec <id>` failed with
`Error response from daemon: No such container: <id>`.
Add a fast path that queries the local docker daemon via
`label=com.docker.swarm.service.name=<svc>` and returns a locally-
running task's container ID directly. The original cluster-wide
task lookup stays as a fallback for the case where no replica is
local — same behaviour as before, no regression possible.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`BASEROW_APP_CONTAINER_ADDRESS` resolves via the `container_address`
lookup to a bash subshell string `"$(/usr/bin/resolve-container-id
<stack> <service>)"` in swarm mode (and to the bare container_name
in compose mode). The migrations + bootstrap-admin tasks fed it
into `container exec -i` through `| quote`, which wraps the whole
value in single quotes — bash then does NOT evaluate `$()` and
`container exec` is handed the literal string, failing with
`Error response from daemon: No such container: "$(/usr/bin/...)"`.
Drop `| quote` on the two `ansible.builtin.shell` invocations
(migrations + bootstrap). The lookup's output is already shell-safe
(the subshell is double-quoted internally, the compose-mode bare
name has no shell metacharacters). The `ansible.builtin.command`
restart task on the compose-only branch keeps its `| quote` because
that uses `command:` (no shell), runs only under `DEPLOYMENT_MODE
\!= 'swarm'`, and the bare name is shell-irrelevant there.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The matomo-bootstrap:1.1.13 Playwright walker fails at the
setupSuperUser step against `matomo:latest` (currently 5.11.x):
`Could not find a Next/Continue control in the installer UI`. The
bootstrap was last aligned with the Matomo DOM in v1.1.12 (changelog
2026-02-15, Matomo 5.7.x). 5.9.0 is the oldest 5.x tag still
pullable from Docker Hub, putting us as close to the bootstrap's
known-good DOM as the registry allows.
Defense-in-depth: raise the installer wait/step/deadline knobs in
bootstrap.env.j2 so slow renders under swarm/NFS don't trip the
Playwright walker. Also lower
`MATOMO_INSTALLER_SUPERUSER_RELOAD_INTERVAL_S` from the upstream
default 30 to 10 so the orchestrator's reload-aware helper recovers
faster when execution reaches that branch.
Downgrade caveat: deploys currently running matomo:latest carry a
5.11.x DB schema; the next re-deploy will pin 5.9.0 and Matomo may
refuse to start or behave unexpectedly. Back up the database before
applying this commit on a live cluster.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Make web-app-keycloak deployable in swarm without the manager-only
assumptions of the original compose-only shape.
Manager-side delegate scaffolding:
* tasks/02_manager_ops.yml: switch the `include_tasks` of
02a_manager_ops_on_host.yml to the dict form so the `apply:
delegate_to: KEYCLOAK_HOST_NODE` block actually attaches (free
form raised `conflicting action statements: include_tasks, apply`
in ansible 2.19).
Swarm bind sources that exist only on the manager:
* tasks/05b_realm_import_swarm.yml: pin the one-shot
`<stack>_<service>_import` service to `node.role == manager` so
the realm-import bind mount `/opt/compose/keycloak/volumes/import`
resolves.
* templates/sso_proxy/container.yml.j2: same manager pin for the
sso-proxy sidecar (its oauth2-proxy cfg bind source also lives
only on the manager). Also add `extra_hosts` for the keycloak
auth domain pointing at `host-gateway` so OIDC discovery reaches
the host's openresty, and mount the self-signed CA when TLS_MODE
is `self_signed`.
* templates/sso_proxy/oauth2-proxy-keycloak.cfg.j2: add
`provider_ca_files` so oauth2-proxy validates the self-signed
cert instead of failing OIDC discovery with TLS EOF.
CA mount path SPOT:
* group_vars/all/02_tls.yml: add `CA_TRUST.cert_container` for the
in-container CA file path so the literal `/etc/ssl/certs/...` is
defined once.
* roles/web-app-matrix/vars/main.yml: consume the new SPOT.
Container ID resolution in delegated tasks:
* vars/main.yml: add `KEYCLOAK_TARGET_CONTAINER` (= LOCAL_CID with
fallback to KEYCLOAK_CONTAINER_ADDRESS) and recompose
`KEYCLOAK_EXEC_CONTAINER` from it, replacing the inline default.
* tasks/update/05c_per_app_mappers.yml and
tasks/update/ldap/per_app_mapper_one.yml: `KC_CONTAINER` env now
resolves via the new SPOT, so the per-app-mapper helper scripts
hit the actual swarm container ID on the delegated host node
(compose mode keeps using the bare container_name via the
fallback).
Delegated script paths:
* tasks/update/08_users_email_verified.yml and
tasks/update/08_users_realm.yml: drop
`chdir: "{{ role_path }}/files"` from `ansible.builtin.script`.
role_path is a controller-side absolute path; on a delegated
DinD swarm node that path doesn't exist and the script fails to
start. Both scripts only consume env vars.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Makefile: `make act-swarm-zombie app=<id> disable=<keys>` now
forwards the `disable=` value into the act run via ACT_ENV. The
swarm provision CLI inside the workflow reads it through
`apply_services_disabled_from_env` and removes the named provider
roles from the test inventory + sets their `enabled: false /
shared: false` overrides in host_vars. Matches the iteration spec
in docs/agents/action/iteration/role.md.
* scripts/tests/deploy/act/workflow.sh: switch the ACT_ENV pair
separator from comma to semicolon so values can themselves carry
commas (the disable= list is comma-separated). Only one in-tree
caller (the Makefile target above) currently uses ACT_ENV with
multiple pairs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* cli/administration/deploy/swarm/closure.py: drop dep-walked roles
that are not present in the inventory's role groups so the
`validate_application_ids` strict in-inventory check no longer
collides with `disable=` (which removes provider roles from the
inventory). Operator-supplied seed IDs and the
`default_placement: manager` safety net are kept unchanged.
* tests/unit/cli/administration/deploy/swarm/test_closure.py: add
`test_operator_ids_dep_walked_not_in_inventory_dropped` to pin
the new behaviour and update the existing scenarios to put the
filtered roles in their test inventories.
* tests/lint/ansible/shell/test_container_exec_is_stack_host_gated.py:
recognise `include_tasks: { file: X, apply: { ... } }` dict form
alongside the free-form string. Without it the lint's include
graph dropped the apply-include edge and false-positived gated
files reached by it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two scalar group-vars are referenced verbatim in
`roles/user-*/meta/users.yml` (`lastname: "{{ ORGANIZATION }}"`,
`description: "owned by {{ SOFTWARE_NAME }}"`). Ansible 2.19's
TrustedAsTemplate gate silently leaves those untagged Jinja
literals unrendered, so downstream consumers received the raw
`{{ ORGANIZATION }}` string. Keycloak rejected the resulting user
updates with `error-person-name-invalid-character` because `{` /
`}` are not valid in a person name.
Add `substitute_scalar_placeholders` next to the existing
`substitute_primary_domain_placeholder`. Both helpers run from
`get_merged_users` immediately before `_render_with_templar`, so the
cache returns fully-resolved scalars regardless of the templar's
trust gate.
Also restructure: move the cache into a `utils.cache.users` package
so the placeholder helpers no longer need a redundant `users_`
prefix:
utils/cache/
users/
__init__.py was utils/cache/users.py (public API
`get_user_defaults` / `get_merged_users` is
unchanged; consumers using
`from utils.cache.users import ...` keep
working via package __init__).
placeholders.py the substitution helpers, dropping the
redundant `users_` prefix.
tests/unit/utils/cache/
users/
__init__.py
test_module.py was test_users_module.py
test_placeholders.py new: 15 unit tests covering both
helpers (nested-structure walk, URL /
port handling for DOMAIN_PRIMARY,
Jinja-in-raw-value resolution, empty /
missing skip paths, and a guardrail
pinning the current
`_SCALAR_USER_PLACEHOLDERS` tuple).
Update README links under `utils/cache/` and
`docs/contributing/artefact/files/plugins/lookup/users.md` to point
at the new package path.
Placeholders covered today: ORGANIZATION, SOFTWARE_NAME. Extend the
tuple in `utils/cache/users/placeholders.py` when a new scalar
group-var appears in user metas.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The container_volumes lookup returns a YAML string starting with
`volumes:` at column 0. Templates consume it via
`{{ lookup('container_volumes', application_id, '<svc>') | indent(4) }}`
in 129 places.
Jinja's `indent(width)` only indents lines AFTER the first by
default (indentfirst=False), so the rendered output kept `volumes:`
at column 0 and the rest at column 4 — producing a top-level
`volumes:` map in the middle of a service block, which broke the
compose.yml structure (the following `ports:` / `networks:` keys
ended up inside the volumes list and `compose build` failed with
`did not find expected '-' indicator`).
Prepending a leading newline to the lookup's return value lets
`indent(4)` indent every meaningful line including the first one,
without forcing 129 templates to switch to `indent(4, True)` (which
would also need a separate audit of which call-sites already supply
their own leading whitespace).
Existing filter-side unit tests in
tests/unit/plugins/filter/test_container_volumes.py exercise the
underlying `container_volumes()` function which is unaffected — the
newline is added only at the lookup boundary.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
container_address + container_service are mode-aware lookups that
build a docker-exec-safe address from `application_id + service_key`.
Two adjustments to align them with how the rest of the project uses
service keys:
* Swarm-mode return value now uses the lookup's `service_key`
argument directly as the swarm service-suffix instead of
`services.<key>.name`. This matches the documented contract in
`roles/sys-svc-compose/files/resolve_container_id.sh` (the helper
composes `<stack>_<SERVICE_KEY>`) and what `docker stack deploy`
names a service after (the compose-yaml service key, not the
compose-side `container_name:` override).
* Compose-mode `_resolve_bare_name` falls back to
`services[entity_name]` when `service_key == 'application'` and
the meta has no top-level `application:` entry. 26 roles use
`application:` as their compose-yaml service key while keying
their meta/services.yml under the entity name (baserow, listmonk,
wordpress, mobilizon, etc.). Without the fallback, every consumer
of `container_address(application_id, 'application')` raised
`service 'application' missing in '<role>' services config` at
task-runtime. The fallback is intentionally narrow (only the
literal 'application' key) so an actual typo in another
service_key still raises clearly.
Unit-test contract update: test_container_address.py replaces
`test_swarm_mode_uses_resolved_service_name` with
`test_swarm_mode_uses_compose_yaml_service_key`, asserting the
resolver receives the service_key ('mattermost') rather than the
`services.<key>.name` value ('custom-mm-name'). Existing
`test_unknown_service_raises` still passes because the fallback only
fires for service_key='application'.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Switch the MDAD upstream from the kevinveenbirkenbach fork back to the canonical spantaleev/matrix-docker-ansible-deploy repo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Synapse's default rc_login (burst 3, ~0.17/s) is too strict for the SSO e2e suite, where consecutive persona sign-ins drain the burst and leave later sign-ins cycling consent <-> M_LIMIT_EXCEEDED until the test is torn down (Matrix avatar never uploaded). Set matrix_synapse_rc_login with a higher burst via the MDAD ansible flavor vars.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After the proxy SSO handoff the page URL is already /admin, so url().replace(/\$/,'')+'/admin' produced /admin/admin (HTTP 404) and the logout probe found no control. Resolve /admin against the origin with new URL() so it always lands on <origin>/admin, whose navbar carries the native logout link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The native-login block was skipped when the non-waiting isVisible check ran before the Vue form mounted, so the admin never authenticated and the company-settings logo upload never happened (bucket stayed empty). Wait for the email field + Vue hydration, fill and submit, then confirm the URL leaves /auth/login.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When no header/nav anchor exists the logout affordance is appended to document.body as a fixed-position control. Move it from top-right to bottom-left so it no longer overlaps Baserow's top-right '+ Add new' action, and drop a duplicate display:inline-flex.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
YOURLS' native admin authenticates against the role-local credentials.administrator_password, not the global user password. Export it as ADMIN_NATIVE_PASSWORD (env keys kept in ASC order) so the admin persona's native-login fallback can sign in at /admin/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ensure_admin_user.rb sets the admin password only when the user is new or the current password does not match (avoids re-validating against former-password rules on every redeploy) and clears force_password_change so native login does not hit the change-password interstitial. playwright.env.j2 blocks the biber persona in the no-SSO variant where biber has no account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ActiveStorage presigned the browser PUT against the internal AWS_ENDPOINT (http://seaweedfs:8333), which the browser cannot reach and CSP disallows, so avatar uploads never landed in the bucket (objects 161->161). Use the public S3 endpoint (objstore public_url minus the bucket path) and allow the SeaweedFS S3 host in connect-src.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gunicorn runs --preload, so workers fork before the Social Login Key is applied and serve /login from a stale provider cache that clear_cache() (a separate one-shot process) cannot refresh. apply_oidc_settings.py now reports whether the key changed; 03_oidc.yml restarts the backend only on a real change (no steady-state bounce). The e2e helper asserts the SSO button is visible and clicks it instead of falling back to the 500ing oauth2_logins callback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When SSO is disabled the OIDC step is a no-op, so drive the role's own username/password form with :visible selectors (skips hidden anti-autofill decoys such as snipe-it's password_fake) and a path-walk over /login,/admin/,/admin. The landing-page probe waits 10s; per-path probes 5s so a slow form at / is not abandoned. Fixes no-SSO admin journeys (snipe-it, yourls, openproject).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Background workers (PeerTube transcode, Pixelfed media jobs) rewrite existing objects during the poll window, so accepting any changed mtime made those consumers false-green. Default is now a new key only (the original count-growth semantics); single-slot consumers that overwrite one fixed key in place (fider tenant logo) pass expectInPlaceOverwrite to also accept a changed mtime.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial audit of a98357418 surfaced five roles whose container_image
lookup passed a service_key that does not exist in the role's
meta/services.yml, plus one role producing a double-tagged ref.
* baserow / listmonk / mobilizon: service_key 'application' is the
compose-internal service name, not the meta key. Correct to the
entity-name key ('baserow', 'listmonk', 'mobilizon').
* wordpress: same fix with custom=True preserved.
* web-svc-xmpp: 'ejabberd' is the image name, not the meta service
entry. The meta key is 'xmpp'; the image lookup returns
'ejabberd/ecs:latest' as before, but via the correct service entry.
* web-app-decidim: DECIDIM_CUSTOM_IMAGE already embeds the tag
('<image>:<version>-infinito'), so passing it as image= AND version=
produced 'image:tag:tag'. Drop the override and pass
version=DECIDIM_VERSION ~ '-infinito' so the lookup composes the ref
cleanly. Remove the now-orphan DECIDIM_CUSTOM_IMAGE var.
Empirically verified all five service_keys resolve via
get_merged_applications. Without these fixes every deploy that
includes any of these roles would have raised
'container_image: service ... missing in ...' at compose render time.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The container_image lookup now returns a registry-prefixed image
string in swarm mode unconditionally. When the resulting compose.yml
references e.g. `swarm-mgr-01:5000/registry:2` but the local docker
daemon has not yet been told to tag that name, the previous
push-only path failed with "An image does not exist locally".
Add a backfill branch: if the image is already prefix-form and
neither has a sibling `build:` block nor an existing manifest in
the registry, strip the prefix to derive the upstream ref, pull it,
retag locally with the prefixed name, then continue to the push
step.
This fixes the svc-registry-cache bootstrap step where the cache
service uses `registry:2` upstream but the swarm scheduler needs
the prefixed `<host>/registry:2` tag in the swarm registry before
it can place the service. Previously this only worked accidentally
for roles whose build pass produced the local tag.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add plugins/lookup/container_image.py: returns the full `image: "<ref>"`
line so templates drop the redundant image: prefix and surrounding
quotes. Supports `custom=True` to auto-construct `<entity_name>_custom`
for locally-built images, plus explicit `image=`/`version=` overrides
for exotic cases (taiga back-auth/front-oidc variants, decidim infinito
suffix, etc.).
* 25 unit tests for the lookup cover compose vs swarm prefix, custom
flag, overrides, tag_only=True, missing version warnings.
* New strict lint (tests/lint/.../test_no_raw_image_in_compose_templates)
fails on any raw `image:` line in roles/*/templates/*.j2 unless
nocheck-suppressed. Legacy `image: "{{ lookup(...) }}"` form no
longer accepted.
* Migrate 159 image refs across ~80 roles to the standalone
`{{ lookup('container_image', application_id, '<service_key>') }}`
form. 26 roles use `custom=True` (mariadb, postgres, openldap,
jenkins, jira, gitlab, nextcloud, mediawiki, shopware, wordpress,
odoo, joomla, openproject, bookwyrm, espocrm, hugo, littlejs,
fediwall, roulette-wheel, openresty, confluence and more).
* Fix svc-db-redis application_id leak: service.yml.j2 is included
into consuming roles where application_id evaluates to the outer
role, so the image lookup needs the hardcoded 'svc-db-redis'.
* Integration tests (test_templates_require_image,
test_build_requires_image) recognise the standalone lookup as an
implicit `image:` at the same indent.
* Mediawiki swarm tasks: add explicit `:{{ MEDIAWIKI_VERSION }}` to
the registry-prefixed image string because the custom var is now
bare.
Only 3 legitimate exotic nocheck-suppressed image lines remain:
matrix-mdad runner (nested services.matrix.runner.image path),
matrix mautrix bridges (per-iteration dynamic) and svc-registry-docker
(chicken-and-egg self-reference).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Workers in the DiD swarm cluster cannot reach upstream registries
(Docker Hub, ghcr) from the overlay network. Image pulls hang
indefinitely in "Preparing", which used to abort with a task-converge
timeout much later in the deploy. Until now the existing
`swarm_registry_push.py` only retagged + pushed services that had a
`build:` key (locally-built images); plain upstream images were left
for each worker to pull on its own, which never works in the DiD path.
This change extends the script to also cache upstream images:
* `docker pull <image>` on the manager,
* `docker tag <image> <prefix><image>`,
* `docker push <prefix><image>` to the on-cluster registry
(`svc-registry-docker`),
* rewrite the on-disk compose.yml in place so the swarm scheduler
pulls the prefixed name from the local registry over the overlay
network.
Idempotency: a `docker manifest inspect <prefix><image>` probe runs
before each push; on re-deploys with no upstream version bump the
push is a fast no-op. Hard-fails on any non-zero docker
pull/tag/push so the standard "fail loud when upstream broken" policy
holds; no `failed_when: false`.
Chicken-and-egg guard: when the registry service itself
(`svc-registry-docker`) is being deployed, the new branch would try to
push `registry:2` to its own not-yet-running registry endpoint and
loop on `connection refused`. The handler now skips the sync step
when `application_id == 'svc-registry-docker'`. The registry's own
image is pulled directly by the manager (default_placement: manager)
and never needs to be cached into itself.
Mechanics:
* `swarm_registry_push.py` renamed to `swarm_registry_sync.py` to
reflect the broadened scope (pull + tag + push, not just push).
* `roles/sys-svc-compose/handlers/swarm.yml`: updated script
reference, updated task title, added the registry-self-skip
conjunction in the `when:` guard.
Validated via act-swarm-zombie web-app-baserow: deploy progressed
from ok=145 (aborted at the chicken-and-egg push) to ok=806 once the
guard landed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sandboxed sessions leave behind devtmpfs bind-mounts under
${ACT_ACTION_CACHE_PATH}/<action>@<ver>/.claude/{hooks,settings.json,
settings.local.json,skills}/ and .mcp.json. Act tries to refresh the
cached action repo on the next run, can't `os.RemoveAll` the mounts
(EBUSY), and dies in `Set up job` with no actionable error.
Add a preflight that detects stale `devtmpfs` mounts under the active
cache path and aborts with exit 3 and a copy-pasteable
`sudo umount … && sudo rm -rf …` remediation BEFORE invoking act. Stays
silent when no stale mounts are present.
Confirmed firing in iter 2 of a sandboxed act-swarm-zombie run; silent
in every subsequent t-series run after the operator cleaned the cache.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`get_user_defaults()` builds every user's email/lastname/description with
a hardcoded `primary_domain="{{ DOMAIN_PRIMARY }}"` Jinja placeholder and
relies on `_render_with_templar` to resolve it later. In some Ansible
templating contexts (notably swarm-mode tasks delegated to STACK_HOST)
the templar can't fully resolve `{{ DOMAIN_PRIMARY }}` against the
inventory expression `{{ lookup('env', 'INFINITO_DOMAIN') }}`, so the
raw placeholder gets cached on the very first miss and reused across
every subsequent `lookup('users', ...)` call in that play. The leak
surfaced downstream as
`MATOMO_ADMIN_EMAIL=administrator@{{ DOMAIN_PRIMARY }}` in the rendered
matomo bootstrap.env, which `docker stack deploy` then refused with
`template: expansion:1: function "DOMAIN_PRIMARY" not defined`.
Two robustness fixes in `get_merged_users` so the cache never holds a
half-resolved placeholder:
1) Pull `DOMAIN_PRIMARY` / `SYSTEM_EMAIL_DOMAIN` from
`templar.available_variables` when missing from the explicit
`variables=` kwarg, so the cache key + downstream resolution see the
same domain regardless of who invokes the lookup.
2) Walk the materialized users dict and replace literal
`{{ DOMAIN_PRIMARY }}` substrings with the resolved primary-domain
value (computed via the existing `_normalize_domain_candidate`-style
logic) *before* handing the dict to `_render_with_templar`. Deterministic
in pure Python, no dependency on templar succeeding for that one
placeholder.
Validated in act-swarm-zombie t11: the exact `MATOMO_ADMIN_EMAIL=...
{{ DOMAIN_PRIMARY }}` error that broke t10 at line 13172 is gone, and
the run progressed past matomo-bootstrap into the post-deploy phase.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related fixes in the volume-rendering filters, both surfaced by
`docker stack deploy` failing on the rendered compose.yml.
1) Render Jinja in mount source/target via the lookup-supplied
render_jinja closure. `container_volumes` previously emitted the
`meta/volumes.yml` `source:` / `mounts[].target:` strings unchanged;
in swarm those flowed verbatim into compose.yml as
`{{ lookup('nginx', ...) }}:/usr/local/openresty/nginx/conf/conf.d`
and broke `docker stack deploy expanding mount target` with
`function "lookup" not defined`. `compose_volumes` had the analogous
bug for top-level `configs:` / `secrets:` source fields.
Add `_maybe_render` to `compose_volumes` and the matching
try/except-AnsibleUndefinedVariable render block to `container_volumes`
for bind source + mount target, raising `AnsibleFilterError` with the
offending expression so failures stay diagnosable.
2) Coerce config/secret `mode:` to int. The YAML/J2 idiom carries `mode`
as a string ("0440"); `docker stack deploy` rejects that as
"services.X.configs.N.mode must be a number". Add `_coerce_mode_int`
(octal-string -> int, with bool + empty + non-octal guards) and
convert before emission. Update the per-service config/secret unit
tests to expect `0o440` / `0o400` instead of the legacy strings.
Validated by the t5 -> t8 act-swarm-zombie progression (mode-int gate
cleared at t6; bind source/target + config/secret source render cleared
at t7 -> t8, lifting ok=452 to ok=798).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`get_canonical_volumes()` returns `meta/volumes.yml` `source:` / `target:`
strings via plain YAML load, so under Ansible 2.19+ they are not
`TrustedAsTemplate`-tagged. When `container_volumes` / `compose_volumes`
later asked their captured `templar.template()` to render those strings,
the trust gate short-circuited silently and the raw `{{ ... }}` flowed
verbatim into the rendered compose.yml, breaking `docker compose build`
and `docker stack deploy` with "service refers to undefined volume
{{ ... }}".
Replace the `contextlib.suppress(Exception)` wrapper in each lookup's
`render_jinja` closure with an unconditional `templar.template` call
whose input is wrapped in `_trust_as_template`, so the engine actually
compiles the template. Nested `lookup('container', ..., 'directories.x')`
calls inside canonical bind sources resolve through normal Jinja
plumbing once the outer string is trusted.
Validated by the t5 -> t8 act-swarm-zombie progression
(ok=261 -> ok=452 -> ok=798).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Without `any_errors_fatal`, a per-host failure (e.g. a stuck swarm task
convergence wait on one node) lets Ansible keep iterating through the
rest of the play before reporting the failure. For swarm test runs that
take 30+ minutes, that wastes a full second pass of irrelevant role
work after the root cause is already visible. Aborting at the first
fatal lets the operator inspect the live cluster while the failure
context is fresh and shortens the iteration loop materially.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extract the mode-specific handlers from a single main.yml with per-task
`when: DEPLOYMENT_MODE ==/\!= 'swarm'` conditions into two sibling files
(compose.yml, swarm.yml) imported via `import_tasks` with the mode guard
on the wrapper. The mode condition is set once at the import site
instead of being repeated on every handler.
main.yml keeps the shared prune handler (storage-constrained, fires for
both compose-up and compose-build listens regardless of mode).
Also: the handler-invocation lint at
tests/integration/ansible/handlers/test_invoked.py now follows
import_tasks references inside handlers/main.yml so it picks up handler
definitions in sibling files. Without this, the new layout fails the
"every notify maps to a handler" check because the scanner previously
only looked at main.yml.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds ~/.cache/act to the sandbox filesystem allowWrite list so the
act-swarm-zombie iteration loop can refresh per-action git caches
(actions/upload-artifact@v7 etc.) without hitting "Read-only file
system" when act re-clones upstream tags. Mirrors the existing
~/.cache/{gh,pip,pre-commit} entries.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>