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
|
|
@ -323,7 +323,7 @@
|
|||
| task | STATE-WP-0084-T04 | wait | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
|
||||
| task | STATE-WP-0085-T01 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T03 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T03 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T04 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T05 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T06 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ class Settings(BaseSettings):
|
|||
sbom_nexus_read_mode: Literal["legacy", "nexus"] = "legacy"
|
||||
sbom_nexus_write_mode: Literal["legacy", "nexus"] = "legacy"
|
||||
sbom_nexus_timeout_seconds: float = 5.0
|
||||
# Repository renames are central-only, compare-and-set operations. The
|
||||
# secret signs short-lived, non-persistent preflight evidence; it must be
|
||||
# supplied by the deployment (normally through OpenBao), never recorded in
|
||||
# State Hub. With no secret the read-only report remains available but no
|
||||
# mutation token can be issued.
|
||||
repository_rename_preflight_secret: str | None = None
|
||||
repository_rename_preflight_ttl_seconds: int = 900
|
||||
repository_rename_forge_timeout_seconds: float = 10.0
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from api.routers import fabric
|
|||
from api.routers import legacy_meter
|
||||
from api.routers import review_contracts
|
||||
from api.routers import identifier_migrations
|
||||
from api.routers import repository_renames
|
||||
|
||||
|
||||
class ETagMiddleware(BaseHTTPMiddleware):
|
||||
|
|
@ -109,6 +110,7 @@ app.include_router(recently_on_scope.hourly_router)
|
|||
app.include_router(recently_on_scope.router)
|
||||
app.include_router(consistency_sweep.router)
|
||||
app.include_router(repos.router)
|
||||
app.include_router(repository_renames.router)
|
||||
app.include_router(topics.router)
|
||||
app.include_router(workstreams.router)
|
||||
app.include_router(workstreams.workplan_router)
|
||||
|
|
|
|||
251
api/routers/repository_renames.py
Normal file
251
api/routers/repository_renames.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.repository_rename import RepositoryRenameOperation
|
||||
from api.schemas.repository_rename import (
|
||||
ForgeIdentityVerifyRequest,
|
||||
RepositoryRenameOperationCreate,
|
||||
RepositoryRenameOperationRead,
|
||||
RepositoryRenamePhaseApply,
|
||||
RepositoryRenamePreflightRead,
|
||||
RepositoryRenamePreflightRequest,
|
||||
RepositoryRenameRollbackPreflightRead,
|
||||
RepositoryRenameRollbackRequest,
|
||||
RepositoryRenameVerificationRead,
|
||||
)
|
||||
from api.services.forge_repository import (
|
||||
ForgeRepositoryGateway,
|
||||
get_forge_repository_gateway,
|
||||
)
|
||||
from api.services.repository_rename import (
|
||||
RenameLifecycleError,
|
||||
apply_phase,
|
||||
apply_rollback,
|
||||
build_preflight,
|
||||
create_operation,
|
||||
list_operations,
|
||||
load_operation,
|
||||
rollback_preflight,
|
||||
verify_forge_identity,
|
||||
verify_operation,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"])
|
||||
|
||||
|
||||
def _raise(exc: RenameLifecycleError) -> None:
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail={"code": exc.code, "message": str(exc), "details": exc.details},
|
||||
) from exc
|
||||
|
||||
|
||||
def _operation_read(operation: RepositoryRenameOperation, *, no_op: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"id": operation.id,
|
||||
"repo_id": operation.repo_id,
|
||||
"phase": operation.phase,
|
||||
"old_slug": operation.old_slug,
|
||||
"new_slug": operation.new_slug,
|
||||
"expected_forge_repository_id": operation.expected_forge_repository_id,
|
||||
"expected_source_commit": operation.expected_source_commit,
|
||||
"expected_default_branch": operation.expected_default_branch,
|
||||
"actor": operation.actor,
|
||||
"phase_changed_at": operation.phase_changed_at,
|
||||
"preflighted_at": operation.preflighted_at,
|
||||
"preflight_expires_at": operation.preflight_expires_at,
|
||||
"completed_at": operation.completed_at,
|
||||
"rolled_back_at": operation.rolled_back_at,
|
||||
"evidence": operation.evidence,
|
||||
"error_code": operation.error_code,
|
||||
"error_message": operation.error_message,
|
||||
"error_details": operation.error_details,
|
||||
"error_at": operation.error_at,
|
||||
"no_op": no_op,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/forge-identity/verify")
|
||||
async def verify_repository_forge_identity(
|
||||
repo_id: uuid.UUID,
|
||||
body: ForgeIdentityVerifyRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
identity = await verify_forge_identity(session, gateway, repo_id, body)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return {
|
||||
"id": identity.id,
|
||||
"repo_id": identity.repo_id,
|
||||
"provider": identity.provider,
|
||||
"forge_instance": identity.forge_instance,
|
||||
"forge_owner": identity.forge_owner,
|
||||
"forge_repository_id": identity.forge_repository_id,
|
||||
"verification_state": identity.verification_state,
|
||||
"verified_at": identity.verified_at,
|
||||
"verified_by": identity.verified_by,
|
||||
"verification_evidence": identity.verification_evidence,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/rename/preflight", response_model=RepositoryRenamePreflightRead)
|
||||
async def repository_rename_preflight(
|
||||
repo_id: uuid.UUID,
|
||||
body: RepositoryRenamePreflightRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await build_preflight(session, gateway, repo_id, body)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rename/operations",
|
||||
response_model=RepositoryRenameOperationRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_repository_rename_operation(
|
||||
repo_id: uuid.UUID,
|
||||
body: RepositoryRenameOperationCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation = await create_operation(session, gateway, repo_id, body)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return _operation_read(operation)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rename/operations", response_model=list[RepositoryRenameOperationRead]
|
||||
)
|
||||
async def list_repository_rename_operations(
|
||||
repo_id: uuid.UUID,
|
||||
active_only: bool = False,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
operations = await list_operations(session, repo_id, active_only=active_only)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return [_operation_read(operation) for operation in operations]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rename/operations/{operation_id}", response_model=RepositoryRenameOperationRead
|
||||
)
|
||||
async def get_repository_rename_operation(
|
||||
repo_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation = await load_operation(session, repo_id, operation_id)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return _operation_read(operation)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rename/operations/{operation_id}/phases/{phase}",
|
||||
response_model=RepositoryRenameOperationRead,
|
||||
)
|
||||
async def apply_repository_rename_phase(
|
||||
repo_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
phase: str,
|
||||
body: RepositoryRenamePhaseApply,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation, no_op = await apply_phase(
|
||||
session, gateway, repo_id, operation_id, phase, body
|
||||
)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return _operation_read(operation, no_op=no_op)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rename/operations/{operation_id}/verify",
|
||||
response_model=RepositoryRenameVerificationRead,
|
||||
)
|
||||
async def verify_repository_rename_operation(
|
||||
repo_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation = await load_operation(session, repo_id, operation_id)
|
||||
return await verify_operation(session, gateway, operation)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rename/operations/{operation_id}/rollback-preflight",
|
||||
response_model=RepositoryRenameRollbackPreflightRead,
|
||||
)
|
||||
async def preflight_repository_rename_rollback(
|
||||
repo_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
body: RepositoryRenameRollbackRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation, report = await rollback_preflight(
|
||||
session,
|
||||
gateway,
|
||||
repo_id,
|
||||
operation_id,
|
||||
expected_phase=body.expected_phase,
|
||||
confirmation=body.confirmation,
|
||||
)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return {
|
||||
"operation_id": operation.id,
|
||||
"repo_id": operation.repo_id,
|
||||
**report,
|
||||
"operation": _operation_read(operation),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rename/operations/{operation_id}/rollback",
|
||||
response_model=RepositoryRenameOperationRead,
|
||||
)
|
||||
async def rollback_repository_rename_operation(
|
||||
repo_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
body: RepositoryRenameRollbackRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation, no_op = await apply_rollback(
|
||||
session,
|
||||
gateway,
|
||||
repo_id,
|
||||
operation_id,
|
||||
expected_phase=body.expected_phase,
|
||||
confirmation=body.confirmation,
|
||||
)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return _operation_read(operation, no_op=no_op)
|
||||
112
api/schemas/repository_rename.py
Normal file
112
api/schemas/repository_rename.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EdgeWriteEvidence(BaseModel):
|
||||
id: str
|
||||
status: str = "queued"
|
||||
source_host: str | None = None
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class RepositoryRenamePreflightRequest(BaseModel):
|
||||
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||
queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RepositoryRenamePreflightRead(BaseModel):
|
||||
schema_version: Literal["state-hub.repository-rename-preflight.v1"]
|
||||
repo_id: uuid.UUID
|
||||
old_slug: str
|
||||
new_slug: str
|
||||
safe_to_apply: bool
|
||||
blockers: list[dict[str, Any]]
|
||||
warnings: list[dict[str, Any]]
|
||||
current: dict[str, Any]
|
||||
target: dict[str, Any]
|
||||
baselines: dict[str, Any]
|
||||
active_work: dict[str, Any]
|
||||
affected: dict[str, Any]
|
||||
queued_edge_writes: list[dict[str, Any]]
|
||||
proposed_mutations: list[dict[str, Any]]
|
||||
retained_history: list[dict[str, Any]]
|
||||
report_checksum: str
|
||||
preflight_token: str | None
|
||||
preflighted_at: datetime
|
||||
expires_at: datetime | None
|
||||
|
||||
|
||||
class ForgeIdentityVerifyRequest(BaseModel):
|
||||
provider: Literal["forgejo"] = "forgejo"
|
||||
forge_instance: str
|
||||
forge_owner: str
|
||||
forge_repository_id: int = Field(gt=0)
|
||||
verified_by: str = Field(min_length=1, max_length=160)
|
||||
|
||||
|
||||
class RepositoryRenameOperationCreate(BaseModel):
|
||||
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||
preflight_token: str
|
||||
confirmation: str
|
||||
actor: str = Field(min_length=1, max_length=160)
|
||||
queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RepositoryRenamePhaseApply(BaseModel):
|
||||
expected_phase: str
|
||||
confirmation: str
|
||||
checks: dict[str, bool] = Field(default_factory=dict)
|
||||
evidence: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RepositoryRenameRollbackRequest(BaseModel):
|
||||
expected_phase: str
|
||||
confirmation: str
|
||||
|
||||
|
||||
class RepositoryRenameOperationRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
phase: str
|
||||
old_slug: str
|
||||
new_slug: str
|
||||
expected_forge_repository_id: int
|
||||
expected_source_commit: str
|
||||
expected_default_branch: str
|
||||
actor: str
|
||||
phase_changed_at: datetime
|
||||
preflighted_at: datetime | None
|
||||
preflight_expires_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
rolled_back_at: datetime | None
|
||||
evidence: dict[str, Any]
|
||||
error_code: str | None
|
||||
error_message: str | None
|
||||
error_details: dict[str, Any] | None
|
||||
error_at: datetime | None
|
||||
no_op: bool = False
|
||||
|
||||
|
||||
class RepositoryRenameVerificationRead(BaseModel):
|
||||
operation_id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
phase: str
|
||||
ok: bool
|
||||
checks: list[dict[str, Any]]
|
||||
baseline_checksum: str
|
||||
current_checksum: str
|
||||
|
||||
|
||||
class RepositoryRenameRollbackPreflightRead(BaseModel):
|
||||
operation_id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
rollback_from_phase: str
|
||||
safe_to_rollback: bool
|
||||
blockers: list[dict[str, Any]]
|
||||
irreversible: list[dict[str, Any]]
|
||||
operation: RepositoryRenameOperationRead
|
||||
203
api/services/forge_repository.py
Normal file
203
api/services/forge_repository.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""Narrow Forgejo repository boundary used by repository rename operations.
|
||||
|
||||
The lifecycle service depends on this protocol instead of calling Forgejo
|
||||
directly. Tests replace it with an in-memory gateway, which makes interruption
|
||||
and resume tests deterministic and guarantees that API tests cannot rename a
|
||||
live repository.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from api.config import settings
|
||||
from api.services.forge_credential import forge_read_token
|
||||
|
||||
|
||||
WRITE_TOKEN_ENV = "FORGE_RENAME_TOKEN"
|
||||
WRITE_TOKEN_FILE_ENV = "FORGE_RENAME_TOKEN_FILE"
|
||||
|
||||
|
||||
class ForgeRepositoryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ForgeRepositoryUnreadable(ForgeRepositoryError):
|
||||
pass
|
||||
|
||||
|
||||
class ForgeRepositoryConflict(ForgeRepositoryError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForgeRepositorySnapshot:
|
||||
repository_id: int
|
||||
owner: str
|
||||
name: str
|
||||
full_name: str
|
||||
default_branch: str
|
||||
head_commit: str
|
||||
clone_url: str | None
|
||||
html_url: str | None
|
||||
projection_readable: bool
|
||||
projection_source_present: bool
|
||||
projection_entry_count: int | None
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"repository_id": self.repository_id,
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"full_name": self.full_name,
|
||||
"default_branch": self.default_branch,
|
||||
"head_commit": self.head_commit,
|
||||
"clone_url": self.clone_url,
|
||||
"html_url": self.html_url,
|
||||
"projection_readable": self.projection_readable,
|
||||
"projection_source_present": self.projection_source_present,
|
||||
"projection_entry_count": self.projection_entry_count,
|
||||
}
|
||||
|
||||
|
||||
class ForgeRepositoryGateway(Protocol):
|
||||
async def inspect(
|
||||
self, *, instance: str, owner: str, name: str
|
||||
) -> ForgeRepositorySnapshot | None: ...
|
||||
|
||||
async def rename(
|
||||
self, *, instance: str, owner: str, old_name: str, new_name: str
|
||||
) -> ForgeRepositorySnapshot: ...
|
||||
|
||||
|
||||
def _write_token() -> str | None:
|
||||
path = os.environ.get(WRITE_TOKEN_FILE_ENV)
|
||||
if path:
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8").strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
return (os.environ.get(WRITE_TOKEN_ENV) or "").strip() or None
|
||||
|
||||
|
||||
class ForgejoRepositoryGateway:
|
||||
"""Forgejo v1 API adapter with separate read and rename credentials."""
|
||||
|
||||
@staticmethod
|
||||
def _headers(token: str | None) -> dict[str, str]:
|
||||
return {"Authorization": f"token {token}"} if token else {}
|
||||
|
||||
@staticmethod
|
||||
def _api(instance: str, owner: str, name: str) -> str:
|
||||
base = instance.rstrip("/")
|
||||
return f"{base}/api/v1/repos/{quote(owner, safe='')}/{quote(name, safe='')}"
|
||||
|
||||
async def _inspect_with_token(
|
||||
self, *, instance: str, owner: str, name: str, token: str | None
|
||||
) -> ForgeRepositorySnapshot | None:
|
||||
url = self._api(instance, owner, name)
|
||||
timeout = settings.repository_rename_forge_timeout_seconds
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, headers=self._headers(token))
|
||||
if response.status_code == 404:
|
||||
if token is None:
|
||||
raise ForgeRepositoryUnreadable(
|
||||
"Forge returned not-found without an authenticated absence proof"
|
||||
)
|
||||
return None
|
||||
response.raise_for_status()
|
||||
repo = response.json()
|
||||
branch_name = str(repo.get("default_branch") or "")
|
||||
if not branch_name:
|
||||
raise ForgeRepositoryUnreadable("Forge repository has no default branch")
|
||||
branch = await client.get(
|
||||
f"{url}/branches/{quote(branch_name, safe='')}",
|
||||
headers=self._headers(token),
|
||||
)
|
||||
branch.raise_for_status()
|
||||
branch_data = branch.json()
|
||||
commit = branch_data.get("commit") or {}
|
||||
head = str(commit.get("id") or commit.get("sha") or "")
|
||||
if not head:
|
||||
raise ForgeRepositoryUnreadable("Forge default branch has no readable head")
|
||||
projection = await client.get(
|
||||
f"{url}/contents/workplans",
|
||||
params={"ref": head},
|
||||
headers=self._headers(token),
|
||||
)
|
||||
source_present = projection.status_code == 200
|
||||
if projection.status_code not in {200, 404}:
|
||||
projection.raise_for_status()
|
||||
entries = projection.json() if source_present else None
|
||||
except ForgeRepositoryError:
|
||||
raise
|
||||
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||
raise ForgeRepositoryUnreadable(
|
||||
f"Forge repository inspection failed ({type(exc).__name__})"
|
||||
) from exc
|
||||
return ForgeRepositorySnapshot(
|
||||
repository_id=int(repo["id"]),
|
||||
owner=str((repo.get("owner") or {}).get("login") or owner),
|
||||
name=str(repo["name"]),
|
||||
full_name=str(repo.get("full_name") or f"{owner}/{name}"),
|
||||
default_branch=branch_name,
|
||||
head_commit=head,
|
||||
clone_url=repo.get("clone_url"),
|
||||
html_url=repo.get("html_url"),
|
||||
projection_readable=True,
|
||||
projection_source_present=source_present,
|
||||
projection_entry_count=(len(entries) if isinstance(entries, list) else None),
|
||||
)
|
||||
|
||||
async def inspect(
|
||||
self, *, instance: str, owner: str, name: str
|
||||
) -> ForgeRepositorySnapshot | None:
|
||||
return await self._inspect_with_token(
|
||||
instance=instance, owner=owner, name=name, token=forge_read_token()
|
||||
)
|
||||
|
||||
async def rename(
|
||||
self, *, instance: str, owner: str, old_name: str, new_name: str
|
||||
) -> ForgeRepositorySnapshot:
|
||||
token = _write_token()
|
||||
if not token:
|
||||
raise ForgeRepositoryUnreadable("Forge rename credential is unavailable")
|
||||
url = self._api(instance, owner, old_name)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=settings.repository_rename_forge_timeout_seconds
|
||||
) as client:
|
||||
response = await client.patch(
|
||||
url,
|
||||
json={"name": new_name},
|
||||
headers=self._headers(token),
|
||||
)
|
||||
if response.status_code in {409, 422}:
|
||||
raise ForgeRepositoryConflict("Forge rejected the target repository name")
|
||||
response.raise_for_status()
|
||||
except ForgeRepositoryError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
raise ForgeRepositoryUnreadable(
|
||||
f"Forge repository rename failed ({type(exc).__name__})"
|
||||
) from exc
|
||||
renamed = await self._inspect_with_token(
|
||||
instance=instance, owner=owner, name=new_name, token=token
|
||||
)
|
||||
if renamed is None:
|
||||
raise ForgeRepositoryUnreadable("Renamed Forge repository is not readable")
|
||||
return renamed
|
||||
|
||||
|
||||
_gateway = ForgejoRepositoryGateway()
|
||||
|
||||
|
||||
def get_forge_repository_gateway() -> ForgeRepositoryGateway:
|
||||
return _gateway
|
||||
1325
api/services/repository_rename.py
Normal file
1325
api/services/repository_rename.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,89 @@
|
|||
"""allow only operation-guarded alias restoration during rollback
|
||||
|
||||
Revision ID: a4d5e6f7b8c9
|
||||
Revises: f3c4d5e6a7b8
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a4d5e6f7b8c9"
|
||||
down_revision = "f3c4d5e6a7b8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# T02 made aliases immutable. Rollback needs exactly one controlled
|
||||
# exception: the alias created by this operation may become canonical while
|
||||
# that same operation is in rollback-preflight. Ownership, spelling,
|
||||
# protection, and provenance remain immutable.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION guard_repository_slug_history()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'repository slug rows are durable';
|
||||
END IF;
|
||||
IF NEW.repo_id IS DISTINCT FROM OLD.repo_id
|
||||
OR NEW.slug IS DISTINCT FROM OLD.slug THEN
|
||||
RAISE EXCEPTION 'repository slug ownership is immutable';
|
||||
END IF;
|
||||
IF OLD.kind = 'alias' AND ROW(
|
||||
NEW.kind, NEW.protected, NEW.source_operation_id
|
||||
) IS DISTINCT FROM ROW(
|
||||
OLD.kind, OLD.protected, OLD.source_operation_id
|
||||
) THEN
|
||||
IF NOT (
|
||||
NEW.kind = 'canonical'
|
||||
AND NEW.protected
|
||||
AND NEW.source_operation_id = OLD.source_operation_id
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM repository_rename_operations operation
|
||||
WHERE operation.id = OLD.source_operation_id
|
||||
AND operation.repo_id = OLD.repo_id
|
||||
AND operation.phase = 'rollback-preflight'
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'protected repository alias is immutable';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION guard_repository_slug_history()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION 'repository slug rows are durable';
|
||||
END IF;
|
||||
IF NEW.repo_id IS DISTINCT FROM OLD.repo_id
|
||||
OR NEW.slug IS DISTINCT FROM OLD.slug THEN
|
||||
RAISE EXCEPTION 'repository slug ownership is immutable';
|
||||
END IF;
|
||||
IF OLD.kind = 'alias' AND ROW(
|
||||
NEW.kind, NEW.protected, NEW.source_operation_id
|
||||
) IS DISTINCT FROM ROW(
|
||||
OLD.kind, OLD.protected, OLD.source_operation_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'protected repository alias is immutable';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
)
|
||||
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")
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ status: active
|
|||
owner: codex
|
||||
topic_slug: infotech
|
||||
created: "2026-08-28"
|
||||
updated: "2026-08-28"
|
||||
updated: "2026-08-29"
|
||||
reviewed_at: "2026-08-28"
|
||||
reviewed_by: codex
|
||||
reviewed_against_commit: "41d80429a77ffeb640f4b34091ebee5c91585373"
|
||||
|
|
@ -239,7 +239,7 @@ full Python suite passes (765 tests).
|
|||
|
||||
```task
|
||||
id: STATE-WP-0085-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "2cb4d7ac-0192-5d46-aa87-7412e99b0cef"
|
||||
```
|
||||
|
|
@ -276,6 +276,24 @@ Acceptance:
|
|||
- API tests interrupt and resume after every phase;
|
||||
- the managed-repository UUID is asserted unchanged throughout.
|
||||
|
||||
Implemented 2026-08-29. UUID-addressed endpoints now verify immutable Forgejo
|
||||
identity, produce HMAC-signed non-mutating preflights, create discoverable
|
||||
operation journals, apply one compare-and-set phase at a time, verify baseline
|
||||
identity continuity, and preflight/apply bounded rollback. The Forge adapter
|
||||
uses authenticated absence proofs and a separate rename credential; API tests
|
||||
replace it with an in-memory boundary and cannot touch live Forgejo. Resume
|
||||
recognizes a Forge rename that committed before its journal phase, completed
|
||||
phase replays are no-ops with the same operation UUID, and rollback recognizes
|
||||
the corresponding unrecorded-Forge state. Baseline IDs must remain present
|
||||
while append-only telemetry may grow during the operation. The follow-on
|
||||
migration permits a protected alias to become canonical only for its own
|
||||
operation in `rollback-preflight`; all other alias mutation remains rejected.
|
||||
Tests cover dry-run persistence, expiry and confirmation, occupied slugs,
|
||||
queued edge writes, wrong Forge ID, moved head/stale evidence, unreadable Forge,
|
||||
phase interruption/replay, telemetry append continuity, rollback, trigger
|
||||
guarding, and full migration upgrade/downgrade. The full Python suite passes
|
||||
(771 tests).
|
||||
|
||||
## Make State Hub reads and routing alias-aware
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue