feat(web-app-matrix): integrate five MDAD addons and repair three dead upstream flags

Add baibot, synapse-usage-exporter, hookshot-webhooks and hookshot-feeds as
declared addons, and make the existing hookshot and jitsi addons reach the
default flavor for the first time.

Three template lines named variables matrix-docker-ansible-deploy never reads,
so the switches they were meant to flip stayed at their upstream defaults:

* matrix_hookshot_{gitlab,jira}_enabled -> matrix_bridge_hookshot_*_enabled
* matrix_bridge_mautrix_meta_enabled -> the Messenger and Instagram switches,
  which upstream keeps as two separate roles
* matrix_bridge_mautrix_gitter_enabled, which has no upstream role at all, is
  removed together with its dead services.matrix.plugins.gitter key

A bridge addon now names the switches it drives under config.upstream_flags,
next to its bridge_name, so the mapping lives with the package instead of in a
hand-written template line per bridge. test_flavor_package_superset checks that
declaration rather than grepping the template, which a loop would defeat.

services.matrix.plugins.hookshot follows its partners' deployment instead of a
static false, so the bridge comes up wherever web-app-gitlab or web-app-n8n is
deployed; n8n joins meta/services.yml as a consumer with the variant coverage
the dynamic-flag contract requires.

Synapse metrics now reach the outer Prometheus. The inner listener binds to the
matrix-mdad container's own interface rather than loopback, because Prometheus
joins this role's docker network and scrapes the container by name, and
native_metrics resolves service_key and port per flavor instead of claiming the
compose flavor is the only one with a target.

jitsi_enabled follows services.jitsi.shared. Enabled implies shared in the base
config, so MDAD's own conferencing stack never starts and Element is pointed at
the co-deployed web-app-jitsi through the client config and the .well-known
property; previously MDAD raised web, prosody, jicofo and jvb in every ansible
round on a domain the outer proxy never routed.

Variant 3 pairs the ansible flavor with svc-ai-litellm and the bridge addons so
baibot and the five mautrix bridges are exercised in the default flavor at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-02 23:26:46 +02:00
1 parent 2556ca4631
commit b3e0dce578
28 files changed
+676 -26

No files matched your search

+57 -3
View File
@@ -96,11 +96,65 @@ bridge's `config.yaml` and the matching Synapse `registration.yaml` from the sam
| `mautrix-signal` | `bridge` | disabled | external network |
| `mautrix-slack` | `bridge` | disabled | external network |
| `mautrix-meta` | `bridge` | disabled | external network |
| `hookshot-gitlab` | `bridge` | follows `services.gitlab.enabled` | `gitlab` |
| `hookshot-jira` | `bridge` | never: `services.jira.enabled` is pinned off because `web-app-jira` is end of life (see [lifecycle.md](../../docs/contributing/design/role/services/lifecycle.md)) | `jira` |
| `hookshot-webhooks` | `bridge` | follows `services.n8n.enabled` | `n8n` |
| `hookshot-feeds` | `bridge` | disabled | RSS, no partner role |
| `jitsi` | `module` | follows `services.jitsi.enabled` | `jitsi` |
| `baibot` | `addon` | follows `services.matrix.plugins.chatgpt` | `litellm` |
| `synapse-usage-exporter` | `addon` | follows `services.prometheus.enabled` | `prometheus` |
The compose flavor derives `MATRIX_BRIDGES` from the enabled bridge addons'
`config:` blocks; the enabled/disabled split is exercised by the compose
variant in [`meta/variants.yml`](./meta/variants.yml). Coverage is via
`test-bridge-roster.js`.
`config:` blocks that declare a `bridge_name`; the enabled/disabled split is
exercised by the compose variant in [`meta/variants.yml`](./meta/variants.yml).
Coverage is via `test-bridge-roster.js`.
The three cross-role addons follow their partner's service flag instead of a
static default, except where the partner is end of life: `services.jira.enabled`
is a literal `false` that no inventory can turn on, so `hookshot-jira` stays
declared and dormant. `hookshot-gitlab` and `hookshot-jira` select which
[matrix-hookshot](https://github.com/matrix-org/matrix-hookshot) connections the
ansible flavor configures, on top of the `services.matrix.plugins.hookshot`
knob that decides whether MDAD deploys the bridge at all. `jitsi` writes
[Element's](https://github.com/element-hq/element-web/blob/develop/docs/jitsi.md)
`jitsi.preferredDomain` so conference widgets open on the co-deployed
`web-app-jitsi` instance: the compose flavor writes it into
`element.config.json`, the ansible flavor overrides MDAD's
`matrix_client_element_jitsi_preferred_domain` and the matching `.well-known`
client property. Which of the two stacks serves is decided by
`services.jitsi.shared`: shared means `web-app-jitsi` provides it and MDAD's own
jitsi role stays off, unshared means MDAD raises web, prosody, jicofo and jvb
inside the runner container and owns the client config itself. The unshared
branch serves on `matrix_server_fqn_jitsi`, which needs a routed domain before
it is reachable from outside the runner. Per-addon specs live under
`files/playwright/addons/`.
`services.matrix.plugins.hookshot` follows the deployment of the partners its
connection addons bind, so the bridge comes up wherever `web-app-gitlab` or
`web-app-n8n` is deployed. The four `hookshot-*` addons then select which
connections MDAD configures through `matrix_bridge_hookshot_<kind>_enabled`;
`generic` and `feeds` are upstream defaults the addons drive explicitly.
`hookshot-feeds` has no partner service of its own, so enabling it alone does
nothing: without `web-app-gitlab` or `web-app-n8n` the bridge it configures is
never deployed. Pin it in a round that already carries one of them.
A bridge addon names the upstream switches it drives under
`config.upstream_flags`, next to its `bridge_name`. The ansible flavor renders
that list; one addon may drive several switches, as `mautrix-meta` does for
MDAD's separate Messenger and Instagram roles.
`services.matrix.plugins.chatgpt` is the single switch for the AI surface in
both flavors: the compose flavor renders `matrix-chatgpt-bot`, the ansible
flavor enables `baibot`. Both reach the model through `svc-ai-litellm` with the
role's `litellm_api_key`, and MDAD creates the `baibot` account itself from
`matrix_user_creator_users_auto`, so no account provisioning step is needed.
`synapse-usage-exporter` turns Synapse's metrics listener on and publishes it on
the `matrix-mdad` container's own interface. The outer Prometheus joins this
role's docker network and scrapes the DiD container by name, which is why
`services.prometheus.native_metrics` resolves its `service_key` and `port` per
flavor: `synapse:9000` for the in-repo compose stack, `matrix:9100` for the
MDAD stack where the homeserver runs one docker layer deeper.
## Conferencing
@@ -0,0 +1,43 @@
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
const BAIBOT_LOCALPART = "baibot";
test("baibot addon: the bot user is provisioned on the Synapse homeserver", async ({ request }) => {
skipUnlessAddonEnabled("baibot");
skipUnlessServiceEnabled("litellm");
test.skip(
!(process.env.MATRIX_FLAVOR || "").toLowerCase().includes("ansible"),
"baibot ships with the matrix-docker-ansible-deploy flavor only",
);
test.setTimeout(resolveTimeout(60_000));
const matrixBaseUrl = shared.env.matrixBaseUrl;
const matrixServerName = shared.env.matrixServerName;
expect(matrixBaseUrl, "MATRIX_BASE_URL must be set").toBeTruthy();
expect(matrixServerName, "MATRIX_SERVER_NAME must be set").toBeTruthy();
const botUserId = `@${BAIBOT_LOCALPART}:${matrixServerName}`;
const profileUrl = `${matrixBaseUrl}/_matrix/client/v3/profile/${encodeURIComponent(botUserId)}`;
const response = await request.get(profileUrl, { failOnStatusCode: false, timeout: resolveTimeout(30_000) });
expect(
response.status(),
`the baibot user ${botUserId} must be registered on Synapse (${matrixBaseUrl}). MDAD creates it from ` +
`matrix_user_creator_users_auto whenever the bot is enabled and carries a password, so a 404 means either ` +
`the password reached the renderer empty or user creation never ran. Got HTTP ${response.status()}.`,
).toBe(200);
const profile = await response.json().catch(() => null);
expect(
profile && typeof profile === "object" && !Array.isArray(profile),
`Synapse must return the baibot profile JSON object for ${botUserId}, proving the bot user exists`,
).toBe(true);
});
@@ -0,0 +1,138 @@
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { decodeDotenvQuotedValue } = require("../personas");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
const HOOKSHOT_BOT_LOCALPART = "hookshot";
const FEED_PATH = "/explore/projects.atom";
async function loginWithPassword(request, matrixBaseUrl, username, password) {
const response = await request.post(`${matrixBaseUrl}/_matrix/client/v3/login`, {
failOnStatusCode: false,
timeout: resolveTimeout(30_000),
data: {
type: "m.login.password",
identifier: { type: "m.id.user", user: username },
password,
},
});
expect(
response.status(),
`biber must obtain a client-server access token by password; the deploy provisions the account, ` +
`so a 403 here means the password in the Playwright env no longer matches Synapse. ` +
`HTTP ${response.status()}: ${(await response.text().catch(() => "")).slice(0, 300)}`,
).toBe(200);
return (await response.json()).access_token;
}
async function botNotices(request, matrixBaseUrl, token, roomId, botUserId) {
const response = await request.get(
`${matrixBaseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=50`,
{ failOnStatusCode: false, timeout: resolveTimeout(30_000), headers: { Authorization: `Bearer ${token}` } },
);
if (response.status() !== 200) return [];
const body = await response.json().catch(() => ({}));
return (body.chunk || [])
.filter((event) => event && event.type === "m.room.message" && event.sender === botUserId)
.map((event) => String((event.content && event.content.body) || ""));
}
test("hookshot-feeds addon: biber subscribes a room to an RSS feed and hookshot confirms", async ({ request }) => {
skipUnlessAddonEnabled("hookshot-feeds");
skipUnlessServiceEnabled("gitlab");
test.skip(
!(process.env.MATRIX_FLAVOR || "").toLowerCase().includes("ansible"),
"hookshot ships with the matrix-docker-ansible-deploy flavor only",
);
const plugins = JSON.parse(decodeDotenvQuotedValue(process.env.MATRIX_PLUGINS_JSON || "") || "{}");
test.skip(
!plugins.hookshot,
`services.matrix.plugins.hookshot is off, so MDAD renders no hookshot bridge (MATRIX_PLUGINS_JSON: ${JSON.stringify(plugins)})`,
);
test.setTimeout(resolveTimeout(240_000));
const matrixBaseUrl = (shared.env.matrixBaseUrl || "").replace(/\/$/, "");
const matrixServerName = shared.env.matrixServerName;
const gitlabBaseUrl = (decodeDotenvQuotedValue(process.env.GITLAB_BASE_URL) || "").replace(/\/$/, "");
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME);
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD);
expect(matrixBaseUrl, "MATRIX_BASE_URL must be set").toBeTruthy();
expect(gitlabBaseUrl, "GITLAB_BASE_URL must be set while the gitlab service is enabled").toBeTruthy();
expect(biberUsername, "BIBER_USERNAME must be set").toBeTruthy();
const botUserId = `@${HOOKSHOT_BOT_LOCALPART}:${matrixServerName}`;
const feedUrl = `${gitlabBaseUrl}${FEED_PATH}`;
const token = await loginWithPassword(request, matrixBaseUrl, biberUsername, biberPassword);
const auth = { Authorization: `Bearer ${token}` };
const createResponse = await request.post(`${matrixBaseUrl}/_matrix/client/v3/createRoom`, {
failOnStatusCode: false,
timeout: resolveTimeout(60_000),
headers: auth,
data: { preset: "private_chat", name: "hookshot feed probe", invite: [botUserId] },
});
expect(
createResponse.status(),
`creating the probe room must succeed: ${(await createResponse.text().catch(() => "")).slice(0, 300)}`,
).toBe(200);
const roomId = (await createResponse.json()).room_id;
await expect
.poll(
async () => {
const members = await request.get(
`${matrixBaseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`,
{ failOnStatusCode: false, timeout: resolveTimeout(30_000), headers: auth },
);
if (members.status() !== 200) return false;
return Object.keys((await members.json()).joined || {}).includes(botUserId);
},
{
timeout: resolveTimeout(120_000),
message: `${botUserId} must accept the invite; if it never joins, the hookshot container is not running or failed to authenticate against Synapse, so no feed command can be processed`,
},
)
.toBe(true);
const sendResponse = await request.put(
`${matrixBaseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(`feed-probe-${process.pid}`)}`,
{
failOnStatusCode: false,
timeout: resolveTimeout(30_000),
headers: auth,
data: { msgtype: "m.text", body: `!hookshot feed ${feedUrl}` },
},
);
expect(
sendResponse.status(),
`sending the feed command must succeed: ${(await sendResponse.text().catch(() => "")).slice(0, 300)}`,
).toBe(200);
let replies = [];
await expect
.poll(
async () => {
replies = await botNotices(request, matrixBaseUrl, token, roomId, botUserId);
return replies.length > 0;
},
{
timeout: resolveTimeout(120_000),
message: `${botUserId} must answer the feed command. Silence means the feeds module is off: MDAD renders it from matrix_bridge_hookshot_feeds_enabled, which follows the hookshot-feeds addon`,
},
)
.toBe(true);
expect(
replies.some((body) => body.includes("Room configured to bridge")),
`hookshot must confirm the subscription with "Room configured to bridge". It validates a feed by fetching ` +
`it, so "Could not read feed from URL" means ${feedUrl} is not readable from inside the runner and the ` +
`probe needs a different feed source, not that the addon is broken. Replies: ${JSON.stringify(replies)}`,
).toBe(true);
});
@@ -0,0 +1,44 @@
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { decodeDotenvQuotedValue } = require("../personas");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
const HOOKSHOT_BOT_LOCALPART = "hookshot";
test("hookshot-webhooks addon: the hookshot appservice bot is provisioned on the Synapse homeserver", async ({ request }) => {
skipUnlessAddonEnabled("hookshot-webhooks");
skipUnlessServiceEnabled("n8n");
test.skip(
!(process.env.MATRIX_FLAVOR || "").toLowerCase().includes("ansible"),
"hookshot ships with the matrix-docker-ansible-deploy flavor only",
);
const plugins = JSON.parse(decodeDotenvQuotedValue(process.env.MATRIX_PLUGINS_JSON || "") || "{}");
test.skip(
!plugins.hookshot,
`services.matrix.plugins.hookshot is off, so MDAD renders no hookshot bridge (MATRIX_PLUGINS_JSON: ${JSON.stringify(plugins)})`,
);
test.setTimeout(resolveTimeout(60_000));
const matrixBaseUrl = shared.env.matrixBaseUrl;
const matrixServerName = shared.env.matrixServerName;
expect(matrixBaseUrl, "MATRIX_BASE_URL must be set").toBeTruthy();
expect(matrixServerName, "MATRIX_SERVER_NAME must be set").toBeTruthy();
const botUserId = `@${HOOKSHOT_BOT_LOCALPART}:${matrixServerName}`;
const profileUrl = `${matrixBaseUrl.replace(/\/$/, "")}/_matrix/client/v3/profile/${encodeURIComponent(botUserId)}`;
const response = await request.get(profileUrl, { failOnStatusCode: false, timeout: resolveTimeout(30_000) });
expect(
response.status(),
`the hookshot appservice bot ${botUserId} must be registered on Synapse (${matrixBaseUrl}) so an inbound ` +
`generic webhook from n8n can be routed into a room. A 404 means the appservice registration never landed. ` +
`Got HTTP ${response.status()}.`,
).toBe(200);
});
@@ -10,10 +10,6 @@ test.use({ ignoreHTTPSErrors: true });
test("jitsi addon: Element advertises the co-deployed Jitsi deployment as its preferred domain", async ({ request }) => {
skipUnlessAddonEnabled("jitsi");
skipUnlessServiceEnabled("jitsi");
test.skip(
!(process.env.MATRIX_FLAVOR || "").toLowerCase().includes("compose"),
"the ansible flavor lets matrix-docker-ansible-deploy render Element's config.json",
);
test.setTimeout(resolveTimeout(60_000));
@@ -0,0 +1,34 @@
const { test, expect } = require("../onion-test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
test("synapse-usage-exporter addon: the usage metrics never reach the public vhost", async ({ request }) => {
skipUnlessAddonEnabled("synapse-usage-exporter");
skipUnlessServiceEnabled("prometheus");
test.setTimeout(resolveTimeout(60_000));
const matrixBaseUrl = shared.env.matrixBaseUrl;
expect(matrixBaseUrl, "MATRIX_BASE_URL must be set").toBeTruthy();
const metricsUrl = `${matrixBaseUrl.replace(/\/$/, "")}/_synapse/metrics`;
const response = await request.get(metricsUrl, { failOnStatusCode: false, timeout: resolveTimeout(30_000) });
const status = response.status();
const body = await response.text();
expect(
status,
`enabling the usage exporter turns Synapse's own metrics listener on, and that listener carries room counts and ` +
`user activity. It MUST stay inside the stack for the Prometheus scrape, never on the public vhost at ` +
`${metricsUrl}. HTTP 200 here means the reverse proxy forwards it to the world. Got HTTP ${status}.`,
).not.toBe(200);
expect(
body.includes("synapse_build_info") || body.includes("# TYPE synapse"),
`the public response for ${metricsUrl} must not contain Prometheus exposition text; got: ${body.slice(0, 200)}`,
).toBe(false);
});
@@ -0,0 +1,11 @@
---
enabled: "{{ lookup('config', 'web-app-matrix', 'services.matrix.plugins.chatgpt') | bool }}"
required: false
mechanism: addon
source: upstream
bridges:
- litellm
config:
user_localpart: baibot
agent_id: litellm
user_password: "{{ lookup('config', 'web-app-matrix', 'secrets.credentials.baibot_user_password', '') }}"
@@ -0,0 +1,5 @@
---
enabled: false
required: false
mechanism: bridge
source: upstream
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-matrix', 'services.n8n.enabled') | bool }}"
required: false
mechanism: bridge
source: upstream
bridges:
- n8n
@@ -9,6 +9,9 @@ update:
upstream_id: mautrix-meta
config:
bridge_name: "meta"
upstream_flags:
- matrix_bridge_mautrix_meta_messenger_enabled
- matrix_bridge_mautrix_meta_instagram_enabled
appservice_port: 29319
database_username: "mautrix_meta_bridge"
database_name: "mautrix_meta_bridge"
@@ -9,6 +9,8 @@ update:
upstream_id: mautrix-signal
config:
bridge_name: "signal"
upstream_flags:
- matrix_bridge_mautrix_signal_enabled
appservice_port: 29328
database_username: "mautrix_signal_bridge"
database_name: "mautrix_signal_bridge"
@@ -9,6 +9,8 @@ update:
upstream_id: mautrix-slack
config:
bridge_name: "slack"
upstream_flags:
- matrix_bridge_mautrix_slack_enabled
appservice_port: 29335
database_username: "mautrix_slack_bridge"
database_name: "mautrix_slack_bridge"
@@ -9,6 +9,8 @@ update:
upstream_id: mautrix-telegram
config:
bridge_name: "telegram"
upstream_flags:
- matrix_bridge_mautrix_telegram_enabled
appservice_port: 29317
database_username: "mautrix_telegram_bridge"
database_name: "mautrix_telegram_bridge"
@@ -9,6 +9,8 @@ update:
upstream_id: mautrix-whatsapp
config:
bridge_name: "whatsapp"
upstream_flags:
- matrix_bridge_mautrix_whatsapp_enabled
appservice_port: 29318
database_username: "mautrix_whatsapp_bridge"
database_name: "mautrix_whatsapp_bridge"
@@ -0,0 +1,7 @@
---
enabled: "{{ lookup('config', 'web-app-matrix', 'services.prometheus.enabled') | bool }}"
required: false
mechanism: addon
source: upstream
bridges:
- prometheus
+4
View File
@@ -42,6 +42,10 @@ credentials:
description: Matrix user password used by the ChatGPT bridge
algorithm: sha256
validation: ^[a-f0-9]{64}$
baibot_user_password:
description: Matrix user password baibot logs in with, also used by MDAD to create the bot account
algorithm: sha256
validation: ^[a-f0-9]{64}$
mautrix_meta_bridge_database_password:
description: Database password for the mautrix-meta bridge (Facebook Messenger + Instagram)
algorithm: bcrypt
+19 -7
View File
@@ -30,6 +30,21 @@ email:
bond: 1
enabled: "{{ 'web-app-mailu' in group_names }}"
shared: "{{ 'web-app-mailu' in group_names }}"
gitlab:
bond: 0.5
enabled: "{{ 'web-app-gitlab' in group_names }}"
shared: "{{ 'web-app-gitlab' in group_names }}"
jira:
enabled: false # nocheck: dynamic-flag - web-app-jira is EOL, no host may bind it
shared: false # nocheck: dynamic-flag - web-app-jira is EOL, no host may bind it
n8n:
bond: 0.5
enabled: "{{ 'web-app-n8n' in group_names }}"
shared: "{{ 'web-app-n8n' in group_names }}"
jitsi:
bond: 0.5
enabled: "{{ 'web-app-jitsi' in group_names }}"
shared: "{{ 'web-app-jitsi' in group_names }}"
# nocheck: playwright-service-flag — TURN/STUN server, no Playwright surface (provider: web-svc-coturn)
coturn:
bond: 1
@@ -103,9 +118,9 @@ prometheus:
enabled: "{{ 'web-app-prometheus' in group_names }}"
shared: "{{ 'web-app-prometheus' in group_names }}"
native_metrics:
enabled: true
port: 9000
service_key: synapse
enabled: true # nocheck: dynamic-flag — both flavors publish a scrape target, so no inventory decides this
port: "{{ 9000 if lookup('config', 'web-app-matrix', 'services.matrix.flavor') == 'compose' else 9100 }}"
service_key: "{{ 'synapse' if lookup('config', 'web-app-matrix', 'services.matrix.flavor') == 'compose' else 'matrix' }}"
communication:
channel: true
# nocheck: playwright-service-flag: shared provider entity; the app itself is covered by this role's own Playwright spec
@@ -145,9 +160,8 @@ matrix:
appservice_irc: false
chatgpt: "{{ 'svc-ai-litellm' in group_names }}"
discord: false
gitter: false
heisenbridge: true
hookshot: false
hookshot: "{{ 'web-app-gitlab' in group_names or 'web-app-n8n' in group_names }}"
mautrix_discord: false
mautrix_signal: false
mautrix_slack: false
@@ -156,7 +170,6 @@ matrix:
mautrix_whatsapp: false
conferencing:
element_call: true
jitsi: true
run_after:
- web-app-matomo
- web-app-keycloak
@@ -169,7 +182,6 @@ matrix:
pids_limit: 256
# nocheck: playwright-service-flag
# nocheck: variants-coverage — sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in
tor:
bond: 0.5
enabled: "{{ 'svc-net-tor' in group_names }}"
+106 -1
View File
@@ -1,9 +1,24 @@
---
# nocheck: variant-limit — variant 3 is the only round that pairs the ansible
# flavor with svc-ai-litellm. Without it the baibot addon is enabled solely in
# variant 2, whose compose flavor never renders the MDAD vars that deploy it, so
# the default flavor's AI surface would ship untested.
#
# nocheck: sso-variant-tor — variant 1 is the round that turns gitlab, n8n and
# jitsi on. jitsi is clearnet-only (its meta/services.yml pins tor off), and
# tor.exclusive swaps this role's domains for the onion, so an onion-only
# homeserver cannot pair with it. The hookshot bridges and Element's jitsi
# conferencing are therefore driven from a clearnet homeserver origin; variant 0
# pairs the onion with SSO.
- services:
container_backup:
enabled: true
matrix:
flavor: ansible
n8n:
enabled: false
shared: false
coturn:
enabled: true
shared: true
@@ -16,6 +31,12 @@
email:
enabled: true
shared: true
gitlab:
enabled: false
shared: false
jitsi:
enabled: false
shared: false
ldap:
enabled: true
shared: true
@@ -59,6 +80,15 @@
email:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
gitlab:
enabled: true
shared: true
n8n:
enabled: true
shared: true
jitsi:
enabled: true
shared: true
ldap:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
@@ -81,7 +111,10 @@
seaweedfs:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
tor:
tor: # nocheck: variant-tor-collateral jitsi refuses the onion and this is the only round pairing it with the homeserver, so gitlab and n8n ride along on clearnet; a tor round of this role has no budget left for either partner
enabled: false
addons:
hookshot-feeds:
enabled: true
- services:
@@ -90,6 +123,9 @@
shared: false # nocheck: variants-static-override backup shared stays literally true
matrix:
flavor: compose
n8n:
enabled: false
shared: false
seaweedfs:
enabled: true
shared: true
@@ -105,6 +141,12 @@
email:
enabled: true
shared: true
gitlab:
enabled: false
shared: false
jitsi:
enabled: false
shared: false
ldap:
enabled: true
shared: true
@@ -137,3 +179,66 @@
enabled: true
mautrix-meta:
enabled: true
- services:
container_backup:
enabled: false
shared: false # nocheck: variants-static-override base shared is a static literal and this round joins no backup host
matrix:
flavor: ansible
coturn:
enabled: false
shared: false
css:
enabled: false
shared: false
dashboard:
enabled: false
shared: false
email:
enabled: false
shared: false
gitlab:
enabled: false
shared: false
n8n:
enabled: false
shared: false
jitsi:
enabled: false
shared: false
ldap:
enabled: false
shared: false
logout:
enabled: false
shared: false
matomo:
enabled: false
shared: false
postgres:
shared: true
litellm:
enabled: true
sso:
enabled: false
shared: false
prometheus:
enabled: false
shared: false
seaweedfs:
enabled: false
shared: false
tor:
enabled: true
addons:
mautrix-whatsapp:
enabled: true
mautrix-telegram:
enabled: true
mautrix-signal:
enabled: true
mautrix-slack:
enabled: true
mautrix-meta:
enabled: true
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -0,0 +1 @@
---
@@ -7,6 +7,8 @@
| selectattr('value.mechanism', 'equalto', 'bridge')
| selectattr('value.enabled', 'equalto', true)
| map(attribute='value.config')
| select('mapping')
| selectattr('bridge_name', 'defined')
| list }}
changed_when: false
@@ -146,12 +146,41 @@ matrix_bridge_heisenbridge_enabled: {{ MATRIX_BRIDGE.heisenbridge | string | low
matrix_bridge_hookshot_enabled: {{ MATRIX_BRIDGE.hookshot | string | lower }}
matrix_bridge_appservice_discord_enabled: {{ MATRIX_BRIDGE.discord | string | lower }}
matrix_bridge_mautrix_discord_enabled: {{ MATRIX_BRIDGE.mautrix_discord | string | lower }}
matrix_bridge_mautrix_slack_enabled: {{ MATRIX_BRIDGE.mautrix_slack | string | lower }}
matrix_bridge_mautrix_signal_enabled: {{ MATRIX_BRIDGE.mautrix_signal | string | lower }}
matrix_bridge_mautrix_telegram_enabled: {{ MATRIX_BRIDGE.mautrix_telegram | string | lower }}
matrix_bridge_mautrix_twitter_enabled: {{ MATRIX_BRIDGE.mautrix_twitter | string | lower }}
matrix_bridge_mautrix_whatsapp_enabled: {{ MATRIX_BRIDGE.mautrix_whatsapp | string | lower }}
matrix_bridge_mautrix_gitter_enabled: {{ MATRIX_BRIDGE.gitter | string | lower }}
{% for bridge_addon in MATRIX_BRIDGE_ADDONS %}
{% for upstream_flag in bridge_addon.value.config.upstream_flags %}
{{ upstream_flag }}: {{ bridge_addon.value.enabled | string | lower }}
{% endfor %}
{% endfor %}
{% if MATRIX_BRIDGE.hookshot %}
matrix_bridge_hookshot_gitlab_enabled: {{ MATRIX_ADDON_HOOKSHOT_GITLAB_ENABLED | string | lower }}
matrix_bridge_hookshot_jira_enabled: {{ MATRIX_ADDON_HOOKSHOT_JIRA_ENABLED | string | lower }}
matrix_bridge_hookshot_generic_enabled: {{ MATRIX_ADDON_HOOKSHOT_WEBHOOKS_ENABLED | string | lower }}
matrix_bridge_hookshot_feeds_enabled: {{ MATRIX_ADDON_HOOKSHOT_FEEDS_ENABLED | string | lower }}
{% endif %}
jitsi_enabled: {{ MATRIX_CONFERENCING_JITSI | string | lower }}
matrix_bot_baibot_enabled: {{ MATRIX_ADDON_BAIBOT_ENABLED | string | lower }}
{% if MATRIX_ADDON_BAIBOT_ENABLED %}
matrix_bot_baibot_config_user_mxid_localpart: "{{ MATRIX_ADDON_BAIBOT_LOCALPART }}"
matrix_bot_baibot_config_user_password: "{{ MATRIX_ADDON_BAIBOT_PASSWORD }}"
matrix_bot_baibot_config_agents_static_definitions_openai_enabled: true
matrix_bot_baibot_config_agents_static_definitions_openai_id: "{{ MATRIX_ADDON_BAIBOT_AGENT_ID }}"
matrix_bot_baibot_config_agents_static_definitions_openai_config_base_url: "{{ LITELLM_OPENAI_BASE_LOCAL_URL }}"
matrix_bot_baibot_config_agents_static_definitions_openai_config_api_key: "{{ MATRIX_CHATGPT_BRIDGE_API_KEY }}"
matrix_bot_baibot_config_agents_static_definitions_openai_config_text_generation_model_id: "{{ LITELLM_CHAT_MODEL }}"
matrix_bot_baibot_config_initial_global_config_handler_catch_all: "static/{{ MATRIX_ADDON_BAIBOT_AGENT_ID }}"
{% endif %}
matrix_synapse_usage_exporter_enabled: {{ MATRIX_ADDON_SYNAPSE_USAGE_EXPORTER_ENABLED | string | lower }}
{% if MATRIX_ADDON_SYNAPSE_USAGE_EXPORTER_ENABLED %}
matrix_synapse_metrics_enabled: true
matrix_synapse_container_metrics_api_host_bind_port: "0.0.0.0:{{ MATRIX_SYNAPSE_METRICS_PORT }}"
matrix_synapse_metrics_port: {{ MATRIX_SYNAPSE_METRICS_PORT }}
{% endif %}
jitsi_enabled: {{ MATRIX_JITSI_SELF_HOSTED | string | lower }}
{% if MATRIX_ADDON_JITSI_ENABLED and not MATRIX_JITSI_SELF_HOSTED | bool %}
matrix_client_element_jitsi_preferred_domain: "{{ MATRIX_ADDON_JITSI_PREFERRED_DOMAIN }}"
matrix_static_files_file_matrix_client_property_io_element_jitsi_preferred_domain: "{{ MATRIX_ADDON_JITSI_PREFERRED_DOMAIN }}"
{% endif %}
matrix_rtc_enabled: {{ MATRIX_CONFERENCING_ELEMENT_CALL | string | lower }}
@@ -52,6 +52,11 @@
"feature_rust_crypto": true,
"feature_new_room_decoration_ui": true
},
{% if MATRIX_ADDON_JITSI_ENABLED | bool %}
"jitsi": {
"preferredDomain": "{{ MATRIX_ADDON_JITSI_PREFERRED_DOMAIN }}"
},
{% endif %}
"element_call": {
"url": "https://call.element.dev"
},
@@ -19,6 +19,8 @@ ELEMENT_CALL_SERVICE_ENABLED={{ MATRIX_CONFERENCING_ELEMENT_CALL | string | lowe
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
GITLAB_BASE_URL={{ lookup('tls', 'web-app-gitlab', 'url.base') | dotenv_quote }}
GITLAB_SERVICE_ENABLED={{ lookup('config', application_id, 'services.gitlab.enabled') | string | lower }}
JIRA_SERVICE_ENABLED={{ lookup('config', application_id, 'services.jira.enabled') | string | lower }}
@@ -62,6 +64,10 @@ SEAWEEDFS_APP_SECRET_KEY={{ lookup('objstore', application_id, 'secret_key') | d
SEAWEEDFS_REGION={{ lookup('config', 'web-svc-seaweedfs', 'services.seaweedfs.region') }}
N8N_SERVICE_ENABLED={{ lookup('config', application_id, 'services.n8n.enabled') | string | lower }}
PROMETHEUS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.prometheus.enabled') | string | lower }}
SEAWEEDFS_S3_URL={{ lookup('canonical_url', 'web-svc-seaweedfs', 'api', consumer=application_id) | dotenv_quote }}
SEAWEEDFS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.seaweedfs.enabled') | string | lower }}
@@ -1,6 +1,3 @@
# Matrix Synapse — server and federation metrics.
# Requires enable_metrics: true in homeserver.yaml.
# Enable via native_metrics.enabled: true in inventory.
- job_name: "matrix-synapse"
scheme: http
metrics_path: /_synapse/metrics
+19 -2
View File
@@ -78,11 +78,28 @@ MATRIX_MDAD_DAEMON_JSON:
max-size: "10m"
max-file: "3"
MATRIX_BRIDGE: "{{ lookup('config', application_id, 'services.matrix.plugins') }}"
MATRIX_BRIDGE_ADDONS: "{{ lookup('config', application_id, 'addons') | dict2items | selectattr('value.mechanism', 'equalto', 'bridge') | selectattr('value.config.bridge_name',
'defined') | list }}"
MATRIX_BRIDGE: "{{ lookup('config', application_id, 'services.matrix.plugins') | combine(dict(MATRIX_BRIDGE_ADDONS | map(attribute='value.config.bridge_name') | map('regex_replace',
'^', 'mautrix_') | zip(MATRIX_BRIDGE_ADDONS | map(attribute='value.enabled')))) }}"
MATRIX_CONFERENCING_JITSI: "{{ lookup('config', application_id, 'services.matrix.conferencing.jitsi') | bool }}"
MATRIX_CONFERENCING_ELEMENT_CALL: "{{ lookup('config', application_id, 'services.matrix.conferencing.element_call') | bool }}"
MATRIX_ADDON_BAIBOT_ENABLED: "{{ lookup('config', application_id, 'addons.baibot.enabled') | bool }}"
MATRIX_ADDON_BAIBOT_LOCALPART: "{{ lookup('config', application_id, 'addons.baibot.config.user_localpart') }}"
MATRIX_ADDON_BAIBOT_PASSWORD: "{{ lookup('config', application_id, 'addons.baibot.config.user_password') }}"
MATRIX_ADDON_BAIBOT_AGENT_ID: "{{ lookup('config', application_id, 'addons.baibot.config.agent_id') }}"
MATRIX_ADDON_SYNAPSE_USAGE_EXPORTER_ENABLED: "{{ lookup('config', application_id, 'addons.synapse-usage-exporter.enabled') | bool }}"
MATRIX_SYNAPSE_METRICS_PORT: "{{ lookup('config', application_id, 'services.prometheus.native_metrics.port') }}"
MATRIX_ADDON_HOOKSHOT_GITLAB_ENABLED: "{{ lookup('config', application_id, 'addons.hookshot-gitlab.enabled') | bool }}"
MATRIX_ADDON_HOOKSHOT_JIRA_ENABLED: "{{ lookup('config', application_id, 'addons.hookshot-jira.enabled') | bool }}"
MATRIX_ADDON_HOOKSHOT_WEBHOOKS_ENABLED: "{{ lookup('config', application_id, 'addons.hookshot-webhooks.enabled') | bool }}"
MATRIX_ADDON_HOOKSHOT_FEEDS_ENABLED: "{{ lookup('config', application_id, 'addons.hookshot-feeds.enabled') | bool }}"
MATRIX_JITSI_SELF_HOSTED: "{{ (lookup('config', application_id, 'services.jitsi.enabled') | bool) and not (lookup('config', application_id, 'services.jitsi.shared') | bool) }}"
MATRIX_ADDON_JITSI_ENABLED: "{{ lookup('config', application_id, 'addons.jitsi.enabled') | bool }}"
MATRIX_ADDON_JITSI_PREFERRED_DOMAIN: "{{ lookup('config', application_id, 'addons.jitsi.config.preferred_domain') }}"
MATRIX_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') | bool }}"
MATRIX_CA_INJECTED: "{{ lookup('ca_injected', application_id) | bool }}"
@@ -0,0 +1,120 @@
"""A role's default flavor MUST address every package its other flavors can.
A role that installs itself in more than one flavor keeps one set of packages
per flavor, and the two sets are derived from different places. For
``web-app-matrix`` the compose flavor builds its bridge loop from
``meta/addons/`` (``tasks/flavor/compose/main.yml`` selects the addons whose
``mechanism`` is ``bridge`` and whose ``config.bridge_name`` is set), while the
default ansible flavor addresses packages through a mapping constant in
``vars/main.yml`` that pairs each package key with the upstream switches it
drives.
Nothing ties those two sources together, so a package added as an addon is
invisible to the default flavor until someone also adds its key to that
mapping, and a deployment that switches flavor silently loses it. The default
flavor is therefore required to be the superset: every bridge the non-default
flavor can construct must have a key the default flavor can address. The
reverse is allowed - the default flavor may know packages no addon declares.
The check compares the two derived sets rather than grepping the templates,
because the compose flavor iterates its bridges instead of naming them and a
token scan would read that as "supports none". The default flavor iterates too,
so its side is read from the mapping constant rather than from the rendered
variable names.
"""
from __future__ import annotations
import unittest
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_str
from . import PROJECT_ROOT
ROLES_DIR = PROJECT_ROOT / "roles"
BRIDGE_MECHANISM = "bridge"
PLUGIN_PREFIX = "mautrix_"
def _bridge_names(role_dir) -> set[str]:
"""Every ``config.bridge_name`` a role's bridge addons declare.
Args:
role_dir: the role's directory.
"""
names: set[str] = set()
for path in sorted((role_dir / "meta" / "addons").glob("*.yml")):
spec = load_yaml_str(read_text(str(path))) or {}
if spec.get("mechanism") != BRIDGE_MECHANISM:
continue
name = (spec.get("config") or {}).get("bridge_name")
if isinstance(name, str) and name:
names.add(name)
return names
def _unaddressed_bridges(role_dir) -> list[str]:
"""Bridge addons that name no upstream switch for the default flavor.
Args:
role_dir: the role's directory.
"""
offenders: list[str] = []
for path in sorted((role_dir / "meta" / "addons").glob("*.yml")):
spec = load_yaml_str(read_text(str(path))) or {}
if spec.get("mechanism") != BRIDGE_MECHANISM:
continue
config = spec.get("config") or {}
name = config.get("bridge_name")
if not isinstance(name, str) or not name:
continue
flags = config.get("upstream_flags")
if not isinstance(flags, list) or not [f for f in flags if str(f).strip()]:
offenders.append(path.stem)
return offenders
def _multi_flavor_roles() -> list:
"""Role directories that ship more than one ``templates/flavor/`` tree."""
return [
flavor_dir.parent.parent
for flavor_dir in sorted(ROLES_DIR.glob("*/templates/flavor"))
if len([d for d in flavor_dir.iterdir() if d.is_dir()]) > 1
]
class TestFlavorPackageSuperset(unittest.TestCase):
def test_the_default_flavor_addresses_every_bridge_addon(self) -> None:
offenders: list[str] = []
for role_dir in _multi_flavor_roles():
offenders.extend(
f"{role_dir.name}: addons.{addon_id} builds a bridge in the "
"non-default flavor but declares no config.upstream_flags, so the "
"default flavor renders no switch for it"
for addon_id in _unaddressed_bridges(role_dir)
)
if offenders:
self.fail(
f"{len(offenders)} bridge addon(s) the default flavor cannot "
"address. List the upstream switches under config.upstream_flags "
"so both flavors deploy the same set:\n" + "\n".join(offenders)
)
def test_the_check_sees_at_least_one_multi_flavor_role(self) -> None:
roles = _multi_flavor_roles()
self.assertTrue(
roles,
"no role ships two flavor trees any more, so this guard passes "
"vacuously and needs rewriting rather than silently succeeding",
)
self.assertTrue(
any(_bridge_names(role_dir) for role_dir in roles),
"no multi-flavor role declares a bridge addon with a bridge_name, so "
"the superset comparison has nothing to compare",
)
if __name__ == "__main__":
unittest.main()