Skip to main content

canic_host/deployment_truth/
executor.rs

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