mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 12:17:13 +00:00
The Cosmos section drew one diagram inside the README and truncated its
dependents at twelve. It is replaced by an `ARCHITECTURE.md` sub-page per
invokable role with four sections, and the README keeps only a link table.
Compose lists the containers with image and published ports. Swarm shows the
replica count and the effective placement constraint per service. Volumes
draws the decision chain `swarm_nfs_backed` walks instead of its outcome,
because whether a volume is NFS-backed depends on the mode, the storage
backend, the entry's own `nfs:` flag and the role's placement at once, and a
diagram stating only the result hides the coupling that makes a placement
change move data silently. Dependencies is the cosmos graph with the cap
removed, so `_DEPENDENTS_CAP`, `shown`, `overflow` and the `dpt_more` node are
gone.
Which containers a role runs cannot be read off `meta/services.yml`: it mixes
the role's own services with the ones it merely consumes, and the entries that
do describe a container carry `name`, or only `image`, or neither when the
image is built, so no field discriminates. A heuristic over that file dropped
`pds` from web-app-bluesky, five of six services from web-app-funkwhale and
the only service of web-svc-cdn. The new `services` module reads the
assignments that actually render a container, `{% set service_name = ... %}`,
resolving literals directly and constants through `vars/main.yml`; every
template in the tree that includes the container base carries one.
The page is written by its own unconditional loop in `__main__.py`, not gated
behind `--update-architecture`, so the documentation build cannot emit the
link table without the page it points at. `--check` compares instead of
writing. A committed copy is refused by
`test_no_role_commits_the_architecture_sub_page`, the rule the managed README
sections already follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
368 lines
13 KiB
Python
368 lines
13 KiB
Python
"""Derive a Cosmos mermaid flowchart from every role's ``meta/services.yml``.
|
|
|
|
The role box lists EVERY service the role declares. Around it:
|
|
|
|
Dependencies roles this role consumes, on the left. A consumption is a
|
|
service whose ``enabled``/``shared`` flag names another role
|
|
(``'<role>' in group_names``), whose key matches another
|
|
role's ``provides`` (provision) name, or whose key is another
|
|
role's own primary service key while both flags are on.
|
|
Dependents roles that consume THIS role, on the right. The mirror of the
|
|
above: another role names this one, or declares a service keyed
|
|
by this role's ``provides`` name (e.g. web-app-keycloak
|
|
``provides: sso`` and every role with an ``sso`` service).
|
|
|
|
``provides`` matching runs in both directions, so a provider (e.g. openldap
|
|
``provides: ldap``) is picked up even when the consuming flag uses a
|
|
``groups[...]`` form the ``group_names`` regex cannot read.
|
|
|
|
Primary-key matching covers the mandatory relationship, where the consumer
|
|
pins the provider on unconditionally (``enabled: true, shared: true``) and no
|
|
``group_names`` reference is left to read — web-svc-asset consuming
|
|
web-svc-file's ``file`` is the canonical case.
|
|
|
|
Edge style encodes the flag: a solid edge is a fixed (literal ``true``)
|
|
relationship, a dashed edge is variable (a ``{{ ... }}`` conditional), and a
|
|
red ``0..0`` edge is one the role turns off (``enabled`` and ``shared`` both
|
|
literally ``false``) — the relationship exists in the metadata but carries
|
|
nothing at deploy time.
|
|
|
|
``run_after`` is deliberately NOT read: it is a deploy-ordering hint, not a
|
|
runtime dependency. The wider cosmos (federation peers, external bridged
|
|
networks) cannot be derived from metadata and is left for the author to add.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from utils.cache.yaml import load_yaml_any
|
|
from utils.roles.applications.services.registry import (
|
|
build_role_to_primary_service_key,
|
|
build_service_registry_from_roles_dir,
|
|
is_explicit_truth,
|
|
)
|
|
from utils.roles.deploy import role_deploy_modes
|
|
from utils.roles.mapping import ROLE_FILE_META_MAIN, ROLE_FILE_META_SERVICES
|
|
from utils.symbol_glossary import to_emoji
|
|
|
|
_GROUP_DEP_RE = re.compile(r"'([a-z0-9][a-z0-9-]*)'\s+in\s+group_names")
|
|
_OFF_STROKE = "red"
|
|
_MERMAID_RESERVED = {
|
|
"call",
|
|
"class",
|
|
"click",
|
|
"default",
|
|
"end",
|
|
"graph",
|
|
"style",
|
|
"subgraph",
|
|
}
|
|
|
|
|
|
def _node_id(raw: str) -> str:
|
|
safe = re.sub(r"[^A-Za-z0-9_]", "_", raw)
|
|
if not safe or safe[0].isdigit():
|
|
safe = f"n_{safe}"
|
|
if safe in _MERMAID_RESERVED:
|
|
safe = f"{safe}_n"
|
|
return safe
|
|
|
|
|
|
def _flag_kind(entry: dict) -> str | None:
|
|
"""Classify a service's enablement: ``fixed``, ``variable`` or ``None``.
|
|
|
|
fixed at least one of enabled/shared is a literal bool.
|
|
variable at least one is a ``{{ ... }}`` string (conditional).
|
|
None neither enabled nor shared is present.
|
|
"""
|
|
present = [entry.get("enabled"), entry.get("shared")]
|
|
present = [v for v in present if v is not None]
|
|
if not present:
|
|
return None
|
|
return "variable" if any(isinstance(v, str) for v in present) else "fixed"
|
|
|
|
|
|
def _group_refs(entry: dict) -> set[str]:
|
|
refs: set[str] = set()
|
|
for flag in (entry.get("enabled"), entry.get("shared")):
|
|
if isinstance(flag, str):
|
|
refs.update(_GROUP_DEP_RE.findall(flag))
|
|
return refs
|
|
|
|
|
|
def _provides_of(services: dict) -> set[str]:
|
|
out: set[str] = set()
|
|
for entry in services.values():
|
|
if isinstance(entry, dict):
|
|
prov = entry.get("provides")
|
|
if isinstance(prov, str) and prov.strip():
|
|
out.add(prov.strip())
|
|
return out
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _roles_meta(roles_root_str: str) -> dict[str, dict]:
|
|
"""Load and cache every role's ``meta/services.yml`` mapping once."""
|
|
roles_root = Path(roles_root_str)
|
|
meta: dict[str, dict] = {}
|
|
for role_dir in sorted(roles_root.iterdir()):
|
|
services_path = role_dir / ROLE_FILE_META_SERVICES
|
|
if not (role_dir.is_dir() and services_path.is_file()):
|
|
continue
|
|
loaded = load_yaml_any(str(services_path), default_if_missing={})
|
|
meta[role_dir.name] = loaded if isinstance(loaded, dict) else {}
|
|
return meta
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _primary_key_providers(roles_root_str: str) -> dict[str, str]:
|
|
"""Map each role's own primary service key to that role."""
|
|
registry = build_service_registry_from_roles_dir(Path(roles_root_str))
|
|
return {
|
|
key: role for role, key in build_role_to_primary_service_key(registry).items()
|
|
}
|
|
|
|
|
|
def _pins_provider_on(entry: dict) -> bool:
|
|
"""True when a consumer keeps a service on unconditionally, the shape a
|
|
mandatory dependency takes once no ``group_names`` reference is left."""
|
|
return is_explicit_truth(entry.get("enabled")) and is_explicit_truth(
|
|
entry.get("shared")
|
|
)
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _roles_ansible_deps(roles_root_str: str) -> dict[str, set[str]]:
|
|
"""Every role's ``meta/main.yml`` ``dependencies`` (string or
|
|
``{role: ...}`` entries), cached once."""
|
|
roles_root = Path(roles_root_str)
|
|
deps: dict[str, set[str]] = {}
|
|
for role_dir in sorted(roles_root.iterdir()):
|
|
main_path = role_dir / ROLE_FILE_META_MAIN
|
|
if not (role_dir.is_dir() and main_path.is_file()):
|
|
continue
|
|
loaded = load_yaml_any(str(main_path), default_if_missing={})
|
|
raw = loaded.get("dependencies") if isinstance(loaded, dict) else None
|
|
names: set[str] = set()
|
|
for entry in raw or []:
|
|
if isinstance(entry, str):
|
|
names.add(entry)
|
|
elif isinstance(entry, dict) and isinstance(entry.get("role"), str):
|
|
names.add(entry["role"])
|
|
deps[role_dir.name] = names
|
|
return deps
|
|
|
|
|
|
def _entry_kind(entry: dict) -> str:
|
|
"""Classify one consumed service: ``off`` when the consumer turns it off,
|
|
otherwise the flag's own fixed/variable shape."""
|
|
if _service_stopped(entry):
|
|
return "off"
|
|
return _flag_kind(entry) or "fixed"
|
|
|
|
|
|
def _consumption_kind(
|
|
services: dict,
|
|
provider_role: str,
|
|
provider_provides: set[str],
|
|
provider_primary_key: str | None = None,
|
|
) -> str | None:
|
|
"""Return how ``services`` consumes ``provider_role`` (fixed/variable/off/None)."""
|
|
kinds: list[str] = []
|
|
for key, entry in services.items():
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if provider_role in _group_refs(entry):
|
|
kinds.append("variable")
|
|
elif key in provider_provides or (
|
|
key == provider_primary_key and _pins_provider_on(entry)
|
|
):
|
|
kinds.append(_entry_kind(entry))
|
|
if not kinds:
|
|
return None
|
|
for kind in ("variable", "fixed"):
|
|
if kind in kinds:
|
|
return kind
|
|
return "off"
|
|
|
|
|
|
def _edge(src: str, dst: str, kind: str) -> str:
|
|
if kind == "variable":
|
|
return f' {src} -. "0..1" .-> {dst}'
|
|
if kind == "off":
|
|
return f' {src} -- "0..0" --> {dst}'
|
|
return f' {src} -- "1:1" --> {dst}'
|
|
|
|
|
|
_EDGE_PRECEDENCE = {"off": 0, "fixed": 1, "variable": 2}
|
|
|
|
|
|
def _merge_edges(edges: list[tuple[str, str, str]]) -> dict[tuple[str, str], str]:
|
|
"""Collapse duplicate ``(src, dst)`` edges; the liveliest kind wins, so a
|
|
relationship one service turns off still shows as on where another keeps it."""
|
|
merged: dict[tuple[str, str], str] = {}
|
|
for src, dst, kind in edges:
|
|
pair = (src, dst)
|
|
if (
|
|
pair not in merged
|
|
or _EDGE_PRECEDENCE[kind] > _EDGE_PRECEDENCE[merged[pair]]
|
|
):
|
|
merged[pair] = kind
|
|
return merged
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _mode_markers(roles_root_str: str) -> dict[str, str]:
|
|
"""Map each role to its deploy-mode emoji marker via the shared resolver."""
|
|
roles_root = Path(roles_root_str)
|
|
return {
|
|
name: "".join(
|
|
to_emoji(mode)
|
|
for mode, enabled in role_deploy_modes(roles_root / name, name).items()
|
|
if enabled
|
|
)
|
|
for name in _roles_meta(roles_root_str)
|
|
}
|
|
|
|
|
|
def _role_label(name: str, markers: dict[str, str]) -> str:
|
|
marker = markers.get(name, "")
|
|
return f"{name} {marker}" if marker else name
|
|
|
|
|
|
def _service_stopped(entry: dict) -> bool:
|
|
"""True for a service explicitly turned off: ``enabled`` and ``shared``
|
|
both literally ``false``."""
|
|
return entry.get("enabled") is False and entry.get("shared") is False
|
|
|
|
|
|
def derive_cosmos_mermaid(role_dir: Path, role_name: str) -> str:
|
|
"""Return a mermaid ``flowchart`` source (no code fences) for the role."""
|
|
role_dir = Path(role_dir)
|
|
meta = _roles_meta(str(role_dir.parent))
|
|
services = meta.get(role_name)
|
|
if services is None:
|
|
services_path = role_dir / ROLE_FILE_META_SERVICES
|
|
loaded = (
|
|
load_yaml_any(str(services_path), default_if_missing={})
|
|
if services_path.is_file()
|
|
else {}
|
|
)
|
|
services = loaded if isinstance(loaded, dict) else {}
|
|
|
|
provides_map = {name: _provides_of(svc) for name, svc in meta.items()}
|
|
my_provides = _provides_of(services)
|
|
providers = _primary_key_providers(str(role_dir.parent))
|
|
my_primary_key = next((k for k, r in providers.items() if r == role_name), None)
|
|
ansible_deps = _roles_ansible_deps(str(role_dir.parent))
|
|
|
|
dep_edges: list[tuple[str, str, str]] = []
|
|
dep_roles: set[str] = set()
|
|
for key, entry in services.items():
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
for ref in _group_refs(entry):
|
|
if ref != role_name and ref in meta:
|
|
dep_roles.add(ref)
|
|
dep_edges.append((ref, key, "variable"))
|
|
for provider, provs in provides_map.items():
|
|
if provider != role_name and key in provs:
|
|
dep_roles.add(provider)
|
|
dep_edges.append((provider, key, _entry_kind(entry)))
|
|
pinned = providers.get(key)
|
|
if (
|
|
pinned
|
|
and pinned != role_name
|
|
and pinned in meta
|
|
and _pins_provider_on(entry)
|
|
):
|
|
dep_roles.add(pinned)
|
|
dep_edges.append((pinned, key, _entry_kind(entry)))
|
|
|
|
gear_deps = {
|
|
dep
|
|
for dep in ansible_deps.get(role_name, set())
|
|
if dep != role_name and dep in meta
|
|
}
|
|
dep_roles |= gear_deps
|
|
|
|
providing_services = [
|
|
key
|
|
for key, entry in services.items()
|
|
if isinstance(entry, dict) and isinstance(entry.get("provides"), str)
|
|
]
|
|
anchor = providing_services or (list(services)[:1] if services else [role_name])
|
|
dpt_kind: dict[str, str] = {}
|
|
gear_dependents: set[str] = set()
|
|
for other, other_services in meta.items():
|
|
if other == role_name:
|
|
continue
|
|
kind = _consumption_kind(other_services, role_name, my_provides, my_primary_key)
|
|
if role_name in ansible_deps.get(other, set()):
|
|
gear_dependents.add(other)
|
|
kind = kind or "fixed"
|
|
if kind is not None:
|
|
dpt_kind[other] = kind
|
|
|
|
markers = _mode_markers(str(role_dir.parent))
|
|
dependents = sorted(dpt_kind)
|
|
|
|
gear = to_emoji("role_dependency")
|
|
|
|
lines = ["flowchart LR"]
|
|
if dep_roles:
|
|
lines.append(" subgraph deps [Dependencies]")
|
|
lines.extend(
|
|
f' dep_{_node_id(r)}["{_role_label(r, markers)}'
|
|
f'{f" {gear}" if r in gear_deps else ""}"]'
|
|
for r in sorted(dep_roles)
|
|
)
|
|
lines.append(" end")
|
|
|
|
lines.append(f" subgraph role [{_role_label(role_name, markers)}]")
|
|
if services:
|
|
for key, entry in services.items():
|
|
stopped = isinstance(entry, dict) and _service_stopped(entry)
|
|
label = f"{key} {to_emoji('disabled')}" if stopped else key
|
|
lines.append(f' svc_{_node_id(key)}["{label}"]')
|
|
else:
|
|
lines.append(f' svc_{_node_id(role_name)}["{role_name}"]')
|
|
lines.append(" end")
|
|
|
|
if dependents:
|
|
lines.append(" subgraph dependents [Dependents]")
|
|
lines.extend(
|
|
f' dpt_{_node_id(r)}["{_role_label(r, markers)}'
|
|
f'{f" {gear}" if r in gear_dependents else ""}"]'
|
|
for r in dependents
|
|
)
|
|
lines.append(" end")
|
|
|
|
gear_edge_list = [
|
|
(f"dep_{_node_id(r)}", f"svc_{_node_id(s)}", "fixed")
|
|
for r in gear_deps
|
|
for s in anchor[:1]
|
|
]
|
|
dep_pairs = _merge_edges(
|
|
[(f"dep_{_node_id(r)}", f"svc_{_node_id(k)}", kind) for r, k, kind in dep_edges]
|
|
+ gear_edge_list
|
|
)
|
|
dpt_edge_list = [
|
|
(f"svc_{_node_id(s)}", f"dpt_{_node_id(o)}", dpt_kind[o])
|
|
for o in dependents
|
|
for s in anchor
|
|
]
|
|
dpt_pairs = _merge_edges(dpt_edge_list)
|
|
emitted: list[str] = []
|
|
for pairs in (dep_pairs, dpt_pairs):
|
|
for (src, dst), kind in sorted(pairs.items()):
|
|
lines.append(_edge(src, dst, kind))
|
|
emitted.append(kind)
|
|
off_edges = [str(i) for i, kind in enumerate(emitted) if kind == "off"]
|
|
if off_edges:
|
|
lines.append(f" linkStyle {','.join(off_edges)} stroke:{_OFF_STROKE};")
|
|
return "\n".join(lines)
|