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