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
69///
70/// TestkitPreflightContext
71///
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct TestkitPreflightContext {
74    context: DeploymentExecutionContextV1,
75}
76
77impl CurrentCliDeploymentExecutor {
78    #[must_use]
79    pub fn new(
80        workspace_root: Option<String>,
81        icp_root: Option<String>,
82        artifact_roots: Vec<String>,
83    ) -> Self {
84        Self {
85            context: current_cli_execution_context(workspace_root, icp_root, artifact_roots),
86        }
87    }
88}
89
90impl TestkitPreflightContext {
91    #[must_use]
92    pub fn new(artifact_roots: Vec<String>) -> Self {
93        Self {
94            context: testkit_execution_context(artifact_roots),
95        }
96    }
97}
98
99impl DeploymentExecutor for CurrentCliDeploymentExecutor {
100    fn execution_context(&self) -> DeploymentExecutionContextV1 {
101        self.context.clone()
102    }
103}
104
105impl DeploymentExecutor for TestkitPreflightContext {
106    fn execution_context(&self) -> DeploymentExecutionContextV1 {
107        self.context.clone()
108    }
109}
110
111pub const CURRENT_CLI_EXECUTOR_CAPABILITIES: &[DeploymentExecutorCapabilityV1] = &[
112    DeploymentExecutorCapabilityV1::CreateCanister,
113    DeploymentExecutorCapabilityV1::CanisterStatus,
114    DeploymentExecutorCapabilityV1::UpdateSettings,
115    DeploymentExecutorCapabilityV1::InstallCode,
116    DeploymentExecutorCapabilityV1::Call,
117    DeploymentExecutorCapabilityV1::Query,
118    DeploymentExecutorCapabilityV1::StageArtifact,
119];
120
121pub const TESTKIT_PREFLIGHT_CAPABILITIES: &[DeploymentExecutorCapabilityV1] =
122    CURRENT_CLI_EXECUTOR_CAPABILITIES;
123
124pub const CURRENT_INSTALL_EXECUTION_PHASES: &[&str] = &[
125    "resolve_root_canister",
126    "build_artifacts",
127    "materialize_artifacts",
128    "execution_preflight",
129    "emit_manifest",
130    "install_root",
131    "fund_root_pre_bootstrap",
132    "stage_release_set",
133    "resume_bootstrap",
134    "wait_ready",
135    "fund_root_post_ready",
136    "write_install_state",
137];
138
139#[must_use]
140pub fn current_cli_execution_context(
141    workspace_root: Option<String>,
142    icp_root: Option<String>,
143    artifact_roots: Vec<String>,
144) -> DeploymentExecutionContextV1 {
145    DeploymentExecutionContextV1 {
146        workspace_root,
147        icp_root,
148        artifact_roots,
149        backend: DeploymentExecutorBackendV1::CurrentCli,
150        backend_capabilities: CURRENT_CLI_EXECUTOR_CAPABILITIES.to_vec(),
151    }
152}
153
154#[must_use]
155pub fn testkit_execution_context(artifact_roots: Vec<String>) -> DeploymentExecutionContextV1 {
156    DeploymentExecutionContextV1 {
157        workspace_root: None,
158        icp_root: None,
159        artifact_roots,
160        backend: DeploymentExecutorBackendV1::PocketIc,
161        backend_capabilities: TESTKIT_PREFLIGHT_CAPABILITIES.to_vec(),
162    }
163}
164
165#[must_use]
166pub fn missing_executor_capabilities(
167    available: &[DeploymentExecutorCapabilityV1],
168    required: &[DeploymentExecutorCapabilityV1],
169) -> Vec<DeploymentExecutorCapabilityV1> {
170    let available = available.iter().copied().collect::<BTreeSet<_>>();
171    required
172        .iter()
173        .copied()
174        .filter(|capability| !available.contains(capability))
175        .collect()
176}
177
178#[must_use]
179pub fn has_executor_capabilities(
180    available: &[DeploymentExecutorCapabilityV1],
181    required: &[DeploymentExecutorCapabilityV1],
182) -> bool {
183    missing_executor_capabilities(available, required).is_empty()
184}
185
186#[must_use]
187pub fn deployment_execution_preflight_from_check(
188    check: &DeploymentCheckV1,
189    executor: &impl DeploymentExecutor,
190    required_capabilities: &[DeploymentExecutorCapabilityV1],
191) -> DeploymentExecutionPreflightV1 {
192    let authority_plan = build_authority_reconciliation_plan(check);
193    deployment_execution_preflight(
194        &check.plan,
195        &check.report,
196        &authority_plan,
197        executor,
198        required_capabilities,
199    )
200}
201
202#[must_use]
203pub fn deployment_execution_preflight(
204    plan: &DeploymentPlanV1,
205    safety_report: &SafetyReportV1,
206    authority_plan: &AuthorityReconciliationPlanV1,
207    executor: &impl DeploymentExecutor,
208    required_capabilities: &[DeploymentExecutorCapabilityV1],
209) -> DeploymentExecutionPreflightV1 {
210    let execution_context = executor.execution_context();
211    let missing_capabilities = missing_executor_capabilities(
212        &execution_context.backend_capabilities,
213        required_capabilities,
214    );
215    let blockers =
216        deployment_execution_blockers(safety_report, authority_plan, &missing_capabilities);
217    let status = if blockers.is_empty() {
218        DeploymentExecutionPreflightStatusV1::Ready
219    } else {
220        DeploymentExecutionPreflightStatusV1::Blocked
221    };
222
223    DeploymentExecutionPreflightV1 {
224        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
225        plan_id: plan.plan_id.clone(),
226        safety_report_id: safety_report.report_id.clone(),
227        authority_plan_id: authority_plan.plan_id.clone(),
228        backend: execution_context.backend,
229        status,
230        planned_phases: CURRENT_INSTALL_EXECUTION_PHASES
231            .iter()
232            .map(|phase| (*phase).to_string())
233            .collect(),
234        required_capabilities: required_capabilities.to_vec(),
235        missing_capabilities,
236        blockers,
237    }
238}
239
240pub fn validate_deployment_execution_preflight(
241    preflight: &DeploymentExecutionPreflightV1,
242) -> Result<(), DeploymentExecutionPreflightError> {
243    if preflight.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
244        return Err(DeploymentExecutionPreflightError::SchemaVersionMismatch {
245            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
246            found: preflight.schema_version,
247        });
248    }
249
250    ensure_preflight_field("plan_id", &preflight.plan_id)?;
251    ensure_preflight_field("safety_report_id", &preflight.safety_report_id)?;
252    ensure_preflight_field("authority_plan_id", &preflight.authority_plan_id)?;
253    ensure_preflight_status_matches_blockers(preflight)?;
254    ensure_unique_capabilities("required_capabilities", &preflight.required_capabilities)?;
255    ensure_unique_capabilities("missing_capabilities", &preflight.missing_capabilities)?;
256    ensure_missing_capabilities_are_required(preflight)?;
257    ensure_missing_capabilities_have_blockers(preflight)?;
258
259    Ok(())
260}
261
262pub fn validate_deployment_execution_preflight_for_check(
263    check: &DeploymentCheckV1,
264    preflight: &DeploymentExecutionPreflightV1,
265) -> Result<(), DeploymentExecutionPreflightError> {
266    validate_deployment_execution_preflight(preflight)?;
267    ensure_preflight_check_match("plan_id", &preflight.plan_id, &check.plan.plan_id)?;
268    ensure_preflight_check_match(
269        "safety_report_id",
270        &preflight.safety_report_id,
271        &check.report.report_id,
272    )?;
273
274    let authority_plan = build_authority_reconciliation_plan(check);
275    ensure_preflight_check_match(
276        "authority_plan_id",
277        &preflight.authority_plan_id,
278        &authority_plan.plan_id,
279    )?;
280
281    Ok(())
282}
283
284fn deployment_execution_blockers(
285    safety_report: &SafetyReportV1,
286    authority_plan: &AuthorityReconciliationPlanV1,
287    missing_capabilities: &[DeploymentExecutorCapabilityV1],
288) -> Vec<SafetyFindingV1> {
289    let mut blockers = Vec::new();
290
291    if matches!(safety_report.status, SafetyStatusV1::Blocked) {
292        blockers.push(SafetyFindingV1 {
293            code: "deployment_safety_blocked".to_string(),
294            message: safety_report.summary.clone(),
295            severity: SafetySeverityV1::HardFailure,
296            subject: Some(safety_report.report_id.clone()),
297        });
298    }
299    blockers.extend(safety_report.hard_failures.clone());
300    blockers.extend(
301        authority_plan
302            .hard_failures
303            .iter()
304            .filter(|failure| failure.code != "authority_unsafe_blocked")
305            .cloned(),
306    );
307
308    for action in &authority_plan.canister_actions {
309        match action.state {
310            AuthorityReconciliationStateV1::AlreadyCorrect => {}
311            AuthorityReconciliationStateV1::CanApplyAutomatically => {
312                blockers.push(SafetyFindingV1 {
313                    code: "authority_controller_change_pending".to_string(),
314                    message: action.reason.clone(),
315                    severity: SafetySeverityV1::HardFailure,
316                    subject: action
317                        .canister_id
318                        .clone()
319                        .or_else(|| action.role.clone())
320                        .or_else(|| Some("authority".to_string())),
321                });
322            }
323            AuthorityReconciliationStateV1::RequiresExternalAction => {
324                blockers.push(SafetyFindingV1 {
325                    code: "authority_external_action_required".to_string(),
326                    message: action.reason.clone(),
327                    severity: SafetySeverityV1::HardFailure,
328                    subject: action
329                        .canister_id
330                        .clone()
331                        .or_else(|| action.role.clone())
332                        .or_else(|| Some("authority".to_string())),
333                });
334            }
335            AuthorityReconciliationStateV1::UnsafeBlocked => {
336                blockers.push(SafetyFindingV1 {
337                    code: "authority_unsafe_blocked".to_string(),
338                    message: action.reason.clone(),
339                    severity: SafetySeverityV1::HardFailure,
340                    subject: action
341                        .canister_id
342                        .clone()
343                        .or_else(|| action.role.clone())
344                        .or_else(|| Some("authority".to_string())),
345                });
346            }
347            AuthorityReconciliationStateV1::Unknown => {
348                blockers.push(SafetyFindingV1 {
349                    code: "authority_observation_missing".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        }
360    }
361
362    for capability in missing_capabilities {
363        blockers.push(SafetyFindingV1 {
364            code: "executor_capability_missing".to_string(),
365            message: format!("executor backend is missing required capability: {capability:?}"),
366            severity: SafetySeverityV1::HardFailure,
367            subject: Some(format!("{capability:?}")),
368        });
369    }
370
371    blockers
372}
373
374fn ensure_preflight_field(
375    field: &'static str,
376    value: &str,
377) -> Result<(), DeploymentExecutionPreflightError> {
378    if value.trim().is_empty() {
379        return Err(DeploymentExecutionPreflightError::MissingRequiredField { field });
380    }
381    Ok(())
382}
383
384const fn ensure_preflight_status_matches_blockers(
385    preflight: &DeploymentExecutionPreflightV1,
386) -> Result<(), DeploymentExecutionPreflightError> {
387    let blocker_count = preflight.blockers.len();
388    let matches_blockers = match preflight.status {
389        DeploymentExecutionPreflightStatusV1::Ready => blocker_count == 0,
390        DeploymentExecutionPreflightStatusV1::Blocked => blocker_count > 0,
391    };
392    if !matches_blockers {
393        return Err(DeploymentExecutionPreflightError::StatusBlockerMismatch {
394            status: preflight.status,
395            blocker_count,
396        });
397    }
398    Ok(())
399}
400
401fn ensure_unique_capabilities(
402    field: &'static str,
403    capabilities: &[DeploymentExecutorCapabilityV1],
404) -> Result<(), DeploymentExecutionPreflightError> {
405    let mut seen = BTreeSet::new();
406    for capability in capabilities {
407        if !seen.insert(*capability) {
408            return Err(DeploymentExecutionPreflightError::DuplicateCapability {
409                field,
410                capability: *capability,
411            });
412        }
413    }
414    Ok(())
415}
416
417fn ensure_missing_capabilities_are_required(
418    preflight: &DeploymentExecutionPreflightV1,
419) -> Result<(), DeploymentExecutionPreflightError> {
420    let required = preflight
421        .required_capabilities
422        .iter()
423        .copied()
424        .collect::<BTreeSet<_>>();
425    for capability in &preflight.missing_capabilities {
426        if !required.contains(capability) {
427            return Err(
428                DeploymentExecutionPreflightError::MissingCapabilityNotRequired {
429                    capability: *capability,
430                },
431            );
432        }
433    }
434    Ok(())
435}
436
437fn ensure_missing_capabilities_have_blockers(
438    preflight: &DeploymentExecutionPreflightV1,
439) -> Result<(), DeploymentExecutionPreflightError> {
440    for capability in &preflight.missing_capabilities {
441        let subject = format!("{capability:?}");
442        if !preflight.blockers.iter().any(|finding| {
443            finding.code == "executor_capability_missing"
444                && finding.subject.as_deref() == Some(subject.as_str())
445        }) {
446            return Err(
447                DeploymentExecutionPreflightError::MissingCapabilityWithoutBlocker {
448                    capability: *capability,
449                },
450            );
451        }
452    }
453    Ok(())
454}
455
456fn ensure_preflight_check_match(
457    field: &'static str,
458    preflight_value: &str,
459    check_value: &str,
460) -> Result<(), DeploymentExecutionPreflightError> {
461    if preflight_value != check_value {
462        return Err(DeploymentExecutionPreflightError::SourceCheckMismatch {
463            field,
464            preflight_value: preflight_value.to_string(),
465            check_value: check_value.to_string(),
466        });
467    }
468    Ok(())
469}