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_with_unknown_authority_policy(
194        &check.plan,
195        &check.report,
196        &authority_plan,
197        executor,
198        required_capabilities,
199        allow_initial_install_unknown_authority(check),
200    )
201}
202
203#[must_use]
204pub fn deployment_execution_preflight(
205    plan: &DeploymentPlanV1,
206    safety_report: &SafetyReportV1,
207    authority_plan: &AuthorityReconciliationPlanV1,
208    executor: &impl DeploymentExecutor,
209    required_capabilities: &[DeploymentExecutorCapabilityV1],
210) -> DeploymentExecutionPreflightV1 {
211    deployment_execution_preflight_with_unknown_authority_policy(
212        plan,
213        safety_report,
214        authority_plan,
215        executor,
216        required_capabilities,
217        false,
218    )
219}
220
221fn deployment_execution_preflight_with_unknown_authority_policy(
222    plan: &DeploymentPlanV1,
223    safety_report: &SafetyReportV1,
224    authority_plan: &AuthorityReconciliationPlanV1,
225    executor: &impl DeploymentExecutor,
226    required_capabilities: &[DeploymentExecutorCapabilityV1],
227    allow_unknown_authority: bool,
228) -> DeploymentExecutionPreflightV1 {
229    let execution_context = executor.execution_context();
230    let missing_capabilities = missing_executor_capabilities(
231        &execution_context.backend_capabilities,
232        required_capabilities,
233    );
234    let blockers = deployment_execution_blockers(
235        safety_report,
236        authority_plan,
237        &missing_capabilities,
238        allow_unknown_authority,
239    );
240    let status = if blockers.is_empty() {
241        DeploymentExecutionPreflightStatusV1::Ready
242    } else {
243        DeploymentExecutionPreflightStatusV1::Blocked
244    };
245
246    DeploymentExecutionPreflightV1 {
247        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
248        plan_id: plan.plan_id.clone(),
249        safety_report_id: safety_report.report_id.clone(),
250        authority_plan_id: authority_plan.plan_id.clone(),
251        backend: execution_context.backend,
252        status,
253        planned_phases: CURRENT_INSTALL_EXECUTION_PHASES
254            .iter()
255            .map(|phase| (*phase).to_string())
256            .collect(),
257        required_capabilities: required_capabilities.to_vec(),
258        missing_capabilities,
259        blockers,
260    }
261}
262
263pub fn validate_deployment_execution_preflight(
264    preflight: &DeploymentExecutionPreflightV1,
265) -> Result<(), DeploymentExecutionPreflightError> {
266    if preflight.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
267        return Err(DeploymentExecutionPreflightError::SchemaVersionMismatch {
268            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
269            found: preflight.schema_version,
270        });
271    }
272
273    ensure_preflight_field("plan_id", &preflight.plan_id)?;
274    ensure_preflight_field("safety_report_id", &preflight.safety_report_id)?;
275    ensure_preflight_field("authority_plan_id", &preflight.authority_plan_id)?;
276    ensure_preflight_status_matches_blockers(preflight)?;
277    ensure_unique_capabilities("required_capabilities", &preflight.required_capabilities)?;
278    ensure_unique_capabilities("missing_capabilities", &preflight.missing_capabilities)?;
279    ensure_missing_capabilities_are_required(preflight)?;
280    ensure_missing_capabilities_have_blockers(preflight)?;
281
282    Ok(())
283}
284
285pub fn validate_deployment_execution_preflight_for_check(
286    check: &DeploymentCheckV1,
287    preflight: &DeploymentExecutionPreflightV1,
288) -> Result<(), DeploymentExecutionPreflightError> {
289    validate_deployment_execution_preflight(preflight)?;
290    ensure_preflight_check_match("plan_id", &preflight.plan_id, &check.plan.plan_id)?;
291    ensure_preflight_check_match(
292        "safety_report_id",
293        &preflight.safety_report_id,
294        &check.report.report_id,
295    )?;
296
297    let authority_plan = build_authority_reconciliation_plan(check);
298    ensure_preflight_check_match(
299        "authority_plan_id",
300        &preflight.authority_plan_id,
301        &authority_plan.plan_id,
302    )?;
303
304    Ok(())
305}
306
307fn deployment_execution_blockers(
308    safety_report: &SafetyReportV1,
309    authority_plan: &AuthorityReconciliationPlanV1,
310    missing_capabilities: &[DeploymentExecutorCapabilityV1],
311    allow_unknown_authority: bool,
312) -> Vec<SafetyFindingV1> {
313    let mut blockers = Vec::new();
314
315    if matches!(safety_report.status, SafetyStatusV1::Blocked) {
316        blockers.push(SafetyFindingV1 {
317            code: "deployment_safety_blocked".to_string(),
318            message: safety_report.summary.clone(),
319            severity: SafetySeverityV1::HardFailure,
320            subject: Some(safety_report.report_id.clone()),
321        });
322    }
323    blockers.extend(safety_report.hard_failures.clone());
324    blockers.extend(
325        authority_plan
326            .hard_failures
327            .iter()
328            .filter(|failure| failure.code != "authority_unsafe_blocked")
329            .cloned(),
330    );
331
332    for action in &authority_plan.canister_actions {
333        match action.state {
334            AuthorityReconciliationStateV1::AlreadyCorrect => {}
335            AuthorityReconciliationStateV1::CanApplyAutomatically => {
336                blockers.push(SafetyFindingV1 {
337                    code: "authority_controller_change_pending".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::RequiresExternalAction => {
348                blockers.push(SafetyFindingV1 {
349                    code: "authority_external_action_required".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::UnsafeBlocked => {
360                blockers.push(SafetyFindingV1 {
361                    code: "authority_unsafe_blocked".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::Unknown => {
372                if allow_unknown_authority {
373                    continue;
374                }
375                blockers.push(SafetyFindingV1 {
376                    code: "authority_observation_missing".to_string(),
377                    message: action.reason.clone(),
378                    severity: SafetySeverityV1::HardFailure,
379                    subject: action
380                        .canister_id
381                        .clone()
382                        .or_else(|| action.role.clone())
383                        .or_else(|| Some("authority".to_string())),
384                });
385            }
386        }
387    }
388
389    for capability in missing_capabilities {
390        blockers.push(SafetyFindingV1 {
391            code: "executor_capability_missing".to_string(),
392            message: format!("executor backend is missing required capability: {capability:?}"),
393            severity: SafetySeverityV1::HardFailure,
394            subject: Some(format!("{capability:?}")),
395        });
396    }
397
398    blockers
399}
400
401fn allow_initial_install_unknown_authority(check: &DeploymentCheckV1) -> bool {
402    check.plan.unresolved_assumptions.iter().any(|assumption| {
403        assumption.key == "local_state.root_canister_id"
404            && assumption
405                .description
406                .contains("no local deployment state exists")
407    })
408}
409
410fn ensure_preflight_field(
411    field: &'static str,
412    value: &str,
413) -> Result<(), DeploymentExecutionPreflightError> {
414    if value.trim().is_empty() {
415        return Err(DeploymentExecutionPreflightError::MissingRequiredField { field });
416    }
417    Ok(())
418}
419
420const fn ensure_preflight_status_matches_blockers(
421    preflight: &DeploymentExecutionPreflightV1,
422) -> Result<(), DeploymentExecutionPreflightError> {
423    let blocker_count = preflight.blockers.len();
424    let matches_blockers = match preflight.status {
425        DeploymentExecutionPreflightStatusV1::Ready => blocker_count == 0,
426        DeploymentExecutionPreflightStatusV1::Blocked => blocker_count > 0,
427    };
428    if !matches_blockers {
429        return Err(DeploymentExecutionPreflightError::StatusBlockerMismatch {
430            status: preflight.status,
431            blocker_count,
432        });
433    }
434    Ok(())
435}
436
437fn ensure_unique_capabilities(
438    field: &'static str,
439    capabilities: &[DeploymentExecutorCapabilityV1],
440) -> Result<(), DeploymentExecutionPreflightError> {
441    let mut seen = BTreeSet::new();
442    for capability in capabilities {
443        if !seen.insert(*capability) {
444            return Err(DeploymentExecutionPreflightError::DuplicateCapability {
445                field,
446                capability: *capability,
447            });
448        }
449    }
450    Ok(())
451}
452
453fn ensure_missing_capabilities_are_required(
454    preflight: &DeploymentExecutionPreflightV1,
455) -> Result<(), DeploymentExecutionPreflightError> {
456    let required = preflight
457        .required_capabilities
458        .iter()
459        .copied()
460        .collect::<BTreeSet<_>>();
461    for capability in &preflight.missing_capabilities {
462        if !required.contains(capability) {
463            return Err(
464                DeploymentExecutionPreflightError::MissingCapabilityNotRequired {
465                    capability: *capability,
466                },
467            );
468        }
469    }
470    Ok(())
471}
472
473fn ensure_missing_capabilities_have_blockers(
474    preflight: &DeploymentExecutionPreflightV1,
475) -> Result<(), DeploymentExecutionPreflightError> {
476    for capability in &preflight.missing_capabilities {
477        let subject = format!("{capability:?}");
478        if !preflight.blockers.iter().any(|finding| {
479            finding.code == "executor_capability_missing"
480                && finding.subject.as_deref() == Some(subject.as_str())
481        }) {
482            return Err(
483                DeploymentExecutionPreflightError::MissingCapabilityWithoutBlocker {
484                    capability: *capability,
485                },
486            );
487        }
488    }
489    Ok(())
490}
491
492fn ensure_preflight_check_match(
493    field: &'static str,
494    preflight_value: &str,
495    check_value: &str,
496) -> Result<(), DeploymentExecutionPreflightError> {
497    if preflight_value != check_value {
498        return Err(DeploymentExecutionPreflightError::SourceCheckMismatch {
499            field,
500            preflight_value: preflight_value.to_string(),
501            check_value: check_value.to_string(),
502        });
503    }
504    Ok(())
505}