94 lines
3.4 KiB
Python
94 lines
3.4 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""custody-inventory — what credentials exist, what each is for, who owns it.
|
||
|
|
|
||
|
|
./scripts/custody-inventory.py operators/ and platform/workloads/
|
||
|
|
./scripts/custody-inventory.py operators one mount or prefix
|
||
|
|
./scripts/custody-inventory.py --undescribed only paths missing metadata
|
||
|
|
|
||
|
|
Reads metadata only — never a value — so it runs under ops-mason-build and can
|
||
|
|
be handed to anyone orienting themselves without granting them a single secret.
|
||
|
|
|
||
|
|
A path with no description is a finding, not a formatting problem: it is a
|
||
|
|
credential nobody can identify without reading it, which is how a store turns
|
||
|
|
back into the drawer of unlabelled keys it was meant to replace.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
|
||
|
|
REQUIRED = ("description", "owner", "used_by", "rotation", "on_loss")
|
||
|
|
DEFAULT_ROOTS = ("operators", "platform/workloads")
|
||
|
|
|
||
|
|
|
||
|
|
def bao(*args: str) -> dict | list | None:
|
||
|
|
env = dict(os.environ)
|
||
|
|
env.setdefault("BAO_ADDR", "https://bao.coulomb.social")
|
||
|
|
grant = os.path.expanduser("~/.claude-bao-token")
|
||
|
|
if "BAO_TOKEN" not in env and os.path.exists(grant):
|
||
|
|
with open(grant) as f:
|
||
|
|
env["BAO_TOKEN"] = f.read().strip()
|
||
|
|
# The Vault/OpenBao CLI rejects flags placed after a positional argument,
|
||
|
|
# so -format=json goes immediately before the path, not at the end.
|
||
|
|
argv = ["bao", *args[:-1], "-format=json", args[-1]]
|
||
|
|
p = subprocess.run(argv, capture_output=True, text=True, timeout=30, env=env)
|
||
|
|
if p.returncode != 0:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
return json.loads(p.stdout)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def walk(prefix: str) -> list[str]:
|
||
|
|
keys = bao("kv", "list", prefix)
|
||
|
|
if not isinstance(keys, list):
|
||
|
|
return []
|
||
|
|
out: list[str] = []
|
||
|
|
for k in keys:
|
||
|
|
child = f"{prefix.rstrip('/')}/{k.rstrip('/')}"
|
||
|
|
out.extend(walk(child) if k.endswith("/") else [child])
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
only_undescribed = "--undescribed" in sys.argv
|
||
|
|
roots = [a for a in sys.argv[1:] if not a.startswith("-")] or list(DEFAULT_ROOTS)
|
||
|
|
|
||
|
|
total = incomplete = 0
|
||
|
|
for root in roots:
|
||
|
|
for path in walk(root):
|
||
|
|
total += 1
|
||
|
|
meta = bao("kv", "metadata", "get", path) or {}
|
||
|
|
d = meta.get("data", {}) if isinstance(meta, dict) else {}
|
||
|
|
cm = d.get("custom_metadata") or {}
|
||
|
|
missing = [k for k in REQUIRED if not cm.get(k)]
|
||
|
|
if missing:
|
||
|
|
incomplete += 1
|
||
|
|
if only_undescribed and not missing:
|
||
|
|
continue
|
||
|
|
|
||
|
|
flag = " " if not missing else "! "
|
||
|
|
print(f"{flag}{path} (v{d.get('current_version', '?')}, "
|
||
|
|
f"{str(d.get('created_time', ''))[:10]})")
|
||
|
|
for label, key in (("", "description"), ("owner: ", "owner"),
|
||
|
|
("used by: ", "used_by"), ("if lost: ", "on_loss")):
|
||
|
|
if cm.get(key):
|
||
|
|
print(f" {label}{cm[key]}")
|
||
|
|
if missing:
|
||
|
|
print(f" MISSING: {', '.join(missing)}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
print("─" * 45)
|
||
|
|
print(f"{total} credential path(s), {incomplete} missing required metadata")
|
||
|
|
if incomplete:
|
||
|
|
print("Paths marked ! need describing — see platform-root-custody.md.")
|
||
|
|
return 1 if incomplete else 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|