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#[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
89pub trait DeploymentExecutor {
93 fn execution_context(&self) -> DeploymentExecutionContextV1;
94}
95
96#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct CurrentCliDeploymentExecutor {
101 context: DeploymentExecutionContextV1,
102}
103
104#[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}