fix(web-app-seaweedfs): keep the frontend on every node that serves clearnet

The frontend and its sso, logout, dashboard and prometheus consumers were
gated on 'svc-net-tor' not in group_names, written when a Tor node served
onion only. On a multi node that removed the clearnet filer vhost, so the
storage specs of every SeaweedFS consumer failed on a clearnet pick with
ERR_SSL_UNRECOGNIZED_NAME_ALERT on filer.<domain>.

lookup('network_served', <network>, *role_ids) answers whether the node's
resolved NETWORK_MODE serves a network and every named role is deployed.
The gates use it instead of restating the mode resolution and the provider
check, so the frontend now drops only on a tor-only node. is_explicit_truth
registers the lookup in GATED_LOOKUPS so the static closure still reads
those flags as dynamic, and the dynamic-flag and universal-logout checks ask
is_explicit_truth instead of matching "in group_names" themselves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-16 13:35:29 +02:00
1 parent 55c95e6960
commit 660ad2ff16
8 files changed
+142 -30

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
"""Whether this host serves one registry network, optionally with given roles deployed."""
from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.networks.reachability import (
NODE_MODES,
network,
resolve_node_mode,
tor_serves_host,
)
class LookupModule(LookupBase):
"""
Usage:
{{ lookup('network_served', network_name) }}
{{ lookup('network_served', network_name, role_id, ...) }}
Returns True when the node's resolved ``NETWORK_MODE`` serves
``network_name`` and every given ``role_id`` is in ``group_names``.
"""
def run(self, terms, variables: dict[str, Any] | None = None, **kwargs):
if not terms or not str(terms[0] or "").strip():
raise AnsibleError(
"lookup('network_served', network, *role_ids) expects a network"
)
variables = variables or getattr(self._templar, "available_variables", {}) or {}
templar = getattr(self, "_templar", None)
raw_mode = variables.get("NETWORK_MODE") or ""
if templar is not None and isinstance(raw_mode, str):
raw_mode = templar.template(raw_mode)
try:
name = network(str(terms[0]).strip()).name
mode = resolve_node_mode(raw_mode, tor_provided=tor_serves_host(variables))
except ValueError as exc:
raise AnsibleError(f"network_served: {exc}") from exc
deployed = set(variables.get("group_names") or [])
roles = [str(role).strip() for role in terms[1:]]
return [name in NODE_MODES[mode] and all(role in deployed for role in roles)]
+14 -14
View File
@@ -3,21 +3,21 @@
# internal network (matrix S3 media etc.), never a Tor-browser surface, so it
# rides no node onion.
# nocheck: dynamic-flag — role-local browser UI, never consumed by other roles; shared stays literal.
# nocheck: variants-coverage — the onion gate is a hard invariant; no variant may force the frontend on a tor node.
# nocheck: variants-coverage the onion gate is a hard invariant; no variant may force the frontend on a tor-only node.
# nocheck: bond-baseline — same reason: variant 0 must not pin the frontend on.
frontend:
bond: 1
enabled: "{{ 'svc-net-tor' not in group_names }}"
enabled: "{{ lookup('network_served', 'clearnet') }}"
shared: false
domains:
- filer
- master
# nocheck: variants-coverage the onion gate is a hard invariant; no variant may force this consumer on a tor node.
# nocheck: variants-coverage the onion gate is a hard invariant; no variant may force this consumer on a tor-only node.
# nocheck: bond-baseline same reason: variant 0 must not pin this consumer on.
sso:
bond: 1
enabled: "{{ ('web-app-keycloak' in group_names) and ('svc-net-tor' not in group_names) }}"
shared: "{{ ('web-app-keycloak' in group_names) and ('svc-net-tor' not in group_names) }}"
enabled: "{{ lookup('network_served', 'clearnet', 'web-app-keycloak') }}"
shared: "{{ lookup('network_served', 'clearnet', 'web-app-keycloak') }}"
flavor: oauth2
oauth2:
origin:
@@ -29,24 +29,24 @@ 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: variants-coverage the onion gate is a hard invariant; no variant may force this consumer on a tor-only 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.
enabled: "{{ lookup('network_served', 'clearnet', 'web-svc-logout') }}"
shared: "{{ lookup('network_served', 'clearnet', 'web-svc-logout') }}"
# nocheck: variants-coverage the onion gate is a hard invariant; no variant may force this consumer on a tor-only 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.
enabled: "{{ lookup('network_served', 'clearnet', 'web-app-dashboard') }}"
shared: "{{ lookup('network_served', 'clearnet', 'web-app-dashboard') }}"
# nocheck: variants-coverage the onion gate is a hard invariant; no variant may force this consumer on a tor-only 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) }}"
shared: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}"
enabled: "{{ lookup('network_served', 'clearnet', 'web-app-prometheus') }}"
shared: "{{ lookup('network_served', 'clearnet', 'web-app-prometheus') }}"
# nocheck: playwright-service-flag — central object-store provider entity
seaweedfs:
healthcheck:
+9 -9
View File
@@ -1,22 +1,22 @@
---
# nocheck: sso-variant-tor — meta/services.yml gates sso on
# "svc-net-tor not in group_names": the frontend is off on a tor node by design, so
# lookup('network_served', 'clearnet'): the frontend is off on a tor-only node by design, so
# there is no SSO-over-onion flow to exercise.
- services:
container_backup:
enabled: true
sso:
enabled: "{{ 'svc-net-tor' not in group_names }}"
shared: "{{ 'svc-net-tor' not in group_names }}"
enabled: "{{ lookup('network_served', 'clearnet') }}"
shared: "{{ lookup('network_served', 'clearnet') }}"
logout:
enabled: "{{ 'svc-net-tor' not in group_names }}"
shared: "{{ 'svc-net-tor' not in group_names }}"
enabled: "{{ lookup('network_served', 'clearnet') }}"
shared: "{{ lookup('network_served', 'clearnet') }}"
dashboard:
enabled: "{{ 'svc-net-tor' not in group_names }}"
shared: "{{ 'svc-net-tor' not in group_names }}"
enabled: "{{ lookup('network_served', 'clearnet') }}"
shared: "{{ lookup('network_served', 'clearnet') }}"
prometheus:
enabled: "{{ 'svc-net-tor' not in group_names }}"
shared: "{{ 'svc-net-tor' not in group_names }}"
enabled: "{{ lookup('network_served', 'clearnet') }}"
shared: "{{ lookup('network_served', 'clearnet') }}"
- services:
container_backup:
enabled: false
@@ -73,6 +73,7 @@ from utils.roles.applications.services.registry import (
build_covered_key_to_role,
build_role_to_primary_service_key,
build_service_registry_from_roles_dir,
is_explicit_truth,
)
from utils.roles.entity.name import get_entity_name
from utils.roles.mapping import ROLE_FILE_META_SERVICES
@@ -143,9 +144,7 @@ _MAIL_PROVIDER_INDIRECTION = "MAIL_PROVIDER in group_names"
def _references_role_in_group_names(value, role_name: str) -> bool:
"""Return True iff *value* is a Jinja string of the shape
``{{ '<role_name>' in group_names }}`` (single or double quotes)."""
if not isinstance(value, str):
return False
if "in group_names" not in value:
if not isinstance(value, str) or not is_explicit_truth(value):
return False
return f"'{role_name}'" in value or f'"{role_name}"' in value
@@ -194,7 +193,7 @@ class TestServicesDynamicFlags(unittest.TestCase):
if (service_key, flag) in inline_exempt:
continue
value = entry[flag]
if not (isinstance(value, str) and "in group_names" in value):
if not (isinstance(value, str) and is_explicit_truth(value)):
offenders.append(
f"{role_name}: services.{service_key}.{flag}={value!r} "
f"is literal; replace with a Jinja "
@@ -4,6 +4,7 @@ import unittest
import yaml
from utils.cache.yaml import load_yaml_any
from utils.roles.applications.services.registry import is_explicit_truth
from utils.roles.mapping import ROLE_FILE_META_SERVICES
@@ -35,7 +36,7 @@ class TestUniversalLogoutSetting(unittest.TestCase):
)
else:
val = logout["enabled"]
is_dynamic = isinstance(val, str) and "in group_names" in val
is_dynamic = isinstance(val, str) and is_explicit_truth(val)
if not isinstance(val, bool) and not is_dynamic:
errors.append(
f"The 'services.logout.enabled' setting in '{file_path}' must be boolean true/false "
@@ -0,0 +1,51 @@
import unittest
from ansible.errors import AnsibleError
import plugins.lookup.network_served as served_mod
class TestNetworkServedLookup(unittest.TestCase):
def _run(self, name, **variables):
return served_mod.LookupModule().run([name], variables=variables)[0]
def test_a_node_without_tor_serves_clearnet_only(self):
self.assertTrue(self._run("clearnet", group_names=["web-app-x"]))
self.assertFalse(self._run("tor", group_names=["web-app-x"]))
def test_a_tor_node_without_a_mode_serves_both(self):
self.assertTrue(self._run("clearnet", group_names=["svc-net-tor"]))
self.assertTrue(self._run("tor", group_names=["svc-net-tor"]))
def test_a_tor_only_node_drops_clearnet(self):
self.assertFalse(
self._run("clearnet", group_names=["svc-net-tor"], NETWORK_MODE="tor")
)
def test_a_tor_node_pinned_to_clearnet_drops_tor(self):
self.assertFalse(
self._run("tor", group_names=["svc-net-tor"], NETWORK_MODE="clearnet")
)
def test_a_named_role_must_be_deployed(self):
lookup = served_mod.LookupModule()
variables = {"group_names": ["svc-net-tor", "web-app-keycloak"]}
self.assertTrue(lookup.run(["clearnet", "web-app-keycloak"], variables=variables)[0])
self.assertFalse(lookup.run(["clearnet", "web-app-dashboard"], variables=variables)[0])
def test_a_deployed_role_on_a_tor_only_node_stays_off(self):
lookup = served_mod.LookupModule()
variables = {"group_names": ["svc-net-tor", "web-app-keycloak"], "NETWORK_MODE": "tor"}
self.assertFalse(lookup.run(["clearnet", "web-app-keycloak"], variables=variables)[0])
def test_a_mode_the_node_cannot_serve_fails_loudly(self):
with self.assertRaises(AnsibleError):
self._run("clearnet", group_names=["web-app-x"], NETWORK_MODE="tor")
def test_an_unknown_network_fails_loudly(self):
with self.assertRaises(AnsibleError):
self._run("handshake", group_names=["svc-net-tor"])
if __name__ == "__main__":
unittest.main()
@@ -7,6 +7,7 @@ from pathlib import Path
from utils.cache.files import PROJECT_ROOT, read_text
from utils.cache.yaml import dump_yaml_str
from utils.roles.applications.services.registry import (
GATED_LOOKUPS,
GROUP_GATED_CONSTANTS,
ServiceRegistryError,
build_service_registry_from_applications,
@@ -357,6 +358,17 @@ class TestExplicitTruth(unittest.TestCase):
with self.subTest(constant=constant):
self.assertTrue(is_explicit_truth("{{ " + constant + " }}"))
def test_every_registered_lookup_resolves(self):
for token in GATED_LOOKUPS:
with self.subTest(token=token):
self.assertTrue(is_explicit_truth("{{ " + token + ", 'clearnet') }}"))
def test_every_registered_lookup_has_a_plugin(self):
for token in GATED_LOOKUPS:
name = token.split("'")[1]
with self.subTest(lookup=name):
self.assertTrue((PROJECT_ROOT / "plugins" / "lookup" / f"{name}.py").is_file())
def test_an_unregistered_jinja_expression_does_not_resolve(self):
self.assertFalse(
is_explicit_truth("{{ lookup('config', 'web-app-gitea', 'mcp.enabled') }}")
@@ -37,12 +37,16 @@ this kind MUST be listed here or the roles referencing it silently drop their
provider from the static closure.
"""
GATED_LOOKUPS: tuple[str, ...] = ("lookup('network_served'",)
"""Lookup calls whose answer depends on the node shape, so a flag built on
one is dynamic even without an ``in group_names`` test."""
def is_explicit_truth(value: Any) -> bool:
"""A services.<key> flag (``enabled`` / ``shared``) MAY be true if it
is the literal Python ``True``, any string containing the substring
``in group_names``, or a string referencing a `GROUP_GATED_CONSTANTS`
entry. Negated and compound predicates
or `GATED_LOOKUPS` entry. Negated and compound predicates
(``"{{ 'X' not in group_names }}"``) match on purpose: they are true
on some node shape, and the static closure is the co-deploy superset.
Mirrors the substring test at
@@ -61,7 +65,7 @@ def is_explicit_truth(value: Any) -> bool:
if not isinstance(value, str):
return False
return "in group_names" in value or any(
constant in value for constant in GROUP_GATED_CONSTANTS
token in value for token in (*GROUP_GATED_CONSTANTS, *GATED_LOOKUPS)
)