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