From 883b9c4f46b338f6fc1aefc9bf413118ac44c10f Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 6 Oct 2026 10:03:49 +0200 Subject: [PATCH 01/14] fix(web-app-prometheus): probe apps where their vhosts answer Every blackbox probe of a Tor deployment answered 404, so AppDown fired for all deployed apps although they were up. The job built its targets from canonical_domains_map(DOMAIN_PRIMARY), which knows no onion domain. In a Tor deployment the vhosts answer under . only, and the front proxy served the clearnet names from its default server. The targets now come from lookup('domains'), the map the vhosts are built from, and each target takes its scheme from the domain it probes instead of from the Prometheus vhost. Which roles are probed follows lookup('roles_with_service', 'prometheus'), the list the role's own spec expects metrics for. It leaves out the database roles, which have a domain in the map and no vhost, and the roles that declare scrape: false because their vhost carries no Prometheus block. Two roles declared more than their vhost delivers. web-svc-mirror renders a vhost of its own with no backend behind it, and on a live stack its probe answered 404 under the onion name as well, so it declares scrape: false now. web-app-peertube has a backend, but its vhost template never included the Prometheus locations. It includes them now. The spec could not see any of this in a compose deployment. dotenv_quote wraps PROMETHEUS_TARGET_ROLES_JSON in quotes there, JSON.parse returned a string, the role list came out empty and the per-role test was skipped. The value is decoded now, and the test also requires probe_success == 1 for every role in the list. Closes #792 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web-app-peertube/templates/proxy.conf.j2 | 2 + .../files/playwright/playwright.spec.js | 23 ++++- .../templates/configuration/prometheus.yml.j2 | 7 +- roles/web-svc-mirror/meta/services.yml | 2 + .../roles/web-app-prometheus/__init__.py | 0 .../test_blackbox_targets.py | 84 +++++++++++++++++++ 6 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 tests/unit/python/roles/web-app-prometheus/__init__.py create mode 100644 tests/unit/python/roles/web-app-prometheus/test_blackbox_targets.py diff --git a/roles/web-app-peertube/templates/proxy.conf.j2 b/roles/web-app-peertube/templates/proxy.conf.j2 index 1049d7bfdb..b8a8a94803 100644 --- a/roles/web-app-peertube/templates/proxy.conf.j2 +++ b/roles/web-app-peertube/templates/proxy.conf.j2 @@ -51,4 +51,6 @@ server { try_files /dev/null {{ webserver_websocket_location }}; } + + {% include 'roles/web-app-prometheus/templates/nginx/locations.conf.j2' %} } \ No newline at end of file diff --git a/roles/web-app-prometheus/files/playwright/playwright.spec.js b/roles/web-app-prometheus/files/playwright/playwright.spec.js index c3a8ecf077..f0c374918b 100644 --- a/roles/web-app-prometheus/files/playwright/playwright.spec.js +++ b/roles/web-app-prometheus/files/playwright/playwright.spec.js @@ -211,7 +211,7 @@ test("prometheus: biber is denied access after sso login", async ({ browser }) = // ----------------------------------------------------------------------------- const prometheusTargetRoles = (() => { - const raw = process.env.PROMETHEUS_TARGET_ROLES_JSON || "[]"; + const raw = decodeDotenvQuotedValue(process.env.PROMETHEUS_TARGET_ROLES_JSON || "[]"); try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; @@ -220,7 +220,7 @@ const prometheusTargetRoles = (() => { } })(); -test("prometheus scrape: every consumer role reports up=1", async ({ page }) => { +test("prometheus scrape: every consumer role reports up=1 and passes its probe", async ({ page }) => { test.skip(prometheusTargetRoles.length === 0, "no prometheus consumer roles in inventory"); const expectedPrometheusBaseUrl = prometheusBaseUrl.replace(/\/$/, ""); @@ -279,6 +279,25 @@ test("prometheus scrape: every consumer role reports up=1", async ({ page }) => failures, `prometheus scrape failures:\n - ${failures.join("\n - ")}` ).toEqual([]); + + const probeUrl = `${expectedPrometheusBaseUrl}/api/v1/query?query=${encodeURIComponent('probe_success{job="blackbox-healthz"}')}`; + await expect + .poll( + async () => { + const probes = (await (await page.request.get(probeUrl, { ignoreHTTPSErrors: true })).json())?.data?.result || []; + return prometheusTargetRoles + .filter((target) => { + const own = probes.filter((entry) => entry?.metric?.app === target.id); + return own.length === 0 || own.some((entry) => entry?.value?.[1] !== "1"); + }) + .map((target) => target.id); + }, + { + timeout: resolveTimeout(120_000), + message: "roles whose /healthz/ready probe is missing or failing (probe_success != 1)", + }, + ) + .toEqual([]); }); // Persona scenarios. diff --git a/roles/web-app-prometheus/templates/configuration/prometheus.yml.j2 b/roles/web-app-prometheus/templates/configuration/prometheus.yml.j2 index 35cd730a20..86ea8e71aa 100644 --- a/roles/web-app-prometheus/templates/configuration/prometheus.yml.j2 +++ b/roles/web-app-prometheus/templates/configuration/prometheus.yml.j2 @@ -41,20 +41,21 @@ scrape_configs: {% endfor %} -{% set canonical_map = lookup('applications') | canonical_domains_map(DOMAIN_PRIMARY) %} +{% set canonical_map = lookup('domains') %} +{% set probed_roles = lookup('roles_with_service', 'prometheus') | map(attribute='id') | list %} - job_name: "blackbox-healthz" metrics_path: /probe params: module: [http_ready] static_configs: {% for _application_id in CURRENT_PLAY_APPLICATIONS | list | sort %} -{% if _application_id != application_id %} +{% if _application_id in probed_roles %} {% set domains_raw = canonical_map.get(_application_id, []) %} {% set canonical_probe_domains = (domains_raw.values() | list) if (domains_raw is mapping) else domains_raw %} {% if canonical_probe_domains | length > 0 %} - targets: {% for domain in canonical_probe_domains %} - - "{{ lookup('tls', application_id, 'protocols.web') }}://{{ domain }}/healthz/ready" + - "{{ lookup('tls', domain, 'url.base') }}healthz/ready" {% endfor %} labels: app: "{{ _application_id }}" diff --git a/roles/web-svc-mirror/meta/services.yml b/roles/web-svc-mirror/meta/services.yml index 398a51e444..2540a50b14 100644 --- a/roles/web-svc-mirror/meta/services.yml +++ b/roles/web-svc-mirror/meta/services.yml @@ -22,6 +22,8 @@ prometheus: bond: 1 enabled: "{{ 'web-app-prometheus' in group_names }}" shared: "{{ 'web-app-prometheus' in group_names }}" + # nocheck: prometheus-scrape + scrape: false # 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: diff --git a/tests/unit/python/roles/web-app-prometheus/__init__.py b/tests/unit/python/roles/web-app-prometheus/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/roles/web-app-prometheus/test_blackbox_targets.py b/tests/unit/python/roles/web-app-prometheus/test_blackbox_targets.py new file mode 100644 index 0000000000..abc914988d --- /dev/null +++ b/tests/unit/python/roles/web-app-prometheus/test_blackbox_targets.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import unittest + +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +from utils.cache.files import PROJECT_ROOT +from utils.cache.yaml import load_yaml_str + +TEMPLATE_DIR = PROJECT_ROOT / "roles/web-app-prometheus/templates/configuration" +ONION = "a" * 56 + ".onion" + +DOMAINS = { + "web-app-prometheus": [f"prometheus.{ONION}"], + "web-app-nextcloud": {"nextcloud": f"next.cloud.{ONION}"}, + "web-app-gitea": ["git.infinito.test"], + "web-svc-cdn": ["cdn.infinito.test"], + "svc-db-postgres": ["postgres.infinito.test"], +} +ROLES_WITH_THE_PROMETHEUS_SERVICE = ["web-app-nextcloud", "web-app-gitea"] + + +def lookup(name, *terms, **_kwargs): + if name == "domains": + return DOMAINS + if name == "roles_with_service": + return [{"id": role} for role in ROLES_WITH_THE_PROMETHEUS_SERVICE] + if name == "tls": + term, want = terms + scheme = "http" if term.endswith(".onion") else "https" + return { + "url.base": f"{scheme}://{term}/", + "protocols.web": "http", + "enabled": False, + }[want] + if name == "domain": + return DOMAINS[terms[0]][0] + if name == "scrape_target": + return f"{terms[1]}:9000" + if name == "native_metrics_apps": + return [] + raise AssertionError(f"the template calls an unexpected lookup: {name!r}") + + +def probed_targets() -> dict[str, list[str]]: + env = Environment( + loader=FileSystemLoader(str(TEMPLATE_DIR)), + undefined=StrictUndefined, + autoescape=select_autoescape(), + ) + env.filters["bool"] = bool + rendered = env.get_template("prometheus.yml.j2").render( + lookup=lookup, + application_id="web-app-prometheus", + CURRENT_PLAY_APPLICATIONS=list(DOMAINS), + DOMAIN_PRIMARY="infinito.test", + ALERT_RULES_CONFIG_DOCKER="/etc/prometheus/alert_rules.yml", + ) + jobs = {job["job_name"]: job for job in load_yaml_str(rendered)["scrape_configs"]} + return { + group["labels"]["app"]: group["targets"] + for group in jobs["blackbox-healthz"]["static_configs"] + } + + +class TestBlackboxTargets(unittest.TestCase): + def test_an_onion_app_is_probed_under_its_onion_name_over_http(self): + self.assertEqual( + probed_targets()["web-app-nextcloud"], + [f"http://next.cloud.{ONION}/healthz/ready"], + ) + + def test_a_clearnet_app_is_probed_under_its_own_scheme(self): + self.assertEqual( + probed_targets()["web-app-gitea"], + ["https://git.infinito.test/healthz/ready"], + ) + + def test_only_the_roles_the_service_lookup_names_are_probed(self): + self.assertEqual(set(probed_targets()), set(ROLES_WITH_THE_PROMETHEUS_SERVICE)) + + +if __name__ == "__main__": + unittest.main() From 2a1c5e13cef45824c9c03764a9ece999c0104153 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 6 Oct 2026 14:27:05 +0200 Subject: [PATCH 02/14] fix(web-app-prometheus): judge each probe by its own target Since the targets take their scheme from the domain they probe, two switches of the http_ready module no longer fit. Both still followed the TLS state of the Prometheus vhost. fail_if_not_ssl refused an http target, an onion vhost for example, as soon as Prometheus itself was served over TLS. It is false now. The module does not follow redirects, so the scheme of the target URL already decides the transport. insecure_skip_verify switched certificate checks off whenever Prometheus itself had no TLS, also for an https target. Checks now stay on, except in a self-signed deployment whose CA is not mounted into the Prometheus containers, where the exporter has nothing to verify against. The per-role test of the spec parsed every answer of the query API as JSON. An answer that is not OK now counts as "no probe yet", so the poll keeps waiting instead of ending on a parse error. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../files/playwright/playwright.spec.js | 3 +- .../templates/configuration/blackbox.yml.j2 | 4 +- .../test_blackbox_ready_module.py | 62 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/unit/python/roles/web-app-prometheus/test_blackbox_ready_module.py diff --git a/roles/web-app-prometheus/files/playwright/playwright.spec.js b/roles/web-app-prometheus/files/playwright/playwright.spec.js index f0c374918b..8ed526e48c 100644 --- a/roles/web-app-prometheus/files/playwright/playwright.spec.js +++ b/roles/web-app-prometheus/files/playwright/playwright.spec.js @@ -284,7 +284,8 @@ test("prometheus scrape: every consumer role reports up=1 and passes its probe", await expect .poll( async () => { - const probes = (await (await page.request.get(probeUrl, { ignoreHTTPSErrors: true })).json())?.data?.result || []; + const response = await page.request.get(probeUrl, { ignoreHTTPSErrors: true }); + const probes = response.ok() ? (await response.json())?.data?.result || [] : []; return prometheusTargetRoles .filter((target) => { const own = probes.filter((entry) => entry?.metric?.app === target.id); diff --git a/roles/web-app-prometheus/templates/configuration/blackbox.yml.j2 b/roles/web-app-prometheus/templates/configuration/blackbox.yml.j2 index b8c78938dc..48147594e0 100644 --- a/roles/web-app-prometheus/templates/configuration/blackbox.yml.j2 +++ b/roles/web-app-prometheus/templates/configuration/blackbox.yml.j2 @@ -24,7 +24,7 @@ modules: method: GET follow_redirects: false fail_if_ssl: false - fail_if_not_ssl: {{ lookup('tls', application_id, 'enabled') | bool | lower }} + fail_if_not_ssl: false tls_config: - insecure_skip_verify: {{ not (lookup('tls', application_id, 'enabled') | bool) }} + insecure_skip_verify: {{ ((TLS_MODE | lower) == 'self_signed' and not lookup('ca_injected', application_id)) | lower }} preferred_ip_protocol: "ip4" diff --git a/tests/unit/python/roles/web-app-prometheus/test_blackbox_ready_module.py b/tests/unit/python/roles/web-app-prometheus/test_blackbox_ready_module.py new file mode 100644 index 0000000000..c49856cbc6 --- /dev/null +++ b/tests/unit/python/roles/web-app-prometheus/test_blackbox_ready_module.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import unittest + +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +from utils.cache.files import PROJECT_ROOT +from utils.cache.yaml import load_yaml_str + +TEMPLATE_DIR = PROJECT_ROOT / "roles/web-app-prometheus/templates/configuration" + + +def ready_module(*, tls_mode: str, prometheus_tls: bool, ca_injected: bool) -> dict: + def lookup(name, *terms): + if name == "tls" and terms[1] == "enabled": + return prometheus_tls + if name == "ca_injected": + return ca_injected + raise AssertionError(f"the template calls an unexpected lookup: {name!r}") + + env = Environment( + loader=FileSystemLoader(str(TEMPLATE_DIR)), + undefined=StrictUndefined, + autoescape=select_autoescape(), + ) + env.filters["bool"] = bool + rendered = env.get_template("blackbox.yml.j2").render( + lookup=lookup, + application_id="web-app-prometheus", + TLS_MODE=tls_mode, + ) + return load_yaml_str(rendered)["modules"]["http_ready"]["http"] + + +class TestBlackboxReadyModule(unittest.TestCase): + def test_an_http_target_passes_while_prometheus_itself_uses_tls(self): + module = ready_module( + tls_mode="letsencrypt", prometheus_tls=True, ca_injected=False + ) + self.assertIs(module["fail_if_not_ssl"], False) + + def test_certificates_are_verified_while_prometheus_itself_has_no_tls(self): + module = ready_module( + tls_mode="letsencrypt", prometheus_tls=False, ca_injected=False + ) + self.assertIs(module["tls_config"]["insecure_skip_verify"], False) + + def test_certificates_are_verified_once_the_private_ca_is_mounted(self): + module = ready_module( + tls_mode="self_signed", prometheus_tls=True, ca_injected=True + ) + self.assertIs(module["tls_config"]["insecure_skip_verify"], False) + + def test_verification_is_skipped_only_without_the_private_ca(self): + module = ready_module( + tls_mode="self_signed", prometheus_tls=False, ca_injected=False + ) + self.assertIs(module["tls_config"]["insecure_skip_verify"], True) + + +if __name__ == "__main__": + unittest.main() From 03e419d153b9eb506f70453071479e609159b444 Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:17:55 +0000 Subject: [PATCH 03/14] update(images): update Docker image versions (web-app-wordpress) --- roles/web-app-wordpress/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-wordpress/meta/services.yml b/roles/web-app-wordpress/meta/services.yml index cdc0f4ada9..ed744d0ce3 100644 --- a/roles/web-app-wordpress/meta/services.yml +++ b/roles/web-app-wordpress/meta/services.yml @@ -47,7 +47,7 @@ wordpress: enabled: true swarm: enabled: true - version: 7.1.2 + version: 7.1.3 image: wordpress custom: true name: wordpress From a6411ebe58b85b200b19438af5130efdbc5ceeb0 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 30 Sep 2026 16:10:13 +0200 Subject: [PATCH 04/14] fix(tor): stop rounds from claiming a surface the onion cannot reach A variant that pins `services.tor.enabled: true` while enabling a role whose own `meta/services.yml` pins `tor.enabled: false` declares a combination the deployment cannot hold. The role rides no node onion, so on that round its surface lives at a clearnet host no onion browser reaches, and the dashboard's `frame-src 'self' http://*.` forbids embedding it outright. Run 36651124458 job 109692287333 shows the result: five specs fail at once, dashboard tile, matomo tracker, css injection, logout and the coturn metric label, all on hosts that are the only non-onion ones in the whole job. web-app-nextcloud variant 11 was such a round: bigbluebutton on, tor on, while bigbluebutton declares no onion. Variant 8 already answers the identical question for PeerTube (65538c40da, "clearnet, because the OIDC plugin rejects a plaintext onion issuer"), so variant 11 now does the same. With that, bigbluebutton never meets a tor node and needs no consumer gates of its own. Two roles keep a gate, for reasons that differ from each other: web-app-seaweedfs is not clearnet-only. It deploys on tor as the object store other roles reach over the internal network, and only its browser frontend is off there, which `frontend.enabled` on main already says. Its logout, dashboard and prometheus declarations claimed a surface that round does not deploy, so they take the same gate the file's own frontend and sso blocks use. web-svc-coturn belongs on a tor node and cannot serve one: STUN/TURN runs on UDP plus a 20000-port relay range that no onion descriptor can publish, and its only HTTP is a gateway answering a plaintext OK. Deploying it is right, advertising it as a prometheus consumer is not, because that scrape goes through the browser path. It is exempt from the rule itself, since it claims no visitable surface, and its `dynamic-flag` markers now carry the reasons the rule's own message asks for; one of the four was a duplicate of the line below it and is gone. The lint states the rule. It found these five pairings on its own, matching a hand count made before it existed, and reports none after the variant fix. `test_sso_variant_requires_tor` covers the complementary half, every variant of a role that has an onion must pin it on, and exempts the clearnet-only role itself; this rule covers the consumers that bind one. The suppression marker belongs on the bound role when the reason is that role's own shape, because a consumer-side marker would also silence every other clearnet role in the file. Co-Authored-By: Claude Opus 5 (1M context) --- roles/web-app-nextcloud/meta/variants.yml | 4 +- roles/web-app-seaweedfs/meta/services.yml | 12 +- roles/web-svc-coturn/meta/services.yml | 12 +- .../test_clearnet_role_forbids_variant_tor.py | 145 ++++++++++++++++++ 4 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 tests/lint/ansible/roles/meta/test_clearnet_role_forbids_variant_tor.py diff --git a/roles/web-app-nextcloud/meta/variants.yml b/roles/web-app-nextcloud/meta/variants.yml index 5dad822253..60a096f8d3 100644 --- a/roles/web-app-nextcloud/meta/variants.yml +++ b/roles/web-app-nextcloud/meta/variants.yml @@ -1097,7 +1097,7 @@ enabled: false shared: false addons: *addons_static_off -# Variant 11: bigbluebutton +# Variant 11: bigbluebutton - clearnet, because bigbluebutton rides no node onion - services: container_backup: enabled: false @@ -1183,7 +1183,7 @@ enabled: false shared: false # nocheck: variants-static-override backup shared stays literally true tor: - enabled: true + enabled: false hermes: enabled: false shared: false diff --git a/roles/web-app-seaweedfs/meta/services.yml b/roles/web-app-seaweedfs/meta/services.yml index 20906c4bb6..b839222f44 100644 --- a/roles/web-app-seaweedfs/meta/services.yml +++ b/roles/web-app-seaweedfs/meta/services.yml @@ -29,16 +29,16 @@ ldap: shared: false logout: bond: 1 - enabled: "{{ 'web-svc-logout' in group_names }}" - shared: "{{ 'web-svc-logout' in group_names }}" + enabled: "{{ ('web-svc-logout' in group_names) and ('svc-net-tor' not in group_names) }}" + shared: "{{ ('web-svc-logout' in group_names) and ('svc-net-tor' not in group_names) }}" dashboard: bond: 1 - enabled: "{{ 'web-app-dashboard' in group_names }}" - shared: "{{ 'web-app-dashboard' in group_names }}" + enabled: "{{ ('web-app-dashboard' in group_names) and ('svc-net-tor' not in group_names) }}" + shared: "{{ ('web-app-dashboard' in group_names) and ('svc-net-tor' not in group_names) }}" prometheus: bond: 1 - enabled: "{{ 'web-app-prometheus' in group_names }}" - shared: "{{ 'web-app-prometheus' in group_names }}" + enabled: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}" + shared: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}" # nocheck: playwright-service-flag — central object-store provider entity seaweedfs: healthcheck: diff --git a/roles/web-svc-coturn/meta/services.yml b/roles/web-svc-coturn/meta/services.yml index 46fd83e125..a27ca559cd 100644 --- a/roles/web-svc-coturn/meta/services.yml +++ b/roles/web-svc-coturn/meta/services.yml @@ -36,7 +36,7 @@ gateway: mem_reservation: 256m mem_limit: 512m pids_limit: 256 -# nocheck: dynamic-flag +# nocheck: dynamic-flag coturn runs with user_auth_enabled false, so it keeps no credential store to back; the entry stays literal until auth is switched on and no template references redis today. redis: backup: disabled: true @@ -44,13 +44,13 @@ redis: shared: "{{ 'svc-db-redis' in group_names }}" prometheus: bond: 1 - enabled: "{{ 'web-app-prometheus' in group_names }}" - shared: "{{ 'web-app-prometheus' in group_names }}" + enabled: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}" + shared: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}" +# nocheck: clearnet-variant-tor — STUN/TURN on UDP plus a 20000-port relay range, which no onion descriptor can publish; the only HTTP is a gateway returning a plaintext OK. No dashboard, logout, css or matomo block and no Playwright spec, so an onion round claims no visitable surface for it. Its one consumer declaration, prometheus, carries the tor gate. tor: - enabled: false # nocheck: dynamic-flag - shared: false # nocheck: dynamic-flag -# nocheck: dynamic-flag + enabled: false # nocheck: dynamic-flag no group can turn this on: see the clearnet-variant-tor reason above + shared: false # nocheck: dynamic-flag same # nocheck: playwright-service-flag, dynamic-flag infrastructure backup consumer container_backup: # nocheck: bond-baseline backup host joins outside the variant topology bond: 1 diff --git a/tests/lint/ansible/roles/meta/test_clearnet_role_forbids_variant_tor.py b/tests/lint/ansible/roles/meta/test_clearnet_role_forbids_variant_tor.py new file mode 100644 index 0000000000..31004faeb9 --- /dev/null +++ b/tests/lint/ansible/roles/meta/test_clearnet_role_forbids_variant_tor.py @@ -0,0 +1,145 @@ +"""Lint guard: a variant binding a clearnet-only role must not pin the onion on. + +A role whose ``meta/services.yml`` pins ``tor.enabled`` to a literal false rides +no node onion, so on a tor deployment its surface lives at a clearnet host that +no onion browser reaches, and the dashboard's own +``frame-src 'self' http://*.`` forbids embedding it. A variant that +enables such a role and pins ``services.tor.enabled: true`` therefore declares a +combination the deployment cannot hold: the round advertises a tile, a tracker, +a stylesheet and a scrape target for a surface that is not there. + +``test_sso_variant_requires_tor`` states the complementary half - every variant +of a role that has an onion MUST pin it on - and exempts the clearnet-only role +itself. This rule covers the consumers it does not: the variants that bind one. + +The project already applies the rule by hand. ``web-app-nextcloud``'s variants +head carries "variant 8 pairs PeerTube, which is clearnet-only since 8ffb1f567, +so that round cannot pin the onion on". + +Suppression (see ``docs/contributing/actions/testing/suppression.md``): + +* ``# nocheck: clearnet-variant-tor`` in the CLEARNET role's + ``meta/services.yml``, which exempts it everywhere: a relay with no browser + surface of its own carries no tile and no injected asset, so an onion round + may deploy it without claiming anything about it. +* the same marker in a consumer's ``meta/variants.yml`` for a case that is + sound only there. + +The marker belongs on the bound role rather than the consumer whenever the +reason is the role's own shape, because a consumer-side marker would also +silence the pairings of every other clearnet role in the same file. +""" + +from __future__ import annotations + +import unittest +from typing import TYPE_CHECKING, Any + +import yaml + +from utils.annotations.suppress import line_has_rule +from utils.cache.files import read_text +from utils.cache.yaml import load_yaml_str +from utils.roles.entity.name import get_entity_name +from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_META_VARIANTS + +from . import PROJECT_ROOT + +if TYPE_CHECKING: + from pathlib import Path + +ROLES_DIR = PROJECT_ROOT / "roles" +_RULE = "clearnet-variant-tor" + + +def _load_yaml(path: Path) -> Any: + if not path.is_file(): + return None + try: + text = read_text(str(path)) + except UnicodeDecodeError: + return None + if not text.strip(): + return None + try: + return load_yaml_str(text) + except yaml.YAMLError: + return None + + +def _suppressed(paths: tuple[Path, ...]) -> bool: + for path in paths: + if not path.is_file(): + continue + if any( + line_has_rule(line, _RULE) for line in read_text(str(path)).splitlines() + ): + return True + return False + + +def _flag(variant: Any, service: str) -> Any: + services = variant.get("services") if isinstance(variant, dict) else None + entry = services.get(service) if isinstance(services, dict) else None + return entry.get("enabled") if isinstance(entry, dict) else None + + +def clearnet_only_keys() -> dict[str, str]: + """Service key to role id, for every clearnet-only role the rule covers. + + Returns: + The key a consumer's variant would bind it under, mapped to the role + that declares itself clearnet-only. A role carrying the marker in its + own ``meta/services.yml`` is left out, so it is exempt everywhere. + """ + found: dict[str, str] = {} + for role_dir in sorted(p for p in ROLES_DIR.iterdir() if p.is_dir()): + services_path = role_dir / ROLE_FILE_META_SERVICES + services = _load_yaml(services_path) + tor = services.get("tor") if isinstance(services, dict) else None + if not isinstance(tor, dict) or tor.get("enabled") is not False: + continue + if _suppressed((services_path,)): + continue + key = get_entity_name(role_dir.name) + if key: + found[key] = role_dir.name + return found + + +class TestClearnetRoleForbidsVariantTor(unittest.TestCase): + def test_no_variant_pins_the_onion_while_binding_a_clearnet_role(self) -> None: + clearnet = clearnet_only_keys() + offenders: list[str] = [] + + for role_dir in sorted(p for p in ROLES_DIR.iterdir() if p.is_dir()): + variants_path = role_dir / ROLE_FILE_META_VARIANTS + variants = _load_yaml(variants_path) + if not isinstance(variants, list) or not variants: + continue + if _suppressed((variants_path,)): + continue + + for index, variant in enumerate(variants): + if _flag(variant, "tor") is not True: + continue + for key, bound_role in sorted(clearnet.items()): + if _flag(variant, key) is True: + offenders.append( + f"{role_dir.name} variant {index}: pins " + f"services.tor.enabled true while enabling {key!r} " + f"({bound_role}), which pins tor.enabled false" + ) + + self.assertEqual( + offenders, + [], + "A variant may not pin the onion on while binding a role that rides " + "none; that round advertises a surface no onion browser can reach. " + f"Turn tor off for the variant, or suppress with '# nocheck: {_RULE}' " + "naming why the pairing is sound:\n " + "\n ".join(offenders), + ) + + +if __name__ == "__main__": + unittest.main() From 50b9f60c43c2fad92fd7bfee0c132785fbeb3436 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 30 Sep 2026 12:22:28 +0200 Subject: [PATCH 05/14] fix(prometheus): resolve the native-metrics scrape target per deployment mode `native_metrics_target` was a Jinja filter, so it could not read DEPLOYMENT_MODE: filters see no Ansible variables. It returned `.name>:` unconditionally, which is `matrix-synapse:9000` for matrix. Compose serves that name; swarm does not, because `docker stack deploy` ignores `container_name` and the alias never exists. This repository says as much itself, through `lookup('compose_only', 'container_name', ...)`. The target was therefore scraped and refused, and `playwright.spec.js:274` reported `web-app-matrix: 1 matching target(s) reporting up != 1`, meaning one matching target whose `up` value is not 1. It becomes a lookup that delegates host resolution to `scrape_target`, which was already mode-aware, so exactly one plugin knows the difference between the two modes. The port has to be handed over explicitly: matrix exposes metrics on 9000 while `services.synapse.ports.internal.http` is 8008, so the existing internal-port lookup cannot serve this case on its own. `scrape_target` now refuses `port` together with an explicit `port_kind`. Before, `port` won silently and the error text still named the `ports.internal.` path that the call had never consulted. `native_metrics_apps` drops an app that pins itself to compose through `compose_mode_force` while the cluster runs swarm, because the scrape target would then name the wrong object. Only the literal case: matrix declares that override as a Jinja expression over its flavour, which this lookup cannot resolve from the scraping role's variable scope, so variant 0 on swarm stays a latent mismatch. The two comment lines claiming `container_name:port` went with the change; after it they state the opposite of what the code does. Verified by mutation: with the swarm branch of `scrape_target` disabled, the new test emits `matrix-synapse:9000`, the exact string CI reported. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/filter/native_metrics_target.py | 51 -------- plugins/lookup/native_metrics_apps.py | 32 ++++- plugins/lookup/native_metrics_target.py | 68 ++++++++++ plugins/lookup/scrape_target.py | 17 ++- .../web-app-gitea/templates/prometheus.yml.j2 | 2 +- .../templates/prometheus.yml.j2 | 4 +- .../templates/prometheus.yml.j2 | 4 +- .../templates/prometheus.yml.j2 | 2 +- .../filter/test_native_metrics_target.py | 111 ---------------- .../lookup/test_native_metrics_target.py | 119 ++++++++++++++++++ 10 files changed, 234 insertions(+), 176 deletions(-) delete mode 100644 plugins/filter/native_metrics_target.py create mode 100644 plugins/lookup/native_metrics_target.py delete mode 100644 tests/unit/python/plugins/filter/test_native_metrics_target.py create mode 100644 tests/unit/python/plugins/lookup/test_native_metrics_target.py diff --git a/plugins/filter/native_metrics_target.py b/plugins/filter/native_metrics_target.py deleted file mode 100644 index cade81dc24..0000000000 --- a/plugins/filter/native_metrics_target.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from ansible.errors import AnsibleFilterError - -from utils.roles.entity.name import get_entity_name - - -def native_metrics_target(app_id: str, applications: dict) -> str: - """Return the container-to-container Prometheus scrape target for a native-metrics app. - - Prometheus connects to the app's Docker network (declared in prometheus's - compose.yml.j2 via the native_metrics_apps loop) and scrapes by container - name and internal port — no host port binding required, no nginx/OAuth2 in - the path. - - Usage in a per-app prometheus.yml.j2 fragment: - targets: ["{{ native_prometheus_application_id | native_metrics_target(lookup('applications')) }}"] - - When the service key differs from the app's entity name (e.g. matrix - uses service key "synapse"), set services.prometheus.native_metrics.service_key - in the app's meta/services.yml to override. - """ - app_conf = applications.get(app_id, {}) - services = app_conf.get("services", {}) if isinstance(app_conf, dict) else {} - native_metrics_conf = ( - services.get("prometheus", {}) if isinstance(services, dict) else {} - ).get("native_metrics", {}) - service_key = native_metrics_conf.get("service_key") or get_entity_name(app_id) - - container = ( - services.get(service_key, {}).get("name") - if isinstance(services, dict) - else None - ) - if not container: - raise AnsibleFilterError( - f"native_metrics_target: no services.{service_key}.name for '{app_id}'" - ) - - port = native_metrics_conf.get("port") - if port is None: - raise AnsibleFilterError( - f"native_metrics_target: no services.prometheus.native_metrics.port for '{app_id}'" - ) - - return f"{container}:{port}" - - -class FilterModule: - def filters(self): - return {"native_metrics_target": native_metrics_target} diff --git a/plugins/lookup/native_metrics_apps.py b/plugins/lookup/native_metrics_apps.py index 6a186041d3..68bc93548f 100644 --- a/plugins/lookup/native_metrics_apps.py +++ b/plugins/lookup/native_metrics_apps.py @@ -1,12 +1,35 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase from utils.cache import ROLES_DIR +from utils.cache.yaml import load_yaml_any from utils.roles.applications.config import get as get_app_conf +from utils.roles.mapping import ROLE_FILE_VARS_MAIN + + +def compose_mode_force(app_id: str) -> str: + """Return the role's own deploy-mode override. + + Args: + app_id: the role directory name. + + Returns: + The literal ``compose_mode_force`` from the role's vars, or ``""`` when + it is absent or a Jinja expression, which cannot be resolved from the + scraping role's variable scope and so leaves the override invisible here. + """ + path = ROLES_DIR / app_id / ROLE_FILE_VARS_MAIN + if not path.is_file(): + return "" + data = load_yaml_any(str(path), default_if_missing={}) + raw = data.get("compose_mode_force") if isinstance(data, Mapping) else None + value = "" if raw is None else str(raw).strip() + return "" if "{{" in value else value class LookupModule(LookupBase): @@ -16,8 +39,10 @@ class LookupModule(LookupBase): 2. a prometheus.yml.j2 template at roles//templates/ 3. reachable from this prometheus: NOT on a node-local force_bridge network under swarm, which a swarm prometheus can neither join nor - resolve. This is the single point that gates force_bridge apps out of - the native-metrics precreate + scrape. + resolve, and NOT pinned to compose by its own compose_mode_force while + the cluster runs swarm, which would make the scrape target name the + wrong object. This is the single point that gates unreachable apps out + of the native-metrics precreate + scrape. Used by web-app-prometheus/templates/configuration/prometheus.yml.j2 to auto-discover apps that expose a native /metrics endpoint without hardcoding each app name. @@ -78,6 +103,9 @@ class LookupModule(LookupBase): if is_swarm and bool(force_bridge): continue + if is_swarm and compose_mode_force(app_id) == "compose": + continue + result.append(app_id) return [result] diff --git a/plugins/lookup/native_metrics_target.py b/plugins/lookup/native_metrics_target.py new file mode 100644 index 0000000000..93140ee56e --- /dev/null +++ b/plugins/lookup/native_metrics_target.py @@ -0,0 +1,68 @@ +"""Lookup ``native_metrics_target``: scrape target for an app that exposes its +own metrics port. Reads ``services.prometheus.native_metrics`` for the service +key and port, then delegates host resolution to ``scrape_target`` so swarm and +compose stay on one implementation. + +Usage in a per-app prometheus.yml.j2 fragment: + targets: ["{{ lookup('native_metrics_target', native_prometheus_application_id) }}"] +""" + +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase + +from utils.roles.applications.config import get +from utils.roles.entity.name import get_entity_name + + +class LookupModule(LookupBase): + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[str]: + if not terms: + raise AnsibleError( + "native_metrics_target lookup requires the application_id term" + ) + + application_id = str(terms[0]).strip() + if not application_id: + raise AnsibleError("native_metrics_target: application_id must be non-empty") + + vars_ = variables or getattr(self._templar, "available_variables", {}) or {} + applications = lookup_loader.get( + "applications", loader=self._loader, templar=getattr(self, "_templar", None) + ).run([], variables=vars_)[0] + + def conf(key: str) -> str: + value = get( + applications=applications, + application_id=application_id, + config_path=f"services.prometheus.native_metrics.{key}", + strict=False, + default="", + ) + return "" if value is None else str(value).strip() + + port = conf("port") + if not port: + raise AnsibleError( + "native_metrics_target: no " + f"services.prometheus.native_metrics.port for {application_id!r}" + ) + + service_key = conf("service_key") or get_entity_name(application_id) + if not service_key: + raise AnsibleError( + f"native_metrics_target: cannot derive service key for {application_id!r}" + ) + + return lookup_loader.get( + "scrape_target", loader=self._loader, templar=getattr(self, "_templar", None) + ).run([application_id, service_key], variables=vars_, port=port) diff --git a/plugins/lookup/scrape_target.py b/plugins/lookup/scrape_target.py index d99674e8d7..06190f50be 100644 --- a/plugins/lookup/scrape_target.py +++ b/plugins/lookup/scrape_target.py @@ -1,6 +1,7 @@ """Lookup ``scrape_target``: mode-aware ``:`` for a Prometheus scrape target. Swarm resolves ``tasks._`` (per-task DNS); -compose uses ``compose_host`` or the service container name.""" +compose uses ``compose_host`` or the service container name. ``port`` overrides +the ``services..ports.internal.`` lookup.""" from __future__ import annotations @@ -37,12 +38,20 @@ class LookupModule(LookupBase): if not service_key: raise AnsibleError("scrape_target: service_key must be non-empty") - port_kind = ( + explicit_port_kind = ( _as_str(terms[2]) if len(terms) > 2 else _as_str(kwargs.get("port_kind")) - ) or "http" + ) + port_kind = explicit_port_kind or "http" compose_host = ( _as_str(terms[3]) if len(terms) > 3 else _as_str(kwargs.get("compose_host")) ) + port_override = _as_str(kwargs.get("port")) + if port_override and explicit_port_kind: + raise AnsibleError( + f"scrape_target: {application_id!r} passed both port={port_override!r} " + f"and port_kind={explicit_port_kind!r}; port wins and port_kind would be " + "silently dropped, so pass only one" + ) vars_ = variables or getattr(self._templar, "available_variables", {}) or {} templar = getattr(self, "_templar", None) @@ -57,7 +66,7 @@ class LookupModule(LookupBase): "applications", loader=self._loader, templar=getattr(self, "_templar", None) ).run([], variables=vars_)[0] - port = _as_str( + port = port_override or _as_str( get( applications=applications, application_id=application_id, diff --git a/roles/web-app-gitea/templates/prometheus.yml.j2 b/roles/web-app-gitea/templates/prometheus.yml.j2 index 3583badebf..ba2fc8151c 100644 --- a/roles/web-app-gitea/templates/prometheus.yml.j2 +++ b/roles/web-app-gitea/templates/prometheus.yml.j2 @@ -9,6 +9,6 @@ bearer_token: "{{ _token }}" {% endif %} static_configs: - - targets: ["{{ native_prometheus_application_id | native_metrics_target(lookup('applications')) }}"] + - targets: ["{{ lookup('native_metrics_target', native_prometheus_application_id) }}"] labels: app: "{{ native_prometheus_application_id }}" diff --git a/roles/web-app-matrix/templates/prometheus.yml.j2 b/roles/web-app-matrix/templates/prometheus.yml.j2 index 79e65ead8f..ac5712173f 100644 --- a/roles/web-app-matrix/templates/prometheus.yml.j2 +++ b/roles/web-app-matrix/templates/prometheus.yml.j2 @@ -1,12 +1,10 @@ # Matrix Synapse — server and federation metrics. # Requires enable_metrics: true in homeserver.yaml. # Enable via native_metrics.enabled: true in inventory. - # Scraped container-to-container: prometheus joins the matrix Docker network and - # reaches synapse directly at container_name:port — no host binding, no nginx/OAuth2. - job_name: "matrix-synapse" scheme: http metrics_path: /_synapse/metrics static_configs: - - targets: ["{{ native_prometheus_application_id | native_metrics_target(lookup('applications')) }}"] + - targets: ["{{ lookup('native_metrics_target', native_prometheus_application_id) }}"] labels: app: "{{ native_prometheus_application_id }}" diff --git a/roles/web-app-mattermost/templates/prometheus.yml.j2 b/roles/web-app-mattermost/templates/prometheus.yml.j2 index a3b4a71621..67a7e7ae66 100644 --- a/roles/web-app-mattermost/templates/prometheus.yml.j2 +++ b/roles/web-app-mattermost/templates/prometheus.yml.j2 @@ -1,11 +1,9 @@ # Mattermost — application and Go runtime metrics. - # Scraped container-to-container: prometheus joins the mattermost Docker network and - # reaches the app directly at container_name:port — no host binding, no nginx/OAuth2. # Requires MM_METRICSSETTINGS_ENABLE=true and MM_METRICSSETTINGS_LISTENADDRESS (set in env.j2 when enabled). - job_name: "mattermost" scheme: http metrics_path: /metrics static_configs: - - targets: ["{{ native_prometheus_application_id | native_metrics_target(lookup('applications')) }}"] + - targets: ["{{ lookup('native_metrics_target', native_prometheus_application_id) }}"] labels: app: "{{ native_prometheus_application_id }}" diff --git a/roles/web-app-openbao/templates/prometheus.yml.j2 b/roles/web-app-openbao/templates/prometheus.yml.j2 index 3dd99470c9..9eedd90c6f 100644 --- a/roles/web-app-openbao/templates/prometheus.yml.j2 +++ b/roles/web-app-openbao/templates/prometheus.yml.j2 @@ -4,6 +4,6 @@ params: format: ["prometheus"] static_configs: - - targets: ["{{ native_prometheus_application_id | native_metrics_target(lookup('applications')) }}"] + - targets: ["{{ lookup('native_metrics_target', native_prometheus_application_id) }}"] labels: app: "{{ native_prometheus_application_id }}" diff --git a/tests/unit/python/plugins/filter/test_native_metrics_target.py b/tests/unit/python/plugins/filter/test_native_metrics_target.py deleted file mode 100644 index e2d773c2e5..0000000000 --- a/tests/unit/python/plugins/filter/test_native_metrics_target.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -import unittest -from unittest.mock import patch - -from ansible.errors import AnsibleFilterError - -from plugins.filter.native_metrics_target import native_metrics_target - - -def _make_applications( - app_id: str, - container: str, - port: int, - entity_name: str, - service_key: str | None = None, -) -> dict: - native_metrics: dict = {"port": port} - if service_key is not None: - native_metrics["service_key"] = service_key - return { - app_id: { - "services": { - entity_name: {"name": container}, - "prometheus": {"native_metrics": native_metrics}, - } - } - } - - -class TestNativeMetricsTargetSuccess(unittest.TestCase): - def test_returns_container_colon_port(self): - apps = _make_applications("web-app-gitea", "gitea", 3000, "gitea") - with patch( - "plugins.filter.native_metrics_target.get_entity_name", return_value="gitea" - ): - result = native_metrics_target("web-app-gitea", apps) - self.assertEqual(result, "gitea:3000") - - def test_different_app(self): - apps = _make_applications( - "web-app-mattermost", "mattermost", 8067, "mattermost" - ) - with patch( - "plugins.filter.native_metrics_target.get_entity_name", - return_value="mattermost", - ): - result = native_metrics_target("web-app-mattermost", apps) - self.assertEqual(result, "mattermost:8067") - - def test_service_key_override(self): - apps = _make_applications( - "web-app-matrix", "matrix-synapse", 9000, "synapse", service_key="synapse" - ) - with patch( - "plugins.filter.native_metrics_target.get_entity_name", - return_value="matrix", - ): - result = native_metrics_target("web-app-matrix", apps) - self.assertEqual(result, "matrix-synapse:9000") - - -class TestNativeMetricsTargetErrors(unittest.TestCase): - def test_raises_when_container_name_missing(self): - apps = { - "web-app-gitea": { - "services": { - "prometheus": {"native_metrics": {"port": 3000}}, - } - } - } - with ( - patch( - "plugins.filter.native_metrics_target.get_entity_name", - return_value="gitea", - ), - self.assertRaises(AnsibleFilterError), - ): - native_metrics_target("web-app-gitea", apps) - - def test_raises_when_port_missing(self): - apps = { - "web-app-gitea": { - "services": { - "gitea": {"name": "gitea"}, - "prometheus": {"native_metrics": {}}, - } - } - } - with ( - patch( - "plugins.filter.native_metrics_target.get_entity_name", - return_value="gitea", - ), - self.assertRaises(AnsibleFilterError), - ): - native_metrics_target("web-app-gitea", apps) - - def test_raises_when_app_not_in_applications(self): - with ( - patch( - "plugins.filter.native_metrics_target.get_entity_name", - return_value="gitea", - ), - self.assertRaises(AnsibleFilterError), - ): - native_metrics_target("web-app-gitea", {}) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/python/plugins/lookup/test_native_metrics_target.py b/tests/unit/python/plugins/lookup/test_native_metrics_target.py new file mode 100644 index 0000000000..e3abbdb9dd --- /dev/null +++ b/tests/unit/python/plugins/lookup/test_native_metrics_target.py @@ -0,0 +1,119 @@ +"""Tests for the native_metrics_target lookup. + +The lookup delegates host resolution to scrape_target, so these run the real +scrape_target rather than a stub: the regression being guarded is that swarm +must not receive a compose container name, and only the delegation proves it. +""" + +from __future__ import annotations + +import unittest +from typing import Any +from unittest.mock import MagicMock, patch + +from ansible.errors import AnsibleError + +from plugins.lookup.native_metrics_target import LookupModule +from plugins.lookup.scrape_target import LookupModule as ScrapeTargetLookup + +ENTITIES = {"web-app-matrix": "matrix", "web-app-gitea": "gitea"} + + +class _Templar: + def __init__(self, vars_=None): + self.available_variables = vars_ or {} + + def template(self, value, **_): + return value + + +def _apps(native_metrics: dict[str, Any]) -> dict[str, Any]: + return { + "web-app-matrix": { + "services": { + "prometheus": {"native_metrics": native_metrics}, + "synapse": { + "name": "matrix-synapse", + "ports": {"internal": {"http": 8008}}, + }, + }, + }, + } + + +def _run(terms, *, mode, apps): + variables = {"DEPLOYMENT_MODE": mode} + lookup = LookupModule() + lookup._templar = _Templar(variables) + lookup._loader = MagicMock() + + def _dispatch(name, **_kwargs): + if name == "applications": + return MagicMock(run=lambda *_a, **_k: [apps]) + scrape = ScrapeTargetLookup() + scrape._templar = _Templar(variables) + scrape._loader = MagicMock() + return scrape + + with ( + patch( + "plugins.lookup.native_metrics_target.lookup_loader" + ) as native_loader_mock, + patch("plugins.lookup.scrape_target.lookup_loader") as scrape_loader_mock, + patch( + "plugins.lookup.native_metrics_target.get_entity_name", + side_effect=lambda app_id: ENTITIES.get(app_id, ""), + ), + patch( + "plugins.lookup.scrape_target.get_entity_name", + side_effect=lambda app_id: ENTITIES.get(app_id, ""), + ), + ): + native_loader_mock.get.side_effect = _dispatch + scrape_loader_mock.get.return_value = MagicMock(run=lambda *_a, **_k: [apps]) + return lookup.run(terms, variables=variables) + + +class TestNativeMetricsTargetLookup(unittest.TestCase): + def test_swarm_uses_per_task_dns_not_the_container_name(self): + apps = _apps({"port": 9000, "service_key": "synapse"}) + result = _run(["web-app-matrix"], mode="swarm", apps=apps) + self.assertEqual(result, ["tasks.matrix_synapse:9000"]) + + def test_compose_uses_the_container_name(self): + apps = _apps({"port": 9000, "service_key": "synapse"}) + result = _run(["web-app-matrix"], mode="compose", apps=apps) + self.assertEqual(result, ["matrix-synapse:9000"]) + + def test_service_key_defaults_to_the_entity_name(self): + apps = { + "web-app-gitea": { + "services": { + "prometheus": {"native_metrics": {"port": 3000}}, + "gitea": { + "name": "gitea", + "ports": {"internal": {"http": 3000}}, + }, + }, + }, + } + result = _run(["web-app-gitea"], mode="swarm", apps=apps) + self.assertEqual(result, ["tasks.gitea_gitea:3000"]) + + def test_native_metrics_port_takes_precedence_over_the_internal_port(self): + apps = _apps({"port": 9000, "service_key": "synapse"}) + result = _run(["web-app-matrix"], mode="compose", apps=apps) + self.assertNotIn("8008", result[0]) + + def test_missing_port_raises(self): + apps = _apps({"service_key": "synapse"}) + with self.assertRaises(AnsibleError): + _run(["web-app-matrix"], mode="compose", apps=apps) + + def test_missing_application_id_raises(self): + with self.assertRaises(AnsibleError): + _run([], mode="compose", apps=_apps({"port": 9000})) + + +if __name__ == "__main__": + unittest.main() From f77ccf56e88459740ac965c82decd7caab81669f Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 8 Oct 2026 08:54:03 +0200 Subject: [PATCH 06/14] fix(web-app-matrix): publish Synapse metrics in the ansible flavor The role advertised `matrix-synapse:9000` to Prometheus in both flavors. That container only exists in the compose flavor. In the ansible flavor, which is the default, Synapse runs inside the `matrix` container and had no metrics listener, so the scrape target was down on every deployment that pairs Matrix with Prometheus. The ansible flavor now enables the Synapse metrics listener on 9100 and the declaration names port and service per flavor, so the target is `matrix:9100` there and stays `matrix-synapse:9000` in the compose flavor. This is the metrics part of b3e0dce578 on `feature/store-i18n`, without the addon switch that branch introduces around it. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-matrix/meta/services.yml | 4 ++-- roles/web-app-matrix/templates/flavor/ansible/vars.yml.j2 | 4 ++++ roles/web-app-matrix/vars/main.yml | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/roles/web-app-matrix/meta/services.yml b/roles/web-app-matrix/meta/services.yml index 1fcc023f32..5a59dbcf13 100644 --- a/roles/web-app-matrix/meta/services.yml +++ b/roles/web-app-matrix/meta/services.yml @@ -104,8 +104,8 @@ prometheus: shared: "{{ 'web-app-prometheus' in group_names }}" native_metrics: enabled: true - port: 9000 - service_key: synapse + 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 diff --git a/roles/web-app-matrix/templates/flavor/ansible/vars.yml.j2 b/roles/web-app-matrix/templates/flavor/ansible/vars.yml.j2 index 8185cfdfd2..9db5433cae 100644 --- a/roles/web-app-matrix/templates/flavor/ansible/vars.yml.j2 +++ b/roles/web-app-matrix/templates/flavor/ansible/vars.yml.j2 @@ -153,5 +153,9 @@ matrix_bridge_mautrix_twitter_enabled: {{ MATRIX_BRIDGE.mautrix_twitter | string matrix_bridge_mautrix_whatsapp_enabled: {{ MATRIX_BRIDGE.mautrix_whatsapp | string | lower }} matrix_bridge_mautrix_gitter_enabled: {{ MATRIX_BRIDGE.gitter | string | lower }} +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 }} + jitsi_enabled: {{ MATRIX_CONFERENCING_JITSI | string | lower }} matrix_rtc_enabled: {{ MATRIX_CONFERENCING_ELEMENT_CALL | string | lower }} diff --git a/roles/web-app-matrix/vars/main.yml b/roles/web-app-matrix/vars/main.yml index deea5c3e51..96f3e2daef 100644 --- a/roles/web-app-matrix/vars/main.yml +++ b/roles/web-app-matrix/vars/main.yml @@ -82,6 +82,7 @@ MATRIX_BRIDGE: "{{ lookup('config', application_id, 'services.matrix.plugins') } 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_SYNAPSE_METRICS_PORT: "{{ lookup('config', application_id, 'services.prometheus.native_metrics.port') }}" MATRIX_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') | bool }}" From 31d391a008f848216373d61a64ebf6202869ceba Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 8 Oct 2026 08:55:51 +0200 Subject: [PATCH 07/14] style: settle the formatter rewrite of the native metrics lookup Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/lookup/native_metrics_target.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/lookup/native_metrics_target.py b/plugins/lookup/native_metrics_target.py index 93140ee56e..201f517f32 100644 --- a/plugins/lookup/native_metrics_target.py +++ b/plugins/lookup/native_metrics_target.py @@ -33,7 +33,9 @@ class LookupModule(LookupBase): application_id = str(terms[0]).strip() if not application_id: - raise AnsibleError("native_metrics_target: application_id must be non-empty") + raise AnsibleError( + "native_metrics_target: application_id must be non-empty" + ) vars_ = variables or getattr(self._templar, "available_variables", {}) or {} applications = lookup_loader.get( @@ -64,5 +66,7 @@ class LookupModule(LookupBase): ) return lookup_loader.get( - "scrape_target", loader=self._loader, templar=getattr(self, "_templar", None) + "scrape_target", + loader=self._loader, + templar=getattr(self, "_templar", None), ).run([application_id, service_key], variables=vars_, port=port) From 428b619ceb762c158306a018f10980a778ba8a8d Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 8 Oct 2026 08:56:00 +0200 Subject: [PATCH 08/14] fix(tor): gate the consumer pins of seaweedfs and coturn in their variants The tor gate that 312d09e290 put into the services.yml of web-app-seaweedfs and web-svc-coturn never took effect on a deployed round. Both roles pin the same flags to a literal true in variant 0, the round writes those pins into the inventory, and an inventory value wins over the expression in services.yml. Measured on a compose round of web-app-matrix variant 0 with tor: the inventory carried prometheus enabled true for both roles, so both stayed in the Prometheus target list and the spec failed on the missing web-app-seaweedfs label and probe. Variant 0 now carries the gate itself for prometheus on both roles and for logout and dashboard on seaweedfs, the three flags 312d09e290 names. The bond-baseline and variants-coverage guards require a literal true in variant 0 for these blocks, so they carry the same markers the frontend block of seaweedfs already has, for the same reason. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-seaweedfs/meta/services.yml | 6 ++++++ roles/web-app-seaweedfs/meta/variants.yml | 12 ++++++------ roles/web-svc-coturn/meta/services.yml | 2 ++ roles/web-svc-coturn/meta/variants.yml | 4 ++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/roles/web-app-seaweedfs/meta/services.yml b/roles/web-app-seaweedfs/meta/services.yml index b839222f44..2ba8b58900 100644 --- a/roles/web-app-seaweedfs/meta/services.yml +++ b/roles/web-app-seaweedfs/meta/services.yml @@ -27,14 +27,20 @@ sso: ldap: enabled: false shared: false +# nocheck: variants-coverage — the onion gate is a hard invariant; no variant may force this consumer on a tor node. +# nocheck: bond-baseline — same reason: variant 0 must not pin this consumer on. logout: bond: 1 enabled: "{{ ('web-svc-logout' in group_names) and ('svc-net-tor' not in group_names) }}" shared: "{{ ('web-svc-logout' in group_names) and ('svc-net-tor' not in group_names) }}" +# nocheck: variants-coverage — the onion gate is a hard invariant; no variant may force this consumer on a tor node. +# nocheck: bond-baseline — same reason: variant 0 must not pin this consumer on. dashboard: bond: 1 enabled: "{{ ('web-app-dashboard' in group_names) and ('svc-net-tor' not in group_names) }}" shared: "{{ ('web-app-dashboard' in group_names) and ('svc-net-tor' not in group_names) }}" +# nocheck: variants-coverage — the onion gate is a hard invariant; no variant may force this consumer on a tor node. +# nocheck: bond-baseline — same reason: variant 0 must not pin this consumer on. prometheus: bond: 1 enabled: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}" diff --git a/roles/web-app-seaweedfs/meta/variants.yml b/roles/web-app-seaweedfs/meta/variants.yml index 77e2c251f1..678afaac86 100644 --- a/roles/web-app-seaweedfs/meta/variants.yml +++ b/roles/web-app-seaweedfs/meta/variants.yml @@ -9,14 +9,14 @@ enabled: true shared: true logout: - enabled: true - shared: true + enabled: "{{ 'svc-net-tor' not in group_names }}" + shared: "{{ 'svc-net-tor' not in group_names }}" dashboard: - enabled: true - shared: true + enabled: "{{ 'svc-net-tor' not in group_names }}" + shared: "{{ 'svc-net-tor' not in group_names }}" prometheus: - enabled: true - shared: true + enabled: "{{ 'svc-net-tor' not in group_names }}" + shared: "{{ 'svc-net-tor' not in group_names }}" - services: container_backup: enabled: false diff --git a/roles/web-svc-coturn/meta/services.yml b/roles/web-svc-coturn/meta/services.yml index a27ca559cd..26f1a9818f 100644 --- a/roles/web-svc-coturn/meta/services.yml +++ b/roles/web-svc-coturn/meta/services.yml @@ -42,6 +42,8 @@ redis: disabled: true enabled: false shared: "{{ 'svc-db-redis' in group_names }}" +# nocheck: variants-coverage — the tor conjunct is the clearnet-variant-tor invariant below, so no variant may pin the true polarity on an onion node +# nocheck: bond-baseline — same reason: variant 0 must not pin the scrape consumer on prometheus: bond: 1 enabled: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}" diff --git a/roles/web-svc-coturn/meta/variants.yml b/roles/web-svc-coturn/meta/variants.yml index c3cf254fc1..1297a4474f 100644 --- a/roles/web-svc-coturn/meta/variants.yml +++ b/roles/web-svc-coturn/meta/variants.yml @@ -9,8 +9,8 @@ container_backup: enabled: true prometheus: - enabled: true - shared: true + enabled: "{{ 'svc-net-tor' not in group_names }}" + shared: "{{ 'svc-net-tor' not in group_names }}" redis: shared: true tor: From 75460b95cd4fd37bc48271b49d9e1e4fe1145dcd Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 8 Oct 2026 10:01:45 +0200 Subject: [PATCH 09/14] feat(update): leave an image pin to the version source it declares A pin that names its own upstream in an update: block was still collected by the image updater, which raises it to the newest tag of its registry. For a component of a release bundle that tag is not the bundle's, so the two updaters would propose different versions for the same line and such a pin had to carry nocheck: docker-version. The image updater now skips a version that declares a source for itself. The source resolver alone bumps it. Co-Authored-By: Claude Opus 5.5 (1M context) --- cli/contributing/update/README.md | 4 +- tests/unit/python/utils/update/test_docker.py | 42 ++++++++++++++++++- utils/update/base.py | 16 +++++++ utils/update/docker.py | 21 ++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/cli/contributing/update/README.md b/cli/contributing/update/README.md index 00eff7b60f..84e5e19a02 100644 --- a/cli/contributing/update/README.md +++ b/cli/contributing/update/README.md @@ -21,7 +21,9 @@ stalwart: ``` `key` names the pinned key and defaults to `version`; an entity with several -pins declares a list of such blocks. Types: +pins declares a list of such blocks. An `image` + `version` pin that declares a +source for `version` is bumped from that source alone, the Docker updater skips +it. Types: | `type` | Fields | Reads | | --------------- | --------------------- | -------------------------------------- | diff --git a/tests/unit/python/utils/update/test_docker.py b/tests/unit/python/utils/update/test_docker.py index 4fac5a7802..b44f72ef2a 100644 --- a/tests/unit/python/utils/update/test_docker.py +++ b/tests/unit/python/utils/update/test_docker.py @@ -5,7 +5,33 @@ import unittest from pathlib import Path from utils.cache.files import read_text -from utils.update.docker import _registry_cursor, update_config_versions +from utils.roles.mapping import ROLE_FILE_META_SERVICES +from utils.update.docker import ( + _registry_cursor, + collect_entries, + sourced_services, + update_config_versions, +) + +SOURCED = """bundled: + image: example/app + version: v1.0.0 + update: + type: http_regex + url: https://example.invalid/bundle + pattern: "app:(v[0-9.]+)" +tracked: + image: example/side + version: v2.0.0 + other_version: v3.0.0 + update: + - key: other_version + type: git_tags + repository: https://example.invalid/side.git +plain: + image: example/plain + version: v4.0.0 +""" class TestRegistryCursor(unittest.TestCase): @@ -16,6 +42,20 @@ class TestRegistryCursor(unittest.TestCase): self.assertIsNone(_registry_cursor("19.1.1")) +class TestDeclaredSource(unittest.TestCase): + def test_a_version_with_its_own_source_is_left_to_that_source(self) -> None: + root = Path(tempfile.mkdtemp()) + config = root / "roles" / "web-app-example" / ROLE_FILE_META_SERVICES + config.parent.mkdir(parents=True) + config.write_text(SOURCED, encoding="utf-8") + + self.assertEqual(sourced_services(config), {"bundled"}) + self.assertEqual( + sorted(entry.service for entry in collect_entries(root)), + ["plain", "tracked"], + ) + + class TestUpdateDocker(unittest.TestCase): def test_update_config_versions_updates_only_target_services(self) -> None: original = """moodle: diff --git a/utils/update/base.py b/utils/update/base.py index 892cbd7be3..c4fa188346 100644 --- a/utils/update/base.py +++ b/utils/update/base.py @@ -105,3 +105,19 @@ def is_maintained(roles_root: Path, role: str) -> bool: """ lifecycle = get_role_lifecycle(roles_root / role, role_name=role) return lifecycle not in UNMAINTAINED_LIFECYCLES + + +def declares_source(config: object, key: str) -> bool: + """Report whether a services entity names its own upstream for a pin. + + Args: + config: one entity of ``meta/services.yml``. + key: the pinned key; an ``update:`` block without ``key`` names + ``version``. + """ + declared = config.get("update") if isinstance(config, dict) else None + sources = declared if isinstance(declared, list) else [declared] + return any( + isinstance(source, dict) and source.get("key", "version") == key + for source in sources + ) diff --git a/utils/update/docker.py b/utils/update/docker.py index 65af50cd0d..7c44bed6bf 100644 --- a/utils/update/docker.py +++ b/utils/update/docker.py @@ -8,6 +8,9 @@ walker that rewrites the matching ``version:`` line. Suppress a check by placing ``# nocheck: docker-version`` on the line directly above the ``version:`` key. + +A ``version`` that names its own upstream in an ``update:`` block belongs to +:mod:`utils.update.source` and is not collected here. """ from __future__ import annotations @@ -28,6 +31,7 @@ import yaml from utils.annotations.suppress import is_suppressed_at from utils.cache.files import read_text +from utils.cache.yaml import load_yaml from utils.docker.image.discovery import iter_role_images from utils.docker.image.ref import ( DOCKER_HUB_REGISTRIES, @@ -38,6 +42,7 @@ from utils.docker.image.ref import ( from utils.docker.registry import fetch_registry_tags from utils.roles.mapping import ROLE_FILE_META_SERVICES from utils.update.base import ( + declares_source, is_maintained, is_semver, latest_semver, @@ -265,6 +270,20 @@ def suppressed_services(config_path: Path) -> set[str]: return names +def sourced_services(config_path: Path) -> set[str]: + """Return service names whose ``version`` declares its own upstream. + + Args: + config_path: a role's ``meta/services.yml``. + """ + services = load_yaml(str(config_path)) or {} + return { + str(service) + for service, config in services.items() + if declares_source(config, "version") + } + + def collect_entries(repo_root: Path) -> list[DockerImageVersionEntry]: roles_root = repo_root / "roles" entries: list[DockerImageVersionEntry] = [] @@ -280,6 +299,8 @@ def collect_entries(repo_root: Path) -> list[DockerImageVersionEntry]: config_path = roles_root / ref.role / ROLE_FILE_META_SERVICES if ref.service in suppressed_services(config_path): continue + if ref.service in sourced_services(config_path): + continue if ref.registry == "docker.io": image = ref.name From 09b594b102e7a842f53cb1ce9b6a73aa775e2b33 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 8 Oct 2026 10:06:44 +0200 Subject: [PATCH 10/14] fix(web-app-opentalk): run product version 26.2.1 and follow its bundle The web frontend, the controller and the recorder were pinned to v2.9.0, v0.34.1 and v0.16.2, a mix of the 26.2 and the 26.1 line, and excluded from the image updater with nocheck: docker-version. Nothing moved them. They now sit on the set OpenTalk publishes as product version 26.2.1 in ot-setup (v2.9.2, v0.34.5, v0.17.5), and each pin names that bundle as its version source, so the three move together with the next product version. The markers are gone. Recorder v0.17 no longer reads recording jobs from RabbitMQ or logs in with an OIDC service account. The controller starts recordings on the recorder's REST API and both sides authenticate with API keys: recorder.toml.j2 names the controller and the recorder's own key, controller.toml.j2 enables roomserver.modules.recording, the secret recorder_api_secret replaces recorder_oidc_client_secret and the Keycloak client opentalk-recorder is no longer created. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-opentalk/README.md | 2 +- roles/web-app-opentalk/meta/secrets.yml | 4 +-- roles/web-app-opentalk/meta/services.yml | 21 ++++++++++-- .../tasks/02_keycloak_client.yml | 32 ------------------- .../web-app-opentalk/templates/compose.yml.j2 | 2 +- .../templates/controller.toml.j2 | 6 ++-- .../templates/recorder.toml.j2 | 14 +++----- roles/web-app-opentalk/vars/main.yml | 8 +++-- 8 files changed, 36 insertions(+), 53 deletions(-) diff --git a/roles/web-app-opentalk/README.md b/roles/web-app-opentalk/README.md index a3e78e943d..412390e70c 100644 --- a/roles/web-app-opentalk/README.md +++ b/roles/web-app-opentalk/README.md @@ -76,7 +76,7 @@ Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional ( - **Single Sign-On:** Frontend and controller share one Keycloak client and realm. - **Federated User Lookup:** Invitee search resolves through the Keycloak admin Web API, so any user federated from OpenLDAP into Keycloak becomes findable inside OpenTalk. - **OpenCloud File Integration:** Pick files from OpenCloud in a meeting without re-authenticating when `web-app-opencloud` is deployed alongside. -- **Meeting Recordings:** A dedicated recorder consumes recording jobs from the RabbitMQ queue and stores MP4 archives in MinIO. +- **Meeting Recordings:** The controller starts recordings on a dedicated recorder over its REST API, and the recorder stores MP4 archives in MinIO. - **Shared Infrastructure:** Reuses `svc-db-postgres` for relational data and `web-svc-coturn` for STUN and TURN. ## Quick Setup diff --git a/roles/web-app-opentalk/meta/secrets.yml b/roles/web-app-opentalk/meta/secrets.yml index a36001f92f..e0b40eb4e4 100644 --- a/roles/web-app-opentalk/meta/secrets.yml +++ b/roles/web-app-opentalk/meta/secrets.yml @@ -15,8 +15,8 @@ credentials: algorithm: alphanumeric validation: min_length: 32 - recorder_oidc_client_secret: - description: Client secret for the dedicated confidential Keycloak client (`opentalk-recorder`) the recorder uses to authenticate against the controller. + recorder_api_secret: + description: API key secret the controller signs its requests to the recorder with (`roomserver.modules.recording.api_key`, `http.api_keys` of the recorder). algorithm: alphanumeric validation: min_length: 32 diff --git a/roles/web-app-opentalk/meta/services.yml b/roles/web-app-opentalk/meta/services.yml index f78205a7a3..4ceba7a5ba 100644 --- a/roles/web-app-opentalk/meta/services.yml +++ b/roles/web-app-opentalk/meta/services.yml @@ -61,7 +61,11 @@ opentalk: placement: manager name: opentalk image: registry.opencode.de/opentalk/web-frontend - version: v2.9.0 # nocheck: docker-version — component of the OpenTalk set; TODO: pin the set to a released bundle, then unpin + version: v2.9.2 + update: + type: http_regex + url: https://gitlab.opencode.de/opentalk/ot-setup/-/raw/main/docker-compose.yaml + pattern: "OT_FRONTEND_IMAGE_TAG:-(v[0-9.]+)\\}" min_storage: 234MB backup: no_stop_required: true @@ -80,7 +84,11 @@ opentalk: controller: name: opentalk-controller image: registry.opencode.de/opentalk/controller - version: v0.34.1 # nocheck: docker-version — component of the OpenTalk set; TODO: pin the set to a released bundle, then unpin + version: v0.34.5 + update: + type: http_regex + url: https://gitlab.opencode.de/opentalk/ot-setup/-/raw/main/docker-compose.yaml + pattern: "OPENTALK_CONTROLLER_IMAGE_TAG:-(v[0-9.]+)\\}" min_storage: 291MB backup: no_stop_required: true @@ -143,7 +151,14 @@ rabbit: recorder: name: opentalk-recorder image: registry.opencode.de/opentalk/recorder - version: v0.16.2 # nocheck: docker-version — v0.17 drops the rabbitmq transport and reshapes [controller]; TODO: migrate recorder.toml.j2, then unpin + version: v0.17.5 + update: + type: http_regex + url: https://gitlab.opencode.de/opentalk/ot-setup/-/raw/main/docker-compose.yaml + pattern: "RECORDER_IMAGE_TAG:-(v[0-9.]+)\\}" + ports: + internal: + http: 11511 min_storage: 775MB cpus: "1.5" mem_reservation: 512m diff --git a/roles/web-app-opentalk/tasks/02_keycloak_client.yml b/roles/web-app-opentalk/tasks/02_keycloak_client.yml index 1b319fcf94..331151333f 100644 --- a/roles/web-app-opentalk/tasks/02_keycloak_client.yml +++ b/roles/web-app-opentalk/tasks/02_keycloak_client.yml @@ -74,38 +74,6 @@ delegate_to: "{{ OPENTALK_KEYCLOAK_HOST_NODE }}" no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" -- name: "📝 Upsert dedicated confidential Keycloak client for the OpenTalk recorder" - keycloak_kcadm_update: - object_kind: "client" - lookup_value: "{{ OPENTALK_RECORDER_OIDC_CLIENT_ID }}" - desired: - clientId: "{{ OPENTALK_RECORDER_OIDC_CLIENT_ID }}" - name: "OpenTalk Recorder" - description: "Confidential service-account client used by the OpenTalk recorder to authenticate against the controller." - enabled: true - protocol: "openid-connect" - publicClient: false - bearerOnly: false - standardFlowEnabled: false - implicitFlowEnabled: false - directAccessGrantsEnabled: false - serviceAccountsEnabled: true - clientAuthenticatorType: "client-secret" - secret: "{{ OPENTALK_RECORDER_OIDC_CLIENT_SECRET }}" - attributes: - "client.secret.creation.time": "0" - "oauth2.device.authorization.grant.enabled": "false" - "use.refresh.tokens": "true" - force_attrs: - publicClient: false - serviceAccountsEnabled: true - secret: "{{ OPENTALK_RECORDER_OIDC_CLIENT_SECRET }}" - kcadm_exec: "container exec -i {{ OPENTALK_KEYCLOAK_LOCAL_CID }} /opt/keycloak/bin/kcadm.sh" # nocheck: container-exec-resolver OPENTALK_KEYCLOAK_LOCAL_CID resolved at delegate_to runtime - realm: "{{ OIDC.CLIENT.REALM }}" - assert_mode: "{{ MODE_ASSERT | bool }}" - delegate_to: "{{ OPENTALK_KEYCLOAK_HOST_NODE }}" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - - name: "🔐 Grant OpenTalk controller service account `view-users` role" ansible.builtin.shell: |- {{ KCEXEC }} sh -lc ' diff --git a/roles/web-app-opentalk/templates/compose.yml.j2 b/roles/web-app-opentalk/templates/compose.yml.j2 index 1f3b15103a..20c05159c8 100644 --- a/roles/web-app-opentalk/templates/compose.yml.j2 +++ b/roles/web-app-opentalk/templates/compose.yml.j2 @@ -56,7 +56,7 @@ {{ lookup('compose_restart') }} {{ lookup('container_volumes', service_name) | indent(4) }} {{ lookup('container_networks') | indent(4) }} - {{ lookup('depends_on', {'rabbit': 'service_healthy', 'controller': 'service_started'}) }} + {{ lookup('depends_on', {'controller': 'service_started'}) }} logging: driver: journald diff --git a/roles/web-app-opentalk/templates/controller.toml.j2 b/roles/web-app-opentalk/templates/controller.toml.j2 index a628775876..69df4a30ed 100644 --- a/roles/web-app-opentalk/templates/controller.toml.j2 +++ b/roles/web-app-opentalk/templates/controller.toml.j2 @@ -6,7 +6,7 @@ base_url = "{{ OPENTALK_URL }}" [http] port = {{ OPENTALK_CTRL_INTERNAL_PORT }} -service_api_keys = [{ "id" = "controller", "secret" = "{{ lookup('config', application_id, 'secrets.credentials.service_api_secret') }}" }] +service_api_keys = [{ "id" = "{{ OPENTALK_CTRL_SERVICE_API_KEY_ID }}", "secret" = "{{ OPENTALK_CTRL_SERVICE_API_SECRET }}" }] [database] url = "postgres://{{ lookup('database', application_id, 'username') }}:{{ lookup('database', application_id, 'password') }}@{{ lookup('database', application_id, 'host') }}:{{ lookup('database', application_id, 'port') }}/{{ lookup('database', application_id, 'name') }}" @@ -38,6 +38,9 @@ public_url = "wss://{{ OPENTALK_DOMAIN }}/livekit" service_url = "http://livekit:{{ OPENTALK_LIVEKIT_HTTP_PORT }}" api_key = "{{ OPENTALK_LIVEKIT_API_KEY }}" api_secret = "{{ OPENTALK_LIVEKIT_API_SECRET }}" +[roomserver.modules.recording] +url = "http://recorder:{{ OPENTALK_RECORDER_HTTP_PORT }}" +api_key = { id = "{{ OPENTALK_RECORDER_API_KEY_ID }}", secret = "{{ OPENTALK_RECORDER_API_SECRET }}" } [roomserver.modules.echo] [roomserver.modules.chat] [roomserver.modules.polls] @@ -55,5 +58,4 @@ secret_key = "{{ lookup('objstore', application_id, 'secret_key') }}" [rabbit_mq] url = "amqp://guest:guest@rabbit/%2F" mail_task_queue = "opentalk_mailer" -recording_task_queue = "{{ OPENTALK_RABBIT_QUEUE }}" diff --git a/roles/web-app-opentalk/templates/recorder.toml.j2 b/roles/web-app-opentalk/templates/recorder.toml.j2 index 8927749a53..f6c538521f 100644 --- a/roles/web-app-opentalk/templates/recorder.toml.j2 +++ b/roles/web-app-opentalk/templates/recorder.toml.j2 @@ -1,14 +1,10 @@ # OpenTalk recorder configuration. # Reference: https://docs.opentalk.eu/admin/recorder/configuration/ -[auth] -issuer = "{{ OIDC.CLIENT.ISSUER_URL }}" -client_id = "{{ OPENTALK_RECORDER_OIDC_CLIENT_ID }}" -client_secret = "{{ OPENTALK_RECORDER_OIDC_CLIENT_SECRET }}" - [controller] -domain = "{{ OPENTALK_DOMAIN }}" +url = "http://controller:{{ OPENTALK_CTRL_INTERNAL_PORT }}" +api_key = { id = "{{ OPENTALK_CTRL_SERVICE_API_KEY_ID }}", secret = "{{ OPENTALK_CTRL_SERVICE_API_SECRET }}" } -[rabbitmq] -uri = "amqp://guest:guest@rabbit/%2F" -queue = "{{ OPENTALK_RABBIT_QUEUE }}" +[http] +port = {{ OPENTALK_RECORDER_HTTP_PORT }} +api_keys = [{ id = "{{ OPENTALK_RECORDER_API_KEY_ID }}", secret = "{{ OPENTALK_RECORDER_API_SECRET }}" }] diff --git a/roles/web-app-opentalk/vars/main.yml b/roles/web-app-opentalk/vars/main.yml index a4625936b4..4a9e652102 100644 --- a/roles/web-app-opentalk/vars/main.yml +++ b/roles/web-app-opentalk/vars/main.yml @@ -12,6 +12,8 @@ entity_name: "{{ application_id | get_entity_name }}" OPENTALK_CTRL_CONTAINER: "{{ lookup('config', application_id, 'services.controller.name') }}" OPENTALK_CTRL_HTTP_PORT: "{{ lookup('config', application_id, 'services.controller.ports.local.http') }}" OPENTALK_CTRL_INTERNAL_PORT: "{{ lookup('config', application_id, 'services.controller.ports.internal.http') }}" +OPENTALK_CTRL_SERVICE_API_KEY_ID: "controller" +OPENTALK_CTRL_SERVICE_API_SECRET: "{{ lookup('config', application_id, 'secrets.credentials.service_api_secret') }}" OPENTALK_FRONTEND_CONTAINER: "{{ lookup('config', application_id, 'services.opentalk.name') }}" OPENTALK_FRONTEND_HTTP_PORT: "{{ lookup('config', application_id, 'services.opentalk.ports.local.http') }}" @@ -34,11 +36,11 @@ OPENTALK_LIVEKIT_API_SECRET: "{{ lookup('config', application_id, 'secrets.crede OPENTALK_OBJSTORE_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, lookup('objstore', application_id, 'engine')) }}" OPENTALK_RABBIT_CONTAINER: "{{ lookup('config', application_id, 'services.rabbit.name') }}" -OPENTALK_RABBIT_QUEUE: "opentalk_recorder" OPENTALK_RECORDER_CONTAINER: "{{ lookup('config', application_id, 'services.recorder.name') }}" -OPENTALK_RECORDER_OIDC_CLIENT_ID: "opentalk-recorder" -OPENTALK_RECORDER_OIDC_CLIENT_SECRET: "{{ lookup('config', application_id, 'secrets.credentials.recorder_oidc_client_secret') }}" +OPENTALK_RECORDER_HTTP_PORT: "{{ lookup('config', application_id, 'services.recorder.ports.internal.http') }}" +OPENTALK_RECORDER_API_KEY_ID: "recorder" +OPENTALK_RECORDER_API_SECRET: "{{ lookup('config', application_id, 'secrets.credentials.recorder_api_secret') }}" OPENTALK_KEYCLOAK_BASE_URL: "{{ lookup('tls', 'web-app-keycloak', 'url.base') | regex_replace('/+$','') }}" OPENTALK_KEYCLOAK_CONTAINER_ADDRESS: "{{ lookup('container_address', 'web-app-keycloak', 'keycloak') }}" From f1dbd2d92f5e8e915ad580a615a17c295481c408 Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:51:11 +0000 Subject: [PATCH 11/14] update(images): update Docker image versions (test-e2e-playwright) --- roles/test-e2e-playwright/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/test-e2e-playwright/meta/services.yml b/roles/test-e2e-playwright/meta/services.yml index 621cb432fd..893702e0a6 100644 --- a/roles/test-e2e-playwright/meta/services.yml +++ b/roles/test-e2e-playwright/meta/services.yml @@ -4,4 +4,4 @@ test-e2e-playwright: lifecycle: beta playwright: image: mcr.microsoft.com/playwright - version: "v1.63.0-noble" + version: "v1.64.0-noble" From 7736755bc877fb6302401031c532c05ecb7b1ca0 Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:51:19 +0000 Subject: [PATCH 12/14] update(images): update Docker image versions (web-app-pixelfed) --- roles/web-app-pixelfed/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-pixelfed/meta/services.yml b/roles/web-app-pixelfed/meta/services.yml index 72dc71d410..10fa557e0a 100644 --- a/roles/web-app-pixelfed/meta/services.yml +++ b/roles/web-app-pixelfed/meta/services.yml @@ -49,7 +49,7 @@ pixelfed: shared: true enabled: true # nocheck: dynamic-flag image: zknt/pixelfed - version: "20261007" + version: "20261008" name: pixelfed min_storage: 588MB backup: From 5baccf49cdcc3fcd049e3643caea83d1d1885c42 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 8 Oct 2026 17:08:35 +0200 Subject: [PATCH 13/14] fix(dev-compose): build the stack image without host networking Buildx 0.38.0 gates host networking in bake behind an entitlement that Compose never grants, so `make compose-up` failed wherever Compose had to build the image itself. The build block asked for it without need: scripts/image/build.sh builds the same Dockerfile on the bridge network. Closes #875 Co-Authored-By: Claude Opus 5.5 (1M context) --- compose.yml | 1 - ...est_compose_build_needs_no_host_network.py | 60 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 tests/lint/repository/test_compose_build_needs_no_host_network.py diff --git a/compose.yml b/compose.yml index 5cc60f4415..3bb9f3e536 100644 --- a/compose.yml +++ b/compose.yml @@ -23,7 +23,6 @@ services: build: context: . dockerfile: Dockerfile - network: host args: INFINITO_PARENT_IMAGE: ${INFINITO_PARENT_IMAGE:?Run 'make dotenv' to generate the .env single source of truth} INFINITO_SRC_DIR: ${INFINITO_SRC_DIR:?Run 'make dotenv' to generate the .env single source of truth} diff --git a/tests/lint/repository/test_compose_build_needs_no_host_network.py b/tests/lint/repository/test_compose_build_needs_no_host_network.py new file mode 100644 index 0000000000..bb951b001a --- /dev/null +++ b/tests/lint/repository/test_compose_build_needs_no_host_network.py @@ -0,0 +1,60 @@ +"""Compose hands a build to ``docker buildx bake`` and grants it only the +``fs.read`` entitlement of the build context. Since Buildx 0.38.0 a block with +``network: host`` stops there with ``additional privileges requested``, and +declaring ``build.entitlements`` or setting ``COMPOSE_BAKE=false`` does not +change that, so such a service cannot be built through ``make compose-up``. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from utils.cache.files import iter_project_files +from utils.cache.yaml import load_yaml_any + +from . import PROJECT_ROOT + +STACK_FILE = str(PROJECT_ROOT / "compose.yml") +OVERRIDES = f"{PROJECT_ROOT / 'compose'}/" +STACK_BUILD = "compose.yml: infinito" + + +def _builds(path): + """Yield ``(label, build)`` for every service of a compose file that builds. + + Args: + path: compose file to read; a YAML file without ``services`` yields nothing. + """ + document = load_yaml_any(path) + if not isinstance(document, dict): + return + for name, service in (document.get("services") or {}).items(): + build = service.get("build") + if isinstance(build, dict): + yield f"{Path(path).relative_to(PROJECT_ROOT)}: {name}", build + + +class TestComposeBuildNeedsNoHostNetwork(unittest.TestCase): + def test_no_build_block_asks_for_host_networking(self) -> None: + compose_files = [ + path + for path in sorted(iter_project_files(extensions=(".yml",))) + if path == STACK_FILE or path.startswith(OVERRIDES) + ] + builds = dict(hit for path in compose_files for hit in _builds(path)) + self.assertIn(STACK_BUILD, builds, "the stack build block was not scanned") + offenders = [ + label for label, build in builds.items() if build.get("network") == "host" + ] + self.assertEqual( + [], + offenders, + "Compose cannot grant bake the network.host entitlement " + "(Buildx >= 0.38.0 refuses the build); drop 'network: host' from: " + f"{offenders}", + ) + + +if __name__ == "__main__": + unittest.main() From 057347df3f5894249485d9aa15077bc751539561 Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:44:00 +0000 Subject: [PATCH 14/14] update(images): update Docker image versions (web-app-socialhome) --- roles/web-app-socialhome/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-socialhome/meta/services.yml b/roles/web-app-socialhome/meta/services.yml index 77098a50d5..43f68c9d7a 100644 --- a/roles/web-app-socialhome/meta/services.yml +++ b/roles/web-app-socialhome/meta/services.yml @@ -13,7 +13,7 @@ socialhome: # nocheck: default-placement-manager embedded sqlite in a node-local volume; no clustered storage placement: manager image: ghcr.io/social-home-io/socialhome - version: "2026.10.7" + version: "2026.10.8" ports: local: http: 8089