state-hub/repository_rename_cli.py
tegwick 6b82215ae7
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s
feat: add repository rename orchestration CLI
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 12:00:22 +02:00

835 lines
28 KiB
Python

"""Non-interactive State Hub repository-rename orchestration CLI."""
from __future__ import annotations
import argparse
import json
import os
import re
import shlex
import stat
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Any, Callable
CLI_SCHEMA_VERSION = "state-hub.repository-rename-cli.v1"
DEFAULT_API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000")
FORWARD_PHASES = (
"forge-renamed",
"statehub-rebound",
"source-synced",
"consumers-verified",
"completed",
)
NEXT_PHASE = {
"preflighted": "forge-renamed",
"forge-renamed": "statehub-rebound",
"statehub-rebound": "source-synced",
"source-synced": "consumers-verified",
"consumers-verified": "completed",
}
_HEADERS = {"X-StateHub-Component": "state-hub.repository-rename-cli"}
_SENSITIVE_KEYS = {
"authorization",
"cookie",
"set-cookie",
"password",
"secret",
"api-key",
"api_key",
"access-token",
"access_token",
"refresh-token",
"refresh_token",
"credential",
}
class RenameCLIError(RuntimeError):
def __init__(
self,
message: str,
*,
code: str = "rename_cli_error",
status_code: int | None = None,
details: Any = None,
) -> None:
super().__init__(message)
self.code = code
self.status_code = status_code
self.details = details
def _redact_text(value: str) -> str:
value = re.sub(
r"(https?://)[^/@\s]+:[^/@\s]+@",
r"\1[REDACTED]@",
value,
flags=re.IGNORECASE,
)
value = re.sub(
r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+",
r"\1 [REDACTED]",
value,
flags=re.IGNORECASE,
)
value = re.sub(
r"([?&][^=&#\s]*(?:api[_-]?key|password|secret|signature|token|credential)"
r"[^=&#\s]*=)[^&#\s]+",
r"\1[REDACTED]",
value,
flags=re.IGNORECASE,
)
return value
def _public(value: Any, *, redact_preflight_token: bool = True) -> Any:
if isinstance(value, dict):
result: dict[str, Any] = {}
for key, item in value.items():
normalized = key.lower()
if normalized in _SENSITIVE_KEYS or any(
marker in normalized
for marker in ("password", "authorization", "credential")
):
result[key] = "[REDACTED]"
elif key == "preflight_token" and redact_preflight_token and item:
result[key] = "[REDACTED: use --output preflight file]"
else:
result[key] = _public(
item, redact_preflight_token=redact_preflight_token
)
return result
if isinstance(value, list):
return [
_public(item, redact_preflight_token=redact_preflight_token)
for item in value
]
if isinstance(value, str):
return _redact_text(value)
return value
def _api_request(
api_base: str,
method: str,
path: str,
body: dict[str, Any] | None = None,
) -> dict[str, Any]:
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = dict(_HEADERS)
if data is not None:
headers["Content-Type"] = "application/json"
request = urllib.request.Request(
api_base.rstrip("/") + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
payload = json.loads(response.read())
except urllib.error.HTTPError as exc:
try:
payload = json.loads(exc.read())
except (json.JSONDecodeError, UnicodeDecodeError):
payload = {}
detail = payload.get("detail", payload) if isinstance(payload, dict) else {}
if isinstance(detail, dict):
message = str(detail.get("message") or f"State Hub returned HTTP {exc.code}")
code = str(detail.get("code") or "state_hub_http_error")
details = detail.get("details")
else:
message = str(detail or f"State Hub returned HTTP {exc.code}")
code = "state_hub_http_error"
details = None
raise RenameCLIError(
_redact_text(message),
code=code,
status_code=exc.code,
details=_public(details),
) from exc
except (urllib.error.URLError, TimeoutError) as exc:
raise RenameCLIError(
"State Hub is unavailable",
code="state_hub_unavailable",
details={"reason": _redact_text(str(exc.reason if hasattr(exc, "reason") else exc))},
) from exc
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise RenameCLIError(
"State Hub returned an invalid JSON response",
code="invalid_state_hub_response",
) from exc
if not isinstance(payload, dict):
raise RenameCLIError(
"State Hub returned a non-object response",
code="invalid_state_hub_response",
)
return payload
def _load_json(path: str, *, expected: type | tuple[type, ...]) -> Any:
try:
if path == "-":
value = json.load(sys.stdin)
else:
target = Path(path)
if target.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
raise RenameCLIError(
f"Refusing non-private input file {target}; require mode 0600",
code="unsafe_input_permissions",
)
value = json.loads(target.read_text(encoding="utf-8"))
except RenameCLIError:
raise
except (OSError, json.JSONDecodeError) as exc:
raise RenameCLIError(
f"Could not read JSON input {path}",
code="invalid_json_input",
details={"reason": _redact_text(str(exc))},
) from exc
if not isinstance(value, expected):
names = (
", ".join(item.__name__ for item in expected)
if isinstance(expected, tuple)
else expected.__name__
)
raise RenameCLIError(
f"JSON input {path} must contain {names}", code="invalid_json_input"
)
return value
def _write_private_json(path: str, payload: dict[str, Any]) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
try:
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError as exc:
raise RenameCLIError(
f"Refusing to overwrite existing preflight file {target}",
code="output_exists",
) from exc
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
except Exception:
target.unlink(missing_ok=True)
raise
def _confirmation(repo_id: str, old_slug: str, new_slug: str) -> str:
return f"rename:{repo_id}:{old_slug}:{new_slug}"
def _rollback_confirmation(operation_id: str) -> str:
return f"rollback:{operation_id}"
def _validated_uuid(value: str, *, field: str = "operation ID") -> str:
try:
return str(uuid.UUID(value))
except (AttributeError, TypeError, ValueError) as exc:
raise RenameCLIError(
f"{field} must be a UUID",
code="invalid_operation_id",
) from exc
def _command(*parts: str | None) -> list[str]:
return [part for part in parts if part is not None]
def _next_for_operation(operation: dict[str, Any]) -> dict[str, Any]:
operation_id = str(operation["id"])
repo_id = str(operation["repo_id"])
phase = str(operation["phase"])
confirmation = _confirmation(
repo_id, str(operation["old_slug"]), str(operation["new_slug"])
)
if phase in NEXT_PHASE:
target = NEXT_PHASE[phase]
command = _command(
"statehub",
"repo",
"rename",
"apply",
operation_id,
"--phase",
target,
"--confirm",
confirmation,
)
if target in {"source-synced", "consumers-verified"}:
command.extend(["--evidence-file", "<evidence.json>"])
if target == "consumers-verified":
command.extend(["--checks-file", "<checks.json>"])
return {
"action": "apply-phase",
"phase": target,
"expected_phase": phase,
"requires_confirmation": True,
"requires_evidence": target in {"source-synced", "consumers-verified"},
"requires_checks": target == "consumers-verified",
"command": command,
}
if phase == "rollback-preflight":
return {
"action": "execute-rollback",
"requires_confirmation": True,
"command": [
"statehub",
"repo",
"rename",
"rollback",
operation_id,
"--confirm",
_rollback_confirmation(operation_id),
"--execute",
],
}
if phase == "completed":
return {"action": "none", "reason": "rename completed", "command": None}
if phase == "rolled-back":
return {"action": "none", "reason": "rename rolled back", "command": None}
return {
"action": "inspect",
"reason": f"unsupported or unknown phase {phase!r}",
"command": ["statehub", "repo", "rename", "status", operation_id],
}
def _state_for_operation(operation: dict[str, Any]) -> str:
if operation.get("phase") == "rolled-back":
return "rolled-back"
if operation.get("error_code"):
return "failed"
if operation.get("phase") == "completed":
return "achieved"
return "planned"
def _envelope(
command: str,
*,
state: str,
ok: bool,
result: dict[str, Any] | None = None,
operation_id: str | None = None,
repo_id: str | None = None,
phase: str | None = None,
next_safe_action: dict[str, Any] | None = None,
error: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"schema_version": CLI_SCHEMA_VERSION,
"command": command,
"ok": ok,
"state": state,
"operation_id": operation_id,
"repo_id": repo_id,
"phase": phase,
"next_safe_action": next_safe_action,
"result": result,
"error": error,
}
def _emit(payload: dict[str, Any], *, as_json: bool) -> None:
public = _public(payload)
if as_json:
print(json.dumps(public, indent=2, sort_keys=True))
return
state = str(public["state"]).upper()
stream = sys.stderr if not public["ok"] else sys.stdout
print(f"{state}: repository rename {public['command']}", file=stream)
if public.get("operation_id"):
print(f" operation: {public['operation_id']}", file=stream)
if public.get("repo_id"):
print(f" repository: {public['repo_id']}", file=stream)
if public.get("phase"):
print(f" phase: {public['phase']}", file=stream)
result = public.get("result") or {}
if public["command"] == "preflight":
print(f" safe to apply: {bool(result.get('safe_to_apply'))}", file=stream)
print(f" blockers: {len(result.get('blockers') or [])}", file=stream)
if result.get("preflight_file"):
print(f" private preflight: {result['preflight_file']}", file=stream)
error = public.get("error")
if error:
print(f" error: {error.get('message')}", file=stream)
action = public.get("next_safe_action") or {}
if action.get("action"):
print(f" next safe action: {action['action']}", file=stream)
if action.get("command"):
print(f" command: {shlex.join(action['command'])}", file=stream)
def _run(args: argparse.Namespace, action: Callable[[], dict[str, Any]]) -> None:
try:
payload = action()
except RenameCLIError as exc:
payload = _envelope(
args.rename_command,
state="failed",
ok=False,
operation_id=getattr(args, "operation_id", None),
error={
"code": exc.code,
"message": str(exc),
"status_code": exc.status_code,
"details": exc.details,
},
next_safe_action={"action": "inspect-input-or-status", "command": None},
)
_emit(payload, as_json=args.as_json)
raise SystemExit(1) from exc
_emit(payload, as_json=args.as_json)
if not payload["ok"]:
raise SystemExit(2)
def _resolve_repo(args: argparse.Namespace, slug: str) -> dict[str, Any]:
quoted = urllib.parse.quote(slug, safe="")
repo = _api_request(args.api_base, "GET", f"/repos/{quoted}")
if repo.get("slug_status") == "alias":
raise RenameCLIError(
f"{slug!r} is a prior alias; current canonical slug is {repo.get('canonical_slug')!r}",
code="old_slug_is_alias",
)
return repo
def cmd_preflight(args: argparse.Namespace) -> None:
def action() -> dict[str, Any]:
operation_id = (
_validated_uuid(args.operation_id)
if args.operation_id
else str(uuid.uuid4())
)
if args.output and Path(args.output).exists():
raise RenameCLIError(
f"Refusing to overwrite existing preflight file {args.output}",
code="output_exists",
)
repo = _resolve_repo(args, args.old_slug)
edge_writes: list[dict[str, Any]] = []
if args.queued_edge_writes:
edge_writes = _load_json(args.queued_edge_writes, expected=list)
if not all(isinstance(item, dict) for item in edge_writes):
raise RenameCLIError(
"Queued edge-write input must be a list of objects",
code="invalid_json_input",
)
report = _api_request(
args.api_base,
"POST",
f"/repos/{repo['id']}/rename/preflight",
{"new_slug": args.new_slug, "queued_edge_writes": edge_writes},
)
confirmation = _confirmation(str(repo["id"]), args.old_slug, args.new_slug)
private = _envelope(
"preflight",
state="planned",
ok=bool(report.get("safe_to_apply")),
result=report,
operation_id=operation_id,
repo_id=str(repo["id"]),
phase=None,
)
if args.output:
_write_private_json(args.output, private)
if report.get("safe_to_apply") and args.output:
next_action = {
"action": "start-operation",
"requires_confirmation": True,
"command": [
"statehub", "repo", "rename", "start",
args.old_slug, args.new_slug,
"--operation-id", operation_id,
"--preflight-file", args.output,
"--actor", "<actor>",
"--confirm", confirmation,
],
}
elif report.get("safe_to_apply"):
next_action = {
"action": "save-private-preflight",
"requires_confirmation": False,
"command": [
"statehub", "repo", "rename", "preflight",
args.old_slug, args.new_slug,
"--operation-id", operation_id,
"--output", "<preflight-report.json>",
],
}
else:
next_action = {
"action": "resolve-blockers",
"requires_confirmation": False,
"command": None,
}
public_report = dict(report)
public_report["preflight_file"] = args.output
return _envelope(
"preflight",
state="planned" if report.get("safe_to_apply") else "failed",
ok=bool(report.get("safe_to_apply")),
result=public_report,
operation_id=operation_id,
repo_id=str(repo["id"]),
next_safe_action=next_action,
)
_run(args, action)
def cmd_start(args: argparse.Namespace) -> None:
def action() -> dict[str, Any]:
operation_id = _validated_uuid(args.operation_id)
try:
existing = _api_request(
args.api_base,
"GET",
f"/repository-renames/operations/{operation_id}",
)
except RenameCLIError as exc:
if exc.status_code != 404:
raise
else:
expected_confirmation = _confirmation(
str(existing["repo_id"]),
str(existing["old_slug"]),
str(existing["new_slug"]),
)
if (
existing.get("old_slug") != args.old_slug
or existing.get("new_slug") != args.new_slug
or existing.get("actor") != args.actor
or args.confirm != expected_confirmation
):
raise RenameCLIError(
"Operation ID is already bound to another repository rename",
code="operation_id_conflict",
)
existing = {**existing, "no_op": True}
return _operation_envelope("start", existing)
wrapper = _load_json(args.preflight_file, expected=dict)
if (
wrapper.get("schema_version") != CLI_SCHEMA_VERSION
or wrapper.get("command") != "preflight"
):
raise RenameCLIError(
"Preflight file is not a repository-rename CLI preflight",
code="invalid_preflight_file",
)
if str(wrapper.get("operation_id")) != operation_id:
raise RenameCLIError(
"Preflight operation ID does not match --operation-id",
code="invalid_preflight_file",
)
report = wrapper.get("result") or {}
repo = _resolve_repo(args, args.old_slug)
if (
str(wrapper.get("repo_id")) != str(repo["id"])
or report.get("old_slug") != args.old_slug
or report.get("new_slug") != args.new_slug
or not report.get("safe_to_apply")
or not report.get("preflight_token")
):
raise RenameCLIError(
"Preflight file does not authorize this repository rename",
code="invalid_preflight_file",
)
expected_confirmation = _confirmation(
str(repo["id"]), args.old_slug, args.new_slug
)
if args.confirm != expected_confirmation:
raise RenameCLIError(
"Explicit repository rename confirmation is incorrect",
code="confirmation_mismatch",
)
operation = _api_request(
args.api_base,
"POST",
f"/repos/{repo['id']}/rename/operations",
{
"operation_id": operation_id,
"new_slug": args.new_slug,
"preflight_token": report["preflight_token"],
"confirmation": args.confirm,
"actor": args.actor,
"queued_edge_writes": report.get("queued_edge_writes") or [],
},
)
return _operation_envelope("start", operation)
_run(args, action)
def _load_operation(args: argparse.Namespace) -> dict[str, Any]:
operation_id = _validated_uuid(args.operation_id)
return _api_request(
args.api_base,
"GET",
f"/repository-renames/operations/{operation_id}",
)
def _operation_envelope(command: str, operation: dict[str, Any]) -> dict[str, Any]:
return _envelope(
command,
state=_state_for_operation(operation),
ok=not bool(operation.get("error_code")),
result=operation,
operation_id=str(operation["id"]),
repo_id=str(operation["repo_id"]),
phase=str(operation["phase"]),
next_safe_action=_next_for_operation(operation),
)
def cmd_status(args: argparse.Namespace) -> None:
_run(args, lambda: _operation_envelope("status", _load_operation(args)))
def cmd_apply(args: argparse.Namespace) -> None:
def action() -> dict[str, Any]:
operation = _load_operation(args)
expected_confirmation = _confirmation(
str(operation["repo_id"]),
str(operation["old_slug"]),
str(operation["new_slug"]),
)
if args.confirm != expected_confirmation:
raise RenameCLIError(
"Explicit repository rename confirmation is incorrect",
code="confirmation_mismatch",
)
evidence = (
_load_json(args.evidence_file, expected=dict)
if args.evidence_file
else {}
)
checks = (
_load_json(args.checks_file, expected=dict)
if args.checks_file
else {}
)
if any(not isinstance(value, bool) for value in checks.values()):
raise RenameCLIError(
"Checks file values must all be booleans",
code="invalid_json_input",
)
updated = _api_request(
args.api_base,
"POST",
f"/repos/{operation['repo_id']}/rename/operations/{operation['id']}/phases/{args.phase}",
{
"expected_phase": args.expected_phase or operation["phase"],
"confirmation": args.confirm,
"checks": checks,
"evidence": evidence,
},
)
return _operation_envelope("apply", updated)
_run(args, action)
def cmd_verify(args: argparse.Namespace) -> None:
def action() -> dict[str, Any]:
operation = _load_operation(args)
verification = _api_request(
args.api_base,
"GET",
f"/repos/{operation['repo_id']}/rename/operations/{operation['id']}/verify",
)
if verification.get("ok"):
next_action = _next_for_operation(operation)
state = "achieved"
else:
next_action = {
"action": "resolve-verification-failures",
"command": [
"statehub", "repo", "rename", "status", str(operation["id"])
],
}
state = "failed"
return _envelope(
"verify",
state=state,
ok=bool(verification.get("ok")),
result=verification,
operation_id=str(operation["id"]),
repo_id=str(operation["repo_id"]),
phase=str(operation["phase"]),
next_safe_action=next_action,
)
_run(args, action)
def cmd_rollback(args: argparse.Namespace) -> None:
def action() -> dict[str, Any]:
operation = _load_operation(args)
operation_id = str(operation["id"])
expected_confirmation = _rollback_confirmation(operation_id)
if args.confirm != expected_confirmation:
raise RenameCLIError(
"Explicit rollback confirmation is incorrect",
code="confirmation_mismatch",
)
if operation["phase"] == "rolled-back":
return _operation_envelope("rollback", operation)
if operation["phase"] != "rollback-preflight":
preflight = _api_request(
args.api_base,
"POST",
f"/repos/{operation['repo_id']}/rename/operations/{operation_id}/rollback-preflight",
{
"expected_phase": args.expected_phase or operation["phase"],
"confirmation": args.confirm,
},
)
operation = preflight["operation"]
if not preflight.get("safe_to_rollback"):
raise RenameCLIError(
"Repository rename rollback is unsafe",
code="rollback_unsafe",
details=preflight,
)
if not args.execute:
return _operation_envelope("rollback", operation)
rolled_back = _api_request(
args.api_base,
"POST",
f"/repos/{operation['repo_id']}/rename/operations/{operation_id}/rollback",
{"expected_phase": "rollback-preflight", "confirmation": args.confirm},
)
return _operation_envelope("rollback", rolled_back)
_run(args, action)
def _common(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--api-base", default=DEFAULT_API_BASE, help="State Hub API base URL"
)
parser.add_argument(
"--json",
action="store_true",
dest="as_json",
help="Emit the stable orchestration JSON contract",
)
def configure_repo_commands(subparsers: argparse._SubParsersAction) -> None:
repo = subparsers.add_parser("repo", help="Repository lifecycle commands")
repo_sub = repo.add_subparsers(dest="repo_command", required=True)
rename = repo_sub.add_parser(
"rename", help="Governed repository rename lifecycle"
)
rename_sub = rename.add_subparsers(dest="rename_command", required=True)
preflight = rename_sub.add_parser(
"preflight",
help="Inspect rename safety without mutating State Hub or Forgejo",
)
preflight.add_argument("old_slug")
preflight.add_argument("new_slug")
preflight.add_argument(
"--operation-id",
default=None,
help="Client-owned UUID; generated when omitted",
)
preflight.add_argument(
"--queued-edge-writes",
default=None,
help="Mode-0600 JSON list of queued-write evidence",
)
preflight.add_argument(
"--output",
default=None,
help="Write the token-bearing preflight envelope to a new mode-0600 file",
)
_common(preflight)
preflight.set_defaults(func=cmd_preflight)
start = rename_sub.add_parser(
"start",
help="Create an idempotent operation journal from a private preflight file",
)
start.add_argument("old_slug")
start.add_argument("new_slug")
start.add_argument("--operation-id", required=True)
start.add_argument(
"--preflight-file",
required=True,
help="Mode-0600 preflight file, or - for stdin",
)
start.add_argument("--actor", required=True)
start.add_argument("--confirm", required=True, metavar="CONFIRMATION")
_common(start)
start.set_defaults(func=cmd_start)
apply = rename_sub.add_parser(
"apply", help="Apply exactly one compare-and-set lifecycle phase"
)
apply.add_argument("operation_id")
apply.add_argument("--phase", required=True, choices=FORWARD_PHASES)
apply.add_argument("--expected-phase", default=None)
apply.add_argument("--confirm", required=True, metavar="CONFIRMATION")
apply.add_argument(
"--evidence-file",
default=None,
help="Mode-0600 JSON object, or - for stdin",
)
apply.add_argument(
"--checks-file", default=None, help="Mode-0600 JSON boolean map"
)
_common(apply)
apply.set_defaults(func=cmd_apply)
status_parser = rename_sub.add_parser(
"status", help="Read the durable operation journal"
)
status_parser.add_argument("operation_id")
_common(status_parser)
status_parser.set_defaults(func=cmd_status)
verify = rename_sub.add_parser(
"verify", help="Verify Forge identity and State Hub continuity"
)
verify.add_argument("operation_id")
_common(verify)
verify.set_defaults(func=cmd_verify)
rollback = rename_sub.add_parser(
"rollback",
help="Preflight rollback; --execute is required to perform it",
)
rollback.add_argument("operation_id")
rollback.add_argument("--expected-phase", default=None)
rollback.add_argument("--confirm", required=True, metavar="CONFIRMATION")
rollback.add_argument(
"--execute",
action="store_true",
help="Execute after rollback preflight succeeds",
)
_common(rollback)
rollback.set_defaults(func=cmd_rollback)
__all__ = [
"CLI_SCHEMA_VERSION",
"RenameCLIError",
"configure_repo_commands",
]