107 lines
4.7 KiB
Python
107 lines
4.7 KiB
Python
# [DEF:backend.src.services.clean_release.candidate_service:Module]
|
|
# @TIER: CRITICAL
|
|
# @SEMANTICS: clean-release, candidate, artifacts, lifecycle, validation
|
|
# @PURPOSE: Register release candidates with validated artifacts and advance lifecycle through legal transitions.
|
|
# @LAYER: Domain
|
|
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
|
# @RELATION: DEPENDS_ON -> backend.src.models.clean_release
|
|
# @PRE: candidate_id must be unique; artifacts input must be non-empty and valid.
|
|
# @POST: candidate and artifacts are persisted; candidate transitions DRAFT -> PREPARED only.
|
|
# @INVARIANT: Candidate lifecycle transitions are delegated to domain guard logic.
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, Iterable, List
|
|
|
|
from ...models.clean_release import CandidateArtifact, ReleaseCandidate
|
|
from .enums import CandidateStatus
|
|
from .repository import CleanReleaseRepository
|
|
|
|
|
|
# [DEF:_validate_artifacts:Function]
|
|
# @PURPOSE: Validate raw artifact payload list for required fields and shape.
|
|
# @PRE: artifacts payload is provided by caller.
|
|
# @POST: Returns normalized artifact list or raises ValueError.
|
|
def _validate_artifacts(artifacts: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
normalized = list(artifacts)
|
|
if not normalized:
|
|
raise ValueError("artifacts must not be empty")
|
|
|
|
required_fields = ("id", "path", "sha256", "size")
|
|
for index, artifact in enumerate(normalized):
|
|
if not isinstance(artifact, dict):
|
|
raise ValueError(f"artifact[{index}] must be an object")
|
|
for field in required_fields:
|
|
if field not in artifact:
|
|
raise ValueError(f"artifact[{index}] missing required field '{field}'")
|
|
if not str(artifact["id"]).strip():
|
|
raise ValueError(f"artifact[{index}] field 'id' must be non-empty")
|
|
if not str(artifact["path"]).strip():
|
|
raise ValueError(f"artifact[{index}] field 'path' must be non-empty")
|
|
if not str(artifact["sha256"]).strip():
|
|
raise ValueError(f"artifact[{index}] field 'sha256' must be non-empty")
|
|
if not isinstance(artifact["size"], int) or artifact["size"] <= 0:
|
|
raise ValueError(f"artifact[{index}] field 'size' must be a positive integer")
|
|
return normalized
|
|
# [/DEF:_validate_artifacts:Function]
|
|
|
|
|
|
# [DEF:register_candidate:Function]
|
|
# @PURPOSE: Register a candidate and persist its artifacts with legal lifecycle transition.
|
|
# @PRE: candidate_id must be unique and artifacts must pass validation.
|
|
# @POST: Candidate exists in repository with PREPARED status and artifacts persisted.
|
|
def register_candidate(
|
|
repository: CleanReleaseRepository,
|
|
candidate_id: str,
|
|
version: str,
|
|
source_snapshot_ref: str,
|
|
created_by: str,
|
|
artifacts: Iterable[Dict[str, Any]],
|
|
) -> ReleaseCandidate:
|
|
if not candidate_id or not candidate_id.strip():
|
|
raise ValueError("candidate_id must be non-empty")
|
|
if not version or not version.strip():
|
|
raise ValueError("version must be non-empty")
|
|
if not source_snapshot_ref or not source_snapshot_ref.strip():
|
|
raise ValueError("source_snapshot_ref must be non-empty")
|
|
if not created_by or not created_by.strip():
|
|
raise ValueError("created_by must be non-empty")
|
|
|
|
existing = repository.get_candidate(candidate_id)
|
|
if existing is not None:
|
|
raise ValueError(f"candidate '{candidate_id}' already exists")
|
|
|
|
validated_artifacts = _validate_artifacts(artifacts)
|
|
|
|
candidate = ReleaseCandidate(
|
|
id=candidate_id,
|
|
version=version,
|
|
source_snapshot_ref=source_snapshot_ref,
|
|
created_by=created_by,
|
|
created_at=datetime.now(timezone.utc),
|
|
status=CandidateStatus.DRAFT.value,
|
|
)
|
|
repository.save_candidate(candidate)
|
|
|
|
for artifact_payload in validated_artifacts:
|
|
artifact = CandidateArtifact(
|
|
id=str(artifact_payload["id"]),
|
|
candidate_id=candidate_id,
|
|
path=str(artifact_payload["path"]),
|
|
sha256=str(artifact_payload["sha256"]),
|
|
size=int(artifact_payload["size"]),
|
|
detected_category=artifact_payload.get("detected_category"),
|
|
declared_category=artifact_payload.get("declared_category"),
|
|
source_uri=artifact_payload.get("source_uri"),
|
|
source_host=artifact_payload.get("source_host"),
|
|
metadata_json=artifact_payload.get("metadata_json", {}),
|
|
)
|
|
repository.save_artifact(artifact)
|
|
|
|
candidate.transition_to(CandidateStatus.PREPARED)
|
|
repository.save_candidate(candidate)
|
|
return candidate
|
|
# [/DEF:register_candidate:Function]
|
|
|
|
# [/DEF:backend.src.services.clean_release.candidate_service:Module] |