CLAUDE.md and GEMINI.md existed as per-runtime extensions of AGENTS.md, and only one of them carried anything at all: GEMINI.md stated that it had no additions, while CLAUDE.md held rules that bind every runtime anyway — the permission announcement, the make-over-raw preference, the loop, chain and heredoc bans, the stream-the-log-to-/tmp rule, the CI-evidence rule, the push ban and the comment rule. A rule that binds every agent does not belong in one agent's file. AGENTS.md absorbs them and now states that no tool-specific extension file exists; agents whose runtime auto-loads a different filename reach it through their own configuration instead. The filenames were machinery as much as prose. The PR templates, the branch and pull-request scope tables, the settings and security docs that justified the loop deny by "the textual CLAUDE.md rule", six requirement documents whose push instruction linked ../../CLAUDE.md, and a test docstring citing the shell-loop ban all named them, and now name AGENTS.md. scripts/meta/resolve/pr/scope.sh deliberately keeps matching CLAUDE.md and GEMINI.md: this change deletes them, and without those patterns the change itself would not classify as agent-scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
21 KiB
025 - Matrix Role Ansible Flavor (matrix-docker-ansible-deploy)
User Story
As a platform administrator of Infinito.Nexus, I want the web-app-matrix role to gain a second deployment flavor that delegates the heavy lifting to the upstream matrix-docker-ansible-deploy (MDAD) playbook, so that the full Matrix ecosystem — Synapse, Element, every supported bridge, Jitsi-backed video conferencing, ma1sd / Dimension / hookshot / etcetera — is available without the role re-implementing each service from scratch. The existing role-internal compose stack stays selectable as the compose flavor for one cycle, marked deprecated.
Background
The current roles/web-app-matrix/ ships a self-contained compose stack with Synapse, Element, a handful of bridges and a ChatGPT bot. Adding a new bridge or service today means hand-rolling its compose service, env file, postgres seed, well-known entries and Playwright coverage inside this repo — the marginal cost per bridge is high, and the matrix-docker-ansible-deploy (MDAD) upstream community already maintains all of that under one umbrella role.
Upstream MDAD is a fully-fledged Ansible playbook (setup.yml plus a top-level role matrix-base that pulls in roughly seventy sub-roles). It is NOT a drop-in Ansible role: it expects to be the root playbook with its own inventory hosts (matrix.{{ DOMAIN }} style) and its own host_vars/<host>/vars.yml.
To integrate it into Infinito.Nexus, the consumer-facing role (web-app-matrix) MUST act as an adapter: clone MDAD at a pinned ref into the role artefact directory, generate an MDAD-shaped inventory + vars file from lookup('config', application_id, ...), and invoke MDAD via ansible-playbook as a child process (or via Ansible's import_playbook). Credentials, domains, Keycloak OIDC client, central postgres / redis endpoints and the existing reverse-proxy front (sys-svc-proxy) all flow from Infinito.Nexus into MDAD via that adapter.
Confirmed Decisions
Decisions 2, 4, 7 and 11 were operator-confirmed before implementation started.
| # | Decision | Rationale |
|---|---|---|
| 1 | Add a services.matrix.flavor field with two values: ansible (new default) and compose (deprecated). Selection of the flavor swaps the entire deploy path: tasks/main.yml dispatches to tasks/flavor/ansible/main.yml or tasks/flavor/compose/main.yml. |
Mirrors the SSO flavor pattern landed in requirement 021. Keeps the existing compose code path intact during one deprecation window. |
| 2 | MDAD is pulled in as a git checkout at deploy time, not vendored. Source: https://github.com/spantaleev/matrix-docker-ansible-deploy. Upstream ships from master (no release tags exist), so the pin is the concrete commit SHA 9634cc3f7956e312eaf04647d85cbab46173f2e9 (master HEAD at PR-cut time) stored in meta/services.yml::matrix.upstream.ref. Clone destination: {{ lookup('container', application_id, 'directories.instance') }}mdad/. Bump path: bump the SHA, redeploy. Operator-confirmed. |
Keeps the upstream playbook auditable (every consumer sees the exact SHA); avoids carrying the ~10MB upstream tree in this repo. |
| 3 | The ansible-flavor tasks render an MDAD inventory under {{ instance_dir }}mdad/inventory/host_vars/matrix.{{ DOMAIN_PRIMARY }}/vars.yml from a Jinja template (templates/flavor/ansible/mdad-vars.yml.j2). The infinito-nexus role then invokes MDAD via ansible.builtin.command running ansible-playbook -i inventory/hosts setup.yml --tags={{ playbook_tags }} against that checkout. |
One canonical place to map every Infinito.Nexus knob to its MDAD counterpart; keeps MDAD's own inventory hierarchy intact. |
| 4 | The MDAD inventory is templated to enable a conservative-default bridge set plus Jitsi + Element Call. Bridge enablement is gated on lookup('config', application_id, 'services.matrix.plugins.<bridge>'). v1 default-ON set (operator-confirmed): mautrix_signal, mautrix_telegram, mautrix_twitter, mautrix_slack, appservice_irc, heisenbridge, discord, gitter, hookshot. Default-OFF (operator opts in): every bridge that needs a third-party API token the operator must obtain manually — facebook, instagram, googlechat, mautrix_whatsapp, sms, appservice_kakaotalk, and the superseded mx_puppet_{slack,discord}. |
Operator phrasing: "alle möglichen bridges und services sollen enabled, aktiviert und wenn möglich integriert sein incl. video conference"; conservative default keeps first-deploy clean. Bridges that need an operator-issued token are documented as opt-in in the role README. |
| 5 | Keycloak OIDC: MDAD's matrix_synapse_oidc_enabled: true plus matrix_synapse_oidc_providers are templated from the same OIDC.CLIENT.* group_vars that the rest of the repo already consumes (no per-app Keycloak entry needed — the existing redirect_uris filter auto-allow-lists every consumer). |
Matches the convention used by web-app-erpnext / web-app-zammad / web-app-odoo. |
| 6 | Central-service reuse: MDAD's matrix_postgres_* keys point at svc-db-postgres (host alias + central root password); MDAD's matrix_redis_* keys point at the in-compose redis alias for svc-db-redis. MDAD's own Synapse-postgres + bridge-postgres seeding is allowed to run against the central DB (it creates per-service databases inside the central instance). |
Same pattern as the other shared-DB consumers. |
| 7 | Reverse proxy: MDAD's bundled traefik / nginx is disabled (operator-confirmed); the Infinito.Nexus sys-svc-proxy front already terminates TLS for {{ DOMAIN_PRIMARY }} and proxies to per-service local ports. The ansible-flavor task block binds MDAD services to localhost ports in the standard Infinito.Nexus port band; sys-svc-proxy vhost templates pick them up via meta/services.yml. Federation port 8448 still public via sys-svc-proxy. |
Avoids two proxies fighting over port 443; preserves the repo's "one TLS terminator" invariant. |
| 8 | Compose flavor is marked deprecated in meta/info.yml::deprecated = "Use flavor=ansible; compose flavor will be removed after two minor releases.". Existing variants that pinned flavor=compose keep working for the deprecation window. |
Operator phrasing: "deprecated flavor soll der alte compose bleiben". |
| 9 | meta/variants.yml adds new variants that exercise the ansible flavor (V1 ansible + sso + ldap + email + all-bridges; V2 ansible + no auth; V3 ansible + ldap-only). Existing compose-flavor variants are renamed Vcompose-* and stay for regression coverage. |
Operator phrasing: "die variants sollen die neue integration testen". |
| 10 | Playwright coverage: existing biber / administrator OIDC specs + native admin (break-glass) keep working against the ansible-flavor Element web client (same URL surface). Two new specs are added: test-element-call.js (verifies the Element-Call / Jitsi conference widget renders) and test-bridge-roster.js (verifies enabled bridges show up under Administrators → Server Notices). |
Matches Decision 19 (playwright meta-services parity) and gives operator-visible signal on the bridge wiring. |
| 11 | Lifecycle stage on the new flavor: lifecycle: beta (operator-confirmed override of the original alpha proposal). The existing compose flavor stays at beta for the deprecation window. |
Operator's signal that the flavor is production-intended from v1; promotion to stable is a separate, later milestone. |
| 12 | Out of scope for v1 of the ansible flavor: matrix-appservice-irc bouncer farms, federation tester self-hosting, the federation-discovery delegation chain via .well-known/matrix/server when running under a custom port (the current well-known template already handles the standard case). Any of these can land in a follow-up requirement. |
Bounds scope; bigger surface to ship cleanly first. |
| 13 | MDAD's ansible-playbook setup.yml invocation is encapsulated in a dedicated sub-container (DinD #2 relative to the Infinito deploy container which is DinD #1). The runner image is built locally from roles/web-app-matrix/files/flavor/ansible/runner/Dockerfile and pinned via services.matrix.runner.image. The runner bind-mounts /var/run/docker.sock, /etc/systemd/system, /run/systemd, /usr/local/bin, /matrix, and the MDAD checkout so MDAD's writes land in the deploy container's namespace (same Docker daemon and systemd as the rest of the stack). |
Operator-requested: isolate MDAD's Ansible runtime from Infinito's so MDAD can pin its own ansible-core / collection versions without crossing into the Infinito Ansible venv. |
Target Schema
Role layout (additions; the existing tasks/, templates/, vars/, meta/ stay)
roles/web-app-matrix/
├── tasks/
│ ├── main.yml # dispatches by services.matrix.flavor
│ ├── flavor/compose/ # existing 01_docker.yml, 02_..., 04_webserver.yml moved here
│ │ ├── main.yml
│ │ ├── 01_docker.yml
│ │ ├── 02_create-and-seed-database.yml
│ │ └── 04_webserver.yml
│ └── flavor/ansible/ # NEW
│ ├── main.yml # clone MDAD + render vars + invoke setup.yml
│ ├── 01_clone_upstream.yml
│ ├── 02_render_inventory.yml
│ ├── 03_run_playbook.yml
│ └── 04_proxy_wiring.yml
├── templates/
│ ├── flavor/compose/ # existing compose.yml.j2, synapse.conf.j2, etc moved here
│ └── flavor/ansible/ # NEW
│ ├── mdad-hosts.j2
│ ├── mdad-vars.yml.j2
│ └── mdad-bridges.yml.j2
└── vars/
└── main.yml # existing + new ANSIBLE_FLAVOR_* paths
meta/services.yml delta
matrix:
flavor: ansible # NEW: default. operator can pin "compose" per-variant.
upstream:
ref: 9634cc3f7956e312eaf04647d85cbab46173f2e9 # NEW: MDAD git ref pin (master HEAD at PR-cut time; upstream ships untagged from master)
repo: https://github.com/spantaleev/matrix-docker-ansible-deploy
playbook_tags: setup-all,start # existing — passed verbatim to MDAD
server_name: "{{ DOMAIN_PRIMARY }}"
plugins: # existing — bridges. Expanded for v1 ansible flavor:
appservice_irc: true
appservice_kakaotalk: false
discord: true
facebook: false # needs operator-issued FB app token
gitter: true
googlechat: false # needs google workspace API token
heisenbridge: true # IRC bouncer
hookshot: true # GitHub / GitLab / Jira webhooks
instagram: false # needs FB app token
irc: true # appservice-irc
linkedin: false # no upstream bridge
mautrix_signal: true
mautrix_telegram: true
mautrix_twitter: true
mautrix_whatsapp: false # needs operator-issued WA-business token
mautrix_youtube: false
mx_puppet_discord: false # superseded by mautrix-discord
mx_puppet_slack: false # superseded by mautrix-slack
mautrix_slack: true
sms: false # needs operator carrier setup
conferencing:
element_call: true # NEW: Element Call (LiveKit-based)
jitsi: true # NEW: Jitsi widget integration into Element
lifecycle: beta # operator-confirmed; was alpha in the first-pass proposal
meta/variants.yml (matrix dimension × ansible-vs-compose dimension)
---
# V1 (ansible): SSO + LDAP + all enabled bridges + Jitsi + Element Call
- services:
matrix:
flavor: ansible
sso: { enabled: true, shared: true }
ldap: { enabled: true, shared: true }
email: { enabled: true, shared: true }
# … other dynamic flags true …
# V2 (ansible): no auth, all bridges off (smoke test of the playbook-only path)
- services:
matrix:
flavor: ansible
plugins: # explicit all-off override
# every plugin: false
sso: { enabled: false, shared: false }
ldap: { enabled: false, shared: false }
email: { enabled: false, shared: false }
# V3 (ansible): LDAP only, no SSO, conference disabled
- services:
matrix:
flavor: ansible
conferencing: { element_call: false, jitsi: false }
sso: { enabled: false, shared: false }
ldap: { enabled: true, shared: true }
email: { enabled: false, shared: false }
# Vcompose-1: the existing compose flavor as regression coverage (existing V1 renamed)
- services:
matrix:
flavor: compose
sso: { enabled: true, shared: true }
ldap: { enabled: true, shared: true }
email: { enabled: true, shared: true }
Acceptance Criteria
Flavor dispatch
meta/services.yml::matrix.flavoraccepts the two valuesansible(default) andcompose. Any other value fails role-meta lint.tasks/main.ymlincludes exactly one oftasks/flavor/ansible/main.ymlortasks/flavor/compose/main.ymlbased onservices.matrix.flavor(no double execution).- Existing compose-flavor tasks land under
tasks/flavor/compose/without behavior change; an existing compose-flavor deploy reuses its existing volumes (make compose-deploy mode=update apps=web-app-matrixon the old inventory still works).
MDAD upstream pulled in cleanly
tasks/flavor/ansible/01_clone_upstream.ymlclones the pinnedservices.matrix.upstream.refofservices.matrix.upstream.repointo{{ instance_dir }}mdad/; rerun is idempotent (fetch + checkout, no destructive reset).- The clone respects the role-wide CA-trust env so HTTPS clone works behind the local CA terminator.
MDAD inventory rendered
tasks/flavor/ansible/02_render_inventory.ymltemplates{{ instance_dir }}mdad/inventory/hosts(single host:matrix.{{ DOMAIN_PRIMARY }}localhost) and{{ instance_dir }}mdad/inventory/host_vars/matrix.{{ DOMAIN_PRIMARY }}/vars.ymlfromtemplates/flavor/ansible/mdad-vars.yml.j2.- Every bridge that is
trueunderservices.matrix.plugins.<bridge>shows up enabled in the renderedvars.ymlwith the matching MDAD enable-flag (matrix_mautrix_telegram_enabled: trueetc.), and every bridge that isfalseshows up disabled. Verified by reading the rendered file. services.matrix.conferencing.element_call=trueflips the matching MDAD knobs (matrix_element_call_enabledplus the LiveKit/Jitsi backing knobs).
MDAD playbook invocation
tasks/flavor/ansible/03_run_playbook.ymlinvokesansible-playbook -i inventory/hosts setup.yml --tags={{ services.matrix.playbook_tags }}from{{ instance_dir }}mdad/and exits cleanly on all three ansible-flavor variants (V1 / V2 / V3) in a fresh-host matrix deploy.- A long-running tag (e.g.
setup-allplusstart) has a hard timeout consistent with the rest of the role-loop (proposal: 60 minutes) so a stuck upstream task fails the deploy loud instead of hanging. - MDAD's
ansible-playbookis invoked inside a dedicated runner sub-container (image:services.matrix.runner.image, built locally fromfiles/flavor/ansible/runner/Dockerfile) so its Ansible runtime is isolated from Infinito-Nexus's. The runner mounts the Docker socket, systemd dirs,/matrix,/usr/local/bin, and the MDAD checkout from the deploy container. Amount --make-rshared /runs once in the deploy container before the runner starts because MDAD's systemd units bind-mount withbind-propagation=slave.
Central-service reuse
- When
svc-db-postgresis ingroup_names, the rendered MDAD inventory points Synapse + all enabled bridges at the central postgres host alias with the central root credential; no in-stack postgres container is spawned by MDAD. - When
svc-db-redisis ingroup_names, the rendered inventory points Synapse + bridges at the central redis alias; no in-stack redis container is spawned by MDAD. - When
web-app-mailuis ingroup_names, the rendered inventory configures MDAD's outbound SMTP against the central Mailu endpoint.
Keycloak OIDC
- When
web-app-keycloakis ingroup_names, theredirect_urisfilter auto-includeshttps://{{ DOMAIN_PRIMARY }}/_synapse/client/oidc/callbackin the shared Keycloak client (no per-role Keycloak entry needed). - MDAD's
matrix_synapse_oidc_*keys are templated fromOIDC.CLIENT.*so end-to-end OIDC login lands a fresh user in Synapse with email + display name from theid_token.
Reverse proxy
- MDAD's bundled traefik is disabled in the rendered inventory.
sys-svc-proxyproxies the canonical hostname to the MDAD-bound localhost port for Synapse, Element, and the federation listener (port 8448 still public).- One TLS certificate covers
{{ DOMAIN_PRIMARY }}plus the well-known aliases MDAD requires.
Variants
meta/variants.ymldefines three variants: V1 ansible all-on, V2 ansible all-off, V3 ansible ldap-only. (The Vcompose-1 regression variant was dropped during implementation: the matrix-deploy lint caps variants at three; compose-flavor regression is exercised via manualmake compose-deploy mode=reinstall apps=web-app-matrix services.matrix.flavor=composeinstead.)- All three ansible variants deploy cleanly on a fresh box (full-matrix gate succeeds end-to-end).
Playwright
- Existing
test-login-administrator/test-login-biber/test-login-native-administratorkeep passing against the ansible-flavor Element surface in V1 ansible all-on. - New
test-element-call.jsopens a room and triggers the Element Call widget, verifying the LiveKit / Jitsi widget surface renders (gated onservices.matrix.conferencing.element_call). - New
test-bridge-roster.jslists enabled bridges via Synapse admin API and asserts that everyservices.matrix.plugins.<bridge>=trueentry has a live appservice record.
Compose-flavor regression
- An existing operator who pins
flavor: composein their inventory keeps the current compose stack (docker compose up -dagainst the existing compose.yml.j2). No state migration in this deploy direction. - The compose flavor's deprecated status is surfaced in
roles/web-app-matrix/README.md(lifecycle/meta/info.yml::deprecatedis not part of the schema; the deprecation contract lives in the README and thetasks/main.ymldispatch comment).
Documentation
roles/web-app-matrix/README.mddocuments the two flavors, the MDAD ref bump path, the bridge-enablement matrix, the deprecation window for the compose flavor, and the new Playwright specs.- This requirement file is cross-linked from the implementing PR.
Validation Apps
The role MUST deploy cleanly under all four variants on a fresh box. V1 (ansible all-on) MUST additionally pass the biber + administrator Playwright personas plus the new test-element-call + test-bridge-roster specs.
INFINITO_APPS="web-app-matrix" \
make deploy-fresh-purged-apps INFINITO_FULL_CYCLE=true
Prerequisites
Before starting any implementation work, the agent MUST read AGENTS.md and follow all instructions in it.
Implementation Strategy
The agent MUST execute this requirement autonomously once Proposed Decisions are confirmed. Open clarifications only when a decision is genuinely ambiguous and would otherwise block progress; default to the intent already captured in this document and proceed.
- Read Compose Loop before starting.
- Move the existing compose-flavor tasks + templates into the new
flavor/compose/subdirectories without behavior change; add a flavor dispatch intasks/main.yml. - Scaffold
tasks/flavor/ansible/(clone, render inventory, run playbook, wire proxy) plus the matching template tree. - Wire central-service reuse + Keycloak OIDC + Mailu SMTP into the rendered MDAD vars.
- Add the new variants, run the matrix gate, iterate per the Compose Loop until green.
- Add the new Playwright specs.
- Mark the compose flavor deprecated in
meta/info.yml; update README.
Commit Policy
- Single commit (or a tight, related sequence) lands the whole ansible-flavor addition; no half-scaffolded intermediate commits.
- When all ACs are checked off,
make testis green, and the four variants deploy cleanly, the agent instructs the operator to rungit-sign-pushoutside the sandbox (per AGENTS.md). The agent MUST NOT push.
Context
- Upstream playbook: https://github.com/spantaleev/matrix-docker-ansible-deploy
- Closest in-repo precedent for adapter-style upstream-playbook invocation: none yet (this requirement establishes it).
- Closest in-repo precedent for a
flavor:discriminator: SSO flavor migration (req 021). - Closest in-repo precedent for the central-service consumer pattern: web-app-erpnext (req 024) + web-app-zammad (req 022).
- Playwright coverage parity contract: req 019.
- Role meta layout contract: req 008.