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