ops-warden/src/warden/cli.py

505 lines
16 KiB
Python
Raw Normal View History

2026-03-28 00:45:43 +00:00
"""OpsWarden CLI."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
2026-03-28 00:45:43 +00:00
from pathlib import Path
from typing import Annotated, List, Optional
import typer
from rich.console import Console
from rich.table import Table
from warden.ca import CAError, LocalCA, parse_cert_metadata
from warden.config import ConfigError, WardenConfig, load_config
from warden.inventory import ActorEntry, InventoryError, PrincipalsInventory, load_inventory, save_inventory
from warden.models import ActorType, CertSpec, DEFAULT_TTL_HOURS, validate_actor_name
from warden.scorecard import run_scorecard
app = typer.Typer(
help="OpsWarden — SSH CA and certificate lifecycle manager",
no_args_is_help=True,
)
inventory_app = typer.Typer(help="Manage principals inventory", no_args_is_help=True)
app.add_typer(inventory_app, name="inventory")
console = Console()
err = Console(stderr=True)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _load_cfg() -> WardenConfig:
try:
return load_config()
except ConfigError as e:
err.print(f"[red]Config error:[/red] {e}")
raise typer.Exit(1)
def _load_inventory(cfg: WardenConfig) -> PrincipalsInventory:
try:
return load_inventory(cfg.inventory_path)
except InventoryError as e:
err.print(f"[red]Inventory error:[/red] {e}")
raise typer.Exit(1)
def _get_ca(cfg: WardenConfig):
if cfg.backend == "vault":
from warden.vault import VaultCA
return VaultCA(cfg.vault, cfg.state_dir)
return LocalCA(cfg.ca_key, cfg.state_dir)
# ---------------------------------------------------------------------------
# warden sign
# ---------------------------------------------------------------------------
@app.command()
def sign(
actor_name: Annotated[str, typer.Argument(help="Actor name (e.g. agt-state-hub-bridge)")],
pubkey: Annotated[Path, typer.Option("--pubkey", help="Path to actor's public key file")],
ttl: Annotated[Optional[int], typer.Option("--ttl", help="Override TTL in hours")] = None,
) -> None:
"""Sign a public key for the given actor. Writes cert text to stdout.
This is the cert_command interface: ops-bridge calls this and uses stdout
as the certificate passed to SSH alongside the private key.
"""
cfg = _load_cfg()
inventory = _load_inventory(cfg)
entry = inventory.actors.get(actor_name)
if entry is None:
err.print(
f"[red]Actor {actor_name!r} not found in inventory.[/red] "
f"Add it with: warden inventory add"
)
raise typer.Exit(1)
spec = CertSpec(
actor_name=actor_name,
actor_type=entry.actor_type,
pubkey_path=pubkey,
ttl_hours=ttl or entry.ttl_hours,
principals=entry.principals,
identity=actor_name,
)
ca = _get_ca(cfg)
try:
record = ca.sign(spec)
except CAError as e:
err.print(f"[red]Signing failed:[/red] {e}")
raise typer.Exit(1)
# cert_command interface: write cert text to stdout only
print(record.cert_path.read_text().strip())
# ---------------------------------------------------------------------------
# warden issue
# ---------------------------------------------------------------------------
@app.command()
def issue(
actor_name: Annotated[str, typer.Argument(help="Actor name")],
ttl: Annotated[Optional[int], typer.Option("--ttl", help="Override TTL in hours")] = None,
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Generate a new keypair and sign it for the given actor.
Only supported with the local backend. Outputs keypair + cert paths and metadata.
"""
cfg = _load_cfg()
if cfg.backend != "local":
err.print("[red]warden issue is only supported with the local backend.[/red]")
raise typer.Exit(1)
inventory = _load_inventory(cfg)
entry = inventory.actors.get(actor_name)
if entry is None:
err.print(f"[red]Actor {actor_name!r} not found in inventory.[/red]")
raise typer.Exit(1)
ca = LocalCA(cfg.ca_key, cfg.state_dir)
try:
privkey_path, pubkey_path = ca.generate_keypair(actor_name)
except CAError as e:
err.print(f"[red]Key generation failed:[/red] {e}")
raise typer.Exit(1)
spec = CertSpec(
actor_name=actor_name,
actor_type=entry.actor_type,
pubkey_path=pubkey_path,
ttl_hours=ttl or entry.ttl_hours,
principals=entry.principals,
identity=actor_name,
)
try:
record = ca.sign(spec)
except CAError as e:
err.print(f"[red]Signing failed:[/red] {e}")
raise typer.Exit(1)
result = {
"actor": actor_name,
"privkey": str(privkey_path),
"cert": str(record.cert_path),
"identity": record.identity,
"principals": record.principals,
"valid_before": record.valid_before.isoformat(),
"signed_at": record.signed_at.isoformat(),
}
if output_json:
print(json.dumps(result, indent=2))
else:
console.print(f"[green]Issued credentials for {actor_name}[/green]")
for k, v in result.items():
console.print(f" {k}: {v}")
# ---------------------------------------------------------------------------
# warden status
# ---------------------------------------------------------------------------
@app.command()
def status(
actor_name: Annotated[Optional[str], typer.Argument(help="Actor name (omit for all)")] = None,
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
state_dir_override: Annotated[Optional[Path], typer.Option("--state-dir", help="State dir path (bypasses config)")] = None,
2026-03-28 00:45:43 +00:00
) -> None:
"""Show certificate status. Exits 1 if any cert is expired."""
now = datetime.now(timezone.utc)
if state_dir_override is not None:
state_dir = state_dir_override
else:
cfg = _load_cfg()
state_dir = cfg.state_dir
2026-03-28 00:45:43 +00:00
if actor_name:
cert_path = state_dir / f"{actor_name}-cert.pub"
2026-03-28 00:45:43 +00:00
paths = [cert_path] if cert_path.exists() else []
else:
paths = sorted(state_dir.glob("*-cert.pub")) if state_dir.exists() else []
2026-03-28 00:45:43 +00:00
if not paths:
msg = (
f"No certificate found for {actor_name!r} (static key / no cert)"
if actor_name
else "No certificates in state dir."
)
console.print(msg)
return
rows = []
for cert_path in paths:
name = cert_path.stem.replace("-cert", "")
try:
meta = parse_cert_metadata(cert_path)
valid_before = meta["valid_before"]
remaining = valid_before - now
secs = remaining.total_seconds()
if secs > 0:
h, rem = divmod(int(secs), 3600)
m = rem // 60
remaining_str = f"{h}h {m}m"
expired = False
else:
remaining_str = "EXPIRED"
expired = True
rows.append({
"actor": name,
"identity": meta["identity"],
"principals": ", ".join(meta["principals"]),
"valid_before": valid_before.isoformat(),
"remaining": remaining_str,
"expired": expired,
})
except Exception as e:
rows.append({"actor": name, "error": str(e), "expired": False})
if output_json:
print(json.dumps(rows, indent=2))
else:
table = Table(title="Certificate Status")
table.add_column("Actor")
table.add_column("Identity")
table.add_column("Principals")
table.add_column("Valid Before (UTC)")
table.add_column("Remaining")
for row in rows:
if "error" in row:
table.add_row(row["actor"], "[red]parse error[/red]", "", "", row["error"])
else:
rem_styled = (
f"[red]{row['remaining']}[/red]" if row["expired"] else row["remaining"]
)
table.add_row(
row["actor"],
row["identity"],
row["principals"],
row["valid_before"],
rem_styled,
)
console.print(table)
if any(r.get("expired") for r in rows):
raise typer.Exit(1)
# ---------------------------------------------------------------------------
# warden scorecard
# ---------------------------------------------------------------------------
@app.command()
def scorecard(
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Run compliance scorecard checks (AccessManagementDirective §5, cert-side)."""
cfg = _load_cfg()
inventory = _load_inventory(cfg)
results = run_scorecard(cfg.state_dir, inventory)
passed = sum(1 for r in results if r.passed)
total = len(results)
if output_json:
print(json.dumps(
[{"check": r.name, "passed": r.passed, "detail": r.detail} for r in results],
indent=2,
))
else:
table = Table(title=f"OpsWarden Scorecard ({passed}/{total})")
table.add_column("Check")
table.add_column("Status")
table.add_column("Detail")
for r in results:
status_str = "[green]PASS[/green]" if r.passed else "[red]FAIL[/red]"
table.add_row(r.name, status_str, r.detail)
console.print(table)
console.print(
f"\nScore: {passed}/{total} "
+ ("[green]Operational[/green]" if passed == total else "[yellow]Needs attention[/yellow]")
)
if passed < total:
raise typer.Exit(1)
# ---------------------------------------------------------------------------
# warden inventory
# ---------------------------------------------------------------------------
@inventory_app.command("list")
def inventory_list(
output_json: Annotated[bool, typer.Option("--json")] = False,
) -> None:
"""List all actors in the principals inventory."""
cfg = _load_cfg()
inventory = _load_inventory(cfg)
if not inventory.actors:
console.print("No actors in inventory.")
return
if output_json:
print(json.dumps({
name: {
"type": e.actor_type.value,
"principals": e.principals,
"ttl_hours": e.ttl_hours,
"description": e.description,
}
for name, e in inventory.actors.items()
}, indent=2))
return
table = Table(title=f"Principals Inventory ({cfg.inventory_path})")
table.add_column("Actor")
table.add_column("Type")
table.add_column("Principals")
table.add_column("TTL (h)")
table.add_column("Description")
for name, e in inventory.actors.items():
table.add_row(
name,
e.actor_type.value,
", ".join(e.principals),
str(e.ttl_hours),
e.description,
)
console.print(table)
@inventory_app.command("add")
def inventory_add(
actor_name: Annotated[str, typer.Argument(help="Actor name (e.g. agt-state-hub-bridge)")],
actor_type: Annotated[ActorType, typer.Option("--type", "-t", help="adm | agt | atm")],
principals: Annotated[
Optional[List[str]],
typer.Option("--principal", "-p", help="Principal (repeat for multiple)"),
] = None,
ttl: Annotated[Optional[int], typer.Option("--ttl", help="TTL in hours")] = None,
description: Annotated[str, typer.Option("--description", "-d")] = "",
) -> None:
"""Add an actor to the principals inventory."""
cfg = _load_cfg()
try:
validate_actor_name(actor_name, actor_type)
except ValueError as e:
err.print(f"[red]{e}[/red]")
raise typer.Exit(1)
resolved_principals: List[str] = principals or [actor_name]
inventory = _load_inventory(cfg)
inventory.actors[actor_name] = ActorEntry(
name=actor_name,
actor_type=actor_type,
principals=resolved_principals,
ttl_hours=ttl or DEFAULT_TTL_HOURS[actor_type],
description=description,
)
try:
save_inventory(inventory, cfg.inventory_path)
except Exception as e:
err.print(f"[red]Failed to save inventory:[/red] {e}")
raise typer.Exit(1)
console.print(
f"[green]Added[/green] {actor_name} "
f"(type={actor_type.value}, principals={resolved_principals}, ttl={ttl or DEFAULT_TTL_HOURS[actor_type]}h)"
)
@inventory_app.command("remove")
def inventory_remove(
actor_name: Annotated[str, typer.Argument(help="Actor name to remove")],
) -> None:
"""Remove an actor from the principals inventory."""
cfg = _load_cfg()
inventory = _load_inventory(cfg)
if actor_name not in inventory.actors:
err.print(f"[red]Actor {actor_name!r} not in inventory.[/red]")
raise typer.Exit(1)
del inventory.actors[actor_name]
try:
save_inventory(inventory, cfg.inventory_path)
except Exception as e:
err.print(f"[red]Failed to save inventory:[/red] {e}")
raise typer.Exit(1)
console.print(f"[green]Removed[/green] {actor_name}")
# ---------------------------------------------------------------------------
# warden cleanup
# ---------------------------------------------------------------------------
@app.command()
def cleanup(
actor_name: Annotated[Optional[str], typer.Argument(help="Actor name (omit for all)")] = None,
dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without deleting")] = False,
) -> None:
"""Remove stale (expired > 5 min) certificates from state dir."""
cfg = _load_cfg()
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
if actor_name:
cert_path = cfg.state_dir / f"{actor_name}-cert.pub"
paths = [cert_path] if cert_path.exists() else []
else:
paths = sorted(cfg.state_dir.glob("*-cert.pub")) if cfg.state_dir.exists() else []
removed = []
for cert_path in paths:
try:
meta = parse_cert_metadata(cert_path)
except Exception:
continue
if meta["valid_before"] < cutoff:
if dry_run:
console.print(f"would remove: {cert_path.name}")
else:
cert_path.unlink()
console.print(f"removed: {cert_path.name}")
removed.append(cert_path.name)
if not removed:
console.print("No stale certificates found.")
# ---------------------------------------------------------------------------
# warden log
# ---------------------------------------------------------------------------
@app.command()
def log(
actor_name: Annotated[Optional[str], typer.Argument(help="Filter by actor name")] = None,
last: Annotated[int, typer.Option("--last", help="Show last N entries")] = 20,
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Show outgoing certificate signing history."""
cfg = _load_cfg()
log_path = cfg.state_dir / "signatures.log"
if not log_path.exists():
if output_json:
print("[]")
else:
console.print("No signatures log found.")
return
entries = []
for line in log_path.read_text().splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if actor_name and entry.get("actor") != actor_name:
continue
entries.append(entry)
entries = entries[-last:]
if output_json:
print(json.dumps(entries, indent=2))
return
if not entries:
console.print("No matching log entries.")
return
table = Table(title="Signatures Log")
table.add_column("Timestamp")
table.add_column("Actor")
table.add_column("Type")
table.add_column("Identity")
table.add_column("TTL (h)")
table.add_column("Valid Before (UTC)")
table.add_column("Backend")
for e in entries:
table.add_row(
e.get("timestamp", "")[:19],
e.get("actor", ""),
e.get("actor_type", ""),
e.get("identity", ""),
str(e.get("ttl_hours", "")),
e.get("valid_before", "")[:19],
e.get("backend", ""),
)
console.print(table)