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