92 lines
3.4 KiB
Python
92 lines
3.4 KiB
Python
# [DEF:backend.src.services.clean_release.preparation_service:Module]
|
|
# @TIER: STANDARD
|
|
# @SEMANTICS: clean-release, preparation, manifest, policy-evaluation
|
|
# @PURPOSE: Prepare release candidate by policy evaluation and deterministic manifest creation.
|
|
# @LAYER: Domain
|
|
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.policy_engine
|
|
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.manifest_builder
|
|
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
|
# @INVARIANT: Candidate preparation always persists manifest and candidate status deterministically.
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Dict, Iterable
|
|
|
|
from .manifest_builder import build_distribution_manifest
|
|
from .policy_engine import CleanPolicyEngine
|
|
from .repository import CleanReleaseRepository
|
|
from .enums import CandidateStatus
|
|
|
|
|
|
def prepare_candidate(
|
|
repository: CleanReleaseRepository,
|
|
candidate_id: str,
|
|
artifacts: Iterable[Dict],
|
|
sources: Iterable[str],
|
|
operator_id: str,
|
|
) -> Dict:
|
|
candidate = repository.get_candidate(candidate_id)
|
|
if candidate is None:
|
|
raise ValueError(f"Candidate not found: {candidate_id}")
|
|
|
|
policy = repository.get_active_policy()
|
|
if policy is None:
|
|
raise ValueError("Active clean policy not found")
|
|
|
|
registry = repository.get_registry(policy.registry_snapshot_id)
|
|
if registry is None:
|
|
raise ValueError("Registry not found for active policy")
|
|
|
|
engine = CleanPolicyEngine(policy=policy, registry=registry)
|
|
validation = engine.validate_policy()
|
|
if not validation.ok:
|
|
raise ValueError(f"Invalid policy: {validation.blocking_reasons}")
|
|
|
|
classified, violations = engine.evaluate_candidate(artifacts=artifacts, sources=sources)
|
|
|
|
manifest = build_distribution_manifest(
|
|
manifest_id=f"manifest-{candidate_id}",
|
|
candidate_id=candidate_id,
|
|
policy_id=policy.policy_id,
|
|
generated_by=operator_id,
|
|
artifacts=classified,
|
|
)
|
|
repository.save_manifest(manifest)
|
|
|
|
# Note: In the new model, BLOCKED is a ComplianceDecision, not a CandidateStatus.
|
|
# CandidateStatus.PREPARED is the correct next state after preparation.
|
|
candidate.transition_to(CandidateStatus.PREPARED)
|
|
repository.save_candidate(candidate)
|
|
|
|
status_value = candidate.status.value if hasattr(candidate.status, "value") else str(candidate.status)
|
|
manifest_id_value = getattr(manifest, "manifest_id", None) or getattr(manifest, "id", "")
|
|
return {
|
|
"candidate_id": candidate_id,
|
|
"status": status_value,
|
|
"manifest_id": manifest_id_value,
|
|
"violations": violations,
|
|
"prepared_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
|
|
# [DEF:prepare_candidate_legacy:Function]
|
|
# @PURPOSE: Legacy compatibility wrapper kept for migration period.
|
|
# @PRE: Same as prepare_candidate.
|
|
# @POST: Delegates to canonical prepare_candidate and preserves response shape.
|
|
def prepare_candidate_legacy(
|
|
repository: CleanReleaseRepository,
|
|
candidate_id: str,
|
|
artifacts: Iterable[Dict],
|
|
sources: Iterable[str],
|
|
operator_id: str,
|
|
) -> Dict:
|
|
return prepare_candidate(
|
|
repository=repository,
|
|
candidate_id=candidate_id,
|
|
artifacts=artifacts,
|
|
sources=sources,
|
|
operator_id=operator_id,
|
|
)
|
|
# [/DEF:prepare_candidate_legacy:Function]
|
|
# [/DEF:backend.src.services.clean_release.preparation_service:Module] |