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