"""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)