diff --git a/90-maintenance/documentation-rules.md b/90-maintenance/documentation-rules.md index f375943..d7c4c4c 100644 --- a/90-maintenance/documentation-rules.md +++ b/90-maintenance/documentation-rules.md @@ -58,3 +58,5 @@ - inventory `docs` links point to existing files; - inventory IDs are not duplicated; - no obvious raw secrets were detected. + +Use `GET /relationships` when an agent needs the current infrastructure graph. This endpoint is intentionally read-only and returns unresolved references separately from validation errors, so partially documented nodes such as future hosts, external VPS names, or route placeholders can be made visible without blocking incremental documentation work. diff --git a/server/README.md b/server/README.md index 7833723..af96f8d 100644 --- a/server/README.md +++ b/server/README.md @@ -25,6 +25,7 @@ - `GET /inventory/{type}` - `GET /inventory/{type}/{id}` - `GET /traffic-routes` +- `GET /relationships` - `GET /health/freshness` - `GET /validate` - `GET /changes` diff --git a/server/app/main.py b/server/app/main.py index d528e5b..9bce044 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -12,6 +12,7 @@ PendingChangeRepository, ProposedChangeRequest, ) +from .relationships import build_relationships from .validation import validate_repository @@ -103,6 +104,14 @@ raise HTTPException(status_code=404, detail=str(exc)) from exc +@app.get("/relationships") +def relationships(repo: InventoryRepository = Depends(get_inventory_repo)) -> dict[str, object]: + try: + return build_relationships(repo) + except InventoryError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/health/freshness") def freshness(settings: Settings = Depends(get_settings)) -> dict[str, object]: return build_freshness_report(settings) diff --git a/server/app/relationships.py b/server/app/relationships.py new file mode 100644 index 0000000..f532560 --- /dev/null +++ b/server/app/relationships.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from typing import Any + +from .inventory import InventoryRepository + + +KNOWN_RELATION_FIELDS = { + "hardware": {"runs_hosts"}, + "virtual-machines": {"hypervisor_host", "runs_services"}, + "hosts": {"hardware_node"}, + "services": {"host", "domains"}, + "domains": {"points_to", "used_by"}, + "traffic-routes": {"source", "entrypoint", "path", "destination", "used_by"}, + "databases": {"host", "used_by", "backup_policy"}, + "backups": {"target"}, +} + + +@dataclass(frozen=True) +class RelationshipEdge: + source: str + target: str + relation: str + + +@dataclass(frozen=True) +class UnresolvedReference: + source: str + field: str + target: str + + +def build_relationships(repo: InventoryRepository) -> dict[str, object]: + records_by_type = _read_inventory_records(repo) + node_by_raw_id = _build_node_lookup(records_by_type) + nodes = _build_nodes(records_by_type) + edges: list[RelationshipEdge] = [] + unresolved: list[UnresolvedReference] = [] + + _add_hardware_edges(records_by_type, node_by_raw_id, edges, unresolved) + _add_vm_edges(records_by_type, node_by_raw_id, edges, unresolved) + _add_host_edges(records_by_type, node_by_raw_id, edges, unresolved) + _add_service_edges(records_by_type, node_by_raw_id, edges, unresolved) + _add_domain_edges(records_by_type, node_by_raw_id, edges, unresolved) + _add_traffic_route_edges(records_by_type, node_by_raw_id, edges, unresolved) + _add_database_edges(records_by_type, node_by_raw_id, edges, unresolved) + _add_backup_edges(records_by_type, node_by_raw_id, edges, unresolved) + + return { + "nodes": nodes, + "edges": _serialize_edges(edges), + "unresolved_references": _serialize_unresolved(unresolved), + "summary": { + "node_count": len(nodes), + "edge_count": len(edges), + "unresolved_reference_count": len(unresolved), + }, + } + + +def _read_inventory_records(repo: InventoryRepository) -> dict[str, list[dict[str, Any]]]: + records_by_type: dict[str, list[dict[str, Any]]] = {} + for inventory_type in repo.list_types(): + parsed = repo.read_parsed(inventory_type) + if not isinstance(parsed, list): + records_by_type[inventory_type] = [] + continue + records_by_type[inventory_type] = [item for item in parsed if isinstance(item, dict)] + return records_by_type + + +def _build_node_lookup(records_by_type: dict[str, list[dict[str, Any]]]) -> dict[str, str]: + lookup: dict[str, str] = {} + for inventory_type, records in records_by_type.items(): + for record in records: + item_id = record.get("id") + if isinstance(item_id, str) and item_id: + lookup.setdefault(item_id, _node_id(inventory_type, item_id)) + return lookup + + +def _build_nodes(records_by_type: dict[str, list[dict[str, Any]]]) -> list[dict[str, object]]: + nodes: list[dict[str, object]] = [] + for inventory_type, records in sorted(records_by_type.items()): + for record in records: + item_id = record.get("id") + if not isinstance(item_id, str) or not item_id: + continue + nodes.append( + { + "id": _node_id(inventory_type, item_id), + "inventory_type": inventory_type, + "item_id": item_id, + "label": record.get("name") or record.get("fqdn") or item_id, + "status": record.get("status", "unknown"), + "docs": record.get("docs"), + "last_reviewed": _normalize_scalar(record.get("last_reviewed")), + } + ) + return nodes + + +def _add_hardware_edges( + records_by_type: dict[str, list[dict[str, Any]]], + node_by_raw_id: dict[str, str], + edges: list[RelationshipEdge], + unresolved: list[UnresolvedReference], +) -> None: + for record in records_by_type.get("hardware", []): + source = _source_node("hardware", record) + for target in _as_list(record.get("runs_hosts")): + _append_reference(source, target, "runs_host", "runs_hosts", node_by_raw_id, edges, unresolved) + + +def _add_vm_edges( + records_by_type: dict[str, list[dict[str, Any]]], + node_by_raw_id: dict[str, str], + edges: list[RelationshipEdge], + unresolved: list[UnresolvedReference], +) -> None: + for record in records_by_type.get("virtual-machines", []): + source = _source_node("virtual-machines", record) + _append_reference( + source, + record.get("hypervisor_host"), + "runs_on", + "hypervisor_host", + node_by_raw_id, + edges, + unresolved, + ) + for target in _as_list(record.get("runs_services")): + _append_reference(source, target, "runs_service", "runs_services", node_by_raw_id, edges, unresolved) + + +def _add_host_edges( + records_by_type: dict[str, list[dict[str, Any]]], + node_by_raw_id: dict[str, str], + edges: list[RelationshipEdge], + unresolved: list[UnresolvedReference], +) -> None: + for record in records_by_type.get("hosts", []): + source = _source_node("hosts", record) + _append_reference( + source, + record.get("hardware_node"), + "hardware_node", + "hardware_node", + node_by_raw_id, + edges, + unresolved, + ) + + +def _add_service_edges( + records_by_type: dict[str, list[dict[str, Any]]], + node_by_raw_id: dict[str, str], + edges: list[RelationshipEdge], + unresolved: list[UnresolvedReference], +) -> None: + for record in records_by_type.get("services", []): + source = _source_node("services", record) + _append_reference(source, record.get("host"), "hosted_on", "host", node_by_raw_id, edges, unresolved) + for target in _as_list(record.get("domains")): + _append_reference(source, target, "uses_domain", "domains", node_by_raw_id, edges, unresolved) + + +def _add_domain_edges( + records_by_type: dict[str, list[dict[str, Any]]], + node_by_raw_id: dict[str, str], + edges: list[RelationshipEdge], + unresolved: list[UnresolvedReference], +) -> None: + for record in records_by_type.get("domains", []): + source = _source_node("domains", record) + for target in _as_list(record.get("points_to")): + _append_reference(source, target, "points_to", "points_to", node_by_raw_id, edges, unresolved) + for target in _as_list(record.get("used_by")): + _append_reference(source, target, "used_by", "used_by", node_by_raw_id, edges, unresolved) + + +def _add_traffic_route_edges( + records_by_type: dict[str, list[dict[str, Any]]], + node_by_raw_id: dict[str, str], + edges: list[RelationshipEdge], + unresolved: list[UnresolvedReference], +) -> None: + for record in records_by_type.get("traffic-routes", []): + source = _source_node("traffic-routes", record) + for field in ("source", "entrypoint", "destination"): + _append_reference(source, record.get(field), field, field, node_by_raw_id, edges, unresolved) + for target in _as_list(record.get("path")): + _append_reference(source, target, "path_step", "path", node_by_raw_id, edges, unresolved) + for target in _as_list(record.get("used_by")): + _append_reference(source, target, "used_by", "used_by", node_by_raw_id, edges, unresolved) + + +def _add_database_edges( + records_by_type: dict[str, list[dict[str, Any]]], + node_by_raw_id: dict[str, str], + edges: list[RelationshipEdge], + unresolved: list[UnresolvedReference], +) -> None: + for record in records_by_type.get("databases", []): + source = _source_node("databases", record) + _append_reference(source, record.get("host"), "hosted_on", "host", node_by_raw_id, edges, unresolved) + _append_reference( + source, + record.get("backup_policy"), + "backup_policy", + "backup_policy", + node_by_raw_id, + edges, + unresolved, + ) + for target in _as_list(record.get("used_by")): + _append_reference(source, target, "used_by", "used_by", node_by_raw_id, edges, unresolved) + + +def _add_backup_edges( + records_by_type: dict[str, list[dict[str, Any]]], + node_by_raw_id: dict[str, str], + edges: list[RelationshipEdge], + unresolved: list[UnresolvedReference], +) -> None: + for record in records_by_type.get("backups", []): + source = _source_node("backups", record) + _append_reference(source, record.get("target"), "backs_up", "target", node_by_raw_id, edges, unresolved) + + +def _append_reference( + source: str, + raw_target: object, + relation: str, + field: str, + node_by_raw_id: dict[str, str], + edges: list[RelationshipEdge], + unresolved: list[UnresolvedReference], +) -> None: + if not isinstance(raw_target, str) or not raw_target: + return + target = node_by_raw_id.get(raw_target) + if target: + edges.append(RelationshipEdge(source=source, target=target, relation=relation)) + else: + unresolved.append(UnresolvedReference(source=source, field=field, target=raw_target)) + + +def _source_node(inventory_type: str, record: dict[str, Any]) -> str: + return _node_id(inventory_type, str(record["id"])) + + +def _node_id(inventory_type: str, item_id: str) -> str: + return f"{inventory_type}/{item_id}" + + +def _as_list(value: object) -> list[object]: + if isinstance(value, list): + return value + if value is None: + return [] + return [value] + + +def _normalize_scalar(value: object) -> object: + if isinstance(value, date): + return value.isoformat() + return value + + +def _serialize_edges(edges: list[RelationshipEdge]) -> list[dict[str, str]]: + unique = {(edge.source, edge.target, edge.relation) for edge in edges} + return [ + {"source": source, "target": target, "relation": relation} + for source, target, relation in sorted(unique) + ] + + +def _serialize_unresolved(unresolved: list[UnresolvedReference]) -> list[dict[str, str]]: + unique = {(item.source, item.field, item.target) for item in unresolved} + return [ + {"source": source, "field": field, "target": target} + for source, field, target in sorted(unique) + ] diff --git a/server/tests/test_api.py b/server/tests/test_api.py index 44a2baf..809ef44 100644 --- a/server/tests/test_api.py +++ b/server/tests/test_api.py @@ -13,6 +13,7 @@ read_change, read_inventory, read_inventory_item, + relationships, read_traffic_routes, validate, ) @@ -75,6 +76,27 @@ assert response[0]["id"] == "public-gnexus-space-to-internal-nginx" +def test_relationships_endpoint() -> None: + response = relationships(InventoryRepository(get_settings())) + + assert response["summary"]["node_count"] > 0 + assert { + "source": "hardware/hp-proliant-dl380-g6", + "target": "virtual-machines/gnauth", + "relation": "runs_host", + } in response["edges"] + assert { + "source": "virtual-machines/gnauth", + "target": "hardware/hp-proliant-dl380-g6", + "relation": "runs_on", + } in response["edges"] + assert any( + item["source"] == "traffic-routes/public-gnexus-space-to-internal-nginx" + and item["target"] == "external-vps" + for item in response["unresolved_references"] + ) + + def test_validate_endpoint_is_clean() -> None: response = validate(get_settings())