mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Fourteen roles repeated the same two lines to say they use the gateway: a
`shared` flag gated on group_names, and an `enabled` flag conjoining that gate
with LITELLM_CHAT_MODEL_SERVED. Both are now single-sourced.
`shared` becomes literal true. The flag means the provider is a shared instance
rather than one deployed per consumer, and a central gateway is always that;
the dynamic form only said "shared if deployed", which `enabled` already
decides. The 50 shared pins under `litellm:` leave meta/variants.yml with it.
The tree had already noticed: 20 of those 50 lines carried
`# nocheck: variants-static-override`, two of them with copy-pasted text about
container_backup. This narrows e5de4e085a for one service key rather than
reversing it; every other dynamic flag keeps the group_names form.
`enabled` becomes `{{ LITELLM_USABLE }}`, defined in group_vars/all/16_ai.yml.
That shorthand is only possible because is_explicit_truth learns about it.
The resolver reads a dependency out of the flag TEXT — "in group_names" in
value — so a constant hides the role id and the provider silently leaves the
static closure of every role referencing it. Measured: the old expression
resolves True, `{{ LITELLM_USABLE }}` resolves False. GROUP_GATED_CONSTANTS
names the constants whose own definition carries the gate, and the docstring
states the obligation: a SPOT of this kind that is not listed there drops its
provider without a word.
The wider fix, accepting any Jinja as dynamic, was measured and rejected: 35
other flags carry a Jinja value without the marker (MCP sidecars, javascript
and SSO gates, recaptcha), and they would all enter closures they are not in
today.
A literal shared flag is no longer dynamic, so test_dynamic_flags wants the
`# nocheck: dynamic-flag` its own message documents for legitimate exceptions;
where a marker already existed the rule list is extended rather than a second
comment added.
Behaviour is unchanged where it counts. The closure keeps litellm with the same
app counts (wordpress 26, openwebui 23, hermes 23, mediawiki 22, flowise 23),
unrelated Jinja still resolves False, and the truth table of the new enabled
form matches the old one in all four served/deployed combinations. Three roles
resolve without litellm, and `git show HEAD:` proves that predates this change:
zammad pins enabled false in variant 0 at line 39, nextcloud at line 97.
Six unit tests cover the resolver, including one asserting that every
registered constant is actually defined in group_vars, so an entry cannot
become a dead promise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
457 lines
16 KiB
Python
457 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import functools
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from utils.cache.yaml import load_yaml_any
|
|
from utils.roles.entity.name import get_entity_name
|
|
from utils.roles.mapping import ROLE_FILE_VARS_MAIN
|
|
from utils.roles.meta_lookup import get_role_run_after
|
|
from utils.roles.validation.invokable import types_from_group_names
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Iterable
|
|
|
|
|
|
class ServiceRegistryError(ValueError):
|
|
"""Raised when role-local service discovery is invalid."""
|
|
|
|
|
|
def _as_mapping(value: Any) -> dict[str, Any]:
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _normalized_name(value: Any) -> str:
|
|
if not isinstance(value, str):
|
|
return ""
|
|
return value.strip()
|
|
|
|
|
|
GROUP_GATED_CONSTANTS: tuple[str, ...] = ("LITELLM_USABLE",)
|
|
"""Group_vars constants whose own definition carries an ``in group_names``
|
|
gate, so a flag referencing one is dynamic even though its own text is not.
|
|
|
|
A constant hides the role id the substring rule looks for, so every SPOT of
|
|
this kind MUST be listed here or the roles referencing it silently drop their
|
|
provider from the static closure.
|
|
"""
|
|
|
|
|
|
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
|
|
(``"{{ '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
|
|
``tests/integration/roles/meta/services/test_dynamic_flags.py:194``.
|
|
|
|
Static-analysis callers (``ServicesResolver``,
|
|
``resolve_service_dependency_roles_from_config``, lint tests) MUST
|
|
use this helper instead of comparing against ``True`` directly so
|
|
that the dynamic-flag form keeps resolving deps at tooling time.
|
|
The runtime path goes through ansible templating before reaching
|
|
these checks, where the Jinja has already been rendered to a real
|
|
boolean, so accepting the unrendered form is purely additive.
|
|
"""
|
|
if value is True:
|
|
return True
|
|
if not isinstance(value, str):
|
|
return False
|
|
return "in group_names" in value or any(
|
|
constant in value for constant in GROUP_GATED_CONSTANTS
|
|
)
|
|
|
|
|
|
def detect_service_channel(role_name: str) -> str:
|
|
return "frontend" if role_name.startswith(("web-app-", "web-svc-")) else "backend"
|
|
|
|
|
|
def detect_deploy_type(role_name: str) -> str:
|
|
detected = types_from_group_names([role_name])
|
|
if "server" in detected:
|
|
return "server"
|
|
if "workstation" in detected:
|
|
return "workstation"
|
|
if "universal" in detected:
|
|
return "universal"
|
|
return "server" if role_name.startswith(("web-app-", "web-svc-")) else "universal"
|
|
|
|
|
|
def detect_service_bucket(role_name: str) -> str:
|
|
deploy_type = detect_deploy_type(role_name)
|
|
if deploy_type == "universal":
|
|
return "universal"
|
|
if deploy_type == "workstation":
|
|
return "workstation"
|
|
if role_name.startswith("web-svc-"):
|
|
return "web-svc"
|
|
if role_name.startswith("web-app-"):
|
|
return "web-app"
|
|
return deploy_type
|
|
|
|
|
|
def read_yaml_file(path: Path) -> dict[str, Any]:
|
|
if not path.is_file():
|
|
return {}
|
|
return _as_mapping(load_yaml_any(str(path), default_if_missing={}) or {})
|
|
|
|
|
|
def load_applications_from_roles_dir(roles_dir: Path) -> dict[str, dict[str, Any]]:
|
|
applications: dict[str, dict[str, Any]] = {}
|
|
for role_dir in sorted(p for p in roles_dir.iterdir() if p.is_dir()):
|
|
vars_file = role_dir / ROLE_FILE_VARS_MAIN
|
|
if not vars_file.is_file():
|
|
continue
|
|
vars_data = read_yaml_file(vars_file)
|
|
application_id = _normalized_name(vars_data.get("application_id"))
|
|
if not application_id:
|
|
continue
|
|
meta_dir = role_dir / "meta"
|
|
config: dict[str, Any] = {}
|
|
services_data = read_yaml_file(meta_dir / "services.yml")
|
|
if services_data:
|
|
config["services"] = services_data
|
|
for topic in ("server", "rbac", "volumes"):
|
|
topic_data = read_yaml_file(meta_dir / f"{topic}.yml")
|
|
if topic_data:
|
|
config[topic] = topic_data
|
|
applications[application_id] = config
|
|
return applications
|
|
|
|
|
|
def discover_role_services(
|
|
role_name: str,
|
|
config: dict[str, Any],
|
|
) -> dict[str, dict[str, Any]]:
|
|
services = _as_mapping(config.get("services"))
|
|
entity_name = get_entity_name(role_name)
|
|
primary_entry = _as_mapping(services.get(entity_name))
|
|
alias_entries = {
|
|
key: _as_mapping(entry)
|
|
for key, entry in services.items()
|
|
if isinstance(entry, dict)
|
|
and _normalized_name(_as_mapping(entry).get("canonical")) == entity_name
|
|
}
|
|
provides = _normalized_name(primary_entry.get("provides"))
|
|
if provides == entity_name:
|
|
provides = ""
|
|
|
|
is_provider = bool(primary_entry) and (
|
|
bool(primary_entry.get("shared"))
|
|
or "provides" in primary_entry
|
|
or alias_entries
|
|
)
|
|
if not is_provider:
|
|
return {}
|
|
|
|
primary_id = provides or entity_name
|
|
raw_covers = primary_entry.get("covers")
|
|
covers: list[str] = (
|
|
[_normalized_name(item) for item in raw_covers if isinstance(item, str)]
|
|
if isinstance(raw_covers, list)
|
|
else []
|
|
)
|
|
covers = [c for c in covers if c]
|
|
base_entry = {
|
|
"role": role_name,
|
|
"entity_name": entity_name,
|
|
"source_key": entity_name,
|
|
"deploy_type": detect_deploy_type(role_name),
|
|
"bucket": detect_service_bucket(role_name),
|
|
"service_type": detect_service_channel(role_name),
|
|
"shared": bool(primary_entry.get("shared", False)),
|
|
"enabled": bool(primary_entry.get("enabled", False)),
|
|
"covers": covers,
|
|
}
|
|
app_networks = _as_mapping(config.get("networks"))
|
|
overlay = app_networks.get("overlay")
|
|
if isinstance(overlay, dict):
|
|
base_entry["overlay"] = overlay
|
|
if provides:
|
|
base_entry["provides"] = provides
|
|
|
|
discovered: dict[str, dict[str, Any]] = {primary_id: base_entry}
|
|
for alias_key, alias_entry in sorted(alias_entries.items()):
|
|
discovered[alias_key] = {
|
|
**base_entry,
|
|
"source_key": alias_key,
|
|
"canonical": primary_id,
|
|
"shared": bool(alias_entry.get("shared", False)),
|
|
"enabled": bool(alias_entry.get("enabled", False)),
|
|
}
|
|
|
|
return discovered
|
|
|
|
|
|
def build_service_registry_from_applications(
|
|
applications: dict[str, Any],
|
|
) -> dict[str, dict[str, Any]]:
|
|
registry: dict[str, dict[str, Any]] = {}
|
|
for role_name, config in sorted(applications.items()):
|
|
role_services = discover_role_services(role_name, _as_mapping(config))
|
|
for service_key, entry in role_services.items():
|
|
existing = registry.get(service_key)
|
|
if existing and existing.get("role") != entry.get("role"):
|
|
raise ServiceRegistryError(
|
|
f"Duplicate service key '{service_key}' is declared by both "
|
|
f"'{existing.get('role')}' and '{entry.get('role')}'."
|
|
)
|
|
registry[service_key] = entry
|
|
return registry
|
|
|
|
|
|
@functools.cache
|
|
def _service_registry_by_roles_dir(roles_dir_key: str) -> dict[str, dict[str, Any]]:
|
|
return build_service_registry_from_applications(
|
|
load_applications_from_roles_dir(Path(roles_dir_key))
|
|
)
|
|
|
|
|
|
def build_service_registry_from_roles_dir(
|
|
roles_dir: Path,
|
|
) -> dict[str, dict[str, Any]]:
|
|
return _service_registry_by_roles_dir(str(Path(roles_dir).resolve()))
|
|
|
|
|
|
def expand_service_tokens(tokens: list[str], roles_dir: Path) -> list[str]:
|
|
"""Normalize service reference tokens so a caller may name either the
|
|
service/entity key (e.g. ``email``, ``prometheus``) or the provider
|
|
application id (e.g. ``web-app-mailu``, ``web-app-prometheus``).
|
|
|
|
An application id is expanded to the service key(s) that role provides;
|
|
every other token is kept verbatim. Order-preserving, de-duplicated.
|
|
Used wherever the operator supplies service references (``disable``,
|
|
include/prune closures) so both spellings resolve identically.
|
|
"""
|
|
if not tokens:
|
|
return []
|
|
try:
|
|
registry = build_service_registry_from_roles_dir(roles_dir)
|
|
except Exception: # noqa: BLE001 - normalization must never crash a caller; fall back to the raw tokens
|
|
return list(dict.fromkeys(tokens))
|
|
|
|
role_to_keys: dict[str, list[str]] = {}
|
|
for key, entry in registry.items():
|
|
role = (entry or {}).get("role")
|
|
if isinstance(role, str) and role:
|
|
role_to_keys.setdefault(role, []).append(key)
|
|
|
|
out: list[str] = []
|
|
for token in tokens:
|
|
if token in registry:
|
|
out.append(token)
|
|
elif token in role_to_keys:
|
|
out.extend(role_to_keys[token])
|
|
else:
|
|
out.append(token)
|
|
return list(dict.fromkeys(out))
|
|
|
|
|
|
def build_role_to_primary_service_key(
|
|
service_registry: dict[str, dict[str, Any]],
|
|
) -> dict[str, str]:
|
|
result: dict[str, str] = {}
|
|
for service_key, entry in service_registry.items():
|
|
if "canonical" in entry:
|
|
continue
|
|
role_name = _normalized_name(entry.get("role"))
|
|
if role_name:
|
|
result[role_name] = service_key
|
|
return result
|
|
|
|
|
|
def build_role_to_covered_keys(
|
|
service_registry: dict[str, dict[str, Any]],
|
|
) -> dict[str, list[str]]:
|
|
"""For each role, return ``[primary_key, *covers]`` — the set of
|
|
service keys whose presence in a consumer's ``services`` map counts
|
|
as an explicit declaration of a dep on that role. ``covers`` comes
|
|
from the primary entity's ``covers:`` list (e.g. ``web-app-keycloak``
|
|
covers ``oauth2`` because the per-app oauth2-proxy only makes sense
|
|
when the IdP is on the host)."""
|
|
result: dict[str, list[str]] = {}
|
|
for service_key, entry in service_registry.items():
|
|
if "canonical" in entry:
|
|
continue
|
|
role_name = _normalized_name(entry.get("role"))
|
|
if not role_name:
|
|
continue
|
|
keys = [service_key]
|
|
for covered in entry.get("covers", []) or []:
|
|
covered_name = _normalized_name(covered)
|
|
if covered_name and covered_name not in keys:
|
|
keys.append(covered_name)
|
|
result[role_name] = keys
|
|
return result
|
|
|
|
|
|
def build_covered_key_to_role(
|
|
service_registry: dict[str, dict[str, Any]],
|
|
) -> dict[str, str]:
|
|
"""Inverse of :func:`build_role_to_covered_keys`: for each covered
|
|
service key (NOT including the primary), the role that covers it.
|
|
Use :func:`build_role_to_primary_service_key` for the primary
|
|
direction; this helper is intentionally limited to the
|
|
``covers:`` extension so callers can distinguish "this role IS the
|
|
provider" from "this role covers this dep transitively"."""
|
|
result: dict[str, str] = {}
|
|
for entry in service_registry.values():
|
|
if "canonical" in entry:
|
|
continue
|
|
role_name = _normalized_name(entry.get("role"))
|
|
if not role_name:
|
|
continue
|
|
for covered in entry.get("covers", []) or []:
|
|
covered_name = _normalized_name(covered)
|
|
if covered_name and covered_name not in result:
|
|
result[covered_name] = role_name
|
|
return result
|
|
|
|
|
|
def canonical_service_key(
|
|
service_registry: dict[str, dict[str, Any]],
|
|
service_key: str,
|
|
) -> str:
|
|
entry = _as_mapping(service_registry.get(service_key))
|
|
return _normalized_name(entry.get("canonical")) or service_key
|
|
|
|
|
|
def equivalent_service_keys(
|
|
service_registry: dict[str, dict[str, Any]],
|
|
service_key: str,
|
|
) -> list[str]:
|
|
primary = canonical_service_key(service_registry, service_key)
|
|
keys = [
|
|
key
|
|
for key, entry in service_registry.items()
|
|
if canonical_service_key(service_registry, key) == primary
|
|
]
|
|
return sorted(keys)
|
|
|
|
|
|
def resolve_service_dependency_roles_from_config(
|
|
config: dict[str, Any],
|
|
service_registry: dict[str, dict[str, Any]],
|
|
) -> list[str]:
|
|
services = _as_mapping(config.get("services"))
|
|
includes: list[str] = []
|
|
for service_key, raw_service_conf in services.items():
|
|
service_conf = _as_mapping(raw_service_conf)
|
|
if not (
|
|
is_explicit_truth(service_conf.get("enabled"))
|
|
and is_explicit_truth(service_conf.get("shared"))
|
|
):
|
|
continue
|
|
|
|
entry = _as_mapping(service_registry.get(service_key))
|
|
role_name = _normalized_name(entry.get("role"))
|
|
if role_name:
|
|
includes.append(role_name)
|
|
|
|
seen = set()
|
|
ordered: list[str] = []
|
|
for role_name in includes:
|
|
if role_name not in seen:
|
|
ordered.append(role_name)
|
|
seen.add(role_name)
|
|
return ordered
|
|
|
|
|
|
def load_run_after_from_roles_dir(roles_dir: Path, role_name: str) -> list[str]:
|
|
try:
|
|
result = get_role_run_after(roles_dir / role_name, role_name=role_name)
|
|
except Exception as exc:
|
|
raise ServiceRegistryError(
|
|
f"Invalid run_after in roles/{role_name}/meta/services.yml: {exc}"
|
|
) from exc
|
|
return result
|
|
|
|
|
|
_BUCKET_ORDER = {
|
|
"universal": 0,
|
|
"workstation": 1,
|
|
"server": 2,
|
|
"web-svc": 3,
|
|
"web-app": 4,
|
|
}
|
|
|
|
|
|
def bucket_order_key(role_name: str, bucket: str | None) -> tuple[int, str]:
|
|
"""Tie-break key for a ready role: coarse bucket first, then name.
|
|
|
|
The bucket is a name-prefix-derived loader phase; it only breaks ties
|
|
among roles with no pending run_after prerequisite, so run_after stays
|
|
bucket-agnostic (an edge orders across any bucket).
|
|
"""
|
|
return (_BUCKET_ORDER.get(bucket, len(_BUCKET_ORDER)), role_name)
|
|
|
|
|
|
def run_after_topological_order(
|
|
nodes: Iterable[str],
|
|
run_after_of: Callable[[str], Iterable[str]],
|
|
tiebreak_of: Callable[[str], Any],
|
|
) -> list[str]:
|
|
"""Order ``nodes`` so every run_after prerequisite precedes its dependents.
|
|
|
|
run_after edges order across any category; ``tiebreak_of`` only decides
|
|
among nodes that are simultaneously ready (no pending prerequisite), so a
|
|
coarse phase can stay the default for edge-less roles without constraining
|
|
run_after itself.
|
|
|
|
Args:
|
|
nodes: role names to order (deduplicated internally).
|
|
run_after_of: role name -> its run_after role names.
|
|
tiebreak_of: role name -> sort key for ready-set tie-breaking.
|
|
|
|
Raises:
|
|
ServiceRegistryError: on a run_after cycle.
|
|
"""
|
|
node_list = list(dict.fromkeys(nodes))
|
|
node_set = set(node_list)
|
|
graph: dict[str, list[str]] = {node: [] for node in node_list}
|
|
indegree: dict[str, int] = dict.fromkeys(node_list, 0)
|
|
|
|
for node in node_list:
|
|
for dep in run_after_of(node):
|
|
if dep not in node_set:
|
|
continue
|
|
graph[dep].append(node)
|
|
indegree[node] += 1
|
|
|
|
ready = [node for node, count in indegree.items() if count == 0]
|
|
ordered: list[str] = []
|
|
while ready:
|
|
ready.sort(key=tiebreak_of)
|
|
node = ready.pop(0)
|
|
ordered.append(node)
|
|
for dependent in sorted(graph[node]):
|
|
indegree[dependent] -= 1
|
|
if indegree[dependent] == 0:
|
|
ready.append(dependent)
|
|
|
|
if len(ordered) != len(node_set):
|
|
raise ServiceRegistryError("Circular run_after dependency detected.")
|
|
|
|
return ordered
|
|
|
|
|
|
def ordered_primary_service_entries(
|
|
service_registry: dict[str, dict[str, Any]],
|
|
roles_dir: Path,
|
|
) -> list[dict[str, Any]]:
|
|
primary_entries = {
|
|
entry["role"]: {"id": service_key, **entry}
|
|
for service_key, entry in service_registry.items()
|
|
if "canonical" not in entry
|
|
}
|
|
|
|
order = run_after_topological_order(
|
|
primary_entries,
|
|
lambda role: load_run_after_from_roles_dir(roles_dir, role),
|
|
lambda role: bucket_order_key(role, primary_entries[role].get("bucket")),
|
|
)
|
|
return [primary_entries[role] for role in order]
|