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
|
|
@ -154,6 +154,55 @@ async def test_dry_run_has_no_persistent_changes(client, test_engine, rename_set
|
|||
assert after == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_operation_id_is_idempotent_and_globally_discoverable(
|
||||
client, rename_setup
|
||||
):
|
||||
repo, _forge = rename_setup
|
||||
preflight = await _preflight(client, repo["id"])
|
||||
operation_id = str(uuid.uuid4())
|
||||
confirmation = f"rename:{repo['id']}:flex-auth:access-engine"
|
||||
payload = {
|
||||
"operation_id": operation_id,
|
||||
"new_slug": "access-engine",
|
||||
"preflight_token": preflight["preflight_token"],
|
||||
"confirmation": confirmation,
|
||||
"actor": "helixforge-test",
|
||||
}
|
||||
|
||||
created = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations", json=payload
|
||||
)
|
||||
replay = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations",
|
||||
json={**payload, "preflight_token": "expired-after-creation"},
|
||||
)
|
||||
assert created.status_code == replay.status_code == 201
|
||||
assert created.json()["id"] == replay.json()["id"] == operation_id
|
||||
assert created.json()["no_op"] is False
|
||||
assert replay.json()["no_op"] is True
|
||||
|
||||
discovered = await client.get(
|
||||
f"/repository-renames/operations/{operation_id}"
|
||||
)
|
||||
assert discovered.status_code == 200
|
||||
assert discovered.json()["repo_id"] == repo["id"]
|
||||
assert discovered.json()["phase"] == "preflighted"
|
||||
|
||||
collision = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations",
|
||||
json={**payload, "new_slug": "another-name"},
|
||||
)
|
||||
assert collision.status_code == 412
|
||||
assert collision.json()["detail"]["code"] == "repository_rename_precondition_failed"
|
||||
|
||||
actor_collision = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations",
|
||||
json={**payload, "actor": "different-actor"},
|
||||
)
|
||||
assert actor_collision.status_code == 412
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_setup):
|
||||
repo, forge = rename_setup
|
||||
|
|
|
|||
390
tests/test_repository_rename_cli.py
Normal file
390
tests/test_repository_rename_cli.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import stat
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
import custodian_cli
|
||||
import repository_rename_cli as rename_cli
|
||||
|
||||
|
||||
REPO_ID = "11111111-1111-4111-8111-111111111111"
|
||||
OPERATION_ID = "22222222-2222-4222-8222-222222222222"
|
||||
CONFIRMATION = f"rename:{REPO_ID}:flex-auth:access-engine"
|
||||
|
||||
|
||||
def _repo() -> dict:
|
||||
return {
|
||||
"id": REPO_ID,
|
||||
"slug": "flex-auth",
|
||||
"canonical_slug": "flex-auth",
|
||||
"requested_slug": "flex-auth",
|
||||
"slug_status": "canonical",
|
||||
}
|
||||
|
||||
|
||||
def _operation(phase: str, *, no_op: bool = False) -> dict:
|
||||
return {
|
||||
"id": OPERATION_ID,
|
||||
"repo_id": REPO_ID,
|
||||
"phase": phase,
|
||||
"old_slug": "flex-auth",
|
||||
"new_slug": "access-engine",
|
||||
"actor": "helixforge",
|
||||
"error_code": None,
|
||||
"error_message": None,
|
||||
"no_op": no_op,
|
||||
}
|
||||
|
||||
|
||||
def _preflight() -> dict:
|
||||
return {
|
||||
"schema_version": "state-hub.repository-rename-preflight.v1",
|
||||
"repo_id": REPO_ID,
|
||||
"old_slug": "flex-auth",
|
||||
"new_slug": "access-engine",
|
||||
"safe_to_apply": True,
|
||||
"blockers": [],
|
||||
"warnings": [],
|
||||
"queued_edge_writes": [],
|
||||
"preflight_token": "signed-preflight-token",
|
||||
}
|
||||
|
||||
|
||||
def _run(monkeypatch, *args: str) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["statehub", *args])
|
||||
custodian_cli.main()
|
||||
|
||||
|
||||
def test_preflight_writes_private_token_file_and_redacts_stdout(
|
||||
monkeypatch, tmp_path, capsys
|
||||
):
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
if method == "GET":
|
||||
return _repo()
|
||||
return _preflight()
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
output = tmp_path / "preflight.json"
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "preflight", "flex-auth", "access-engine",
|
||||
"--operation-id", OPERATION_ID,
|
||||
"--output", str(output),
|
||||
"--json",
|
||||
)
|
||||
|
||||
public = json.loads(capsys.readouterr().out)
|
||||
private = json.loads(output.read_text())
|
||||
assert public["schema_version"] == rename_cli.CLI_SCHEMA_VERSION
|
||||
assert public["operation_id"] == OPERATION_ID
|
||||
assert public["next_safe_action"]["action"] == "start-operation"
|
||||
assert public["result"]["preflight_token"].startswith("[REDACTED")
|
||||
assert "signed-preflight-token" not in json.dumps(public)
|
||||
assert private["result"]["preflight_token"] == "signed-preflight-token"
|
||||
assert stat.S_IMODE(output.stat().st_mode) == 0o600
|
||||
assert calls[1][2] == {
|
||||
"new_slug": "access-engine",
|
||||
"queued_edge_writes": [],
|
||||
}
|
||||
|
||||
|
||||
def test_start_uses_client_operation_id_and_retry_safe_api_body(
|
||||
monkeypatch, tmp_path, capsys
|
||||
):
|
||||
preflight_file = tmp_path / "preflight.json"
|
||||
preflight_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": rename_cli.CLI_SCHEMA_VERSION,
|
||||
"command": "preflight",
|
||||
"operation_id": OPERATION_ID,
|
||||
"repo_id": REPO_ID,
|
||||
"result": _preflight(),
|
||||
}
|
||||
)
|
||||
)
|
||||
preflight_file.chmod(0o600)
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
if path.startswith("/repository-renames/"):
|
||||
raise rename_cli.RenameCLIError(
|
||||
"not found", code="repository_rename_not_found", status_code=404
|
||||
)
|
||||
return _repo() if method == "GET" else _operation("preflighted")
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "start", "flex-auth", "access-engine",
|
||||
"--operation-id", OPERATION_ID,
|
||||
"--preflight-file", str(preflight_file),
|
||||
"--actor", "helixforge",
|
||||
"--confirm", CONFIRMATION,
|
||||
"--json",
|
||||
)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["phase"] == "preflighted"
|
||||
assert result["next_safe_action"]["phase"] == "forge-renamed"
|
||||
create_body = calls[2][2]
|
||||
assert create_body["operation_id"] == OPERATION_ID
|
||||
assert create_body["preflight_token"] == "signed-preflight-token"
|
||||
assert create_body["actor"] == "helixforge"
|
||||
assert "signed-preflight-token" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_start_retry_uses_operation_journal_after_old_slug_becomes_alias(
|
||||
monkeypatch, capsys
|
||||
):
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
return _operation("statehub-rebound")
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo",
|
||||
"rename",
|
||||
"start",
|
||||
"flex-auth",
|
||||
"access-engine",
|
||||
"--operation-id",
|
||||
OPERATION_ID,
|
||||
"--preflight-file",
|
||||
"/already-consumed/preflight.json",
|
||||
"--actor",
|
||||
"helixforge",
|
||||
"--confirm",
|
||||
CONFIRMATION,
|
||||
"--json",
|
||||
)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["phase"] == "statehub-rebound"
|
||||
assert result["result"]["no_op"] is True
|
||||
assert calls == [
|
||||
(
|
||||
"GET",
|
||||
f"/repository-renames/operations/{OPERATION_ID}",
|
||||
None,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_apply_uses_files_and_names_the_next_safe_phase(
|
||||
monkeypatch, tmp_path, capsys
|
||||
):
|
||||
evidence = tmp_path / "evidence.json"
|
||||
evidence.write_text('{"fresh_clone": true}')
|
||||
evidence.chmod(0o600)
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
if method == "GET":
|
||||
return _operation("statehub-rebound")
|
||||
return _operation("source-synced")
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "apply", OPERATION_ID,
|
||||
"--phase", "source-synced",
|
||||
"--confirm", CONFIRMATION,
|
||||
"--evidence-file", str(evidence),
|
||||
"--json",
|
||||
)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["state"] == "planned"
|
||||
assert result["phase"] == "source-synced"
|
||||
assert result["next_safe_action"]["phase"] == "consumers-verified"
|
||||
apply_body = calls[1][2]
|
||||
assert apply_body["expected_phase"] == "statehub-rebound"
|
||||
assert apply_body["evidence"] == {"fresh_clone": True}
|
||||
|
||||
|
||||
def test_rollback_requires_separate_execute_flag(monkeypatch, capsys):
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
if method == "GET":
|
||||
return _operation("source-synced")
|
||||
return {
|
||||
"safe_to_rollback": True,
|
||||
"operation": _operation("rollback-preflight"),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "rollback", OPERATION_ID,
|
||||
"--confirm", f"rollback:{OPERATION_ID}",
|
||||
"--json",
|
||||
)
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["phase"] == "rollback-preflight"
|
||||
assert result["next_safe_action"]["action"] == "execute-rollback"
|
||||
assert [method for method, _path, _body in calls] == ["GET", "POST"]
|
||||
|
||||
|
||||
def test_rollback_execute_reaches_terminal_state(monkeypatch, capsys):
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
return (
|
||||
_operation("rollback-preflight")
|
||||
if method == "GET"
|
||||
else _operation("rolled-back")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "rollback", OPERATION_ID,
|
||||
"--confirm", f"rollback:{OPERATION_ID}",
|
||||
"--execute",
|
||||
"--json",
|
||||
)
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["state"] == "rolled-back"
|
||||
assert result["next_safe_action"]["action"] == "none"
|
||||
assert [method for method, _path, _body in calls] == ["GET", "POST"]
|
||||
|
||||
|
||||
def test_json_error_output_redacts_credentials_and_userinfo(monkeypatch, capsys):
|
||||
def request(*_args, **_kwargs):
|
||||
raise rename_cli.RenameCLIError(
|
||||
"failed via https://user:password@forge.example/repo"
|
||||
"?X-Amz-Credential=url-credential&X-Amz-Signature=url-secret",
|
||||
code="simulated",
|
||||
details={"authorization": "Bearer top-secret", "password": "hidden"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "status", OPERATION_ID, "--json",
|
||||
)
|
||||
assert exc.value.code == 1
|
||||
output = capsys.readouterr().out
|
||||
assert "user:password" not in output
|
||||
assert "top-secret" not in output
|
||||
assert "hidden" not in output
|
||||
assert "url-secret" not in output
|
||||
assert "url-credential" not in output
|
||||
assert "[REDACTED]" in output
|
||||
|
||||
|
||||
def test_invalid_confirmation_stops_before_mutating_request(monkeypatch, capsys):
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
return _operation("preflighted")
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "apply", OPERATION_ID,
|
||||
"--phase", "forge-renamed",
|
||||
"--confirm", "yes",
|
||||
"--json",
|
||||
)
|
||||
assert exc.value.code == 1
|
||||
assert [method for method, _path, _body in calls] == ["GET"]
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["code"] == "confirmation_mismatch"
|
||||
|
||||
|
||||
def test_unsafe_preflight_is_machine_detectable(monkeypatch, capsys):
|
||||
def request(_api_base, method, _path, body=None):
|
||||
del body
|
||||
if method == "GET":
|
||||
return _repo()
|
||||
return {
|
||||
**_preflight(),
|
||||
"safe_to_apply": False,
|
||||
"blockers": [{"code": "target_exists"}],
|
||||
"preflight_token": None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo",
|
||||
"rename",
|
||||
"preflight",
|
||||
"flex-auth",
|
||||
"access-engine",
|
||||
"--json",
|
||||
)
|
||||
assert exc.value.code == 2
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["state"] == "failed"
|
||||
assert result["next_safe_action"]["action"] == "resolve-blockers"
|
||||
|
||||
|
||||
def test_invalid_operation_id_is_stable_json(monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
rename_cli,
|
||||
"_api_request",
|
||||
lambda *_args, **_kwargs: pytest.fail("API should not be called"),
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo",
|
||||
"rename",
|
||||
"status",
|
||||
"not-a-uuid",
|
||||
"--json",
|
||||
)
|
||||
assert exc.value.code == 1
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["schema_version"] == rename_cli.CLI_SCHEMA_VERSION
|
||||
assert result["state"] == "failed"
|
||||
assert result["error"]["code"] == "invalid_operation_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("operation", "label"),
|
||||
[
|
||||
(_operation("preflighted"), "PLANNED"),
|
||||
(_operation("completed"), "ACHIEVED"),
|
||||
({**_operation("source-synced"), "error_code": "failed"}, "FAILED"),
|
||||
(_operation("rolled-back"), "ROLLED-BACK"),
|
||||
],
|
||||
)
|
||||
def test_human_status_distinguishes_lifecycle_states(
|
||||
monkeypatch, capsys, operation, label
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
rename_cli,
|
||||
"_api_request",
|
||||
lambda *_args, **_kwargs: operation,
|
||||
)
|
||||
if label == "FAILED":
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_run(monkeypatch, "repo", "rename", "status", OPERATION_ID)
|
||||
assert exc.value.code == 2
|
||||
else:
|
||||
_run(monkeypatch, "repo", "rename", "status", OPERATION_ID)
|
||||
captured = capsys.readouterr()
|
||||
assert f"{label}: repository rename status" in captured.out + captured.err
|
||||
Loading…
Add table
Add a link
Reference in a new issue