Skip to main content

canic_host/deployment_truth/
executor.rs

1use super::authority::AUTHORITY_UNSAFE_BLOCKED_CODE;
2use super::{
3    AuthorityReconciliationPlanV1, AuthorityReconciliationStateV1, CanisterAuthorityActionV1,
4    DEPLOYMENT_TRUTH_SCHEMA_VERSION, DeploymentAssumptionKindV1, DeploymentCheckV1,
5    DeploymentExecutionContextV1, DeploymentExecutionPreflightStatusV1,
6    DeploymentExecutionPreflightV1, DeploymentExecutorBackendV1, DeploymentExecutorCapabilityV1,
7    DeploymentPlanV1, SafetyFindingV1, SafetyReportV1, SafetySeverityV1, SafetyStatusV1,
8    build_authority_reconciliation_plan,
9};
10use std::collections::BTreeSet;
11use thiserror::Error as ThisError;
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14struct DeploymentExecutionPreflightBlockerCode(&'static str);
15
16impl DeploymentExecutionPreflightBlockerCode {
17    const AUTHORITY_CONTROLLER_CHANGE_PENDING: Self = Self("authority_controller_change_pending");
18    const AUTHORITY_EXTERNAL_ACTION_REQUIRED: Self = Self("authority_external_action_required");
19    const AUTHORITY_OBSERVATION_MISSING: Self = Self("authority_observation_missing");
20    const DEPLOYMENT_SAFETY_BLOCKED: Self = Self("deployment_safety_blocked");
21    const EXECUTOR_CAPABILITY_MISSING: Self = Self("executor_capability_missing");
22
23    #[must_use]
24    const fn as_str(self) -> &'static str {
25        self.0
26    }
27}
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30struct DeploymentExecutionPreflightSubjectLabel(&'static str);
31
32impl DeploymentExecutionPreflightSubjectLabel {
33    const AUTHORITY: Self = Self("authority");
34
35    #[must_use]
36    const fn as_str(self) -> &'static str {
37        self.0
38    }
39}
40
41pub(in crate::deployment_truth) const DEPLOYMENT_SAFETY_BLOCKED_CODE: &str =
42    DeploymentExecutionPreflightBlockerCode::DEPLOYMENT_SAFETY_BLOCKED.as_str();
43pub(in crate::deployment_truth) const EXECUTOR_CAPABILITY_MISSING_CODE: &str =
44    DeploymentExecutionPreflightBlockerCode::EXECUTOR_CAPABILITY_MISSING.as_str();
45
46///
47/// DeploymentExecutionPreflightError
48///
49#[derive(Debug, ThisError)]
50pub enum DeploymentExecutionPreflightError {
51    #[error("deployment execution preflight schema mismatch: expected {expected}, found {found}")]
52    SchemaVersionMismatch { expected: u32, found: u32 },
53    #[error("deployment execution preflight is missing required field: {field}")]
54    MissingRequiredField { field: &'static str },
55    #[error(
56        "deployment execution preflight status {status:?} does not match blocker count {blocker_count}"
57    )]
58    StatusBlockerMismatch {
59        status: DeploymentExecutionPreflightStatusV1,
60        blocker_count: usize,
61    },
62    #[error(
63        "deployment execution preflight contains duplicate capability in {field}: {capability:?}"
64    )]
65    DuplicateCapability {
66        field: &'static str,
67        capability: DeploymentExecutorCapabilityV1,
68    },
69    #[error(
70        "deployment execution preflight reports missing capability that was not required: {capability:?}"
71    )]
72    MissingCapabilityNotRequired {
73        capability: DeploymentExecutorCapabilityV1,
74    },
75    #[error("deployment execution preflight missing capability has no blocker: {capability:?}")]
76    MissingCapabilityWithoutBlocker {
77        capability: DeploymentExecutorCapabilityV1,
78    },
79    #[error(
80        "deployment execution preflight {field} does not match source check: preflight={preflight_value}, check={check_value}"
81    )]
82    SourceCheckMismatch {
83        field: &'static str,
84        preflight_value: String,
85        check_value: String,
86    },
87}
88
89///
90/// DeploymentExecutor
91///
92pub trait DeploymentExecutor {
93    fn execution_context(&self) -> DeploymentExecutionContextV1;
94}
95
96///
97/// CurrentCliDeploymentExecutor
98///
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct CurrentCliDeploymentExecutor {
101    context: DeploymentExecutionContextV1,
102}
103
104///
105/// TestkitPreflightContext
106///
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct TestkitPreflightContext {
109    context: DeploymentExecutionContextV1,
110}
111
112impl CurrentCliDeploymentExecutor {
113    #[must_use]
114    pub fn new(
115        workspace_root: Option<String>,
116        icp_root: Option<String>,
117        artifact_roots: Vec<String>,
118    ) -> Self {
119        Self {
120            context: current_cli_execution_context(workspace_root, icp_root, artifact_roots),
121        }
122    }
123}
124
125impl TestkitPreflightContext {
126    #[must_use]
127    pub fn new(artifact_roots: Vec<String>) -> Self {
128        Self {
129            context: testkit_execution_context(artifact_roots),
130        }
131    }
132}
133
134impl DeploymentExecutor for CurrentCliDeploymentExecutor {
135    fn execution_context(&self) -> DeploymentExecutionContextV1 {
136        self.context.clone()
137    }
138}
139
140impl DeploymentExecutor for TestkitPreflightContext {
141    fn execution_context(&self) -> DeploymentExecutionContextV1 {
142        self.context.clone()
143    }
144}
145
146pub const CURRENT_CLI_EXECUTOR_CAPABILITIES: &[DeploymentExecutorCapabilityV1] = &[
147    DeploymentExecutorCapabilityV1::CreateCanister,
148    DeploymentExecutorCapabilityV1::CanisterStatus,
149    DeploymentExecutorCapabilityV1::UpdateSettings,
150    DeploymentExecutorCapabilityV1::InstallCode,
151    DeploymentExecutorCapabilityV1::Call,
152    DeploymentExecutorCapabilityV1::Query,
153    DeploymentExecutorCapabilityV1::StageArtifact,
154];
155
156pub const TESTKIT_PREFLIGHT_CAPABILITIES: &[DeploymentExecutorCapabilityV1] =
157    CURRENT_CLI_EXECUTOR_CAPABILITIES;
158
159#[derive(Clone, Copy, Debug, Eq, PartialEq)]
160struct CurrentInstallExecutionPhaseLabel(&'static str);
161
162impl CurrentInstallExecutionPhaseLabel {
163    const BUILD_ARTIFACTS: Self = Self("build_artifacts");
164    const EMIT_MANIFEST: Self = Self("emit_manifest");
165    const EXECUTION_PREFLIGHT: Self = Self("execution_preflight");
166    const FUND_ROOT_POST_READY: Self = Self("fund_root_post_ready");
167    const FUND_ROOT_PRE_BOOTSTRAP: Self = Self("fund_root_pre_bootstrap");
168    const INSTALL_ROOT: Self = Self("install_root");
169    const MATERIALIZE_ARTIFACTS: Self = Self("materialize_artifacts");
170    const RESOLVE_ROOT_CANISTER: Self = Self("resolve_root_canister");
171    const RESUME_BOOTSTRAP: Self = Self("resume_bootstrap");
172    const STAGE_RELEASE_SET: Self = Self("stage_release_set");
173    const WAIT_READY: Self = Self("wait_ready");
174    const WRITE_INSTALL_STATE: Self = Self("write_install_state");
175
176    #[must_use]
177    const fn as_str(self) -> &'static str {
178        self.0
179    }
180}
181
182#[derive(Clone, Copy, Debug, Eq, PartialEq)]
183struct DeploymentExecutionPreflightFieldLabel(&'static str);
184
185impl DeploymentExecutionPreflightFieldLabel {
186    const AUTHORITY_PLAN_ID: Self = Self("authority_plan_id");
187    const MISSING_CAPABILITIES: Self = Self("missing_capabilities");
188    const PLAN_ID: Self = Self("plan_id");
189    const REQUIRED_CAPABILITIES: Self = Self("required_capabilities");
190    const SAFETY_REPORT_ID: Self = Self("safety_report_id");
191
192    #[must_use]
193    const fn as_str(self) -> &'static str {
194        self.0
195    }
196}
197
198const CURRENT_INSTALL_EXECUTION_PHASES: &[CurrentInstallExecutionPhaseLabel] = &[
199    CurrentInstallExecutionPhaseLabel::RESOLVE_ROOT_CANISTER,
200    CurrentInstallExecutionPhaseLabel::BUILD_ARTIFACTS,
201    CurrentInstallExecutionPhaseLabel::MATERIALIZE_ARTIFACTS,
202    CurrentInstallExecutionPhaseLabel::EXECUTION_PREFLIGHT,
203    CurrentInstallExecutionPhaseLabel::EMIT_MANIFEST,
204    CurrentInstallExecutionPhaseLabel::INSTALL_ROOT,
205    CurrentInstallExecutionPhaseLabel::FUND_ROOT_PRE_BOOTSTRAP,
206    CurrentInstallExecutionPhaseLabel::STAGE_RELEASE_SET,
207    CurrentInstallExecutionPhaseLabel::RESUME_BOOTSTRAP,
208    CurrentInstallExecutionPhaseLabel::WAIT_READY,
209    CurrentInstallExecutionPhaseLabel::FUND_ROOT_POST_READY,
210    CurrentInstallExecutionPhaseLabel::WRITE_INSTALL_STATE,
211];
212
213#[must_use]
214pub fn current_cli_execution_context(
215    workspace_root: Option<String>,
216    icp_root: Option<String>,
217    artifact_roots: Vec<String>,
218) -> DeploymentExecutionContextV1 {
219    DeploymentExecutionContextV1 {
220        workspace_root,
221        icp_root,
222        artifact_roots,
223        backend: DeploymentExecutorBackendV1::CurrentCli,
224        backend_capabilities: CURRENT_CLI_EXECUTOR_CAPABILITIES.to_vec(),
225    }
226}
227
228#[must_use]
229pub fn testkit_execution_context(artifact_roots: Vec<String>) -> DeploymentExecutionContextV1 {
230    DeploymentExecutionContextV1 {
231        workspace_root: None,
232        icp_root: None,
233        artifact_roots,
234        backend: DeploymentExecutorBackendV1::PocketIc,
235        backend_capabilities: TESTKIT_PREFLIGHT_CAPABILITIES.to_vec(),
236    }
237}
238
239#[must_use]
240pub fn missing_executor_capabilities(
241    available: &[DeploymentExecutorCapabilityV1],
242    required: &[DeploymentExecutorCapabilityV1],
243) -> Vec<DeploymentExecutorCapabilityV1> {
244    let available = available.iter().copied().collect::<BTreeSet<_>>();
245    required
246        .iter()
247        .copied()
248        .filter(|capability| !available.contains(capability))
249        .collect()
250}
251
252#[must_use]
253pub fn has_executor_capabilities(
254    available: &[DeploymentExecutorCapabilityV1],
255    required: &[DeploymentExecutorCapabilityV1],
256) -> bool {
257    missing_executor_capabilities(available, required).is_empty()
258}
259
260#[must_use]
261pub fn deployment_execution_preflight_from_check(
262    check: &DeploymentCheckV1,
263    executor: &impl DeploymentExecutor,
264    required_capabilities: &[DeploymentExecutorCapabilityV1],
265) -> DeploymentExecutionPreflightV1 {
266    let authority_plan = build_authority_reconciliation_plan(check);
267    deployment_execution_preflight_with_unknown_authority_policy(
268        &check.plan,
269        &check.report,
270        &authority_plan,
271        executor,
272        required_capabilities,
273        allow_initial_install_unknown_authority(check),
274    )
275}
276
277#[must_use]
278pub fn deployment_execution_preflight(
279    plan: &DeploymentPlanV1,
280    safety_report: &SafetyReportV1,
281    authority_plan: &AuthorityReconciliationPlanV1,
282    executor: &impl DeploymentExecutor,
283    required_capabilities: &[DeploymentExecutorCapabilityV1],
284) -> DeploymentExecutionPreflightV1 {
285    deployment_execution_preflight_with_unknown_authority_policy(
286        plan,
287        safety_report,
288        authority_plan,
289        executor,
290        required_capabilities,
291        false,
292    )
293}
294
295fn deployment_execution_preflight_with_unknown_authority_policy(
296    plan: &DeploymentPlanV1,
297    safety_report: &SafetyReportV1,
298    authority_plan: &AuthorityReconciliationPlanV1,
299    executor: &impl DeploymentExecutor,
300    required_capabilities: &[DeploymentExecutorCapabilityV1],
301    allow_unknown_authority: bool,
302) -> DeploymentExecutionPreflightV1 {
303    let execution_context = executor.execution_context();
304    let missing_capabilities = missing_executor_capabilities(
305        &execution_context.backend_capabilities,
306        required_capabilities,
307    );
308    let blockers = deployment_execution_blockers(
309        safety_report,
310        authority_plan,
311        &missing_capabilities,
312        allow_unknown_authority,
313    );
314    let status = if blockers.is_empty() {
315        DeploymentExecutionPreflightStatusV1::Ready
316    } else {
317        DeploymentExecutionPreflightStatusV1::Blocked
318    };
319
320    DeploymentExecutionPreflightV1 {
321        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
322        plan_id: plan.plan_id.clone(),
323        safety_report_id: safety_report.report_id.clone(),
324        authority_plan_id: authority_plan.plan_id.clone(),
325        backend: execution_context.backend,
326        status,
327        planned_phases: CURRENT_INSTALL_EXECUTION_PHASES
328            .iter()
329            .map(|phase| phase.as_str().to_string())
330            .collect(),
331        required_capabilities: required_capabilities.to_vec(),
332        missing_capabilities,
333        blockers,
334    }
335}
336
337pub fn validate_deployment_execution_preflight(
338    preflight: &DeploymentExecutionPreflightV1,
339) -> Result<(), DeploymentExecutionPreflightError> {
340    if preflight.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
341        return Err(DeploymentExecutionPreflightError::SchemaVersionMismatch {
342            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
343            found: preflight.schema_version,
344        });
345    }
346
347    ensure_preflight_field(
348        DeploymentExecutionPreflightFieldLabel::PLAN_ID,
349        &preflight.plan_id,
350    )?;
351    ensure_preflight_field(
352        DeploymentExecutionPreflightFieldLabel::SAFETY_REPORT_ID,
353        &preflight.safety_report_id,
354    )?;
355    ensure_preflight_field(
356        DeploymentExecutionPreflightFieldLabel::AUTHORITY_PLAN_ID,
357        &preflight.authority_plan_id,
358    )?;
359    ensure_preflight_status_matches_blockers(preflight)?;
360    ensure_unique_capabilities(
361        DeploymentExecutionPreflightFieldLabel::REQUIRED_CAPABILITIES,
362        &preflight.required_capabilities,
363    )?;
364    ensure_unique_capabilities(
365        DeploymentExecutionPreflightFieldLabel::MISSING_CAPABILITIES,
366        &preflight.missing_capabilities,
367    )?;
368    ensure_missing_capabilities_are_required(preflight)?;
369    ensure_missing_capabilities_have_blockers(preflight)?;
370
371    Ok(())
372}
373
374pub fn validate_deployment_execution_preflight_for_check(
375    check: &DeploymentCheckV1,
376    preflight: &DeploymentExecutionPreflightV1,
377) -> Result<(), DeploymentExecutionPreflightError> {
378    validate_deployment_execution_preflight(preflight)?;
379    ensure_preflight_check_match(
380        DeploymentExecutionPreflightFieldLabel::PLAN_ID,
381        &preflight.plan_id,
382        &check.plan.plan_id,
383    )?;
384    ensure_preflight_check_match(
385        DeploymentExecutionPreflightFieldLabel::SAFETY_REPORT_ID,
386        &preflight.safety_report_id,
387        &check.report.report_id,
388    )?;
389
390    let authority_plan = build_authority_reconciliation_plan(check);
391    ensure_preflight_check_match(
392        DeploymentExecutionPreflightFieldLabel::AUTHORITY_PLAN_ID,
393        &preflight.authority_plan_id,
394        &authority_plan.plan_id,
395    )?;
396
397    Ok(())
398}
399
400fn deployment_execution_blockers(
401    safety_report: &SafetyReportV1,
402    authority_plan: &AuthorityReconciliationPlanV1,
403    missing_capabilities: &[DeploymentExecutorCapabilityV1],
404    allow_unknown_authority: bool,
405) -> Vec<SafetyFindingV1> {
406    let mut blockers = Vec::new();
407
408    if matches!(safety_report.status, SafetyStatusV1::Blocked) {
409        blockers.push(SafetyFindingV1 {
410            code: DEPLOYMENT_SAFETY_BLOCKED_CODE.to_string(),
411            message: safety_report.summary.clone(),
412            severity: SafetySeverityV1::HardFailure,
413            subject: Some(safety_report.report_id.clone()),
414        });
415    }
416    blockers.extend(safety_report.hard_failures.clone());
417    blockers.extend(
418        authority_plan
419            .hard_failures
420            .iter()
421            .filter(|failure| failure.code != AUTHORITY_UNSAFE_BLOCKED_CODE)
422            .cloned(),
423    );
424
425    for action in &authority_plan.canister_actions {
426        match action.state {
427            AuthorityReconciliationStateV1::AlreadyCorrect => {}
428            AuthorityReconciliationStateV1::CanApplyAutomatically => {
429                blockers.push(SafetyFindingV1 {
430                    code:
431                        DeploymentExecutionPreflightBlockerCode::AUTHORITY_CONTROLLER_CHANGE_PENDING
432                            .as_str()
433                            .to_string(),
434                    message: action.reason.clone(),
435                    severity: SafetySeverityV1::HardFailure,
436                    subject: Some(authority_blocker_subject(action)),
437                });
438            }
439            AuthorityReconciliationStateV1::RequiresExternalAction => {
440                blockers.push(SafetyFindingV1 {
441                    code:
442                        DeploymentExecutionPreflightBlockerCode::AUTHORITY_EXTERNAL_ACTION_REQUIRED
443                            .as_str()
444                            .to_string(),
445                    message: action.reason.clone(),
446                    severity: SafetySeverityV1::HardFailure,
447                    subject: Some(authority_blocker_subject(action)),
448                });
449            }
450            AuthorityReconciliationStateV1::UnsafeBlocked => {
451                blockers.push(SafetyFindingV1 {
452                    code: AUTHORITY_UNSAFE_BLOCKED_CODE.to_string(),
453                    message: action.reason.clone(),
454                    severity: SafetySeverityV1::HardFailure,
455                    subject: Some(authority_blocker_subject(action)),
456                });
457            }
458            AuthorityReconciliationStateV1::Unknown => {
459                if allow_unknown_authority {
460                    continue;
461                }
462                blockers.push(SafetyFindingV1 {
463                    code: DeploymentExecutionPreflightBlockerCode::AUTHORITY_OBSERVATION_MISSING
464                        .as_str()
465                        .to_string(),
466                    message: action.reason.clone(),
467                    severity: SafetySeverityV1::HardFailure,
468                    subject: Some(authority_blocker_subject(action)),
469                });
470            }
471        }
472    }
473
474    for capability in missing_capabilities {
475        blockers.push(SafetyFindingV1 {
476            code: DeploymentExecutionPreflightBlockerCode::EXECUTOR_CAPABILITY_MISSING
477                .as_str()
478                .to_string(),
479            message: format!("executor backend is missing required capability: {capability:?}"),
480            severity: SafetySeverityV1::HardFailure,
481            subject: Some(format!("{capability:?}")),
482        });
483    }
484
485    blockers
486}
487
488fn authority_blocker_subject(action: &CanisterAuthorityActionV1) -> String {
489    action
490        .canister_id
491        .clone()
492        .or_else(|| action.role.clone())
493        .unwrap_or_else(|| {
494            DeploymentExecutionPreflightSubjectLabel::AUTHORITY
495                .as_str()
496                .to_string()
497        })
498}
499
500fn allow_initial_install_unknown_authority(check: &DeploymentCheckV1) -> bool {
501    check
502        .plan
503        .unresolved_assumptions
504        .iter()
505        .any(|assumption| assumption.has_kind(DeploymentAssumptionKindV1::LocalStateMissing))
506}
507
508fn ensure_preflight_field(
509    field: DeploymentExecutionPreflightFieldLabel,
510    value: &str,
511) -> Result<(), DeploymentExecutionPreflightError> {
512    if value.trim().is_empty() {
513        return Err(DeploymentExecutionPreflightError::MissingRequiredField {
514            field: field.as_str(),
515        });
516    }
517    Ok(())
518}
519
520const fn ensure_preflight_status_matches_blockers(
521    preflight: &DeploymentExecutionPreflightV1,
522) -> Result<(), DeploymentExecutionPreflightError> {
523    let blocker_count = preflight.blockers.len();
524    let matches_blockers = match preflight.status {
525        DeploymentExecutionPreflightStatusV1::Ready => blocker_count == 0,
526        DeploymentExecutionPreflightStatusV1::Blocked => blocker_count > 0,
527    };
528    if !matches_blockers {
529        return Err(DeploymentExecutionPreflightError::StatusBlockerMismatch {
530            status: preflight.status,
531            blocker_count,
532        });
533    }
534    Ok(())
535}
536
537fn ensure_unique_capabilities(
538    field: DeploymentExecutionPreflightFieldLabel,
539    capabilities: &[DeploymentExecutorCapabilityV1],
540) -> Result<(), DeploymentExecutionPreflightError> {
541    let mut seen = BTreeSet::new();
542    for capability in capabilities {
543        if !seen.insert(*capability) {
544            return Err(DeploymentExecutionPreflightError::DuplicateCapability {
545                field: field.as_str(),
546                capability: *capability,
547            });
548        }
549    }
550    Ok(())
551}
552
553fn ensure_missing_capabilities_are_required(
554    preflight: &DeploymentExecutionPreflightV1,
555) -> Result<(), DeploymentExecutionPreflightError> {
556    let required = preflight
557        .required_capabilities
558        .iter()
559        .copied()
560        .collect::<BTreeSet<_>>();
561    for capability in &preflight.missing_capabilities {
562        if !required.contains(capability) {
563            return Err(
564                DeploymentExecutionPreflightError::MissingCapabilityNotRequired {
565                    capability: *capability,
566                },
567            );
568        }
569    }
570    Ok(())
571}
572
573fn ensure_missing_capabilities_have_blockers(
574    preflight: &DeploymentExecutionPreflightV1,
575) -> Result<(), DeploymentExecutionPreflightError> {
576    for capability in &preflight.missing_capabilities {
577        let subject = format!("{capability:?}");
578        if !preflight.blockers.iter().any(|finding| {
579            finding.code == EXECUTOR_CAPABILITY_MISSING_CODE
580                && finding.subject.as_deref() == Some(subject.as_str())
581        }) {
582            return Err(
583                DeploymentExecutionPreflightError::MissingCapabilityWithoutBlocker {
584                    capability: *capability,
585                },
586            );
587        }
588    }
589    Ok(())
590}
591
592fn ensure_preflight_check_match(
593    field: DeploymentExecutionPreflightFieldLabel,
594    preflight_value: &str,
595    check_value: &str,
596) -> Result<(), DeploymentExecutionPreflightError> {
597    if preflight_value != check_value {
598        return Err(DeploymentExecutionPreflightError::SourceCheckMismatch {
599            field: field.as_str(),
600            preflight_value: preflight_value.to_string(),
601            check_value: check_value.to_string(),
602        });
603    }
604    Ok(())
605}