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
133const CURRENT_INSTALL_EXECUTION_PHASES: &[&str] = &[
134    "resolve_root_canister",
135    "build_artifacts",
136    "materialize_artifacts",
137    "execution_preflight",
138    "emit_manifest",
139    "install_root",
140    "fund_root_pre_bootstrap",
141    "stage_release_set",
142    "resume_bootstrap",
143    "wait_ready",
144    "fund_root_post_ready",
145    "write_install_state",
146];
147
148#[must_use]
149pub fn current_cli_execution_context(
150    workspace_root: Option<String>,
151    icp_root: Option<String>,
152    artifact_roots: Vec<String>,
153) -> DeploymentExecutionContextV1 {
154    DeploymentExecutionContextV1 {
155        workspace_root,
156        icp_root,
157        artifact_roots,
158        backend: DeploymentExecutorBackendV1::CurrentCli,
159        backend_capabilities: CURRENT_CLI_EXECUTOR_CAPABILITIES.to_vec(),
160    }
161}
162
163#[must_use]
164pub fn testkit_execution_context(artifact_roots: Vec<String>) -> DeploymentExecutionContextV1 {
165    DeploymentExecutionContextV1 {
166        workspace_root: None,
167        icp_root: None,
168        artifact_roots,
169        backend: DeploymentExecutorBackendV1::PocketIc,
170        backend_capabilities: TESTKIT_PREFLIGHT_CAPABILITIES.to_vec(),
171    }
172}
173
174#[must_use]
175pub fn missing_executor_capabilities(
176    available: &[DeploymentExecutorCapabilityV1],
177    required: &[DeploymentExecutorCapabilityV1],
178) -> Vec<DeploymentExecutorCapabilityV1> {
179    let available = available.iter().copied().collect::<BTreeSet<_>>();
180    required
181        .iter()
182        .copied()
183        .filter(|capability| !available.contains(capability))
184        .collect()
185}
186
187#[must_use]
188pub fn has_executor_capabilities(
189    available: &[DeploymentExecutorCapabilityV1],
190    required: &[DeploymentExecutorCapabilityV1],
191) -> bool {
192    missing_executor_capabilities(available, required).is_empty()
193}
194
195#[must_use]
196pub fn deployment_execution_preflight_from_check(
197    check: &DeploymentCheckV1,
198    executor: &impl DeploymentExecutor,
199    required_capabilities: &[DeploymentExecutorCapabilityV1],
200) -> DeploymentExecutionPreflightV1 {
201    let authority_plan = build_authority_reconciliation_plan(check);
202    deployment_execution_preflight_with_unknown_authority_policy(
203        &check.plan,
204        &check.report,
205        &authority_plan,
206        executor,
207        required_capabilities,
208        allow_initial_install_unknown_authority(check),
209    )
210}
211
212#[must_use]
213pub fn deployment_execution_preflight(
214    plan: &DeploymentPlanV1,
215    safety_report: &SafetyReportV1,
216    authority_plan: &AuthorityReconciliationPlanV1,
217    executor: &impl DeploymentExecutor,
218    required_capabilities: &[DeploymentExecutorCapabilityV1],
219) -> DeploymentExecutionPreflightV1 {
220    deployment_execution_preflight_with_unknown_authority_policy(
221        plan,
222        safety_report,
223        authority_plan,
224        executor,
225        required_capabilities,
226        false,
227    )
228}
229
230fn deployment_execution_preflight_with_unknown_authority_policy(
231    plan: &DeploymentPlanV1,
232    safety_report: &SafetyReportV1,
233    authority_plan: &AuthorityReconciliationPlanV1,
234    executor: &impl DeploymentExecutor,
235    required_capabilities: &[DeploymentExecutorCapabilityV1],
236    allow_unknown_authority: bool,
237) -> DeploymentExecutionPreflightV1 {
238    let execution_context = executor.execution_context();
239    let missing_capabilities = missing_executor_capabilities(
240        &execution_context.backend_capabilities,
241        required_capabilities,
242    );
243    let blockers = deployment_execution_blockers(
244        safety_report,
245        authority_plan,
246        &missing_capabilities,
247        allow_unknown_authority,
248    );
249    let status = if blockers.is_empty() {
250        DeploymentExecutionPreflightStatusV1::Ready
251    } else {
252        DeploymentExecutionPreflightStatusV1::Blocked
253    };
254
255    DeploymentExecutionPreflightV1 {
256        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
257        plan_id: plan.plan_id.clone(),
258        safety_report_id: safety_report.report_id.clone(),
259        authority_plan_id: authority_plan.plan_id.clone(),
260        backend: execution_context.backend,
261        status,
262        planned_phases: CURRENT_INSTALL_EXECUTION_PHASES
263            .iter()
264            .map(|phase| (*phase).to_string())
265            .collect(),
266        required_capabilities: required_capabilities.to_vec(),
267        missing_capabilities,
268        blockers,
269    }
270}
271
272pub fn validate_deployment_execution_preflight(
273    preflight: &DeploymentExecutionPreflightV1,
274) -> Result<(), DeploymentExecutionPreflightError> {
275    if preflight.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
276        return Err(DeploymentExecutionPreflightError::SchemaVersionMismatch {
277            expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
278            found: preflight.schema_version,
279        });
280    }
281
282    ensure_preflight_field("plan_id", &preflight.plan_id)?;
283    ensure_preflight_field("safety_report_id", &preflight.safety_report_id)?;
284    ensure_preflight_field("authority_plan_id", &preflight.authority_plan_id)?;
285    ensure_preflight_status_matches_blockers(preflight)?;
286    ensure_unique_capabilities("required_capabilities", &preflight.required_capabilities)?;
287    ensure_unique_capabilities("missing_capabilities", &preflight.missing_capabilities)?;
288    ensure_missing_capabilities_are_required(preflight)?;
289    ensure_missing_capabilities_have_blockers(preflight)?;
290
291    Ok(())
292}
293
294pub fn validate_deployment_execution_preflight_for_check(
295    check: &DeploymentCheckV1,
296    preflight: &DeploymentExecutionPreflightV1,
297) -> Result<(), DeploymentExecutionPreflightError> {
298    validate_deployment_execution_preflight(preflight)?;
299    ensure_preflight_check_match("plan_id", &preflight.plan_id, &check.plan.plan_id)?;
300    ensure_preflight_check_match(
301        "safety_report_id",
302        &preflight.safety_report_id,
303        &check.report.report_id,
304    )?;
305
306    let authority_plan = build_authority_reconciliation_plan(check);
307    ensure_preflight_check_match(
308        "authority_plan_id",
309        &preflight.authority_plan_id,
310        &authority_plan.plan_id,
311    )?;
312
313    Ok(())
314}
315
316fn deployment_execution_blockers(
317    safety_report: &SafetyReportV1,
318    authority_plan: &AuthorityReconciliationPlanV1,
319    missing_capabilities: &[DeploymentExecutorCapabilityV1],
320    allow_unknown_authority: bool,
321) -> Vec<SafetyFindingV1> {
322    let mut blockers = Vec::new();
323
324    if matches!(safety_report.status, SafetyStatusV1::Blocked) {
325        blockers.push(SafetyFindingV1 {
326            code: DEPLOYMENT_SAFETY_BLOCKED_CODE.to_string(),
327            message: safety_report.summary.clone(),
328            severity: SafetySeverityV1::HardFailure,
329            subject: Some(safety_report.report_id.clone()),
330        });
331    }
332    blockers.extend(safety_report.hard_failures.clone());
333    blockers.extend(
334        authority_plan
335            .hard_failures
336            .iter()
337            .filter(|failure| failure.code != AUTHORITY_UNSAFE_BLOCKED_CODE)
338            .cloned(),
339    );
340
341    for action in &authority_plan.canister_actions {
342        match action.state {
343            AuthorityReconciliationStateV1::AlreadyCorrect => {}
344            AuthorityReconciliationStateV1::CanApplyAutomatically => {
345                blockers.push(SafetyFindingV1 {
346                    code: AUTHORITY_CONTROLLER_CHANGE_PENDING_CODE.to_string(),
347                    message: action.reason.clone(),
348                    severity: SafetySeverityV1::HardFailure,
349                    subject: Some(authority_blocker_subject(action)),
350                });
351            }
352            AuthorityReconciliationStateV1::RequiresExternalAction => {
353                blockers.push(SafetyFindingV1 {
354                    code: AUTHORITY_EXTERNAL_ACTION_REQUIRED_CODE.to_string(),
355                    message: action.reason.clone(),
356                    severity: SafetySeverityV1::HardFailure,
357                    subject: Some(authority_blocker_subject(action)),
358                });
359            }
360            AuthorityReconciliationStateV1::UnsafeBlocked => {
361                blockers.push(SafetyFindingV1 {
362                    code: AUTHORITY_UNSAFE_BLOCKED_CODE.to_string(),
363                    message: action.reason.clone(),
364                    severity: SafetySeverityV1::HardFailure,
365                    subject: Some(authority_blocker_subject(action)),
366                });
367            }
368            AuthorityReconciliationStateV1::Unknown => {
369                if allow_unknown_authority {
370                    continue;
371                }
372                blockers.push(SafetyFindingV1 {
373                    code: AUTHORITY_OBSERVATION_MISSING_CODE.to_string(),
374                    message: action.reason.clone(),
375                    severity: SafetySeverityV1::HardFailure,
376                    subject: Some(authority_blocker_subject(action)),
377                });
378            }
379        }
380    }
381
382    for capability in missing_capabilities {
383        blockers.push(SafetyFindingV1 {
384            code: EXECUTOR_CAPABILITY_MISSING_CODE.to_string(),
385            message: format!("executor backend is missing required capability: {capability:?}"),
386            severity: SafetySeverityV1::HardFailure,
387            subject: Some(format!("{capability:?}")),
388        });
389    }
390
391    blockers
392}
393
394fn authority_blocker_subject(action: &CanisterAuthorityActionV1) -> String {
395    action
396        .canister_id
397        .clone()
398        .or_else(|| action.role.clone())
399        .unwrap_or_else(|| "authority".to_string())
400}
401
402fn allow_initial_install_unknown_authority(check: &DeploymentCheckV1) -> bool {
403    check.plan.unresolved_assumptions.iter().any(|assumption| {
404        assumption.key == "local_state.root_canister_id"
405            && assumption
406                .description
407                .contains("no local deployment state exists")
408    })
409}
410
411fn ensure_preflight_field(
412    field: &'static str,
413    value: &str,
414) -> Result<(), DeploymentExecutionPreflightError> {
415    if value.trim().is_empty() {
416        return Err(DeploymentExecutionPreflightError::MissingRequiredField { field });
417    }
418    Ok(())
419}
420
421const fn ensure_preflight_status_matches_blockers(
422    preflight: &DeploymentExecutionPreflightV1,
423) -> Result<(), DeploymentExecutionPreflightError> {
424    let blocker_count = preflight.blockers.len();
425    let matches_blockers = match preflight.status {
426        DeploymentExecutionPreflightStatusV1::Ready => blocker_count == 0,
427        DeploymentExecutionPreflightStatusV1::Blocked => blocker_count > 0,
428    };
429    if !matches_blockers {
430        return Err(DeploymentExecutionPreflightError::StatusBlockerMismatch {
431            status: preflight.status,
432            blocker_count,
433        });
434    }
435    Ok(())
436}
437
438fn ensure_unique_capabilities(
439    field: &'static str,
440    capabilities: &[DeploymentExecutorCapabilityV1],
441) -> Result<(), DeploymentExecutionPreflightError> {
442    let mut seen = BTreeSet::new();
443    for capability in capabilities {
444        if !seen.insert(*capability) {
445            return Err(DeploymentExecutionPreflightError::DuplicateCapability {
446                field,
447                capability: *capability,
448            });
449        }
450    }
451    Ok(())
452}
453
454fn ensure_missing_capabilities_are_required(
455    preflight: &DeploymentExecutionPreflightV1,
456) -> Result<(), DeploymentExecutionPreflightError> {
457    let required = preflight
458        .required_capabilities
459        .iter()
460        .copied()
461        .collect::<BTreeSet<_>>();
462    for capability in &preflight.missing_capabilities {
463        if !required.contains(capability) {
464            return Err(
465                DeploymentExecutionPreflightError::MissingCapabilityNotRequired {
466                    capability: *capability,
467                },
468            );
469        }
470    }
471    Ok(())
472}
473
474fn ensure_missing_capabilities_have_blockers(
475    preflight: &DeploymentExecutionPreflightV1,
476) -> Result<(), DeploymentExecutionPreflightError> {
477    for capability in &preflight.missing_capabilities {
478        let subject = format!("{capability:?}");
479        if !preflight.blockers.iter().any(|finding| {
480            finding.code == EXECUTOR_CAPABILITY_MISSING_CODE
481                && finding.subject.as_deref() == Some(subject.as_str())
482        }) {
483            return Err(
484                DeploymentExecutionPreflightError::MissingCapabilityWithoutBlocker {
485                    capability: *capability,
486                },
487            );
488        }
489    }
490    Ok(())
491}
492
493fn ensure_preflight_check_match(
494    field: &'static str,
495    preflight_value: &str,
496    check_value: &str,
497) -> Result<(), DeploymentExecutionPreflightError> {
498    if preflight_value != check_value {
499        return Err(DeploymentExecutionPreflightError::SourceCheckMismatch {
500            field,
501            preflight_value: preflight_value.to_string(),
502            check_value: check_value.to_string(),
503        });
504    }
505    Ok(())
506}