In swarm the role gated init only through "wait for Zammad init (seeds
+ pk-sequence reset)", which checks database state. On an existing
installation that state is already there, so a swarm deploy that
restarts init while Elasticsearch refuses shards passed: the init task
kept failing on the allocation precheck and nothing reported it.
A swarm-only task now runs sys-svc-compose's job_state.sh against the
init service after the seeds wait. It passes once the newest init task
is Complete. If the newest task stays Failed, it fails the deploy
after 60 retries 5 s apart. An init the stack deploy did not restart
is already Complete and passes at once. Compose needs no extra wait,
because railsserver depends on init with
service_completed_successfully.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On a data disk with less free space than the high disk watermark asks
for, Elasticsearch creates indices but assigns none of their primary
shards. `zammad-init` then reports every search index update as failed
and never exits, so `compose up` waits on
`service_completed_successfully` until its 1800 s task timeout and the
deploy fails without naming the cause (#850). Elasticsearch's default
high watermark is an absolute 150 GB headroom on a large disk.
A new one-shot `precheck` service runs `search_allocation_precheck.sh`
with `restart: "no"`, and `init` depends on it with
`service_completed_successfully`:
- it waits a bounded time for `_cluster/health`;
- it creates a one-shard, zero-replica probe index with
`wait_for_active_shards=1`;
- once Elasticsearch allocates it, it deletes the probe and exits 0;
- otherwise it prints the creation answer and the answer of
`_cluster/allocation/explain` and exits 1.
The check sits in its own service because `init` keeps
`restart: on-failure`: wrapped into `init`, the failing check was
restarted seven times within eight minutes on a local deploy while
`compose up` kept waiting. Inside the stack, a `restart: "no"`
dependency that exits 1 made `compose up` fail after one second with
`service "pre" didn't complete successfully: exit 1`.
Every curl call carries --connect-timeout and --max-time, so a hanging
request cannot outlast the bounded wait. The image's entrypoint passes
any non-`zammad-*` command through `exec "$@"` (bin/docker-entrypoint of
7.1.2), and the image ships `curl`. The watermark values themselves come
with feature/store-i18n (b82591ba9d); this change covers inventories
without a variant cap.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
At -vvv Ansible prints each module argument and result, so a password in
a command line, a request body, a header or a fact lands in the deploy
log. Nothing enforced masking outside the MCP task files, and no check
looked at parents at all.
A new lint, test_credential_tasks_are_masked, finds tasks whose printed
parts (module arguments, environment, loop) reference an identifier ending
in password, secret, token, api_key or private_key, or
secrets.credentials. A task passes when it or an enclosing block sets
no_log, when every file that pulls it in does so through include_tasks
with apply.no_log or import_tasks with no_log (transitively), when the
secret only reaches a parameter the module masks itself, or when the
module masks its values. no_log on an include_tasks task itself does not
count, because it does not reach the included tasks. The rule can be
suppressed per task with `# nocheck: credential-masking`, now listed in
the suppression catalog, and the lint tests its parent and suppression
logic on synthetic cases.
It found 39 unmasked tasks in 19 roles, all real: join tokens, client
secrets, API tokens in Cloudflare headers, database passwords in
command lines, the ChatGPT bot login body and more. Each now sets
no_log from MASK_CREDENTIALS_IN_LOGS. The sys-dns-hetzner-rdns alias
hetzner_no_log, which only restated that switch, is gone.
Not covered yet: template and copy tasks whose rendered file carries a
secret print it as a diff under --diff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The contract check ran as an Ansible task in the middle of every provider deploy, and the two client checks ran the same way. A test that is part of the deploy cannot be run without deploying, cannot be re-run on its own, and reports its verdict as a deploy failure. Twenty-seven task files lose 491 lines to this.
It is now what it always was. Each provider ships files/test/test.sh, templates/test.env.j2 and meta/tests.yml, and test-e2e-cli stages one shared harness beside it: run.sh drives contract.sh in a throwaway container on the provider's own network, then isolation.sh for adapter-fronted providers. The read probe each provider offers moved from the calling task into meta/mcp.yml next to the allowlist it must satisfy, and the schema now rejects a read probe naming a tool outside that allowlist.
The bearer the test presents is the one a client resolves through mcp.credential, via the new mcp_client_bearer lookup over resolve_credential, which moved to utils.roles.applications.mcp so it is the same function mcp_servers renders every client connection with. The probe presented whatever the calling role passed, which for the fifteen adapter-fronted providers is a different secret than any client holds.
Sidecar isolation is read from the engine rather than measured inside the container, so it no longer needs a delegation to the node holding the task: attachment is compared by network name, which drops the gwbridge special case, and a swarm worker says it cannot measure instead of skipping silently.
Retrying is bounded by wall clock against MCP_DEADLINE_SECONDS, which scales through the same lookup('timeout') as the harness budget it has to stay under. A flag that is neither true nor false aborts rather than reading as off.
mcp-provider-probe becomes mcp-provider-cli-test: an enabled provider must ship the test rather than include the task. mcp-probe-bearer becomes mcp-adapter-bearer, tying ADAPTER_BEARER to the declared credential key now that no probe include carries it. A new integration test renders the shared env template for every provider through Ansible's own templar, so a later change to it breaks visibly instead of poisoning twenty deploys quietly.
Hermes and OpenClaw keep their client-side proof as CLI tests of their own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
834 commits from the Tor onion-service branch against 299 of ours, from merge
base 7b646bdece: 127 conflicted files, 3506 files changed.
The two sides are largely orthogonal. Theirs adds svc-net-tor and a `tor` service
across the role tree, plus two sweeping refactors: shipped scripts sorted by
language (files/ruby/, files/python/, files/shell/, scripts/lint/*.sh) and the
unit suite moved under tests/unit/python/. Ours is requirement 036, the native AI
gateway, plus the MCP role work. Most conflicts were both sides appending to the
same service map, resolved as unions.
Resolutions that were not unions:
Credentials moved namespace. Theirs renamed meta/schema.yml to meta/secrets.yml
and re-addressed credentials as secrets.credentials.<name>. Git carried our
litellm_api_key blocks through the rename, but every consumer we added still read
'credentials.<name>', which no longer resolves. All 45 call sites are migrated.
plugins/filter/container_volumes.py is deleted by theirs and relocated to
plugins/lookup/container_volumes.py, where it carries the same tmpfs defect our
f85996b43 had just fixed: a hard-coded {"size": ...} that silently drops a per
mount `mode`. The fix is re-applied in the relocated file, as that commit's merge
note anticipated.
compose.yml keeps only INFINITO_PLAYWRIGHT_REPORTS_BASE_DIR. Theirs also forwards
INFINITO_PLAYWRIGHT_STAGE_BASE_DIR; that line is deliberately dropped, per the
analysis recorded in 45804e9ad -- STAGE has a drift-proof role-vars fallback,
while forwarding it promotes a setdefault-pinnable .env value above that SPOT.
tasks/stages/01_constructor.yml takes their include, ./tasks/utils/setup/group.yml,
because their refactor removed tasks/groups/*-roles.yml, leaving our include
target nonexistent. Our task name is kept, since svc-virt is in the loop.
Makefile swarm-zombie is a union: their validate plus teardown, then our
lab_subnet cleanup, which their side dropped while leaving lab_subnet.sh as the
only orphaned reference in the file.
01_bootstrap.sh keeps our deterministic --ip pinning, which distinguishes an
already-attached node from a real failure, over their `|| true`.
Their language-sorted layout orphaned references our side still held: zammad's
two scripts/apply/*.rb, jenkins' java-with-ca-trust.sh, and the notify topics
their handlers renamed to hyphenated form. All corrected.
roles/sys-svc-compose/files/shell/swarm/stack_ready.sh gains our latched-update
block: paused, rollback_paused, rollback_started and rollback_completed exit 2
rather than polling a state that cannot resolve without another deploy.
config_from_run is unioned to (title, logged, jobs); the production caller in
trigger/__main__.py now passes jobs, without which our suite suppression would
have been inert.
READMEs are regenerated rather than hand-merged: 15 LiteLLM rows and 91 Tor rows
now derive from the merged metadata, which is itself evidence the meta merge is
coherent.
Verified: 5161 unit tests pass. The single failure, test_query.py TestSortSpec,
also fails on fork/feature/svc-net-tor at b51d2f9919, measured in a baseline
worktree -- so this merge introduces no new unit-test failure. Two modules are
excluded from that run because `baudolo`, a dependency their branch introduces,
is not installed on this host.
Not verified: no deploy has run against the merged tree, and make quality has not
been run end to end here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zammad 6.5.0 has no AI surface to configure: neither `ai_provider` nor
`ai_provider_config` exists in that line, so the role could not point Smart
Assist anywhere. 7.1.2 is the newest version whose contract is still that
setting pair; from 7.2 the provider moves into an `AI::ProviderConnection`
record and the shape changes, so the bump lands here rather than on latest.
Elasticsearch is unaffected: 7.1.2 accepts >= 7.8, < 10 and the role's 8.13.4
pin stays.
The provider is written by piping files/scripts/apply/ai_provider.rb into
`rails r -` over stdin, with url, model and token passed as environment
variables instead of interpolated into Ruby source, so no quoting path can
mangle a virtual key. Order inside the script is load-bearing and documented
there: `ai_provider` is the boolean "a provider is configured" and its
validator reads `ai_provider_config`, so the config hash has to land first or
the flag is refused. `UserInfo.current_user_id` is set because `Setting.set`
needs an acting user.
This is the strongest evidence in the requirement, because it is not a
read-back of something the deploy just wrote: `Setting.set("ai_provider_config")`
runs `AI::Provider::CustomOpenAI.ping!` upstream, so the write itself is a live
round trip. An unreachable gateway or a rejected key fails the task.
Two consequences of the bump ride along, because the version does not work
without them.
`/api/v1/getting_started` answers 403 from Zammad 7.0 on, so the Playwright
helper could no longer lift a csrf token there; it uses `/api/v1/signshow`,
the unauthenticated seed. Its old fallback read `csrf_token` from a JSON body
that endpoint never returns, so the failure surfaced as a confusing throw
rather than the 403 it was.
env.j2 drops the hand-built DATABASE_URL in favour of POSTGRESQL_OPTIONS
`?pool=`. Zammad composes its DSN from the POSTGRESQL_* parts itself, so the
explicit URL both duplicated and competed with them while leaving the
connection pool at its default.
The spec decodes ZAMMAD_LITELLM_BASE_URL and ZAMMAD_LITELLM_MODEL through
decodeDotenvQuotedValue. Without it the values arrive still wrapped in the
literal quotes dotenv_quote adds and the strict comparison can never match in
compose mode -- measured as expected `"\"http://litellm:4000/v1\""` against
received `http://litellm:4000/v1`.
Verified: the spec passes against the live stack (1 passed, via
scripts/tests/e2e/rerun-spec.sh --grep=Assist, which restages the spec from the
repo), and both the contract and the Smart Assist write ran ok in a variant-2
deploy. `make quality` green on test-lint, test-unit, test-integration,
test-external.
Not covered: the no-gateway path (ZAMMAD_LITELLM_ENABLED false) has not run
since the bump, and Zammad features this deploy does not exercise -- email
channel, LDAP sync, ticket workflows -- are unproven on 7.1.2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
meta/schema.yml became meta/secrets.yml, but its content still landed at
applications.<app>.credentials — the one meta file whose payload did not sit
under a root named after the file. Every other topic obeys that rule:
meta/volumes.yml goes to volumes, meta/rbac.yml to rbac. Credentials now go to
secrets.credentials.
The path exists exactly once, in utils/manager/credential_key.py, and
OVERRIDE_SECTION is derived from its two segments rather than spelled beside
them, so the dotted form used by --set and the nested form used by the
inventory cannot drift. Six consumers read it: the loader, the generator, the
CLI override parser, the two emit helpers, and the four lookup validators.
--set now accepts exactly one key shape,
applications.<app>.secrets.credentials.<key>. The three short forms are
rejected instead of guessed at: the same short key names different credentials
on different apps. That also retired primary_app_id, which only existed to
disambiguate them.
Three defects surfaced while wiring this up. _apply_one_role_schema still wrote
to the old root, so an app's own credentials would have moved while its
providers' stayed behind. web-app-pgadmin reads through
lookup('applications', ...).credentials.<key>, an access path no regex over
lookup('config', ...) could see, and would have deployed with an empty postgres
password. And _META_NON_GENERIC_TOPICS still listed 'schema' — a bare topic
name, invisible to any path search — so the generic file-root loop assigned the
raw schema under secrets and the follow-up write landed inside the cached
document, truncating keycloak's four credentials to one for every later
reader.
tests/integration/ansible/lookups/config/test_credentials_roundtrip.py pins both
directions of the seam and the cache invariant that last defect broke.
Existing inventories still carry the pre-rename root; migrating them is
deliberately out of scope here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten applications with no MCP server of their own get one without a line of code
inside them: qdrant, checkmk, fider, jellyfin, listmonk, pretix, prometheus,
snipe-it, zammad and libretranslate.
The sidecar is confined rather than trusted. It runs read-only, drops every
capability, forbids privilege escalation, publishes no port and joins only its
own provider's network, carrying neither that provider's alias nor the consumer
networks the provider itself joined. Its tool surface is a JSON contract, not
code: which tools exist, which upstream path each maps to and what shape the
arguments take. Adding a tool is a line of JSON, not a new image.
The image is built locally from the audited adapter source with the base image
as a build argument, so all ten share one reviewed build instead of ten upstream
dependencies. Each role provisions its own read-only identity in the language
its application speaks, and every adapter include is guarded by the role's MCP
flag, so switching it off leaves no container behind.
Setting.any? turns true after the first of ~22 seed files, while
user_nr_1 inserts user id=1 explicitly and DbHelper.import_post resets
the sequences only after all seed files. An AutoWizard run inside that
window gets nextval=1 and dies on users_pkey (swarm variant-2 failure
in run 30161503176). users_id_seq.is_called becomes true exactly at
the reset, so the gate now waits for completed init.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Setting.any? turns true after the first of ~22 seed files, while
user_nr_1 inserts user id=1 explicitly and DbHelper.import_post resets
the sequences only after all seed files. An AutoWizard run inside that
window gets nextval=1 and dies on users_pkey (swarm variant-2 failure
in run 30161503176). users_id_seq.is_called becomes true exactly at
the reset, so the gate now waits for completed init.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add tests/lint/repository/test_task_file_numbering.py: numbered task
files use exactly two leading digits, a numbered *_core.yml must be
00_core.yml, per directory the numbers are unique/consecutive and start
at 01 (00 with a core file), numbered includes appear in ascending
order, and shared helpers live in tasks/utils/ instead of the numbered
chain.
Apply it across every role: rename NN_core.yml to 00_core.yml with a
consecutive down-shift of the siblings, move letter-suffix children into
a subdirectory named after their parent task, relocate helpers included
from more than one place into tasks/utils/, and update every reference
(include_tasks, path_absolute, .ansible-lint-ignore, requirement docs).
Legacy narration comments in the touched files are deleted; the upstream
xwiki.properties reference block opts out via nocheck. The unused
MAILU_IP6_PUBLIC var (only ever named in now-removed comments) is dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move files that share a delimiter-bounded name prefix within a folder (threshold 2) into a subfolder named after the prefix, dropping the prefix from the filename. Covers plugins/filter (docker, generate, has, merge, get/all), env handler subpackages, utils/roles/entity, utils/tests/swarm/write, role SQL/scripts/templates, keycloak/mailu task-includes, shared tasks/utils/load, and CI scripts. Update every import, query_file/script/include_tasks/src/lookup target, compose bind mount, and python -m path to the new locations.
Extend the prefix-grouped-files lint to threshold 2 with dir-level .nocheck and file-head nocheck opt-outs, and skip groups whose shortest member's stem equals the shared prefix. Keep flat only where a loader forbids nesting: WordPress mu-plugins and the docs/requirements namespace. Ansible filter discovery is recursive, so nested filters stay Jinja-resolvable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve every 3+-file same-prefix group flagged by the new prefix lint
by moving the files into a subfolder named after the shared prefix and
dropping the prefix from the file names, updating all references:
- utils/env/handlers/infinito_*.py -> infinito/ (dir/, package_cache/
nested a second level); registry imports and the four handler-dir
globs switched to iter_project_files
- utils/install/lint/ansible_*.py -> ansible/ (aliased imports)
- roles/sys-svc-compose/files/swarm_*.{py,sh} -> files/swarm/
- web-app-erpnext/web-app-zammad files/scripts/apply_*.{py,rb} -> apply/
- web-app-odoo files/sql/{select,update}-* and sql/oidc/{grant,insert,
select,update}-* -> matching subfolders
- web-app-keycloak tasks/update/05_ldap*.yml -> 05_ldap/ (internal
includes made role_path-absolute)
- plugins/filter/get_*.py -> plugins/filter/get/ (filter names are the
filters() dict keys, so Jinja usage is unaffected; ansible discovers
the one-level-deep subdir)
Ansible lookup dirs, addon dirs and .github/workflows keep their flat
layout (the filename is the public id / GitHub only discovers flat).
Unit tests mirror the new source layout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
container exec ... < {{ role_path }}/files/X reads the file on the
executing host. Under swarm the manager-ops execs are delegated to the
node hosting the container, where the controller checkout does not
exist, so the redirect failed with 'No such file or directory'.
Read the script on the controller via lookup('file') and pass it as
module stdin instead, matching the existing set_siteurl.php pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A per-item boolean guard carried as a data field named 'when' must hold {{ EXPR }} so the consumer can template it to a real bool; but ansible-core 2.21+ then flags 'conditionals surrounded by templating delimiters'. Rename the data field to 'condition' (a data field may legitimately hold {{ }}) so no genuine conditional carries {{ }}. Also wraps the four previously-bare listmonk entries, which were silently always-False, so those settings now apply when enabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce plugins/lookup/path_absolute.py as the single source for the
repo-root anchor: lookup('path_absolute', 'roles/...') returns an absolute
path under playbook_dir, joining every term. Replace all hand-built
playbook_dir path constructions across .yml/.yaml and .j2 templates with it:
[playbook_dir, '<rel>'] | path_join -> lookup('path_absolute', '<rel>')
playbook_dir ~ '/<rel>' -> lookup('path_absolute', '<rel>')
{{ playbook_dir }}/<rel> -> lookup('path_absolute', '<rel>')
Unrelated repo_path symbols in cli/meta/roles/applications/resolution and the
single bare discover_playwright_roles(playbook_dir) call are left untouched.
Add tests/lint/ansible/test_playbook_dir_via_lookup.py: fails the build if any
of the three construction forms reappears in .yml/.yaml/.j2, pointing callers
at the lookup. Unit test covers the plugin.
Touching these files pulled their pre-existing narration comments into
test_comments_valid scope; clean them (delete restatement/banners, keep real
trip-wires as Workaround/Upstream/Invariant markers). Drop caution, important
and pitfall from the comment-lint marker vocabulary and remove the comments
that used them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add tests/lint/repository/test_name_emoji_and_length.py: every Ansible task
name and GitHub Actions workflow/job/step name must start with an emoji (first
char not 0-9/A-Za-z) and be <= 80 chars.
- emit emoji names at the source: cli/build/include generator now writes
"🔧 setup <app>" / "🚿 flush handlers after <app>" so generated
tasks/groups/*-roles.yml satisfy the convention too
- convert existing task/step/job/workflow names to emoji-prefixed, choosing a
task-fitting icon per action (📦 install, ✅ ensure, 🔍 probe, 🥾 bootstrap,
🚀 deploy, 🧩 service-module load, ⬇️ checkout, 📂 include-routines, ...)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a mode-aware `container_address` lookup plugin and a
`resolve-container-id` helper script that resolve swarm task IDs at
exec time (compose mode returns bare names unchanged). Migrates ~25
roles + ~28 task files from `command:` to `shell:` so the lookup's
`$(...)` subshell evaluates correctly, and extends `database` lookup
with an `address` key for cross-role DB exec.
Three new lint tests enforce the contract: every container exec
target goes through the resolver, lives behind an IS_STACK_HOST gate
(walks the include graph one hop deep), and uses `shell:` rather than
`command:` when the target is a Jinja reference. Existing nocheck
suppressions on bare-name DB exec sites (nextcloud redis, listmonk,
moodle OIDC, mailu) are replaced with the lookup-based form.
Fixes the systemd Environment= injection in sys-ctl-cln-disc-space
(it never expanded $(...)) by passing STACK / SERVICE_KEY parts and
resolving inside the script. Extracts shopware admin and mediawiki
extension install bodies into dedicated .sh files invoked via env.
Rewrites password-bearing shell tasks to either `| quote` directly or
the `-e VAR=... | quote` + `sh -c '... ${VAR} ...'` env-bridge pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Across roles/web-app-*, the typical body shape is:
- include_role: sys-stk-full (or sys-svc-compose) cluster prereqs
- <host-fs writes, container exec, compose exec> stack-host only
Under swarm-mode delegation the second group only makes sense on the
host that holds the compose stack (manager), but the role plays on
every host in groups[<role>] so workers abort on missing paths.
Two workflow sweeps (gate-manager-ops + gate-manager-ops-01core, 58
agents total, discover/plan/adversarial-verify per role) plus three
manual fixes surfaced 27 roles to refactor. Each role keeps
sys-stk-full ungated so worker-side prereqs (sys-svc-container, NFS
client) still bootstrap; the trailing manager-only block either moves
into a new tasks/02_manager_ops.yml included with
when: IS_STACK_HOST | bool (SUB_FILE pattern) or gets the gate
inlined on the one or two trailing tasks (INLINE pattern). In
compose mode IS_STACK_HOST stays true so single-host deploys are
unchanged.
Manual catches the workflow agents missed:
- web-app-fediwall: utils/once/flag.yml lives AT THE END of 01_core,
not at the start; preserved verbatim outside the gate so workers
still flip the run_once_<role> fact.
- web-app-xwiki: a pre-existing block(...) wraps sys-stk-full plus
_flush_config.yml; gated only the _flush_config.yml include so
sys-stk-full keeps running on workers.
- web-app-dashboard: 02_javascript.yml (creates /opt/compose/dashboard/
JS dirs) ran BEFORE sys-stk-full and was ungated; added the gate.
Roles touched (INLINE): akaunting, jenkins, magento, mobilizon
Roles touched (SUB_FILE): baserow, dashboard, decidim, erpnext,
fediwall, friendica, gitea, jitsi, keycloak, listmonk, littlejs,
mailu, matomo, moodle, odoo, openproject, opentalk, pixelfed,
shopware, suitecrm, taiga, xwiki, zammad
- meta/volumes.yml: declare zammad_storage + zammad_search named volumes
- vars/main.yml: ZAMMAD_STORAGE_VOLUME / ZAMMAD_SEARCH_VOLUME constants reading from meta
- compose.yml.j2: extra_volumes uses the constants instead of empty dicts
- tasks/01_core.yml: 4 host-rendered init files (auto_wizard.json, zz_omniauth_allow_get.rb, telegram_helper_stub.rb, zz_oidc_group_role_mapping.rb) migrate from inline template: tasks to the central render_replicated_templates.yml helper so the bind-mounted host paths resolve on every swarm peer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure whitespace reflow applied by `make autoformat` — column-aligned
mapping values were collapsed to single-space alignment so the files
match the project-wide YAML formatter output. No behavior change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Drop test-mail-to-ticket Playwright spec + its playwright.env keys; mail/SMTP
integration is mostly testing Mailu, not Zammad, and required either a /28
subnet resize or an email-lookup-plugin shared-mode branch to actually pass.
- Set auth_third_party_auto_link_at_inital_login=true in apply_oidc_settings.rb
so the first OIDC sign-in of an LDAP-synced user links to the existing record
by email instead of returning 422. Drops the conditional LDAP user_filter
workaround and the LDAP_OIDC_COEXISTS env wiring that propped it up.
- Replace the RUBYOPT-loaded Telegram constant shim with a bind-mounted
TelegramHelper stub that cuts the require 'telegram/bot' chain (the gem's
Zeitwerk bypasses Kernel#require intercepts under bootsnap).
- Strip verbose docblocks across vars/tasks/templates/scripts/playwright; keep
only WHY-non-obvious notes (upstream gem mismatches, security defaults, the
intentional inital-typo).
- Fix broken Docker Compose CLI docs URL in sys-svc-compose/README.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User identity SPOT
- meta/users.yml gains zammad-api-bot (Basic-auth REST API bot) and
zammad-wizard-bypass-admin (auto_wizard.json setup-bypass admin).
- vars/main.yml reads identity (login/email/firstname/lastname) via
lookup('users', …); password stays in credentials/ to guarantee the
shell-safe alphanumeric form needed for the Ansible→bash→docker-exec
→Ruby ENV chain.
- auto_wizard.json.j2, apply_api_bot.rb, tasks/01_core.yml: consume the
lookup-resolved identity.
Boot reliability
- templates/zz_telegram_constant_shim.rb.j2: pre-loaded via RUBYOPT;
pre-declares the Telegram::Bot namespace and intercepts Kernel#require
to refuse telegram/* loads, sidestepping the broken telegram-bot-ruby
2.4.0 gem that crashes Zammad 6.5.0 boot. Telegram channel is inert.
- env.j2 sets RUBYOPT=-r/opt/zammad/zz_telegram_constant_shim.rb.
- tasks/01_core.yml stages the shim; compose.yml.j2 bind-mounts it into
zammad-init/rails/websocket/scheduler/nginx.
- tasks/01_core.yml adds an explicit AutoWizard.setup post-init step
(upstream zammad-init does NOT auto-process auto_wizard.json), which
is what V2 (no-auth) and V3 (LDAP-only) need to skip the setup wizard.
LDAP source wire-up (TODO #1)
- files/scripts/apply_ldap_source.rb creates an LdapSource record
pointing at svc-db-openldap (inetOrgPerson + groupOfNames mapping).
- tasks/01_core.yml runs it when services.ldap.enabled is true.
OIDC group→role mapping (TODO #3)
- templates/zz_oidc_group_role_mapping.rb.j2 patches Zammad's
User.create_from_hash / update_from_hash to translate the RBAC
group-path claim (e.g. /roles/web-app-zammad/administrator) into
the matching Zammad role (Admin / Agent / Customer fallback).
- apply_oidc_settings.rb pins uid_field=preferred_username so OIDC
matches the human-readable Keycloak username, NOT the sub UUID.
- compose.yml.j2 bind-mounts the initializer when OIDC is enabled.
Test coverage
- new test-login-via-ldap.js (driven only in LDAP-only V3).
- test-alias-domain.js accepts 2xx-direct OR 301-to-canonical
(sys-svc-proxy renders aliases as 301 sources, not server-name
aliases — server.yml updated to canonical=helpdesk + alias=
zammad.helpdesk).
- test-mail-to-ticket.js: stage-level SMTP trace logging; reaches
Mailu via host.docker.internal:465.
- test-ticket-reply-as-agent.js: scoped contenteditable lookup to
.article-add to avoid hitting the ticket-title field.
- _shared.js: signInAsApiBot drives /api/v1/signin via the server-side
APIRequestContext (shared cookie jar with the browser), unblocking
the SPA-OIDC interlock.
SQL outsourcing
- templates/sql/grant_createdb.sql.j2: the CREATEDB grant now lives in
a Jinja-templated .sql file; tasks/01_core.yml consumes it via
lookup('template', …). Role name is rendered into the SQL by Jinja
(NOT via database_query named_args), because psql parameter binding
quotes string values as literals (`'zammad'`) which is invalid SQL
for ALTER ROLE.
Misc
- meta/server.yml: canonical=helpdesk.<domain>, aliases=[zammad.helpdesk.<domain>]
to match the 022 requirement (Decision #1).
- TODO.md drained — all seven items either landed or are explicitly
documented as out-of-scope / brittle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename credential basic_auth_password → api_bot_password and var
ZAMMAD_ADMINISTRATOR_PASSWORD → ZAMMAD_API_BOT_PASSWORD to reflect
that the value is the role-internal bot's local password, not the
administrator's (reused for the wizard-bypass admin only because both
need the same shell-safe alphanumeric form).
- Introduce ORGANIZATION constant (defaults to SOFTWARE_NAME) and migrate
organization-string consumers (mattermost team, shopware shop title,
dashboard login/logout descriptions, simpleicons "provided by",
user-administrator description/lastname).
- Add HOST_LL_CC_TAG (BCP-47 form, e.g. en-gb) and use in auto_wizard.
- Add NETWORK_TRUSTED_PROXY_CIDRS SPOT and consume from suitecrm,
shopware and zammad reverse-proxy configs.
- Extract ZAMMAD_WEB_PROTOCOL via lookup('tls', …, 'protocols.web')
instead of inline 'https' if … else 'http'.
- Use database_query for CREATEDB grant so it works on shared+non-shared
databases; back the SQL with files/sql/grant_createdb.sql SPOT.
- Read administrator firstname/lastname from users lookup in
auto_wizard.json.j2 instead of hardcoding.
- Drop redundant elasticsearch: block from web-app-zammad services.yml
(no consumer reads it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 7 new Playwright scenarios covering helpdesk-core workflows and
infrastructure regressions:
- test-csp-headers — CSP response + meta-tag parity
- test-alias-domain — server-name alias serves 2xx, not 30x
- test-rest-api-ticket-create — POST /api/v1/tickets via Basic auth
- test-ticket-create-as-customer — biber SPA → fill form → submit
- test-ticket-reply-as-agent — API-seeded ticket; admin posts a reply
- test-websocket-realtime — open ticket; API appends article;
expect realtime push into the open SPA
- test-mail-to-ticket — raw-socket SMTP to Mailu helpdesk
mailbox; poll Zammad until ticket
appears
Auto-configures Zammad's Email channel (inbound IMAP + outbound SMTP)
against Mailu via a new post-bootstrap rails-runner step. The Ruby for
both bootstrap steps lives in standalone files and is piped through
`container exec -i ... bundle exec rails r -`; the previous wrapper
shell scripts (`apply_*.sh`) are removed in favour of inline `shell:`
calls in tasks/01_core.yml. Env vars are forwarded into the container
via `container exec -e VAR ...` so the Ruby uses `ENV.fetch` and stays
value-free / portable.
vars/main.yml grows three constants:
- ZAMMAD_OMNIAUTH_INIT_CONTAINER_PATH — bind-mount target inside the
container (was hard-coded four times in compose.yml.j2)
- ZAMMAD_HELPDESK_USER / _EMAIL / _PASS — derived from the `helpdesk`
mail-bot user provisioned via meta/users.yml
- ZAMMAD_MAIL / ZAMMAD_EMAIL_ENABLED — derived from the central email
lookup + the services.email gate
playwright.env.j2 grows MAIL_SMTP_HOST/PORT/USER/PASS + HELPDESK_EMAIL
for the SMTP injection in the mail-to-ticket scenario, sorted ASC per
the env-key lint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Playwright spec split into per-test partials, mirroring the web-app-kix
layout (lint-conform: top-level `playwright.spec.js` requires the
partials and shared module):
- `files/playwright/_shared.js` — env vars, `signInViaZammadOidc`,
`zammadLogout`, `beforeEach`
- `files/playwright/test-landing.js`
- `files/playwright/test-login-administrator.js`
- `files/playwright/test-login-biber.js`
- `files/playwright/test-guest-persona.js`
Variable dedup in `vars/main.yml`:
- `ZAMMAD_VOLUMES_DIR` — single base path
- `ZAMMAD_AUTO_WIZARD_HOST_PATH` — derived
- `ZAMMAD_OMNIAUTH_INIT_HOST_PATH` — derived (was repeated 4x in compose)
- `ZAMMAD_OIDC_ENABLED` — single source for the OIDC gate
All consumers in `tasks/01_core.yml`, `templates/compose.yml.j2`,
`templates/auto_wizard.json.j2`, `templates/playwright.env.j2` now read
the constants instead of inlining the `lookup(...)` expressions.
Drive-by: `meta/main.yml` description narrowed to describe Zammad itself
(role-specific text moved out of the upstream-facing galaxy_info field).
`make autoformat` clean, `make test` green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds roles/web-app-zammad/ — a brand-new web-app role deploying Zammad as
the Infinito.Nexus helpdesk with direct OpenID Connect SSO against the
shared Keycloak client. Tracks requirement 022.
Stack: ghcr.io/zammad/zammad 6.5.0 (init / railsserver / websocket /
scheduler / nginx, all driven by upstream command modes), bundled
elasticsearch 8.13.4 + bundled memcached (no central svc role exists),
central svc-db-postgres reused via lookup('database', application_id).
Variant matrix mirrors web-app-kix: V1 oidc+ldap, V2 all-false, V3 ldap-only.
Bootstrap flow (tasks/01_core.yml):
- Stage auto_wizard.json into the role's volumes dir AFTER sys-stk-full
creates the directory; bind-mount it into zammad-init so the upstream
rake zammad:setup:auto_wizard bypasses the setup UI on first boot.
- Stage zz_omniauth_allow_get.rb initializer + bind-mount into rails /
websocket / scheduler / nginx, since the Zammad SPA's SSO link is a
plain GET and omniauth 2.x forbids GET by default.
- ALTER ROLE <zammad-db-user> WITH CREATEDB so Zammad's unconditional
`rake db:create` doesn't trip on insufficient privilege when the
database is pre-created by svc-db-postgres.
- Apply post-bootstrap settings via apply_oidc_settings.sh
(rails-runner inside zammad-railsserver): fqdn, http_type so Zammad
emits the correct redirect_uri to Keycloak; auth_openid_connect +
auth_openid_connect_credentials so the SSO button works.
Workarounds embedded in compose.yml.j2:
- zammad-nginx command override that copies /docker-entrypoint.sh to
/tmp and patches `grep "nameserver"` to `grep -E "^nameserver"`,
because Docker writes comment lines like `# Overrides: …` into
/etc/resolv.conf that the upstream grep matches as substrings,
yielding an invalid `resolver … Overrides: …` directive nginx rejects.
- DATABASE_URL pre-populated in env.j2 so post-bootstrap
`container exec rails r` calls (which bypass the entrypoint) can
load Rails without database.yml.
Playwright (files/playwright/playwright.spec.js, single-file lint-conform
layout): landing-reachable + administrator OIDC + biber OIDC + guest
public-landing scenarios. Personas skip-correctly via
skipUnlessServiceEnabled('oidc') under V2 / V3.
V1 end-to-end green: admin + biber Playwright pass through the full
Keycloak SSO round-trip. V2 (all-false) and V3 (ldap-only) green on
baseline reinstall with full_cycle=true.
Follow-ups indexed in roles/web-app-zammad/TODO.md: Zammad LdapSource
auto-create for V3, mail channel auto-config + end-to-end mail-to-ticket
test, Keycloak-group-to-Zammad-role mapping (Zammad doesn't ship a
built-in claim-to-role translator — currently all OIDC logins default to
Customer per Decision #5 fallback), Playwright assertion for the
`zammad.helpdesk.*` server-name alias.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>