mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
1 parent
55c95e6960
commit
660ad2ff16
8 files changed
+142
-30
No files matched your search
@@ -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)]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user