feat: add repository rename lifecycle API
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
parent
9e77a4a9a2
commit
82ea38b180
11 changed files with 2464 additions and 3 deletions
394
tests/test_repository_rename_api.py
Normal file
394
tests/test_repository_rename_api.py
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from api.config import settings
|
||||
from api.main import app
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repository_rename import RepositoryRenameOperation, RepositorySlug
|
||||
from api.services.forge_repository import (
|
||||
ForgeRepositorySnapshot,
|
||||
get_forge_repository_gateway,
|
||||
)
|
||||
from tests.conftest import (
|
||||
create_test_domain,
|
||||
create_test_repo,
|
||||
create_test_workplan,
|
||||
)
|
||||
|
||||
|
||||
class FakeForge:
|
||||
def __init__(self, *, name: str = "flex-auth", repository_id: int = 417, head: str = "a" * 40):
|
||||
self.name = name
|
||||
self.repository_id = repository_id
|
||||
self.head = head
|
||||
self.rename_calls = 0
|
||||
self.extra: dict[str, int] = {}
|
||||
self.unreadable = False
|
||||
|
||||
def snapshot(self, name: str, repository_id: int | None = None) -> ForgeRepositorySnapshot:
|
||||
return ForgeRepositorySnapshot(
|
||||
repository_id=repository_id or self.repository_id,
|
||||
owner="coulomb",
|
||||
name=name,
|
||||
full_name=f"coulomb/{name}",
|
||||
default_branch="main",
|
||||
head_commit=self.head,
|
||||
clone_url=f"https://forge.example/coulomb/{name}.git",
|
||||
html_url=f"https://forge.example/coulomb/{name}",
|
||||
projection_readable=True,
|
||||
projection_source_present=True,
|
||||
projection_entry_count=1,
|
||||
)
|
||||
|
||||
async def inspect(self, *, instance: str, owner: str, name: str):
|
||||
if self.unreadable:
|
||||
from api.services.forge_repository import ForgeRepositoryUnreadable
|
||||
|
||||
raise ForgeRepositoryUnreadable("simulated unreadable forge")
|
||||
if name == self.name:
|
||||
return self.snapshot(name)
|
||||
if name in self.extra:
|
||||
return self.snapshot(name, self.extra[name])
|
||||
return None
|
||||
|
||||
async def rename(self, *, instance: str, owner: str, old_name: str, new_name: str):
|
||||
assert old_name == self.name
|
||||
assert new_name not in self.extra
|
||||
self.rename_calls += 1
|
||||
self.name = new_name
|
||||
return self.snapshot(new_name)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def rename_setup(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "repository_rename_preflight_secret", "test-only-preflight-secret")
|
||||
domain = await create_test_domain(client)
|
||||
repo = await create_test_repo(
|
||||
client,
|
||||
domain_slug=domain["slug"],
|
||||
slug="flex-auth",
|
||||
remote_url="https://forge.example/coulomb/flex-auth.git",
|
||||
local_path="/srv/flex-auth",
|
||||
host_paths={"workstation": "/home/operator/flex-auth"},
|
||||
)
|
||||
forge = FakeForge()
|
||||
app.dependency_overrides[get_forge_repository_gateway] = lambda: forge
|
||||
verified = await client.post(
|
||||
f"/repos/{repo['id']}/forge-identity/verify",
|
||||
json={
|
||||
"provider": "forgejo",
|
||||
"forge_instance": "https://forge.example",
|
||||
"forge_owner": "coulomb",
|
||||
"forge_repository_id": forge.repository_id,
|
||||
"verified_by": "pytest",
|
||||
},
|
||||
)
|
||||
assert verified.status_code == 200, verified.text
|
||||
return repo, forge
|
||||
|
||||
|
||||
async def _preflight(client, repo_id: str, new_slug: str = "access-engine"):
|
||||
response = await client.post(
|
||||
f"/repos/{repo_id}/rename/preflight", json={"new_slug": new_slug}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
async def _operation(client, repo_id: str, preflight: dict):
|
||||
confirmation = f"rename:{repo_id}:flex-auth:access-engine"
|
||||
response = await client.post(
|
||||
f"/repos/{repo_id}/rename/operations",
|
||||
json={
|
||||
"new_slug": "access-engine",
|
||||
"preflight_token": preflight["preflight_token"],
|
||||
"confirmation": confirmation,
|
||||
"actor": "pytest",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json(), confirmation
|
||||
|
||||
|
||||
async def _phase(client, repo_id: str, operation_id: str, phase: str, expected: str, confirmation: str, **extra):
|
||||
response = await client.post(
|
||||
f"/repos/{repo_id}/rename/operations/{operation_id}/phases/{phase}",
|
||||
json={
|
||||
"expected_phase": expected,
|
||||
"confirmation": confirmation,
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_has_no_persistent_changes(client, test_engine, rename_setup):
|
||||
repo, _forge = rename_setup
|
||||
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
before_operations = await session.scalar(select(func.count()).select_from(RepositoryRenameOperation))
|
||||
before_slugs = await session.scalar(select(func.count()).select_from(RepositorySlug))
|
||||
before_repo = await session.get(ManagedRepo, uuid.UUID(repo["id"]))
|
||||
before = (before_operations, before_slugs, before_repo.slug, before_repo.updated_at)
|
||||
|
||||
report = await _preflight(client, repo["id"])
|
||||
assert report["safe_to_apply"] is True
|
||||
assert report["preflight_token"]
|
||||
assert report["current"]["statehub"]["repo_id"] == repo["id"]
|
||||
assert report["current"]["forge"]["repository_id"] == 417
|
||||
assert report["retained_history"]
|
||||
|
||||
async with factory() as session:
|
||||
after_operations = await session.scalar(select(func.count()).select_from(RepositoryRenameOperation))
|
||||
after_slugs = await session.scalar(select(func.count()).select_from(RepositorySlug))
|
||||
after_repo = await session.get(ManagedRepo, uuid.UUID(repo["id"]))
|
||||
after = (after_operations, after_slugs, after_repo.slug, after_repo.updated_at)
|
||||
assert after == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_setup):
|
||||
repo, forge = rename_setup
|
||||
workplan = await create_test_workplan(
|
||||
client, repo_id=repo["id"], slug="FLEX-WP-0001", status="active"
|
||||
)
|
||||
task = await client.post(
|
||||
"/tasks/",
|
||||
json={
|
||||
"workplan_id": workplan["id"],
|
||||
"title": "Security migration",
|
||||
"status": "todo",
|
||||
"priority": "high",
|
||||
},
|
||||
)
|
||||
assert task.status_code == 201, task.text
|
||||
progress = await client.post(
|
||||
"/progress/",
|
||||
json={"workplan_id": workplan["id"], "task_id": task.json()["id"], "summary": "baseline", "event_type": "note"},
|
||||
)
|
||||
assert progress.status_code == 201, progress.text
|
||||
|
||||
preflight = await _preflight(client, repo["id"])
|
||||
operation, confirmation = await _operation(client, repo["id"], preflight)
|
||||
operation_id = operation["id"]
|
||||
discover = await client.get(
|
||||
f"/repos/{repo['id']}/rename/operations", params={"active_only": True}
|
||||
)
|
||||
assert discover.status_code == 200
|
||||
assert [item["id"] for item in discover.json()] == [operation_id]
|
||||
|
||||
# Simulate the hardest interruption: Forge committed the rename but the
|
||||
# State Hub request died before recording forge-renamed.
|
||||
await forge.rename(
|
||||
instance="https://forge.example",
|
||||
owner="coulomb",
|
||||
old_name="flex-auth",
|
||||
new_name="access-engine",
|
||||
)
|
||||
phase = await _phase(
|
||||
client, repo["id"], operation_id, "forge-renamed", "preflighted", confirmation
|
||||
)
|
||||
assert phase["phase"] == "forge-renamed"
|
||||
assert phase["evidence"]["phases"]["forge-renamed"]["resumed"] is True
|
||||
assert forge.rename_calls == 1
|
||||
|
||||
sequence = [
|
||||
("statehub-rebound", "forge-renamed", {}),
|
||||
("source-synced", "statehub-rebound", {"evidence": {"clone": "fresh"}}),
|
||||
("consumers-verified", "source-synced", {"checks": {"routes": True, "security-stack": True}}),
|
||||
("completed", "consumers-verified", {}),
|
||||
]
|
||||
for requested, expected, extra in sequence:
|
||||
read = await client.get(f"/repos/{repo['id']}/rename/operations/{operation_id}")
|
||||
assert read.status_code == 200
|
||||
assert read.json()["phase"] == expected
|
||||
phase = await _phase(
|
||||
client, repo["id"], operation_id, requested, expected, confirmation, **extra
|
||||
)
|
||||
assert phase["phase"] == requested
|
||||
if requested == "source-synced":
|
||||
appended = await client.post(
|
||||
"/progress/",
|
||||
json={
|
||||
"workplan_id": workplan["id"],
|
||||
"task_id": task.json()["id"],
|
||||
"summary": "rename phase completed",
|
||||
"event_type": "note",
|
||||
},
|
||||
)
|
||||
assert appended.status_code == 201, appended.text
|
||||
replay = await _phase(
|
||||
client, repo["id"], operation_id, requested, expected, confirmation, **extra
|
||||
)
|
||||
assert replay["id"] == operation_id
|
||||
assert replay["no_op"] is True
|
||||
|
||||
verification = await client.get(
|
||||
f"/repos/{repo['id']}/rename/operations/{operation_id}/verify"
|
||||
)
|
||||
assert verification.status_code == 200, verification.text
|
||||
assert verification.json()["ok"] is True
|
||||
discover = await client.get(
|
||||
f"/repos/{repo['id']}/rename/operations", params={"active_only": True}
|
||||
)
|
||||
assert discover.json() == []
|
||||
current = await client.get(f"/repos/by-id/{repo['id']}")
|
||||
assert current.status_code == 200, current.text
|
||||
assert current.json()["id"] == repo["id"]
|
||||
assert current.json()["slug"] == "access-engine"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_head_wrong_id_target_conflict_and_queued_writes_fail_closed(client, rename_setup):
|
||||
repo, forge = rename_setup
|
||||
|
||||
queued = await client.post(
|
||||
f"/repos/{repo['id']}/rename/preflight",
|
||||
json={
|
||||
"new_slug": "access-engine",
|
||||
"queued_edge_writes": [{"id": "edge-1", "source_host": "laptop"}],
|
||||
},
|
||||
)
|
||||
assert queued.status_code == 200
|
||||
assert queued.json()["safe_to_apply"] is False
|
||||
assert queued.json()["preflight_token"] is None
|
||||
|
||||
forge.extra["access-engine"] = 999
|
||||
conflict = await _preflight(client, repo["id"])
|
||||
assert conflict["safe_to_apply"] is False
|
||||
assert {item["code"] for item in conflict["blockers"]} >= {"forge_target_claimed"}
|
||||
del forge.extra["access-engine"]
|
||||
|
||||
forge.repository_id = 999
|
||||
wrong_id = await _preflight(client, repo["id"])
|
||||
assert wrong_id["safe_to_apply"] is False
|
||||
assert {item["code"] for item in wrong_id["blockers"]} >= {"wrong_forge_repository_id"}
|
||||
forge.repository_id = 417
|
||||
|
||||
preflight = await _preflight(client, repo["id"])
|
||||
forge.head = "b" * 40
|
||||
create = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations",
|
||||
json={
|
||||
"new_slug": "access-engine",
|
||||
"preflight_token": preflight["preflight_token"],
|
||||
"confirmation": f"rename:{repo['id']}:flex-auth:access-engine",
|
||||
"actor": "pytest",
|
||||
},
|
||||
)
|
||||
assert create.status_code == 412, create.text
|
||||
assert "stale" in create.json()["detail"]["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_token_bad_confirmation_and_unreadable_forge_fail_closed(
|
||||
client, rename_setup, monkeypatch
|
||||
):
|
||||
repo, forge = rename_setup
|
||||
monkeypatch.setattr(settings, "repository_rename_preflight_ttl_seconds", -1)
|
||||
expired = await _preflight(client, repo["id"])
|
||||
response = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations",
|
||||
json={
|
||||
"new_slug": "access-engine",
|
||||
"preflight_token": expired["preflight_token"],
|
||||
"confirmation": f"rename:{repo['id']}:flex-auth:access-engine",
|
||||
"actor": "pytest",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 412
|
||||
assert "expired" in response.json()["detail"]["message"].lower()
|
||||
|
||||
monkeypatch.setattr(settings, "repository_rename_preflight_ttl_seconds", 900)
|
||||
preflight = await _preflight(client, repo["id"])
|
||||
response = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations",
|
||||
json={
|
||||
"new_slug": "access-engine",
|
||||
"preflight_token": preflight["preflight_token"],
|
||||
"confirmation": "yes",
|
||||
"actor": "pytest",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 412
|
||||
forge.unreadable = True
|
||||
unreadable = await _preflight(client, repo["id"])
|
||||
assert unreadable["safe_to_apply"] is False
|
||||
assert {item["code"] for item in unreadable["blockers"]} >= {"forge_unreadable"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_restores_old_canonical_and_remains_auditable(client, rename_setup):
|
||||
repo, forge = rename_setup
|
||||
preflight = await _preflight(client, repo["id"])
|
||||
operation, confirmation = await _operation(client, repo["id"], preflight)
|
||||
operation_id = operation["id"]
|
||||
for requested, expected, extra in [
|
||||
("forge-renamed", "preflighted", {}),
|
||||
("statehub-rebound", "forge-renamed", {}),
|
||||
("source-synced", "statehub-rebound", {"evidence": {"clone": "fresh"}}),
|
||||
]:
|
||||
await _phase(client, repo["id"], operation_id, requested, expected, confirmation, **extra)
|
||||
|
||||
rollback_confirmation = f"rollback:{operation_id}"
|
||||
report = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations/{operation_id}/rollback-preflight",
|
||||
json={"expected_phase": "source-synced", "confirmation": rollback_confirmation},
|
||||
)
|
||||
assert report.status_code == 200, report.text
|
||||
assert report.json()["safe_to_rollback"] is True
|
||||
assert report.json()["irreversible"]
|
||||
|
||||
rollback = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations/{operation_id}/rollback",
|
||||
json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation},
|
||||
)
|
||||
assert rollback.status_code == 200, rollback.text
|
||||
assert rollback.json()["phase"] == "rolled-back"
|
||||
assert forge.name == "flex-auth"
|
||||
current = await client.get(f"/repos/by-id/{repo['id']}")
|
||||
assert current.json()["id"] == repo["id"]
|
||||
assert current.json()["slug"] == "flex-auth"
|
||||
history = rollback.json()["evidence"]["phases"]
|
||||
assert set(history) >= {"preflighted", "forge-renamed", "statehub-rebound", "source-synced", "rollback-preflight", "rolled-back"}
|
||||
replay = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations/{operation_id}/rollback",
|
||||
json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation},
|
||||
)
|
||||
assert replay.status_code == 200
|
||||
assert replay.json()["no_op"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_recovers_unrecorded_forge_rename(client, rename_setup):
|
||||
repo, forge = rename_setup
|
||||
preflight = await _preflight(client, repo["id"])
|
||||
operation, _confirmation = await _operation(client, repo["id"], preflight)
|
||||
await forge.rename(
|
||||
instance="https://forge.example",
|
||||
owner="coulomb",
|
||||
old_name="flex-auth",
|
||||
new_name="access-engine",
|
||||
)
|
||||
rollback_confirmation = f"rollback:{operation['id']}"
|
||||
report = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations/{operation['id']}/rollback-preflight",
|
||||
json={"expected_phase": "preflighted", "confirmation": rollback_confirmation},
|
||||
)
|
||||
assert report.status_code == 200, report.text
|
||||
assert report.json()["rollback_from_phase"] == "forge-renamed-unrecorded"
|
||||
rollback = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations/{operation['id']}/rollback",
|
||||
json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation},
|
||||
)
|
||||
assert rollback.status_code == 200, rollback.text
|
||||
assert rollback.json()["phase"] == "rolled-back"
|
||||
assert forge.name == "flex-auth"
|
||||
|
|
@ -421,6 +421,9 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
|
|||
migration = importlib.import_module(
|
||||
"migrations.versions.f3c4d5e6a7b8_repository_rename_identity"
|
||||
)
|
||||
rollback_migration = importlib.import_module(
|
||||
"migrations.versions.a4d5e6f7b8c9_allow_guarded_slug_rollback"
|
||||
)
|
||||
schema = f"rename_migration_{uuid.uuid4().hex}"
|
||||
repo_ids = [uuid.uuid4(), uuid.uuid4()]
|
||||
sentinel_ids = {
|
||||
|
|
@ -462,9 +465,12 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
|
|||
)
|
||||
|
||||
original_op = migration.op
|
||||
original_rollback_op = rollback_migration.op
|
||||
migration.op = Operations(MigrationContext.configure(sync_connection))
|
||||
rollback_migration.op = migration.op
|
||||
try:
|
||||
migration.upgrade()
|
||||
rollback_migration.upgrade()
|
||||
tables = set(inspect(sync_connection).get_table_names(schema=schema))
|
||||
assert {
|
||||
"repository_forge_identities",
|
||||
|
|
@ -568,6 +574,57 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
|
|||
),
|
||||
{"id": operation_id},
|
||||
)
|
||||
# A forward cutover may make the old canonical an alias. T03
|
||||
# permits restoring it only while its own operation is in the
|
||||
# durable rollback-preflight phase.
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"UPDATE repository_slugs SET kind = 'alias', "
|
||||
"source_operation_id = :operation_id "
|
||||
"WHERE repo_id = :repo_id AND slug = 'flex-auth'"
|
||||
),
|
||||
{"operation_id": operation_id, "repo_id": repo_ids[0]},
|
||||
)
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"INSERT INTO repository_slugs "
|
||||
"(id, repo_id, slug, kind, protected, source_operation_id, created_at, updated_at) "
|
||||
"VALUES (gen_random_uuid(), :repo_id, 'access-engine', 'canonical', true, "
|
||||
":operation_id, now(), now())"
|
||||
),
|
||||
{"operation_id": operation_id, "repo_id": repo_ids[0]},
|
||||
)
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"UPDATE repository_slugs SET kind = 'alias' "
|
||||
"WHERE repo_id = :repo_id AND slug = 'access-engine'"
|
||||
),
|
||||
{"repo_id": repo_ids[0]},
|
||||
)
|
||||
with pytest.raises(DBAPIError):
|
||||
with sync_connection.begin_nested():
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"UPDATE repository_slugs SET kind = 'canonical' "
|
||||
"WHERE repo_id = :repo_id AND slug = 'flex-auth'"
|
||||
),
|
||||
{"repo_id": repo_ids[0]},
|
||||
)
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"UPDATE repository_rename_operations SET "
|
||||
"phase = 'rollback-preflight', phase_changed_at = now() "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"id": operation_id},
|
||||
)
|
||||
sync_connection.execute(
|
||||
text(
|
||||
"UPDATE repository_slugs SET kind = 'canonical' "
|
||||
"WHERE repo_id = :repo_id AND slug = 'flex-auth'"
|
||||
),
|
||||
{"repo_id": repo_ids[0]},
|
||||
)
|
||||
with pytest.raises(DBAPIError):
|
||||
with sync_connection.begin_nested():
|
||||
sync_connection.execute(
|
||||
|
|
@ -588,6 +645,7 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
|
|||
text(f"SELECT id, marker FROM {table_name}")
|
||||
).one() == (sentinel_id, "keep")
|
||||
|
||||
rollback_migration.downgrade()
|
||||
migration.downgrade()
|
||||
tables = set(inspect(sync_connection).get_table_names(schema=schema))
|
||||
assert "repository_forge_identities" not in tables
|
||||
|
|
@ -599,6 +657,7 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
|
|||
).one() == (sentinel_id, "keep")
|
||||
finally:
|
||||
migration.op = original_op
|
||||
rollback_migration.op = original_rollback_op
|
||||
sync_connection.exec_driver_sql("SET LOCAL search_path TO public")
|
||||
sync_connection.exec_driver_sql(f"DROP SCHEMA {quoted_schema} CASCADE")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue