feat(clean-release): complete compliance redesign phases and polish tasks T047-T052
This commit is contained in:
107
backend/src/services/clean_release/candidate_service.py
Normal file
107
backend/src/services/clean_release/candidate_service.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# [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]
|
||||
Reference in New Issue
Block a user