Skip to main content

canic_host/deployment_truth/
promotion.rs

1use super::{
2    DEPLOYMENT_TRUTH_SCHEMA_VERSION, DeploymentPlanV1, PromotionArtifactLevelV1,
3    PromotionReadinessStatusV1, PromotionReadinessV1, RoleArtifactSourceKindV1,
4    RoleArtifactSourceV1, RoleArtifactV1, RolePromotionInputV1, RolePromotionReadinessV1,
5    SafetyFindingV1, SafetySeverityV1,
6};
7use thiserror::Error as ThisError;
8
9///
10/// PromotionArtifactSourceError
11///
12#[derive(Debug, ThisError)]
13pub enum PromotionArtifactSourceError {
14    #[error("promotion artifact source is missing required field: {field}")]
15    MissingRequiredField { field: &'static str },
16    #[error("promotion artifact source field {field} must be a lowercase sha256 hex digest")]
17    InvalidSha256Digest { field: &'static str },
18    #[error("promotion artifact source kind {kind:?} requires a digest pin")]
19    MissingDigestPin { kind: RoleArtifactSourceKindV1 },
20    #[error("promotion artifact source kind {kind:?} cannot carry previous receipt kind")]
21    UnexpectedPreviousReceiptKind { kind: RoleArtifactSourceKindV1 },
22    #[error(
23        "promotion artifact source kind PreviousReceiptArtifact requires an eligible receipt kind"
24    )]
25    MissingPreviousReceiptKind,
26}
27
28///
29/// PromotionReadinessError
30///
31#[derive(Debug, ThisError)]
32pub enum PromotionReadinessError {
33    #[error("promotion readiness schema mismatch: expected {expected}, found {found}")]
34    SchemaVersionMismatch { expected: u32, found: u32 },
35    #[error("promotion readiness is missing required field: {field}")]
36    MissingRequiredField { field: &'static str },
37    #[error("promotion readiness status {status:?} does not match blocker count {blocker_count}")]
38    StatusBlockerMismatch {
39        status: PromotionReadinessStatusV1,
40        blocker_count: usize,
41    },
42    #[error("promotion readiness contains duplicate role: {role}")]
43    DuplicateRole { role: String },
44    #[error("promotion readiness role {role} has inconsistent restage state")]
45    RestageStateMismatch { role: String },
46    #[error("promotion readiness finding in {field} has severity {severity:?}")]
47    FindingSeverityMismatch {
48        field: &'static str,
49        severity: SafetySeverityV1,
50    },
51    #[error("promotion readiness field {field} must be a lowercase sha256 hex digest")]
52    InvalidSha256Digest { field: &'static str },
53}
54
55///
56/// PromotionReadinessRequest
57///
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct PromotionReadinessRequest {
60    pub readiness_id: String,
61    pub target_plan: DeploymentPlanV1,
62    pub inputs: Vec<RolePromotionInputV1>,
63}
64
65pub fn check_promotion_readiness(
66    request: &PromotionReadinessRequest,
67) -> Result<PromotionReadinessV1, PromotionReadinessError> {
68    ensure_readiness_field("readiness_id", &request.readiness_id)?;
69    let readiness = promotion_readiness_from_inputs(
70        &request.readiness_id,
71        &request.target_plan,
72        &request.inputs,
73    );
74    validate_promotion_readiness(&readiness)?;
75    Ok(readiness)
76}
77
78#[must_use]
79pub fn promotion_readiness_from_inputs(
80    readiness_id: impl Into<String>,
81    target_plan: &DeploymentPlanV1,
82    inputs: &[RolePromotionInputV1],
83) -> PromotionReadinessV1 {
84    let mut roles = Vec::with_capacity(inputs.len());
85    let mut blockers = Vec::new();
86    let mut warnings = Vec::new();
87
88    for input in inputs {
89        let target_artifact = target_plan
90            .role_artifacts
91            .iter()
92            .find(|artifact| artifact.role == input.role);
93        let Some(target_artifact) = target_artifact else {
94            blockers.push(promotion_finding(
95                "promotion_target_role_missing",
96                format!("target plan does not contain role {}", input.role),
97                SafetySeverityV1::HardFailure,
98                &input.role,
99            ));
100            continue;
101        };
102
103        let role_readiness = role_promotion_readiness(input, target_artifact);
104        collect_role_findings(input, &role_readiness, &mut blockers, &mut warnings);
105        roles.push(role_readiness);
106    }
107
108    let status = if blockers.is_empty() {
109        PromotionReadinessStatusV1::Ready
110    } else {
111        PromotionReadinessStatusV1::Blocked
112    };
113
114    PromotionReadinessV1 {
115        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
116        readiness_id: readiness_id.into(),
117        target_plan_id: target_plan.plan_id.clone(),
118        status,
119        roles,
120        blockers,
121        warnings,
122    }
123}
124
125pub fn validate_promotion_readiness(
126    readiness: &PromotionReadinessV1,
127) -> Result<(), PromotionReadinessError> {
128    if readiness.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
129        return Err(PromotionReadinessError::SchemaVersionMismatch {
130            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
131            found: readiness.schema_version,
132        });
133    }
134    ensure_readiness_field("readiness_id", &readiness.readiness_id)?;
135    ensure_readiness_field("target_plan_id", &readiness.target_plan_id)?;
136    ensure_readiness_status_matches_blockers(readiness)?;
137    ensure_unique_readiness_roles(&readiness.roles)?;
138    for role in &readiness.roles {
139        validate_role_readiness(role)?;
140    }
141    validate_readiness_findings(
142        "blockers",
143        &readiness.blockers,
144        SafetySeverityV1::HardFailure,
145    )?;
146    validate_readiness_findings("warnings", &readiness.warnings, SafetySeverityV1::Warning)?;
147    Ok(())
148}
149
150pub fn validate_role_artifact_source(
151    source: &RoleArtifactSourceV1,
152) -> Result<(), PromotionArtifactSourceError> {
153    ensure_field("role", &source.role)?;
154    ensure_locator_requirement(source)?;
155    ensure_previous_receipt_requirement(source)?;
156    ensure_digest_requirement(source)?;
157    ensure_optional_sha256(
158        "expected_wasm_sha256",
159        source.expected_wasm_sha256.as_deref(),
160    )?;
161    ensure_optional_sha256(
162        "expected_wasm_gz_sha256",
163        source.expected_wasm_gz_sha256.as_deref(),
164    )?;
165    ensure_optional_sha256(
166        "expected_candid_sha256",
167        source.expected_candid_sha256.as_deref(),
168    )?;
169    ensure_optional_sha256(
170        "expected_canonical_embedded_config_sha256",
171        source.expected_canonical_embedded_config_sha256.as_deref(),
172    )?;
173    Ok(())
174}
175
176fn validate_role_readiness(role: &RolePromotionReadinessV1) -> Result<(), PromotionReadinessError> {
177    ensure_readiness_field("role", &role.role)?;
178    ensure_readiness_optional_sha256("source_wasm_sha256", role.source_wasm_sha256.as_deref())?;
179    ensure_readiness_optional_sha256(
180        "source_wasm_gz_sha256",
181        role.source_wasm_gz_sha256.as_deref(),
182    )?;
183    ensure_readiness_optional_sha256("target_wasm_sha256", role.target_wasm_sha256.as_deref())?;
184    ensure_readiness_optional_sha256(
185        "target_wasm_gz_sha256",
186        role.target_wasm_gz_sha256.as_deref(),
187    )?;
188    ensure_readiness_optional_sha256(
189        "source_canonical_embedded_config_sha256",
190        role.source_canonical_embedded_config_sha256.as_deref(),
191    )?;
192    ensure_readiness_optional_sha256(
193        "target_canonical_embedded_config_sha256",
194        role.target_canonical_embedded_config_sha256.as_deref(),
195    )?;
196    if role.restage_required != (role.target_store_has_artifact == Some(false)) {
197        return Err(PromotionReadinessError::RestageStateMismatch {
198            role: role.role.clone(),
199        });
200    }
201    Ok(())
202}
203
204fn role_promotion_readiness(
205    input: &RolePromotionInputV1,
206    target_artifact: &RoleArtifactV1,
207) -> RolePromotionReadinessV1 {
208    let source_wasm_sha256 = input.source.expected_wasm_sha256.clone();
209    let source_wasm_gz_sha256 = input.source.expected_wasm_gz_sha256.clone();
210    let target_wasm_sha256 = target_artifact.wasm_sha256.clone();
211    let target_wasm_gz_sha256 = target_artifact.wasm_gz_sha256.clone();
212    let byte_identical_wasm =
213        matching_optional_digest(source_wasm_sha256.as_ref(), target_wasm_sha256.as_ref()).or_else(
214            || {
215                matching_optional_digest(
216                    source_wasm_gz_sha256.as_ref(),
217                    target_wasm_gz_sha256.as_ref(),
218                )
219            },
220        );
221    let embedded_config_identical = matching_optional_digest(
222        input
223            .source
224            .expected_canonical_embedded_config_sha256
225            .as_ref(),
226        target_artifact.canonical_embedded_config_sha256.as_ref(),
227    );
228
229    RolePromotionReadinessV1 {
230        role: input.role.clone(),
231        promotion_level: input.promotion_level,
232        source_kind: input.source.kind,
233        source_locator: input.source.locator.clone(),
234        source_wasm_sha256,
235        source_wasm_gz_sha256,
236        target_wasm_sha256,
237        target_wasm_gz_sha256,
238        source_canonical_embedded_config_sha256: input
239            .source
240            .expected_canonical_embedded_config_sha256
241            .clone(),
242        target_canonical_embedded_config_sha256: target_artifact
243            .canonical_embedded_config_sha256
244            .clone(),
245        byte_identical_wasm,
246        embedded_config_identical,
247        target_store_has_artifact: input.target_store_has_artifact,
248        restage_required: input.target_store_has_artifact == Some(false),
249    }
250}
251
252fn collect_role_findings(
253    input: &RolePromotionInputV1,
254    readiness: &RolePromotionReadinessV1,
255    blockers: &mut Vec<SafetyFindingV1>,
256    warnings: &mut Vec<SafetyFindingV1>,
257) {
258    if let Err(err) = validate_role_artifact_source(&input.source) {
259        blockers.push(promotion_finding(
260            "promotion_artifact_source_invalid",
261            err.to_string(),
262            SafetySeverityV1::HardFailure,
263            &input.role,
264        ));
265    }
266
267    if input.role != input.source.role {
268        blockers.push(promotion_finding(
269            "promotion_source_role_mismatch",
270            format!(
271                "promotion input role {} does not match artifact source role {}",
272                input.role, input.source.role
273            ),
274            SafetySeverityV1::HardFailure,
275            &input.role,
276        ));
277    }
278
279    if input.require_byte_identical_wasm && readiness.byte_identical_wasm != Some(true) {
280        blockers.push(promotion_finding(
281            "promotion_wasm_digest_mismatch",
282            "promotion requires byte-identical wasm but source and target digests differ or are incomplete",
283            SafetySeverityV1::HardFailure,
284            &input.role,
285        ));
286    }
287
288    if input.require_target_embedded_config
289        && readiness
290            .target_canonical_embedded_config_sha256
291            .as_deref()
292            .is_none_or(str::is_empty)
293    {
294        blockers.push(promotion_finding(
295            "promotion_target_embedded_config_missing",
296            "promotion requires target canonical embedded config but target plan has no digest",
297            SafetySeverityV1::HardFailure,
298            &input.role,
299        ));
300    }
301
302    if input.promotion_level == PromotionArtifactLevelV1::SealedWasm
303        && readiness.embedded_config_identical != Some(true)
304    {
305        blockers.push(promotion_finding(
306            "promotion_sealed_wasm_embedded_config_mismatch",
307            "sealed wasm promotion requires embedded config identity to be acceptable for the target",
308            SafetySeverityV1::HardFailure,
309            &input.role,
310        ));
311    }
312
313    if readiness.restage_required {
314        warnings.push(promotion_finding(
315            "promotion_target_store_restage_required",
316            "target artifact store does not already contain the artifact; restaging is required",
317            SafetySeverityV1::Warning,
318            &input.role,
319        ));
320    }
321}
322
323fn matching_optional_digest(left: Option<&String>, right: Option<&String>) -> Option<bool> {
324    match (left.map(String::as_str), right.map(String::as_str)) {
325        (Some(left), Some(right)) => Some(left == right),
326        _ => None,
327    }
328}
329
330fn promotion_finding(
331    code: impl Into<String>,
332    message: impl Into<String>,
333    severity: SafetySeverityV1,
334    role: &str,
335) -> SafetyFindingV1 {
336    SafetyFindingV1 {
337        code: code.into(),
338        message: message.into(),
339        severity,
340        subject: Some(role.to_string()),
341    }
342}
343
344fn ensure_locator_requirement(
345    source: &RoleArtifactSourceV1,
346) -> Result<(), PromotionArtifactSourceError> {
347    match source.kind {
348        RoleArtifactSourceKindV1::CanonicalWasmStoreDefault => Ok(()),
349        _ => ensure_option_field("locator", source.locator.as_deref()),
350    }
351}
352
353const fn ensure_previous_receipt_requirement(
354    source: &RoleArtifactSourceV1,
355) -> Result<(), PromotionArtifactSourceError> {
356    match (source.kind, source.previous_receipt_kind) {
357        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, Some(_)) => Ok(()),
358        (RoleArtifactSourceKindV1::PreviousReceiptArtifact, None) => {
359            Err(PromotionArtifactSourceError::MissingPreviousReceiptKind)
360        }
361        (_, Some(_)) => {
362            Err(PromotionArtifactSourceError::UnexpectedPreviousReceiptKind { kind: source.kind })
363        }
364        (_, None) => Ok(()),
365    }
366}
367
368const fn ensure_digest_requirement(
369    source: &RoleArtifactSourceV1,
370) -> Result<(), PromotionArtifactSourceError> {
371    let has_digest =
372        source.expected_wasm_sha256.is_some() || source.expected_wasm_gz_sha256.is_some();
373    match source.kind {
374        RoleArtifactSourceKindV1::LocalWasm if source.expected_wasm_sha256.is_none() => {
375            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
376        }
377        RoleArtifactSourceKindV1::LocalWasmGz if source.expected_wasm_gz_sha256.is_none() => {
378            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
379        }
380        RoleArtifactSourceKindV1::PublishedPackage
381        | RoleArtifactSourceKindV1::PreviousReceiptArtifact
382            if !has_digest =>
383        {
384            Err(PromotionArtifactSourceError::MissingDigestPin { kind: source.kind })
385        }
386        _ => Ok(()),
387    }
388}
389
390fn ensure_option_field(
391    field: &'static str,
392    value: Option<&str>,
393) -> Result<(), PromotionArtifactSourceError> {
394    match value {
395        Some(value) => ensure_field(field, value),
396        None => Err(PromotionArtifactSourceError::MissingRequiredField { field }),
397    }
398}
399
400fn ensure_field(field: &'static str, value: &str) -> Result<(), PromotionArtifactSourceError> {
401    if value.trim().is_empty() {
402        return Err(PromotionArtifactSourceError::MissingRequiredField { field });
403    }
404    Ok(())
405}
406
407fn ensure_optional_sha256(
408    field: &'static str,
409    value: Option<&str>,
410) -> Result<(), PromotionArtifactSourceError> {
411    let Some(value) = value else {
412        return Ok(());
413    };
414    if is_lower_hex_sha256(value) {
415        Ok(())
416    } else {
417        Err(PromotionArtifactSourceError::InvalidSha256Digest { field })
418    }
419}
420
421fn is_lower_hex_sha256(value: &str) -> bool {
422    value.len() == 64
423        && value
424            .bytes()
425            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
426}
427
428const fn ensure_readiness_status_matches_blockers(
429    readiness: &PromotionReadinessV1,
430) -> Result<(), PromotionReadinessError> {
431    match (readiness.status, readiness.blockers.is_empty()) {
432        (PromotionReadinessStatusV1::Ready, false)
433        | (PromotionReadinessStatusV1::Blocked, true) => {
434            Err(PromotionReadinessError::StatusBlockerMismatch {
435                status: readiness.status,
436                blocker_count: readiness.blockers.len(),
437            })
438        }
439        _ => Ok(()),
440    }
441}
442
443fn ensure_unique_readiness_roles(
444    roles: &[RolePromotionReadinessV1],
445) -> Result<(), PromotionReadinessError> {
446    let mut seen = std::collections::BTreeSet::new();
447    for role in roles {
448        if !seen.insert(role.role.as_str()) {
449            return Err(PromotionReadinessError::DuplicateRole {
450                role: role.role.clone(),
451            });
452        }
453    }
454    Ok(())
455}
456
457fn validate_readiness_findings(
458    field: &'static str,
459    findings: &[SafetyFindingV1],
460    expected_severity: SafetySeverityV1,
461) -> Result<(), PromotionReadinessError> {
462    for finding in findings {
463        ensure_readiness_field("finding.code", &finding.code)?;
464        ensure_readiness_field("finding.message", &finding.message)?;
465        if finding.severity != expected_severity {
466            return Err(PromotionReadinessError::FindingSeverityMismatch {
467                field,
468                severity: finding.severity,
469            });
470        }
471    }
472    Ok(())
473}
474
475fn ensure_readiness_field(field: &'static str, value: &str) -> Result<(), PromotionReadinessError> {
476    if value.trim().is_empty() {
477        return Err(PromotionReadinessError::MissingRequiredField { field });
478    }
479    Ok(())
480}
481
482fn ensure_readiness_optional_sha256(
483    field: &'static str,
484    value: Option<&str>,
485) -> Result<(), PromotionReadinessError> {
486    let Some(value) = value else {
487        return Ok(());
488    };
489    if is_lower_hex_sha256(value) {
490        Ok(())
491    } else {
492        Err(PromotionReadinessError::InvalidSha256Digest { field })
493    }
494}