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-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-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-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-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-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 |
|
| 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_read_mode: Literal["legacy", "nexus"] = "legacy"
|
||||||
sbom_nexus_write_mode: Literal["legacy", "nexus"] = "legacy"
|
sbom_nexus_write_mode: Literal["legacy", "nexus"] = "legacy"
|
||||||
sbom_nexus_timeout_seconds: float = 5.0
|
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()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from api.routers import fabric
|
||||||
from api.routers import legacy_meter
|
from api.routers import legacy_meter
|
||||||
from api.routers import review_contracts
|
from api.routers import review_contracts
|
||||||
from api.routers import identifier_migrations
|
from api.routers import identifier_migrations
|
||||||
|
from api.routers import repository_renames
|
||||||
|
|
||||||
|
|
||||||
class ETagMiddleware(BaseHTTPMiddleware):
|
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(recently_on_scope.router)
|
||||||
app.include_router(consistency_sweep.router)
|
app.include_router(consistency_sweep.router)
|
||||||
app.include_router(repos.router)
|
app.include_router(repos.router)
|
||||||
|
app.include_router(repository_renames.router)
|
||||||
app.include_router(topics.router)
|
app.include_router(topics.router)
|
||||||
app.include_router(workstreams.router)
|
app.include_router(workstreams.router)
|
||||||
app.include_router(workstreams.workplan_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(
|
migration = importlib.import_module(
|
||||||
"migrations.versions.f3c4d5e6a7b8_repository_rename_identity"
|
"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}"
|
schema = f"rename_migration_{uuid.uuid4().hex}"
|
||||||
repo_ids = [uuid.uuid4(), uuid.uuid4()]
|
repo_ids = [uuid.uuid4(), uuid.uuid4()]
|
||||||
sentinel_ids = {
|
sentinel_ids = {
|
||||||
|
|
@ -462,9 +465,12 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
|
||||||
)
|
)
|
||||||
|
|
||||||
original_op = migration.op
|
original_op = migration.op
|
||||||
|
original_rollback_op = rollback_migration.op
|
||||||
migration.op = Operations(MigrationContext.configure(sync_connection))
|
migration.op = Operations(MigrationContext.configure(sync_connection))
|
||||||
|
rollback_migration.op = migration.op
|
||||||
try:
|
try:
|
||||||
migration.upgrade()
|
migration.upgrade()
|
||||||
|
rollback_migration.upgrade()
|
||||||
tables = set(inspect(sync_connection).get_table_names(schema=schema))
|
tables = set(inspect(sync_connection).get_table_names(schema=schema))
|
||||||
assert {
|
assert {
|
||||||
"repository_forge_identities",
|
"repository_forge_identities",
|
||||||
|
|
@ -568,6 +574,57 @@ async def test_migration_upgrade_and_downgrade_are_additive(test_engine):
|
||||||
),
|
),
|
||||||
{"id": operation_id},
|
{"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 pytest.raises(DBAPIError):
|
||||||
with sync_connection.begin_nested():
|
with sync_connection.begin_nested():
|
||||||
sync_connection.execute(
|
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}")
|
text(f"SELECT id, marker FROM {table_name}")
|
||||||
).one() == (sentinel_id, "keep")
|
).one() == (sentinel_id, "keep")
|
||||||
|
|
||||||
|
rollback_migration.downgrade()
|
||||||
migration.downgrade()
|
migration.downgrade()
|
||||||
tables = set(inspect(sync_connection).get_table_names(schema=schema))
|
tables = set(inspect(sync_connection).get_table_names(schema=schema))
|
||||||
assert "repository_forge_identities" not in tables
|
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")
|
).one() == (sentinel_id, "keep")
|
||||||
finally:
|
finally:
|
||||||
migration.op = original_op
|
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("SET LOCAL search_path TO public")
|
||||||
sync_connection.exec_driver_sql(f"DROP SCHEMA {quoted_schema} CASCADE")
|
sync_connection.exec_driver_sql(f"DROP SCHEMA {quoted_schema} CASCADE")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
created: "2026-08-28"
|
created: "2026-08-28"
|
||||||
updated: "2026-08-28"
|
updated: "2026-08-29"
|
||||||
reviewed_at: "2026-08-28"
|
reviewed_at: "2026-08-28"
|
||||||
reviewed_by: codex
|
reviewed_by: codex
|
||||||
reviewed_against_commit: "41d80429a77ffeb640f4b34091ebee5c91585373"
|
reviewed_against_commit: "41d80429a77ffeb640f4b34091ebee5c91585373"
|
||||||
|
|
@ -239,7 +239,7 @@ full Python suite passes (765 tests).
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: STATE-WP-0085-T03
|
id: STATE-WP-0085-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "2cb4d7ac-0192-5d46-aa87-7412e99b0cef"
|
state_hub_task_id: "2cb4d7ac-0192-5d46-aa87-7412e99b0cef"
|
||||||
```
|
```
|
||||||
|
|
@ -276,6 +276,24 @@ Acceptance:
|
||||||
- API tests interrupt and resume after every phase;
|
- API tests interrupt and resume after every phase;
|
||||||
- the managed-repository UUID is asserted unchanged throughout.
|
- 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
|
## Make State Hub reads and routing alias-aware
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue