Files
computer-playbook/utils/github/variant/network.py
T
kevinveenbirkenbachandClaude Opus 5.5 b87bf5f979 fix(ci): disable partners a tor-only node serves no network of
A variant that pairs a clearnet-only partner with the onion (Matrix
variant 2 with web-svc-coturn) could draw the tor-only network mode, and
the deploy's domain map then aborted with "web-svc-coturn: allows
['clearnet'] but the node runs in 'tor' mode". disabled_services() now
also emits every deployed partner with domains and a Tor bond whose
networks the node mode does not serve, mirroring utils/cache/domains.py,
so the inventory provision drops it the same way it drops Tor on
clearnet rows. Measured across every role: 8 tor-only rows change, all of
which failed at that point before; clearnet and multi rows are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:16:53 +01:00

249 lines
8.7 KiB
Python

"""The network axis of the CI deploy matrix: which node network modes a row may take.
Separate from :mod:`utils.github.variant.axes` because it answers a different
question. ``axes`` decides which combination a row takes this sweep; this
module decides which network modes exist for that row at all -- read off the
role's ``meta/services.yml`` and ``meta/networks.yml``, off the covered
variant, off the deploy mode, and off the run's own ``network`` input.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from utils import PROJECT_ROOT
from utils.cache.yaml import load_yaml_any
from utils.networks.reachability import (
CLEARNET,
MULTI,
NETWORKS,
NODE_MODES,
TOR,
role_reachability,
)
from utils.roles.applications.services.registry import (
build_service_registry_from_roles_dir,
)
from utils.roles.mapping import (
ROLE_FILE_META_DOMAINS,
ROLE_FILE_META_NETWORKS,
ROLE_FILE_META_SERVICES,
)
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from typing import Any
ROLES_DIR = PROJECT_ROOT / "roles"
AUTO = "auto"
TOR_SERVICE = "tor"
NETWORK_INPUTS = (AUTO, *NODE_MODES)
NETWORK_DEPLOY_MODES = ("swarm", "compose")
def _meta(app: str, filename: str) -> dict[str, Any]:
path = ROLES_DIR / app / filename
if not path.exists():
return {}
try:
loaded = load_yaml_any(path) or {}
except Exception: # noqa: BLE001 malformed role meta must not break the matrix
return {}
return loaded if isinstance(loaded, dict) else {}
def _tor_flag(config: Mapping[str, Any]) -> Any:
"""The ``services.tor.enabled`` value of one config, ``None`` without a bond."""
services = config.get("services") if isinstance(config, dict) else None
tor = services.get("tor") if isinstance(services, dict) else None
return tor.get("enabled") if isinstance(tor, dict) else None
def _row_tor_flag(
app: str,
variant: int | None,
variants_per_app: Mapping[str, Sequence[Mapping[str, Any]]] | None,
) -> Any:
"""The row's ``services.tor.enabled``: the covered variant's value, else
the role's base ``meta/services.yml`` one; ``None`` without a Tor bond.
Roles pin the gate ``true`` in variant 0 and ``false`` in the rest."""
declared = (variants_per_app or {}).get(app) or []
if variant is None or not 0 <= variant < len(declared):
return _tor_flag({"services": _meta(app, ROLE_FILE_META_SERVICES)})
return _tor_flag(declared[variant])
def _reachability(
app: str,
variant: int | None,
variants_per_app: Mapping[str, Sequence[Mapping[str, Any]]] | None,
) -> tuple[tuple[str, ...], bool]:
declared = (variants_per_app or {}).get(app) or []
config: Mapping[str, Any] = {}
if variant is not None and 0 <= variant < len(declared):
config = declared[variant]
if not isinstance(config, dict) or "networks" not in config:
config = {"networks": _meta(app, ROLE_FILE_META_NETWORKS)}
return role_reachability(config, app=app)
def tor_provider() -> str | None:
"""Application id of the role providing the ``tor`` service, ``None`` if
the registry names none. Resolved rather than hardcoded so renaming the
provider role cannot leave the matrix pointing at a dead id."""
entry = build_service_registry_from_roles_dir(ROLES_DIR).get(TOR_SERVICE) or {}
role = entry.get("role")
return role if isinstance(role, str) and role else None
def row_pulls_tor(row: Mapping[str, Any]) -> bool:
"""Whether a discovery row's ``services`` closure takes the Tor provider in."""
return tor_provider() in (row.get("services") or ())
def _partner_networks(app: str) -> tuple[str, ...] | None:
"""The networks a deployed partner may be served on.
Args:
app: the partner's application id.
Returns:
``None`` for a partner without domains or without a Tor bond, which the
deploy's domain map never refuses; else its ``reachability.modes``,
without ``tor`` when its Tor gate is a literal false.
"""
if not _meta(app, ROLE_FILE_META_DOMAINS):
return None
flag = _tor_flag({"services": _meta(app, ROLE_FILE_META_SERVICES)})
if flag is None:
return None
modes, _ = role_reachability(
{"networks": _meta(app, ROLE_FILE_META_NETWORKS)}, app=app
)
return tuple(name for name in modes or NETWORKS if flag is not False or name != TOR)
def row_states(
app: str,
variant: int | None = None,
variants_per_app: Mapping[str, Sequence[Mapping[str, Any]]] | None = None,
*,
pulls_tor: bool = False,
) -> tuple[str, ...]:
"""Every node network mode the row's role can be served in.
Args:
app: application id.
variant: the row's variant index; ``None`` for a role declaring none.
variants_per_app: rendered variant configs per app.
pulls_tor: the row's deploy takes the Tor provider in
(:func:`row_pulls_tor`).
Returns:
``clearnet`` alone when the covered variant pins the Tor gate to a
literal false, or when the role has no Tor bond and nothing pulls the
provider in. ``clearnet`` and ``multi`` for a role without a bond whose
deploy pulls the provider in anyway: the node then runs Tor while the
role stays on clearnet. Otherwise, a reactive Jinja gate included, the
modes whose networks all lie inside the role's ``reachability.modes``,
without ``multi`` for a ``single_mode`` role and without ``clearnet``
for the Tor provider, which disabling Tor would strip out of its own
deploy.
"""
flag = _row_tor_flag(app, variant, variants_per_app)
if flag is None:
return (CLEARNET, MULTI) if pulls_tor else (CLEARNET,)
if flag is False:
return (CLEARNET,)
modes, single_mode = _reachability(app, variant, variants_per_app)
allowed = modes or tuple(NETWORKS)
states = [
state
for state, networks in NODE_MODES.items()
if all(name in allowed for name in networks)
]
if single_mode:
states = [state for state in states if state != MULTI]
if app == tor_provider():
states = [state for state in states if state != CLEARNET]
return tuple(states)
def resolve_network_input(raw: str | None = None) -> str:
"""Network axis input from ``INFINITO_NETWORK``; unknown or empty means ``auto``.
Args:
raw: explicit value; ``None`` reads the environment.
Returns:
one of :data:`NETWORK_INPUTS`.
"""
if raw is None:
raw = os.environ.get("INFINITO_NETWORK")
value = (raw or "").strip().lower()
return value if value in NETWORK_INPUTS else AUTO
def network_states(
mode: str, *, states: Sequence[str], network_input: str
) -> list[str]:
"""The network modes one deploy *mode* is worth running for a row.
Args:
mode: the deploy mode the row runs in.
states: the network modes the row's role can be served in
(:func:`row_states`).
network_input: the run's network axis.
Returns:
Every state under ``auto``, the pinned state alone when the row can
take it and nothing otherwise. A deploy mode that carries no network
axis (host) only ever yields ``clearnet``.
"""
if mode not in NETWORK_DEPLOY_MODES:
states = [state for state in states if state == CLEARNET]
if network_input == AUTO:
return list(states)
return [network_input] if network_input in states else []
def combinations(
offered: Sequence[str], *, states: Sequence[str], network_input: str
) -> list[tuple[str, str]]:
"""Every ``(mode, network)`` pair a priority row is deployed in.
Priority rows are the ones a run must not sample: they cover the whole
cross-product of the modes their role offers and the network modes each
of those deploy modes can take, in one sweep.
"""
return [
(mode, state)
for mode in offered
for state in network_states(mode, states=states, network_input=network_input)
]
def disabled_services(state: str, services: Sequence[str] = ()) -> str:
"""The ``disable`` tokens a row deploys with.
Args:
state: the node network mode of the row.
services: the roles the row's variant deploys.
Returns:
Tor when the node serves no network that needs it, and every deployed
partner (:func:`_partner_networks`) the node serves none of the
networks of, since the deploy refuses that partner there.
"""
tokens = [] if TOR in NODE_MODES[state] else [TOR_SERVICE]
for name in services:
networks = _partner_networks(name)
if networks is not None and not set(NODE_MODES[state]) & set(networks):
tokens.append(name)
return " ".join(tokens)