Reconcile orphaned Campaign artifacts

This commit is contained in:
2026-08-03 09:23:27 +02:00
parent d635f3a5fc
commit d9195a2d2b
12 changed files with 1320 additions and 14 deletions
@@ -0,0 +1,102 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from govoplan_campaign.backend.artifact_reconciliation import (
CampaignArtifactReconciliationError,
reconcile_campaign_artifacts,
)
from govoplan_campaign.backend.persistence.campaigns import _object_storage
from govoplan_campaign.backend.schemas import (
CampaignArtifactReconcileRequest,
CampaignArtifactReconcileResponse,
)
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.object_storage import StorageBackendError
from govoplan_core.core.recovery import RecoveryGuaranteeError
from govoplan_core.core.recovery_runtime import (
RecoveryOperationBusy,
RecoveryOperationStateConflict,
)
from govoplan_core.db.session import get_database, get_session
from govoplan_core.server.runtime_agent import application_runtime_identity
router = APIRouter(prefix="/campaigns/operations", tags=["campaigns"])
@router.post(
"/artifacts/reconcile",
response_model=CampaignArtifactReconcileResponse,
)
def reconcile_artifacts(
payload: CampaignArtifactReconcileRequest,
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
) -> CampaignArtifactReconcileResponse:
"""Inventory or remove old, unreferenced Campaign-owned build objects."""
try:
result = reconcile_campaign_artifacts(
get_database().SessionLocal,
storage=_object_storage(),
identity=application_runtime_identity(request.app),
tenant_id=principal.tenant_id,
apply=payload.apply,
idempotency_key=payload.idempotency_key,
grace_period_hours=payload.grace_period_hours,
cursor=payload.cursor,
page_size=payload.page_size,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (
CampaignArtifactReconciliationError,
RecoveryGuaranteeError,
StorageBackendError,
SQLAlchemyError,
OSError,
) as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Campaign artifact reconciliation is temporarily unavailable "
f"({type(exc).__name__})."
),
) from exc
audit_from_principal(
session,
principal,
action=(
"campaign.artifact_orphans_reconciled"
if payload.apply
else "campaign.artifact_inventory_scanned"
),
object_type="campaign_artifact_namespace",
object_id=principal.tenant_id,
details={
"apply": payload.apply,
"status": result["status"],
"scanned_count": result["scanned_count"],
"candidate_count": result["candidate_count"],
"deleted_count": result["deleted_count"],
"failure_count": result["failure_count"],
"manifest_sha256": result["manifest_sha256"],
"recovery_operation_id": result["recovery_operation_id"],
},
commit=True,
)
return CampaignArtifactReconcileResponse.model_validate(result)