Adopt recovery ledger for Campaign builds
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
@@ -22,10 +23,21 @@ from govoplan_campaign.backend.schemas import (
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, 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,
|
||||
RecoveryMode,
|
||||
RecoveryPlan,
|
||||
)
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
begin_durable_recovery_operation,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.db.session import get_database, get_session
|
||||
from govoplan_core.server.runtime_agent import application_runtime_identity
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_payload,
|
||||
)
|
||||
@@ -74,6 +86,47 @@ from govoplan_campaign.backend.routes.attachments import (
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _campaign_build_recovery_plan(raw_json: dict[str, object]) -> RecoveryPlan:
|
||||
delivery = raw_json.get("delivery")
|
||||
print_config = delivery.get("print") if isinstance(delivery, dict) else None
|
||||
persists_managed_output = bool(
|
||||
isinstance(print_config, dict)
|
||||
and print_config.get("persist_to_files", True)
|
||||
)
|
||||
if persists_managed_output:
|
||||
return RecoveryPlan(
|
||||
mode=RecoveryMode.FORWARD_RECOVERY,
|
||||
preconditions=("validated Campaign version is locked",),
|
||||
forward_recovery_steps=(
|
||||
"reuse the Templates render idempotency key",
|
||||
"reconcile the managed output and Campaign build manifests",
|
||||
),
|
||||
verification_steps=(
|
||||
"compare committed Campaign jobs with generated object evidence",
|
||||
),
|
||||
)
|
||||
return RecoveryPlan(
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
preconditions=("validated Campaign version is locked",),
|
||||
compensation_steps=("delete every object under the reserved build prefix",),
|
||||
verification_steps=(
|
||||
"compare committed Campaign jobs with generated object evidence",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/versions/{version_id}/print-output/download")
|
||||
def download_print_output(
|
||||
campaign_id: str,
|
||||
@@ -728,6 +781,7 @@ def validate_version(
|
||||
@router.post("/versions/{version_id}/build")
|
||||
def build_version(
|
||||
version_id: str,
|
||||
request: Request,
|
||||
payload: BuildCampaignRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:build")),
|
||||
@@ -737,14 +791,80 @@ def build_version(
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
write_eml = payload.write_eml if payload else True
|
||||
source_sha256 = _canonical_sha256(raw_json)
|
||||
validation_sha256 = _canonical_sha256(
|
||||
version.validation_summary
|
||||
if isinstance(version.validation_summary, dict)
|
||||
else {}
|
||||
)
|
||||
idempotency_key = (
|
||||
payload.idempotency_key
|
||||
if payload and payload.idempotency_key
|
||||
else (
|
||||
f"campaign-build:{version.id}:{source_sha256}:"
|
||||
f"{validation_sha256}:{int(write_eml)}"
|
||||
)
|
||||
)
|
||||
try:
|
||||
identity = application_runtime_identity(request.app)
|
||||
recovery_start = begin_durable_recovery_operation(
|
||||
get_database().SessionLocal,
|
||||
identity=identity,
|
||||
module_id="campaigns",
|
||||
operation_type="build-artifacts",
|
||||
idempotency_key=idempotency_key,
|
||||
request={
|
||||
"tenant_id": principal.tenant_id,
|
||||
"campaign_id": version.campaign_id,
|
||||
"version_id": version.id,
|
||||
"source_sha256": source_sha256,
|
||||
"validation_sha256": validation_sha256,
|
||||
"write_eml": write_eml,
|
||||
},
|
||||
recovery_plan=_campaign_build_recovery_plan(raw_json),
|
||||
precondition_evidence={
|
||||
"campaign_version_id": version.id,
|
||||
"source_sha256": source_sha256,
|
||||
"validation_sha256": validation_sha256,
|
||||
"locked_at": version.locked_at.isoformat()
|
||||
if version.locked_at
|
||||
else None,
|
||||
"workflow_state": version.workflow_state,
|
||||
},
|
||||
lease_resource_key=(
|
||||
f"campaign:build:{principal.tenant_id}:{version.id}"
|
||||
),
|
||||
lease_ttl_seconds=30 * 60,
|
||||
resource_type="campaign_version",
|
||||
resource_id=version.id,
|
||||
metadata={"actor_account_id": principal.account_id},
|
||||
)
|
||||
if recovery_start.replayed:
|
||||
session.refresh(version)
|
||||
if not isinstance(version.build_summary, dict):
|
||||
raise RecoveryGuaranteeError(
|
||||
"A completed build operation has no committed Campaign summary"
|
||||
)
|
||||
return public_campaign_payload(
|
||||
version.build_summary,
|
||||
include_diagnostics=has_scope(
|
||||
principal, "campaigns:diagnostic:read"
|
||||
),
|
||||
)
|
||||
recovery_operation = recovery_start.operation
|
||||
if recovery_operation is None: # pragma: no cover - guarded by replay branch
|
||||
raise RecoveryGuaranteeError("Campaign build authority was not created")
|
||||
result = build_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
version_id=version_id,
|
||||
write_eml=payload.write_eml if payload else True,
|
||||
write_eml=write_eml,
|
||||
user_id=principal.user.id,
|
||||
principal=principal,
|
||||
recovery_operation=recovery_operation,
|
||||
build_id=recovery_start.operation_id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
@@ -753,8 +873,9 @@ def build_version(
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"write_eml": payload.write_eml if payload else True,
|
||||
"write_eml": write_eml,
|
||||
"built_count": result.get("built_count"),
|
||||
"recovery_operation_id": recovery_start.operation_id,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
@@ -766,6 +887,21 @@ def build_version(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except RecoveryGuaranteeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Campaign build coordination is unavailable",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
|
||||
Reference in New Issue
Block a user