Recover external repository renames through verified Forgejo redirects
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a070b5-4994-7271-bd8b-7c3dbcedec4b
This commit is contained in:
parent
022cf4b727
commit
fe6b8d96c2
4 changed files with 166 additions and 2 deletions
|
|
@ -12,7 +12,7 @@ import os
|
|||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -32,6 +32,14 @@ class ForgeRepositoryUnreadable(ForgeRepositoryError):
|
|||
pass
|
||||
|
||||
|
||||
class ForgeRepositoryRedirected(ForgeRepositoryUnreadable):
|
||||
"""Requested coordinate redirects; no credentials are forwarded."""
|
||||
|
||||
def __init__(self, source_url: str, location: str):
|
||||
super().__init__("Forge repository coordinate redirects")
|
||||
self.target_url = urljoin(source_url, location) if location else None
|
||||
|
||||
|
||||
class ForgeRepositoryConflict(ForgeRepositoryError):
|
||||
pass
|
||||
|
||||
|
|
@ -106,6 +114,10 @@ class ForgejoRepositoryGateway:
|
|||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, headers=self._headers(token))
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
raise ForgeRepositoryRedirected(
|
||||
url, response.headers.get("location", "")
|
||||
)
|
||||
if response.status_code == 404:
|
||||
if token is None:
|
||||
raise ForgeRepositoryUnreadable(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from copy import deepcopy
|
|||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
|
@ -44,6 +45,7 @@ from api.schemas.repository_rename import (
|
|||
from api.services.forge_repository import (
|
||||
ForgeRepositoryConflict,
|
||||
ForgeRepositoryGateway,
|
||||
ForgeRepositoryRedirected,
|
||||
ForgeRepositorySnapshot,
|
||||
ForgeRepositoryUnreadable,
|
||||
)
|
||||
|
|
@ -1101,13 +1103,36 @@ async def _forge_at(
|
|||
async def _apply_forge_rename(
|
||||
gateway: ForgeRepositoryGateway, operation: RepositoryRenameOperation
|
||||
) -> dict[str, Any]:
|
||||
old = await _forge_at(gateway, operation, operation.old_slug)
|
||||
new = await _forge_at(gateway, operation, operation.new_slug)
|
||||
if new is not None:
|
||||
# A redirect is compatibility evidence only. Establish immutable identity
|
||||
# at the requested new coordinate before accepting the old-name alias.
|
||||
_assert_snapshot(operation, new, expected_name=operation.new_slug)
|
||||
try:
|
||||
old = await gateway.inspect(
|
||||
instance=operation.expected_forge_instance,
|
||||
owner=operation.expected_forge_owner,
|
||||
name=operation.old_slug,
|
||||
)
|
||||
except ForgeRepositoryRedirected as exc:
|
||||
canonical_api = (
|
||||
operation.expected_forge_instance.rstrip("/")
|
||||
+ "/api/v1/repos/"
|
||||
+ quote(operation.expected_forge_owner, safe="")
|
||||
+ "/"
|
||||
+ quote(operation.new_slug, safe="")
|
||||
)
|
||||
if exc.target_url != canonical_api:
|
||||
raise RenamePreconditionFailed(
|
||||
"Old Forge name redirects outside the verified new coordinate"
|
||||
) from exc
|
||||
old = None
|
||||
except ForgeRepositoryUnreadable as exc:
|
||||
raise RenamePreconditionFailed(str(exc)) from exc
|
||||
if old is not None:
|
||||
raise RenamePreconditionFailed("Both old and new Forge names are claimed")
|
||||
return {"resumed": True, "forge": new.as_dict()}
|
||||
old = await _forge_at(gateway, operation, operation.old_slug)
|
||||
if old is None:
|
||||
raise RenamePreconditionFailed("Forge repository is absent at both expected names")
|
||||
_assert_snapshot(operation, old, expected_name=operation.old_slug)
|
||||
|
|
|
|||
|
|
@ -929,3 +929,78 @@ async def test_every_forward_phase_failure_is_retry_safe(client, rename_setup):
|
|||
"consumers-verified",
|
||||
"completed",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('location', [
|
||||
'/api/v1/repos/coulomb/access-engine',
|
||||
'https://forge.example/api/v1/repos/coulomb/access-engine',
|
||||
])
|
||||
async def test_resume_external_rename_with_verified_old_coordinate_redirect(client, rename_setup, location):
|
||||
from api.services.forge_repository import ForgeRepositoryRedirected
|
||||
repo, forge = rename_setup
|
||||
operation, confirmation = await _operation(client, repo['id'], await _preflight(client, repo['id']))
|
||||
forge.name = 'access-engine' # operator renamed before the journal advanced
|
||||
inspect = forge.inspect
|
||||
|
||||
async def redirected(**kwargs):
|
||||
if kwargs['name'] == 'flex-auth':
|
||||
raise ForgeRepositoryRedirected('https://forge.example/api/v1/repos/coulomb/flex-auth', location)
|
||||
return await inspect(**kwargs)
|
||||
|
||||
forge.inspect = redirected
|
||||
result = await _phase(client, repo['id'], operation['id'], 'forge-renamed', 'preflighted', confirmation)
|
||||
assert result['phase'] == 'forge-renamed'
|
||||
assert forge.rename_calls == 0
|
||||
rebound = await _phase(client, repo['id'], operation['id'], 'statehub-rebound', 'forge-renamed', confirmation)
|
||||
assert rebound['phase'] == 'statehub-rebound'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('location,wrong_identity', [
|
||||
('https://evil.example/api/v1/repos/coulomb/access-engine', False),
|
||||
('/api/v1/repos/other/access-engine', False),
|
||||
('', False),
|
||||
('/api/v1/repos/coulomb/access-engine', True),
|
||||
])
|
||||
async def test_redirect_resume_rejects_wrong_target_or_identity(client, rename_setup, location, wrong_identity):
|
||||
from api.services.forge_repository import ForgeRepositoryRedirected
|
||||
repo, forge = rename_setup
|
||||
operation, confirmation = await _operation(client, repo['id'], await _preflight(client, repo['id']))
|
||||
forge.name = 'access-engine'
|
||||
if wrong_identity:
|
||||
forge.repository_id += 1
|
||||
inspect = forge.inspect
|
||||
|
||||
async def redirected(**kwargs):
|
||||
if kwargs['name'] == 'flex-auth':
|
||||
raise ForgeRepositoryRedirected('https://forge.example/api/v1/repos/coulomb/flex-auth', location)
|
||||
return await inspect(**kwargs)
|
||||
|
||||
forge.inspect = redirected
|
||||
response = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations/{operation['id']}/phases/forge-renamed",
|
||||
json={'expected_phase': 'preflighted', 'confirmation': confirmation},
|
||||
)
|
||||
assert response.status_code == 412
|
||||
assert forge.rename_calls == 0
|
||||
status = (await client.get(f"/repository-renames/operations/{operation['id']}")).json()
|
||||
assert status['phase'] == 'preflighted'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forge_http_redirect_is_reported_without_following(monkeypatch):
|
||||
import httpx
|
||||
from api.services.forge_repository import ForgejoRepositoryGateway, ForgeRepositoryRedirected
|
||||
seen = []
|
||||
|
||||
def handler(request):
|
||||
seen.append(str(request.url))
|
||||
return httpx.Response(307, headers={'location': '/api/v1/repos/coulomb/access-engine'})
|
||||
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: original(transport=httpx.MockTransport(handler), **kwargs))
|
||||
with pytest.raises(ForgeRepositoryRedirected) as error:
|
||||
await ForgejoRepositoryGateway()._inspect_with_token(instance='https://forge.example', owner='coulomb', name='flex-auth', token='test-only')
|
||||
assert error.value.target_url == 'https://forge.example/api/v1/repos/coulomb/access-engine'
|
||||
assert seen == ['https://forge.example/api/v1/repos/coulomb/flex-auth']
|
||||
|
|
|
|||
52
workplans/STATE-WP-0089-rename-redirect-recovery.md
Normal file
52
workplans/STATE-WP-0089-rename-redirect-recovery.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
id: STATE-WP-0089
|
||||
type: workplan
|
||||
title: "Recover an externally completed Forgejo rename through its old-name redirect"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
status: active
|
||||
owner: codex
|
||||
created: "2026-09-05"
|
||||
updated: "2026-09-05"
|
||||
---
|
||||
|
||||
# Recover the canon federation rename journal
|
||||
|
||||
CFED-WP-0001-T03 renamed Forgejo repository 46 from identity-canon to
|
||||
commerce-canon through the approved operator credential lane. Journal
|
||||
615e7b44-d84e-4feb-92c5-1708feaf1e65 remains preflighted because the Forge adapter
|
||||
rejects the old coordinate's 307 redirect. New coordinate and exact source head
|
||||
40d5792fafbb2de778eabb56cfeaf00aecd058e1 are independently verified.
|
||||
|
||||
## Handle the redirect without weakening identity checks
|
||||
|
||||
```task
|
||||
id: STATE-WP-0089-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Report redirects without following them or forwarding credentials. For forward
|
||||
recovery, inspect the new coordinate directly and verify immutable ID, branch,
|
||||
head and projection first. Accept the old redirect only if it names that exact
|
||||
same-instance/owner/new-coordinate API URL. Wrong identity, external redirects,
|
||||
wrong owner, missing Location and both names occupied remain failures.
|
||||
Add transport and API regression tests, and run the rename suite.
|
||||
|
||||
## Promote the tested fix and resume the existing journal
|
||||
|
||||
```task
|
||||
id: STATE-WP-0089-T02
|
||||
status: progress
|
||||
priority: high
|
||||
```
|
||||
|
||||
Commit/push; promote the immutable CI image through the existing Helm release
|
||||
with reused values and atomic rollback. Preserve API-only signing configuration.
|
||||
Verify healthy primary/railiance01, then retry forge-renamed and statehub-rebound
|
||||
on the same journal. Record image/revision and live recovery evidence. The donor
|
||||
rename, checkout moves and consumer acceptance remain CFED-WP-0001-T03 and
|
||||
IDENTITY-WP-0004 responsibilities. No direct DB edits or journal replacement.
|
||||
|
||||
Validation: rename API, CLI and workplan tests pass (46 tests), including seven
|
||||
new transport/forward-recovery cases. `git diff --check` passes.
|
||||
Loading…
Add table
Add a link
Reference in a new issue