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