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