generated from coulomb/repo-seed
feat(WP-0011): warden route lookup CLI over the pointer catalog
Add a read-only `warden route` command group (list/show/find) that reads registry/routing/catalog.yaml and tells a worker which subsystem owns a need and which wiki/canon doc to follow. ops-warden still executes exactly one lane (SSH); routed entries return a pointer and never call any subsystem. - src/warden/routing/: models.py + catalog.py loader; enforces the no-double-source rule (non-SSH entries with steps/cert_command fail validation), dup-id and schema checks. - route list (active-only unless --all, --tag), route show (SSH appends steps + cert pattern; routed ends with "next action on <owner> — see <wiki_ref>"), route find (keyword ranking, --json). - tests/test_routing.py: load/validation, find ranking, CLI JSON shapes, plus a drift guard (every wiki_ref anchor resolves; every entry has a reviewed date). - Docs: wiki/AccessRouting.md CLI section, README quick reference, SCOPE A3 -> A4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,11 @@ app = typer.Typer(
|
||||
)
|
||||
inventory_app = typer.Typer(help="Manage principals inventory", no_args_is_help=True)
|
||||
app.add_typer(inventory_app, name="inventory")
|
||||
route_app = typer.Typer(
|
||||
help="Look up which subsystem owns a credential need (read-only pointer layer)",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
app.add_typer(route_app, name="route")
|
||||
|
||||
console = Console()
|
||||
err = Console(stderr=True)
|
||||
@@ -512,3 +517,143 @@ def log(
|
||||
e.get("backend", ""),
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# warden route — read-only routing lookup over the pointer catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_catalog():
|
||||
from warden.routing import CatalogError, load_catalog
|
||||
try:
|
||||
return load_catalog()
|
||||
except CatalogError as e:
|
||||
err.print(f"[red]Routing catalog error:[/red] {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _entry_summary(entry) -> dict:
|
||||
"""Pointer-only summary. Never includes secret material."""
|
||||
return {
|
||||
"id": entry.id,
|
||||
"title": entry.title,
|
||||
"owner_repo": entry.owner_repo,
|
||||
"subsystem": entry.subsystem,
|
||||
"warden_executes": entry.warden_executes,
|
||||
"wiki_ref": entry.wiki_ref,
|
||||
"canon_ref": entry.canon_ref,
|
||||
"reviewed": entry.reviewed,
|
||||
"status": entry.status,
|
||||
}
|
||||
|
||||
|
||||
def _print_entry_table(entries, title: str) -> None:
|
||||
table = Table(title=title)
|
||||
table.add_column("ID")
|
||||
table.add_column("Need")
|
||||
table.add_column("Owner")
|
||||
table.add_column("warden")
|
||||
table.add_column("Status")
|
||||
for e in entries:
|
||||
executes = "[green]issue[/green]" if e.warden_executes else "route"
|
||||
status_styled = e.status if e.status == "active" else f"[yellow]{e.status}[/yellow]"
|
||||
table.add_row(e.id, e.title, e.owner_repo, executes, status_styled)
|
||||
console.print(table)
|
||||
|
||||
|
||||
@route_app.command("list")
|
||||
def route_list(
|
||||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||||
all_entries: Annotated[bool, typer.Option("--all", help="Include draft entries")] = False,
|
||||
tag: Annotated[Optional[str], typer.Option("--tag", help="Filter by need keyword")] = None,
|
||||
) -> None:
|
||||
"""List routing scenarios. Active-only unless --all."""
|
||||
catalog = _load_catalog()
|
||||
entries = catalog.listed(include_draft=all_entries)
|
||||
if tag:
|
||||
t = tag.lower()
|
||||
entries = [e for e in entries if t in [k.lower() for k in e.need_keywords]]
|
||||
|
||||
if output_json:
|
||||
print(json.dumps([_entry_summary(e) for e in entries], indent=2))
|
||||
return
|
||||
|
||||
if not entries:
|
||||
console.print("No matching routing entries.")
|
||||
return
|
||||
_print_entry_table(entries, "Routing scenarios")
|
||||
|
||||
|
||||
@route_app.command("show")
|
||||
def route_show(
|
||||
entry_id: Annotated[str, typer.Argument(help="Catalog entry id (see `warden route list`)")],
|
||||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||||
) -> None:
|
||||
"""Show owner, pointers, and (SSH only) the authored steps for one scenario."""
|
||||
catalog = _load_catalog()
|
||||
entry = catalog.get(entry_id)
|
||||
if entry is None:
|
||||
err.print(
|
||||
f"[red]Unknown routing id {entry_id!r}.[/red] "
|
||||
f"Try: warden route find {entry_id!r}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if output_json:
|
||||
summary = _entry_summary(entry)
|
||||
summary["need_keywords"] = entry.need_keywords
|
||||
if entry.warden_executes:
|
||||
summary["steps"] = entry.steps
|
||||
summary["cert_command"] = entry.cert_command
|
||||
else:
|
||||
summary["next_action"] = (
|
||||
f"next action on `{entry.owner_repo}` — see `{entry.wiki_ref}`"
|
||||
)
|
||||
print(json.dumps(summary, indent=2))
|
||||
return
|
||||
|
||||
console.print(f"[bold]{entry.title}[/bold] ([cyan]{entry.id}[/cyan])")
|
||||
console.print(f" owner : {entry.owner_repo} ({entry.subsystem})")
|
||||
console.print(f" wiki : {entry.wiki_ref}")
|
||||
console.print(f" canon : {entry.canon_ref}")
|
||||
console.print(f" reviewed : {entry.reviewed} status: {entry.status}")
|
||||
|
||||
if entry.warden_executes:
|
||||
console.print("\n[green]ops-warden issues this directly.[/green]")
|
||||
console.print(f" cert_command: [bold]{entry.cert_command}[/bold]")
|
||||
if entry.steps:
|
||||
console.print(" steps:")
|
||||
for i, step in enumerate(entry.steps, 1):
|
||||
console.print(f" {i}. {step}")
|
||||
console.print(
|
||||
" precondition: actor in inventory? backend configured? run `warden status`."
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"\n[yellow]ops-warden does not issue this.[/yellow] "
|
||||
f"Next action on [bold]{entry.owner_repo}[/bold] — see {entry.wiki_ref}."
|
||||
)
|
||||
|
||||
|
||||
@route_app.command("find")
|
||||
def route_find(
|
||||
query: Annotated[str, typer.Argument(help="Free-text need, e.g. 'issue core api key'")],
|
||||
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
|
||||
all_entries: Annotated[bool, typer.Option("--all", help="Include draft entries")] = False,
|
||||
limit: Annotated[int, typer.Option("--limit", help="Max matches")] = 5,
|
||||
) -> None:
|
||||
"""Rank routing scenarios by keyword overlap with the query."""
|
||||
catalog = _load_catalog()
|
||||
matches = catalog.find(query, include_draft=all_entries, limit=limit)
|
||||
|
||||
if output_json:
|
||||
print(json.dumps([_entry_summary(e) for e in matches], indent=2))
|
||||
return
|
||||
|
||||
if not matches:
|
||||
console.print(
|
||||
f"No routing match for {query!r}. "
|
||||
"Try `warden route list --all` to browse all scenarios."
|
||||
)
|
||||
return
|
||||
_print_entry_table(matches, f"Matches for {query!r}")
|
||||
|
||||
17
src/warden/routing/__init__.py
Normal file
17
src/warden/routing/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Routing lookup — read-only pointer layer over registry/routing/catalog.yaml.
|
||||
|
||||
This package never calls OpenBao, flex-auth, key-cape, ops-bridge, or any other
|
||||
subsystem. It loads the machine-readable routing catalog and answers "who owns
|
||||
this need and where is the authoritative doc". The one lane ops-warden executes
|
||||
(SSH certificate issuance) is the only entry that carries authored steps.
|
||||
"""
|
||||
from warden.routing.catalog import Catalog, CatalogError, find_catalog_path, load_catalog
|
||||
from warden.routing.models import RouteEntry
|
||||
|
||||
__all__ = [
|
||||
"Catalog",
|
||||
"CatalogError",
|
||||
"RouteEntry",
|
||||
"find_catalog_path",
|
||||
"load_catalog",
|
||||
]
|
||||
171
src/warden/routing/catalog.py
Normal file
171
src/warden/routing/catalog.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Load and validate the routing pointer catalog.
|
||||
|
||||
The catalog lives at ``registry/routing/catalog.yaml`` in the repo root. Resolution
|
||||
order:
|
||||
|
||||
1. ``WARDEN_ROUTING_CATALOG`` env var, if set (used by tests / overrides).
|
||||
2. Walk upward from this module looking for ``registry/routing/catalog.yaml``.
|
||||
|
||||
Validation enforces the **no-double-source rule**: only ``warden_executes: true``
|
||||
entries may carry an authored ``steps`` block or a ``cert_command``. Any non-SSH
|
||||
entry that does so is a validation error — ops-warden points at the owner's doc, it
|
||||
never restates another subsystem's procedure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from warden.routing.models import RouteEntry
|
||||
|
||||
_REQUIRED_FIELDS = (
|
||||
"id",
|
||||
"title",
|
||||
"need_keywords",
|
||||
"owner_repo",
|
||||
"subsystem",
|
||||
"warden_executes",
|
||||
"wiki_ref",
|
||||
"canon_ref",
|
||||
"reviewed",
|
||||
"status",
|
||||
)
|
||||
_VALID_STATUS = ("active", "draft")
|
||||
|
||||
|
||||
class CatalogError(Exception):
|
||||
"""Raised when the routing catalog is missing or invalid."""
|
||||
|
||||
|
||||
def find_catalog_path(start: Optional[Path] = None) -> Path:
|
||||
"""Locate registry/routing/catalog.yaml.
|
||||
|
||||
Honors WARDEN_ROUTING_CATALOG first; otherwise walks up from `start`
|
||||
(default: this module) until a repo root containing the catalog is found.
|
||||
"""
|
||||
override = os.environ.get("WARDEN_ROUTING_CATALOG")
|
||||
if override:
|
||||
return Path(os.path.expanduser(override))
|
||||
|
||||
rel = Path("registry") / "routing" / "catalog.yaml"
|
||||
here = (start or Path(__file__)).resolve()
|
||||
for parent in [here, *here.parents]:
|
||||
candidate = parent / rel
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
raise CatalogError(
|
||||
f"Routing catalog not found ({rel}). Set WARDEN_ROUTING_CATALOG to override."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Catalog:
|
||||
path: Path
|
||||
entries: List[RouteEntry]
|
||||
|
||||
# --- lookup helpers ---------------------------------------------------
|
||||
|
||||
def get(self, entry_id: str) -> Optional[RouteEntry]:
|
||||
for e in self.entries:
|
||||
if e.id == entry_id:
|
||||
return e
|
||||
return None
|
||||
|
||||
def listed(self, include_draft: bool = False) -> List[RouteEntry]:
|
||||
if include_draft:
|
||||
return list(self.entries)
|
||||
return [e for e in self.entries if e.is_active]
|
||||
|
||||
def find(self, query: str, include_draft: bool = False, limit: int = 5) -> List[RouteEntry]:
|
||||
"""Rank entries by keyword overlap with the query. Highest first."""
|
||||
tokens = [t for t in query.lower().replace("-", " ").split() if t]
|
||||
pool = self.listed(include_draft=include_draft)
|
||||
scored = [(e.match_score(tokens), e) for e in pool]
|
||||
scored = [(s, e) for s, e in scored if s > 0]
|
||||
scored.sort(key=lambda pair: (-pair[0], pair[1].id))
|
||||
return [e for _, e in scored[:limit]]
|
||||
|
||||
|
||||
def _parse_entry(raw: dict, index: int) -> RouteEntry:
|
||||
if not isinstance(raw, dict):
|
||||
raise CatalogError(f"entry #{index} is not a mapping")
|
||||
|
||||
missing = [f for f in _REQUIRED_FIELDS if f not in raw]
|
||||
if missing:
|
||||
ident = raw.get("id", f"#{index}")
|
||||
raise CatalogError(f"entry {ident!r} missing required field(s): {', '.join(missing)}")
|
||||
|
||||
warden_executes = bool(raw["warden_executes"])
|
||||
steps = raw.get("steps") or []
|
||||
cert_command = raw.get("cert_command")
|
||||
status = str(raw["status"])
|
||||
|
||||
if status not in _VALID_STATUS:
|
||||
raise CatalogError(
|
||||
f"entry {raw['id']!r} has invalid status {status!r} (expected one of {_VALID_STATUS})"
|
||||
)
|
||||
|
||||
# No-double-source rule: authored procedure only on the SSH lane.
|
||||
if not warden_executes and steps:
|
||||
raise CatalogError(
|
||||
f"entry {raw['id']!r} is not warden_executes but carries a `steps` block "
|
||||
"— routed needs point at the owner's doc; they must not restate procedure "
|
||||
"(no-double-source rule)."
|
||||
)
|
||||
if not warden_executes and cert_command:
|
||||
raise CatalogError(
|
||||
f"entry {raw['id']!r} is not warden_executes but carries a `cert_command`."
|
||||
)
|
||||
|
||||
if not isinstance(raw["need_keywords"], list):
|
||||
raise CatalogError(f"entry {raw['id']!r} need_keywords must be a list")
|
||||
|
||||
return RouteEntry(
|
||||
id=str(raw["id"]),
|
||||
title=str(raw["title"]),
|
||||
need_keywords=[str(k) for k in raw["need_keywords"]],
|
||||
owner_repo=str(raw["owner_repo"]),
|
||||
subsystem=str(raw["subsystem"]),
|
||||
warden_executes=warden_executes,
|
||||
wiki_ref=str(raw["wiki_ref"]),
|
||||
canon_ref=str(raw["canon_ref"]),
|
||||
reviewed=str(raw["reviewed"]),
|
||||
status=status,
|
||||
steps=[str(s) for s in steps],
|
||||
cert_command=str(cert_command) if cert_command else None,
|
||||
)
|
||||
|
||||
|
||||
def load_catalog(path: Optional[Path] = None) -> Catalog:
|
||||
"""Load, parse, and validate the routing catalog."""
|
||||
catalog_path = path or find_catalog_path()
|
||||
if not catalog_path.exists():
|
||||
raise CatalogError(f"Routing catalog not found: {catalog_path}")
|
||||
|
||||
try:
|
||||
with catalog_path.open() as f:
|
||||
raw = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
raise CatalogError(f"Invalid YAML in {catalog_path}: {e}") from e
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise CatalogError("Catalog must be a YAML mapping")
|
||||
|
||||
raw_entries = raw.get("entries")
|
||||
if not isinstance(raw_entries, list) or not raw_entries:
|
||||
raise CatalogError("Catalog has no `entries` list")
|
||||
|
||||
entries: List[RouteEntry] = []
|
||||
seen: set[str] = set()
|
||||
for i, raw_entry in enumerate(raw_entries):
|
||||
entry = _parse_entry(raw_entry, i)
|
||||
if entry.id in seen:
|
||||
raise CatalogError(f"duplicate entry id: {entry.id!r}")
|
||||
seen.add(entry.id)
|
||||
entries.append(entry)
|
||||
|
||||
return Catalog(path=catalog_path, entries=entries)
|
||||
49
src/warden/routing/models.py
Normal file
49
src/warden/routing/models.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Data model for routing catalog entries.
|
||||
|
||||
A `RouteEntry` is a pointer: it names the owner and the authoritative doc for a
|
||||
credential need. Only the SSH lane (`warden_executes: true`) may carry an authored
|
||||
`steps` block and a `cert_command` pattern — every other entry is identifiers and
|
||||
pointers only (the no-double-source rule, enforced in `catalog.py`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteEntry:
|
||||
id: str
|
||||
title: str
|
||||
need_keywords: List[str]
|
||||
owner_repo: str
|
||||
subsystem: str
|
||||
warden_executes: bool
|
||||
wiki_ref: str
|
||||
canon_ref: str
|
||||
reviewed: str
|
||||
status: str # "active" | "draft"
|
||||
# SSH lane only — None/empty for routed (non-executed) needs.
|
||||
steps: List[str] = field(default_factory=list)
|
||||
cert_command: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status == "active"
|
||||
|
||||
def match_score(self, tokens: List[str]) -> int:
|
||||
"""Keyword-overlap score against need_keywords, title, and id.
|
||||
|
||||
Pure ranking helper — no I/O, no external calls.
|
||||
"""
|
||||
haystack = set(k.lower() for k in self.need_keywords)
|
||||
haystack.update(self.id.lower().replace("-", " ").split())
|
||||
haystack.update(self.title.lower().replace("-", " ").split())
|
||||
score = 0
|
||||
for tok in tokens:
|
||||
t = tok.lower()
|
||||
if t in haystack:
|
||||
score += 2
|
||||
elif any(t in h or h in t for h in haystack):
|
||||
score += 1
|
||||
return score
|
||||
Reference in New Issue
Block a user