Skip to main content

canic_host/deployment_truth/
executor.rs

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