# #region CleanReleaseApi [C:4] [TYPE Module] [SEMANTICS fastapi, clean-release, api, compliance, candidate, release] # @BRIEF Expose clean release endpoints for candidate preparation and subsequent compliance flow. # @LAYER: API # @RELATION DEPENDS_ON -> [get_clean_release_repository] # @RELATION DEPENDS_ON -> [PreparationService] # @PRE: Clean release repository and preparation service dependencies are configured for the current request scope. # @POST: Candidate preparation, manifest, and compliance routes expose deterministic API payloads without reporting prepared state on failed preparation. # @SIDE_EFFECT: Persists candidate/compliance lifecycle state and triggers clean-release orchestration services. # @INVARIANT: API never reports prepared status if preparation errors are present. from __future__ import annotations from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from ...core.logger import belief_scope, logger from ...dependencies import get_clean_release_repository from ...models.clean_release import ( CandidateArtifact, ComplianceStageRun, ComplianceViolation, ReleaseCandidate, ) from ...services.clean_release.compliance_orchestrator import ( CleanComplianceOrchestrator, ) from ...services.clean_release.dto import ( CandidateDTO, CandidateOverviewDTO, ManifestDTO, ) from ...services.clean_release.enums import ( CandidateStatus, ComplianceDecision, ComplianceStageName, RunStatus, ViolationSeverity, ) from ...services.clean_release.preparation_service import prepare_candidate from ...services.clean_release.report_builder import ComplianceReportBuilder from ...services.clean_release.repository import CleanReleaseRepository router = APIRouter(prefix="/api/clean-release", tags=["Clean Release"]) # #region PrepareCandidateRequest [TYPE Class] # @BRIEF Request schema for candidate preparation endpoint. class PrepareCandidateRequest(BaseModel): candidate_id: str = Field(min_length=1) artifacts: list[dict[str, Any]] = Field(default_factory=list) sources: list[str] = Field(default_factory=list) operator_id: str = Field(min_length=1) # #endregion PrepareCandidateRequest # #region StartCheckRequest [TYPE Class] # @BRIEF Request schema for clean compliance check run startup. class StartCheckRequest(BaseModel): candidate_id: str = Field(min_length=1) profile: str = Field(default="enterprise-clean") execution_mode: str = Field(default="tui") triggered_by: str = Field(default="system") # #endregion StartCheckRequest # #region RegisterCandidateRequest [TYPE Class] # @BRIEF Request schema for candidate registration endpoint. class RegisterCandidateRequest(BaseModel): id: str = Field(min_length=1) version: str = Field(min_length=1) source_snapshot_ref: str = Field(min_length=1) created_by: str = Field(min_length=1) # #endregion RegisterCandidateRequest # #region ImportArtifactsRequest [TYPE Class] # @BRIEF Request schema for candidate artifact import endpoint. class ImportArtifactsRequest(BaseModel): artifacts: list[dict[str, Any]] = Field(default_factory=list) # #endregion ImportArtifactsRequest # #region BuildManifestRequest [TYPE Class] # @BRIEF Request schema for manifest build endpoint. class BuildManifestRequest(BaseModel): created_by: str = Field(default="system") # #endregion BuildManifestRequest # #region CreateComplianceRunRequest [TYPE Class] # @BRIEF Request schema for compliance run creation with optional manifest pinning. class CreateComplianceRunRequest(BaseModel): requested_by: str = Field(min_length=1) manifest_id: str | None = None # #endregion CreateComplianceRunRequest # #region register_candidate_v2_endpoint [TYPE Function] # @BRIEF Register a clean-release candidate for headless lifecycle. # @PRE: Candidate identifier is unique. # @POST: Candidate is persisted in DRAFT status. @router.post( "/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED ) async def register_candidate_v2_endpoint( payload: RegisterCandidateRequest, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): existing = repository.get_candidate(payload.id) if existing is not None: raise HTTPException( status_code=409, detail={"message": "Candidate already exists", "code": "CANDIDATE_EXISTS"}, ) candidate = ReleaseCandidate( id=payload.id, version=payload.version, source_snapshot_ref=payload.source_snapshot_ref, created_by=payload.created_by, created_at=datetime.now(UTC), status=CandidateStatus.DRAFT.value, ) repository.save_candidate(candidate) return CandidateDTO( id=candidate.id, version=candidate.version, source_snapshot_ref=candidate.source_snapshot_ref, created_at=candidate.created_at, created_by=candidate.created_by, status=CandidateStatus(candidate.status), ) # #endregion register_candidate_v2_endpoint # #region import_candidate_artifacts_v2_endpoint [TYPE Function] # @BRIEF Import candidate artifacts in headless flow. # @PRE: Candidate exists and artifacts array is non-empty. # @POST: Artifacts are persisted and candidate advances to PREPARED if it was DRAFT. @router.post("/candidates/{candidate_id}/artifacts") async def import_candidate_artifacts_v2_endpoint( candidate_id: str, payload: ImportArtifactsRequest, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): candidate = repository.get_candidate(candidate_id) if candidate is None: raise HTTPException( status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"}, ) if not payload.artifacts: raise HTTPException( status_code=400, detail={"message": "Artifacts list is required", "code": "ARTIFACTS_EMPTY"}, ) for artifact in payload.artifacts: required = ("id", "path", "sha256", "size") for field_name in required: if field_name not in artifact: raise HTTPException( status_code=400, detail={ "message": f"Artifact missing field '{field_name}'", "code": "ARTIFACT_INVALID", }, ) artifact_model = CandidateArtifact( id=str(artifact["id"]), candidate_id=candidate_id, path=str(artifact["path"]), sha256=str(artifact["sha256"]), size=int(artifact["size"]), detected_category=artifact.get("detected_category"), declared_category=artifact.get("declared_category"), source_uri=artifact.get("source_uri"), source_host=artifact.get("source_host"), metadata_json=artifact.get("metadata_json", {}), ) repository.save_artifact(artifact_model) if candidate.status == CandidateStatus.DRAFT.value: candidate.transition_to(CandidateStatus.PREPARED) repository.save_candidate(candidate) return {"status": "success"} # #endregion import_candidate_artifacts_v2_endpoint # #region build_candidate_manifest_v2_endpoint [TYPE Function] # @BRIEF Build immutable manifest snapshot for prepared candidate. # @PRE: Candidate exists and has imported artifacts. # @POST: Returns created ManifestDTO with incremented version. @router.post( "/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED, ) async def build_candidate_manifest_v2_endpoint( candidate_id: str, payload: BuildManifestRequest, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): from ...services.clean_release.manifest_service import build_manifest_snapshot try: manifest = build_manifest_snapshot( repository=repository, candidate_id=candidate_id, created_by=payload.created_by, ) except ValueError as exc: raise HTTPException( status_code=400, detail={"message": str(exc), "code": "MANIFEST_BUILD_ERROR"}, ) return ManifestDTO( id=manifest.id, candidate_id=manifest.candidate_id, manifest_version=manifest.manifest_version, manifest_digest=manifest.manifest_digest, artifacts_digest=manifest.artifacts_digest, created_at=manifest.created_at, created_by=manifest.created_by, source_snapshot_ref=manifest.source_snapshot_ref, content_json=manifest.content_json, ) # #endregion build_candidate_manifest_v2_endpoint # #region get_candidate_overview_v2_endpoint [TYPE Function] # @BRIEF Return expanded candidate overview DTO for headless lifecycle visibility. # @PRE: Candidate exists. # @POST: Returns CandidateOverviewDTO built from the same repository state used by headless US1 endpoints. @router.get("/candidates/{candidate_id}/overview", response_model=CandidateOverviewDTO) async def get_candidate_overview_v2_endpoint( candidate_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): candidate = repository.get_candidate(candidate_id) if candidate is None: raise HTTPException( status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"}, ) manifests = repository.get_manifests_by_candidate(candidate_id) latest_manifest = ( sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0] if manifests else None ) runs = [ run for run in repository.check_runs.values() if run.candidate_id == candidate_id ] latest_run = ( sorted( runs, key=lambda run: run.requested_at or datetime.min.replace(tzinfo=UTC), reverse=True, )[0] if runs else None ) latest_report = None if latest_run is not None: latest_report = next( (r for r in repository.reports.values() if r.run_id == latest_run.id), None ) latest_policy_snapshot = ( repository.get_policy(latest_run.policy_snapshot_id) if latest_run else None ) latest_registry_snapshot = ( repository.get_registry(latest_run.registry_snapshot_id) if latest_run else None ) approval_decisions = getattr(repository, "approval_decisions", []) latest_approval = ( sorted( [item for item in approval_decisions if item.candidate_id == candidate_id], key=lambda item: item.decided_at or datetime.min.replace(tzinfo=UTC), reverse=True, )[0] if approval_decisions and any(item.candidate_id == candidate_id for item in approval_decisions) else None ) publication_records = getattr(repository, "publication_records", []) latest_publication = ( sorted( [item for item in publication_records if item.candidate_id == candidate_id], key=lambda item: item.published_at or datetime.min.replace(tzinfo=UTC), reverse=True, )[0] if publication_records and any(item.candidate_id == candidate_id for item in publication_records) else None ) return CandidateOverviewDTO( candidate_id=candidate.id, version=candidate.version, source_snapshot_ref=candidate.source_snapshot_ref, status=CandidateStatus(candidate.status), latest_manifest_id=latest_manifest.id if latest_manifest else None, latest_manifest_digest=latest_manifest.manifest_digest if latest_manifest else None, latest_run_id=latest_run.id if latest_run else None, latest_run_status=RunStatus(latest_run.status) if latest_run else None, latest_report_id=latest_report.id if latest_report else None, latest_report_final_status=ComplianceDecision(latest_report.final_status) if latest_report else None, latest_policy_snapshot_id=latest_policy_snapshot.id if latest_policy_snapshot else None, latest_policy_version=latest_policy_snapshot.policy_version if latest_policy_snapshot else None, latest_registry_snapshot_id=latest_registry_snapshot.id if latest_registry_snapshot else None, latest_registry_version=latest_registry_snapshot.registry_version if latest_registry_snapshot else None, latest_approval_decision=latest_approval.decision if latest_approval else None, latest_publication_id=latest_publication.id if latest_publication else None, latest_publication_status=latest_publication.status if latest_publication else None, ) # #endregion get_candidate_overview_v2_endpoint # #region prepare_candidate_endpoint [TYPE Function] # @BRIEF Prepare candidate with policy evaluation and deterministic manifest generation. # @PRE: Candidate and active policy exist in repository. # @POST: Returns preparation result including manifest reference and violations. @router.post("/candidates/prepare") async def prepare_candidate_endpoint( payload: PrepareCandidateRequest, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): try: result = prepare_candidate( repository=repository, candidate_id=payload.candidate_id, artifacts=payload.artifacts, sources=payload.sources, operator_id=payload.operator_id, ) legacy_status = result.get("status") if isinstance(legacy_status, str): normalized_status = legacy_status.lower() if normalized_status == "check_blocked": normalized_status = "blocked" result["status"] = normalized_status return result except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"message": str(exc), "code": "CLEAN_PREPARATION_ERROR"}, ) # #endregion prepare_candidate_endpoint # #region start_check [TYPE Function] # @BRIEF Start and finalize a clean compliance check run and persist report artifacts. # @PRE: Active policy and candidate exist. # @POST: Returns accepted payload with check_run_id and started_at. @router.post("/checks", status_code=status.HTTP_202_ACCEPTED) async def start_check( payload: StartCheckRequest, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): with belief_scope("clean_release.start_check"): logger.reason("Starting clean-release compliance check run") policy = repository.get_active_policy() if policy is None: raise HTTPException( status_code=409, detail={ "message": "Active policy not found", "code": "POLICY_NOT_FOUND", }, ) candidate = repository.get_candidate(payload.candidate_id) if candidate is None: raise HTTPException( status_code=409, detail={ "message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND", }, ) manifests = repository.get_manifests_by_candidate(payload.candidate_id) if not manifests: logger.explore( "No manifest found for candidate; bootstrapping legacy empty manifest for compatibility" ) from ...services.clean_release.manifest_builder import ( build_distribution_manifest, ) boot_manifest = build_distribution_manifest( manifest_id=f"manifest-{payload.candidate_id}", candidate_id=payload.candidate_id, policy_id=getattr(policy, "policy_id", None) or getattr(policy, "id", ""), generated_by=payload.triggered_by, artifacts=[], ) repository.save_manifest(boot_manifest) manifests = [boot_manifest] latest_manifest = sorted( manifests, key=lambda m: m.manifest_version, reverse=True )[0] orchestrator = CleanComplianceOrchestrator(repository) run = orchestrator.start_check_run( candidate_id=payload.candidate_id, policy_id=policy.id, requested_by=payload.triggered_by, manifest_id=latest_manifest.id, ) forced = [ ComplianceStageRun( id=f"stage-{run.id}-1", run_id=run.id, stage_name=ComplianceStageName.DATA_PURITY.value, status=RunStatus.SUCCEEDED.value, decision=ComplianceDecision.PASSED.value, details_json={"message": "ok"}, ), ComplianceStageRun( id=f"stage-{run.id}-2", run_id=run.id, stage_name=ComplianceStageName.INTERNAL_SOURCES_ONLY.value, status=RunStatus.SUCCEEDED.value, decision=ComplianceDecision.PASSED.value, details_json={"message": "ok"}, ), ComplianceStageRun( id=f"stage-{run.id}-3", run_id=run.id, stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value, status=RunStatus.SUCCEEDED.value, decision=ComplianceDecision.PASSED.value, details_json={"message": "ok"}, ), ComplianceStageRun( id=f"stage-{run.id}-4", run_id=run.id, stage_name=ComplianceStageName.MANIFEST_CONSISTENCY.value, status=RunStatus.SUCCEEDED.value, decision=ComplianceDecision.PASSED.value, details_json={"message": "ok"}, ), ] run = orchestrator.execute_stages(run, forced_results=forced) run = orchestrator.finalize_run(run) if str(run.final_status) in { ComplianceDecision.BLOCKED.value, "CheckFinalStatus.BLOCKED", "BLOCKED", }: logger.explore( "Run ended as BLOCKED, persisting synthetic external-source violation" ) violation = ComplianceViolation( id=f"viol-{run.id}", run_id=run.id, stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value, code="EXTERNAL_SOURCE_DETECTED", severity=ViolationSeverity.CRITICAL.value, message="Replace with approved internal server", evidence_json={"location": "external.example.com"}, ) repository.save_violation(violation) builder = ComplianceReportBuilder(repository) report = builder.build_report_payload( run, repository.get_violations_by_run(run.id) ) builder.persist_report(report) logger.reflect(f"Compliance report persisted for run_id={run.id}") return { "check_run_id": run.id, "candidate_id": run.candidate_id, "status": "running", "started_at": run.started_at.isoformat() if run.started_at else None, } # #endregion start_check # #region get_check_status [TYPE Function] # @BRIEF Return terminal/intermediate status payload for a check run. # @PRE: check_run_id references an existing run. # @POST: Deterministic payload shape includes checks and violations arrays. @router.get("/checks/{check_run_id}") async def get_check_status( check_run_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): with belief_scope("clean_release.get_check_status"): run = repository.get_check_run(check_run_id) if run is None: raise HTTPException( status_code=404, detail={"message": "Check run not found", "code": "CHECK_NOT_FOUND"}, ) logger.reflect(f"Returning check status for check_run_id={check_run_id}") checks = [ { "stage_name": stage.stage_name, "status": stage.status, "decision": stage.decision, "details": stage.details_json, } for stage in repository.stage_runs.values() if stage.run_id == run.id ] violations = [ { "violation_id": violation.id, "category": violation.stage_name, "code": violation.code, "message": violation.message, "evidence": violation.evidence_json, } for violation in repository.get_violations_by_run(run.id) ] return { "check_run_id": run.id, "candidate_id": run.candidate_id, "final_status": getattr(run.final_status, "value", run.final_status), "started_at": run.started_at.isoformat() if run.started_at else None, "finished_at": run.finished_at.isoformat() if run.finished_at else None, "checks": checks, "violations": violations, } # #endregion get_check_status # #region get_report [TYPE Function] # @BRIEF Return persisted compliance report by report_id. # @PRE: report_id references an existing report. # @POST: Returns serialized report object. @router.get("/reports/{report_id}") async def get_report( report_id: str, repository: CleanReleaseRepository = Depends(get_clean_release_repository), ): with belief_scope("clean_release.get_report"): report = repository.get_report(report_id) if report is None: raise HTTPException( status_code=404, detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"}, ) logger.reflect(f"Returning compliance report report_id={report_id}") return { "report_id": report.id, "check_run_id": report.run_id, "candidate_id": report.candidate_id, "final_status": getattr(report.final_status, "value", report.final_status), "generated_at": report.generated_at.isoformat() if getattr(report, "generated_at", None) else None, "operator_summary": getattr(report, "operator_summary", ""), "structured_payload_ref": getattr(report, "structured_payload_ref", None), "violations_count": getattr(report, "violations_count", 0), "blocking_violations_count": getattr( report, "blocking_violations_count", 0 ), } # #endregion get_report # #endregion CleanReleaseApi