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)