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