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(
194 &check.plan,
195 &check.report,
196 &authority_plan,
197 executor,
198 required_capabilities,
199 )
200}
201
202#[must_use]
203pub fn deployment_execution_preflight(
204 plan: &DeploymentPlanV1,
205 safety_report: &SafetyReportV1,
206 authority_plan: &AuthorityReconciliationPlanV1,
207 executor: &impl DeploymentExecutor,
208 required_capabilities: &[DeploymentExecutorCapabilityV1],
209) -> DeploymentExecutionPreflightV1 {
210 let execution_context = executor.execution_context();
211 let missing_capabilities = missing_executor_capabilities(
212 &execution_context.backend_capabilities,
213 required_capabilities,
214 );
215 let blockers =
216 deployment_execution_blockers(safety_report, authority_plan, &missing_capabilities);
217 let status = if blockers.is_empty() {
218 DeploymentExecutionPreflightStatusV1::Ready
219 } else {
220 DeploymentExecutionPreflightStatusV1::Blocked
221 };
222
223 DeploymentExecutionPreflightV1 {
224 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
225 plan_id: plan.plan_id.clone(),
226 safety_report_id: safety_report.report_id.clone(),
227 authority_plan_id: authority_plan.plan_id.clone(),
228 backend: execution_context.backend,
229 status,
230 planned_phases: CURRENT_INSTALL_EXECUTION_PHASES
231 .iter()
232 .map(|phase| (*phase).to_string())
233 .collect(),
234 required_capabilities: required_capabilities.to_vec(),
235 missing_capabilities,
236 blockers,
237 }
238}
239
240pub fn validate_deployment_execution_preflight(
241 preflight: &DeploymentExecutionPreflightV1,
242) -> Result<(), DeploymentExecutionPreflightError> {
243 if preflight.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
244 return Err(DeploymentExecutionPreflightError::SchemaVersionMismatch {
245 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
246 found: preflight.schema_version,
247 });
248 }
249
250 ensure_preflight_field("plan_id", &preflight.plan_id)?;
251 ensure_preflight_field("safety_report_id", &preflight.safety_report_id)?;
252 ensure_preflight_field("authority_plan_id", &preflight.authority_plan_id)?;
253 ensure_preflight_status_matches_blockers(preflight)?;
254 ensure_unique_capabilities("required_capabilities", &preflight.required_capabilities)?;
255 ensure_unique_capabilities("missing_capabilities", &preflight.missing_capabilities)?;
256 ensure_missing_capabilities_are_required(preflight)?;
257 ensure_missing_capabilities_have_blockers(preflight)?;
258
259 Ok(())
260}
261
262pub fn validate_deployment_execution_preflight_for_check(
263 check: &DeploymentCheckV1,
264 preflight: &DeploymentExecutionPreflightV1,
265) -> Result<(), DeploymentExecutionPreflightError> {
266 validate_deployment_execution_preflight(preflight)?;
267 ensure_preflight_check_match("plan_id", &preflight.plan_id, &check.plan.plan_id)?;
268 ensure_preflight_check_match(
269 "safety_report_id",
270 &preflight.safety_report_id,
271 &check.report.report_id,
272 )?;
273
274 let authority_plan = build_authority_reconciliation_plan(check);
275 ensure_preflight_check_match(
276 "authority_plan_id",
277 &preflight.authority_plan_id,
278 &authority_plan.plan_id,
279 )?;
280
281 Ok(())
282}
283
284fn deployment_execution_blockers(
285 safety_report: &SafetyReportV1,
286 authority_plan: &AuthorityReconciliationPlanV1,
287 missing_capabilities: &[DeploymentExecutorCapabilityV1],
288) -> Vec<SafetyFindingV1> {
289 let mut blockers = Vec::new();
290
291 if matches!(safety_report.status, SafetyStatusV1::Blocked) {
292 blockers.push(SafetyFindingV1 {
293 code: "deployment_safety_blocked".to_string(),
294 message: safety_report.summary.clone(),
295 severity: SafetySeverityV1::HardFailure,
296 subject: Some(safety_report.report_id.clone()),
297 });
298 }
299 blockers.extend(safety_report.hard_failures.clone());
300 blockers.extend(
301 authority_plan
302 .hard_failures
303 .iter()
304 .filter(|failure| failure.code != "authority_unsafe_blocked")
305 .cloned(),
306 );
307
308 for action in &authority_plan.canister_actions {
309 match action.state {
310 AuthorityReconciliationStateV1::AlreadyCorrect => {}
311 AuthorityReconciliationStateV1::CanApplyAutomatically => {
312 blockers.push(SafetyFindingV1 {
313 code: "authority_controller_change_pending".to_string(),
314 message: action.reason.clone(),
315 severity: SafetySeverityV1::HardFailure,
316 subject: action
317 .canister_id
318 .clone()
319 .or_else(|| action.role.clone())
320 .or_else(|| Some("authority".to_string())),
321 });
322 }
323 AuthorityReconciliationStateV1::RequiresExternalAction => {
324 blockers.push(SafetyFindingV1 {
325 code: "authority_external_action_required".to_string(),
326 message: action.reason.clone(),
327 severity: SafetySeverityV1::HardFailure,
328 subject: action
329 .canister_id
330 .clone()
331 .or_else(|| action.role.clone())
332 .or_else(|| Some("authority".to_string())),
333 });
334 }
335 AuthorityReconciliationStateV1::UnsafeBlocked => {
336 blockers.push(SafetyFindingV1 {
337 code: "authority_unsafe_blocked".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::Unknown => {
348 blockers.push(SafetyFindingV1 {
349 code: "authority_observation_missing".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 }
360 }
361
362 for capability in missing_capabilities {
363 blockers.push(SafetyFindingV1 {
364 code: "executor_capability_missing".to_string(),
365 message: format!("executor backend is missing required capability: {capability:?}"),
366 severity: SafetySeverityV1::HardFailure,
367 subject: Some(format!("{capability:?}")),
368 });
369 }
370
371 blockers
372}
373
374fn ensure_preflight_field(
375 field: &'static str,
376 value: &str,
377) -> Result<(), DeploymentExecutionPreflightError> {
378 if value.trim().is_empty() {
379 return Err(DeploymentExecutionPreflightError::MissingRequiredField { field });
380 }
381 Ok(())
382}
383
384const fn ensure_preflight_status_matches_blockers(
385 preflight: &DeploymentExecutionPreflightV1,
386) -> Result<(), DeploymentExecutionPreflightError> {
387 let blocker_count = preflight.blockers.len();
388 let matches_blockers = match preflight.status {
389 DeploymentExecutionPreflightStatusV1::Ready => blocker_count == 0,
390 DeploymentExecutionPreflightStatusV1::Blocked => blocker_count > 0,
391 };
392 if !matches_blockers {
393 return Err(DeploymentExecutionPreflightError::StatusBlockerMismatch {
394 status: preflight.status,
395 blocker_count,
396 });
397 }
398 Ok(())
399}
400
401fn ensure_unique_capabilities(
402 field: &'static str,
403 capabilities: &[DeploymentExecutorCapabilityV1],
404) -> Result<(), DeploymentExecutionPreflightError> {
405 let mut seen = BTreeSet::new();
406 for capability in capabilities {
407 if !seen.insert(*capability) {
408 return Err(DeploymentExecutionPreflightError::DuplicateCapability {
409 field,
410 capability: *capability,
411 });
412 }
413 }
414 Ok(())
415}
416
417fn ensure_missing_capabilities_are_required(
418 preflight: &DeploymentExecutionPreflightV1,
419) -> Result<(), DeploymentExecutionPreflightError> {
420 let required = preflight
421 .required_capabilities
422 .iter()
423 .copied()
424 .collect::<BTreeSet<_>>();
425 for capability in &preflight.missing_capabilities {
426 if !required.contains(capability) {
427 return Err(
428 DeploymentExecutionPreflightError::MissingCapabilityNotRequired {
429 capability: *capability,
430 },
431 );
432 }
433 }
434 Ok(())
435}
436
437fn ensure_missing_capabilities_have_blockers(
438 preflight: &DeploymentExecutionPreflightV1,
439) -> Result<(), DeploymentExecutionPreflightError> {
440 for capability in &preflight.missing_capabilities {
441 let subject = format!("{capability:?}");
442 if !preflight.blockers.iter().any(|finding| {
443 finding.code == "executor_capability_missing"
444 && finding.subject.as_deref() == Some(subject.as_str())
445 }) {
446 return Err(
447 DeploymentExecutionPreflightError::MissingCapabilityWithoutBlocker {
448 capability: *capability,
449 },
450 );
451 }
452 }
453 Ok(())
454}
455
456fn ensure_preflight_check_match(
457 field: &'static str,
458 preflight_value: &str,
459 check_value: &str,
460) -> Result<(), DeploymentExecutionPreflightError> {
461 if preflight_value != check_value {
462 return Err(DeploymentExecutionPreflightError::SourceCheckMismatch {
463 field,
464 preflight_value: preflight_value.to_string(),
465 check_value: check_value.to_string(),
466 });
467 }
468 Ok(())
469}