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