feat: add repository rename orchestration CLI
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
parent
6312db8700
commit
6b82215ae7
11 changed files with 1373 additions and 4 deletions
835
repository_rename_cli.py
Normal file
835
repository_rename_cli.py
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
"""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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue