Files
computer-playbook/cli/build/docs/readme/architecture.py
T
kevinveenbirkenbachandClaude Opus 5 46445552b8 fix(docs): emit the Architecture section only where its page is written
`sphinx -b gettext -q -W` turns a warning into a failure, and
`make i18n-extract` had been dying on 512 of them. 505 were one defect
repeated: the README template emitted an Architecture section for every
role, while `generate_architecture_page` writes ARCHITECTURE.md only for
invokable ones. 101 roles therefore linked five times each to a page
that does not exist. The section now carries the same
`application_invokable` gate as the page, measured from 101 mismatched
roles to 0.

The remaining seven were separate markup defects, each fatal on its own
under -W. architecture.py's four-entry block was an indented paragraph
that docutils read as a definition list ending abruptly, so it becomes a
literal block. branding.py held a double-backtick literal spanning four
lines, which RST does not allow; it becomes a literal block too.
container_extra_hosts.py wrote `Kwargs:`, which napoleon does not know -
a blank line is measurably not enough, the section has to reach
napoleon, so it is `Keyword Args:`. postgres_libraries.py needed one
blank line before its bullet list, and the cleanup skill an H1 above its
first `##`.

conf.py gains two settings. autodoc_mock_imports covers
`ansible.module_utils.kcadm_json`: that import is correct for an Ansible
module, which resolves module_utils only inside a play, so autodoc on
the control host is what has to yield, not the module.
napoleon_use_ivar stops an `Attributes:` section from emitting
`.. attribute::` directives that collide with the dataclass fields
autodoc already documents - eight duplicate-object warnings, reproduced
and cleared on demand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-18 01:24:35 +02:00

224 lines
8.2 KiB
Python

"""Derive the per-role ARCHITECTURE sub-page from the role's own metadata.
Four sections, each a mermaid diagram over a different ``meta/`` file::
Compose the services `meta/services.yml` declares, with the image and
the ports each one publishes.
Swarm what changes when the same role runs under swarm: the replica
count and the placement constraint, both resolved per service
rather than per role.
Volumes the decision chain `utils.storage.nfs.swarm_nfs_backed` walks,
drawn rather than its outcome. Whether a volume is NFS-backed
depends on the mode, the storage backend, the entry's own
``nfs:`` flag AND whether the role is manager-pinned, so a
diagram that states only the outcome hides the coupling that
makes a placement change move data silently.
Dependencies the cosmos graph, uncapped.
"""
from __future__ import annotations
from pathlib import Path
from cli.build.docs.readme.cosmos import derive_cosmos_mermaid
from cli.build.docs.readme.services import service_keys
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_META_VOLUMES
from utils.roles.meta_lookup import get_role_placement
from utils.symbol_glossary import to_emoji
def _node_id(raw: str) -> str:
return "".join(character if character.isalnum() else "_" for character in raw)
def _load(role_dir: Path, filename: str) -> dict:
path = Path(role_dir) / filename
if not path.is_file():
return {}
loaded = load_yaml_any(str(path), default_if_missing={})
return loaded if isinstance(loaded, dict) else {}
def _services(role_dir: Path) -> dict:
"""The containers this role renders, each with its ``meta`` entry."""
meta = _load(role_dir, ROLE_FILE_META_SERVICES)
return {
key: meta[key] if isinstance(meta.get(key), dict) else {}
for key in service_keys(role_dir)
}
def _ports(entry: dict) -> list[str]:
internal = entry.get("ports", {})
internal = internal.get("internal", {}) if isinstance(internal, dict) else {}
if not isinstance(internal, dict):
return []
return [f"{name} {value}" for name, value in internal.items()]
def _compose_mermaid(role_dir: Path, role_name: str) -> str:
services = _services(role_dir)
if not services:
return f'flowchart LR\n none["{role_name} declares no service"]'
lines = ["flowchart LR", f" subgraph role [{role_name}]"]
for key, entry in services.items():
image = entry.get("image", "")
version = entry.get("version", "")
reference = f"{image}:{version}" if image and version else image or "built here"
ports = _ports(entry)
label = f"{key}<br>{reference}"
if ports:
label += "<br>" + " · ".join(ports)
lines.append(f' svc_{_node_id(key)}["{label}"]')
lines.append(" end")
return "\n".join(lines)
def _swarm_mermaid(role_dir: Path, role_name: str, role_placement: str) -> str:
services = _services(role_dir)
if not services:
return f'flowchart LR\n none["{role_name} declares no service"]'
lines = ["flowchart LR"]
for key, entry in services.items():
replicas = entry.get("replicas")
placement = entry.get("placement") or role_placement
node = f"svc_{_node_id(key)}"
lines.append(f' {node}["{key}"]')
count = "from the host group" if replicas is None else str(replicas)
lines.append(f' {node} --> rep_{_node_id(key)}["replicas: {count}"]')
if placement:
lines.append(
f" {node} --> plc_{_node_id(key)}"
f'["node.role == {placement}"]:::pinned'
)
lines.append(" classDef pinned fill:#fef7e0,stroke:#b06000,color:#3d2600;")
return "\n".join(lines)
def _volumes_mermaid(role_dir: Path, role_name: str, role_placement: str) -> str:
volumes = _load(role_dir, ROLE_FILE_META_VOLUMES)
entries = {
key: entry
for key, entry in volumes.items()
if isinstance(entry, dict) and entry.get("type", "volume") == "volume"
}
if not entries:
return f'flowchart LR\n none["{role_name} declares no volume"]'
pinned = role_placement == "manager"
lines = ["flowchart LR"]
for key, entry in entries.items():
node = f"vol_{_node_id(key)}"
opted_out = entry.get("nfs") is False
label = entry.get("name", key)
lines.append(f' {node}["{label}"]')
if opted_out:
lines.append(
f' {node} -->|"nfs: false"| local_{_node_id(key)}'
f'["node-local on every node"]:::local'
)
elif pinned:
lines.append(
f' {node} -->|"role is manager-pinned"| local_{_node_id(key)}'
f'["node-local on the manager"]:::local'
)
else:
lines.append(
f' {node} -->|"swarm + storage.backend nfs"| nfs_{_node_id(key)}'
f'["shared over NFS"]:::shared'
)
lines.append(
f' {node} -->|"compose, or backend local"| cl_{_node_id(key)}'
f'["node-local"]:::local'
)
lines.append(" classDef shared fill:#e6f4ea,stroke:#137333,color:#0b2e13;")
lines.append(" classDef local fill:#fef7e0,stroke:#b06000,color:#3d2600;")
return "\n".join(lines)
def _volumes_legend() -> list[str]:
return [
"| Condition | Effect |",
"| --- | --- |",
"| swarm, `storage.backend: nfs`, no `nfs: false`, role not pinned "
"| shared over NFS |",
"| any one of those missing | node-local |",
"",
"All four conditions must hold, so pinning a role takes every one of "
"its volumes node-local. Only the role's primary entry counts as "
"pinned here: a `placement` on any other service pins that service "
"alone and leaves the backing of every volume unchanged. The chain is "
"drawn rather than its result, because that coupling is what makes a "
"placement change move data without saying so.",
]
def _dependencies_legend() -> list[str]:
return [
"| Symbol | Meaning |",
"| --- | --- |",
"| `1:1` solid | fixed relationship |",
"| `0..1` dashed | conditional, enabled only in matching deployments |",
"| `0..0` red | declared but turned off in this role |",
f"| {to_emoji('host')} | deploys to the host |",
f"| {to_emoji('compose')} | deploys under compose |",
f"| {to_emoji('swarm')} | deploys under swarm |",
f"| {to_emoji('disabled')} | service explicitly turned off |",
f"| {to_emoji('role_dependency')} | Ansible role dependency from "
"`meta/main.yml` |",
"",
"Every dependency and dependent is listed; the graph is not truncated.",
]
def derive_architecture_page(role_dir, role_name: str, app_name: str = "") -> str:
"""Return the ARCHITECTURE.md body for one role, fences included.
Args:
role_dir: the role directory.
role_name: the role identifier.
app_name: title the README gives the application; the page is titled
after it so both documents name the application the same way.
"""
role_dir = Path(role_dir)
role_placement = (
get_role_placement(role_dir, role_name=role_name) or ""
).strip()
sections = [
f"# {app_name or role_name} Architecture",
"",
f"## Compose {to_emoji('compose')}",
"",
"```mermaid",
_compose_mermaid(role_dir, role_name),
"```",
"",
f"## Swarm {to_emoji('swarm')}",
"",
"```mermaid",
_swarm_mermaid(role_dir, role_name, role_placement),
"```",
"",
"## Volumes",
"",
"```mermaid",
_volumes_mermaid(role_dir, role_name, role_placement),
"```",
"",
*_volumes_legend(),
"",
"## Dependencies",
"",
"```mermaid",
derive_cosmos_mermaid(role_dir, role_name),
"```",
"",
*_dependencies_legend(),
"",
]
return "\n".join(sections)