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
69impl CurrentCliDeploymentExecutor {
70 #[must_use]
71 pub fn new(
72 workspace_root: Option<String>,
73 icp_root: Option<String>,
74 artifact_roots: Vec<String>,
75 ) -> Self {
76 Self {
77 context: current_cli_execution_context(workspace_root, icp_root, artifact_roots),
78 }
79 }
80}
81
82impl DeploymentExecutor for CurrentCliDeploymentExecutor {
83 fn execution_context(&self) -> DeploymentExecutionContextV1 {
84 self.context.clone()
85 }
86}
87
88pub const CURRENT_CLI_EXECUTOR_CAPABILITIES: &[DeploymentExecutorCapabilityV1] = &[
89 DeploymentExecutorCapabilityV1::CreateCanister,
90 DeploymentExecutorCapabilityV1::CanisterStatus,
91 DeploymentExecutorCapabilityV1::UpdateSettings,
92 DeploymentExecutorCapabilityV1::InstallCode,
93 DeploymentExecutorCapabilityV1::Call,
94 DeploymentExecutorCapabilityV1::Query,
95 DeploymentExecutorCapabilityV1::StageArtifact,
96];
97
98pub const CURRENT_INSTALL_EXECUTION_PHASES: &[&str] = &[
99 "create_root",
100 "build_artifacts",
101 "materialize_artifacts",
102 "install_root",
103 "stage_release_set",
104 "resume_bootstrap",
105 "wait_ready",
106 "post_validate",
107];
108
109#[must_use]
110pub fn current_cli_execution_context(
111 workspace_root: Option<String>,
112 icp_root: Option<String>,
113 artifact_roots: Vec<String>,
114) -> DeploymentExecutionContextV1 {
115 DeploymentExecutionContextV1 {
116 workspace_root,
117 icp_root,
118 artifact_roots,
119 backend: DeploymentExecutorBackendV1::CurrentCli,
120 backend_capabilities: CURRENT_CLI_EXECUTOR_CAPABILITIES.to_vec(),
121 }
122}
123
124#[must_use]
125pub fn missing_executor_capabilities(
126 available: &[DeploymentExecutorCapabilityV1],
127 required: &[DeploymentExecutorCapabilityV1],
128) -> Vec<DeploymentExecutorCapabilityV1> {
129 let available = available.iter().copied().collect::<BTreeSet<_>>();
130 required
131 .iter()
132 .copied()
133 .filter(|capability| !available.contains(capability))
134 .collect()
135}
136
137#[must_use]
138pub fn has_executor_capabilities(
139 available: &[DeploymentExecutorCapabilityV1],
140 required: &[DeploymentExecutorCapabilityV1],
141) -> bool {
142 missing_executor_capabilities(available, required).is_empty()
143}
144
145#[must_use]
146pub fn deployment_execution_preflight_from_check(
147 check: &DeploymentCheckV1,
148 executor: &impl DeploymentExecutor,
149 required_capabilities: &[DeploymentExecutorCapabilityV1],
150) -> DeploymentExecutionPreflightV1 {
151 let authority_plan = build_authority_reconciliation_plan(check);
152 deployment_execution_preflight(
153 &check.plan,
154 &check.report,
155 &authority_plan,
156 executor,
157 required_capabilities,
158 )
159}
160
161#[must_use]
162pub fn deployment_execution_preflight(
163 plan: &DeploymentPlanV1,
164 safety_report: &SafetyReportV1,
165 authority_plan: &AuthorityReconciliationPlanV1,
166 executor: &impl DeploymentExecutor,
167 required_capabilities: &[DeploymentExecutorCapabilityV1],
168) -> DeploymentExecutionPreflightV1 {
169 let execution_context = executor.execution_context();
170 let missing_capabilities = missing_executor_capabilities(
171 &execution_context.backend_capabilities,
172 required_capabilities,
173 );
174 let blockers =
175 deployment_execution_blockers(safety_report, authority_plan, &missing_capabilities);
176 let status = if blockers.is_empty() {
177 DeploymentExecutionPreflightStatusV1::Ready
178 } else {
179 DeploymentExecutionPreflightStatusV1::Blocked
180 };
181
182 DeploymentExecutionPreflightV1 {
183 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
184 plan_id: plan.plan_id.clone(),
185 safety_report_id: safety_report.report_id.clone(),
186 authority_plan_id: authority_plan.plan_id.clone(),
187 backend: execution_context.backend,
188 status,
189 planned_phases: CURRENT_INSTALL_EXECUTION_PHASES
190 .iter()
191 .map(|phase| (*phase).to_string())
192 .collect(),
193 required_capabilities: required_capabilities.to_vec(),
194 missing_capabilities,
195 blockers,
196 }
197}
198
199pub fn validate_deployment_execution_preflight(
200 preflight: &DeploymentExecutionPreflightV1,
201) -> Result<(), DeploymentExecutionPreflightError> {
202 if preflight.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
203 return Err(DeploymentExecutionPreflightError::SchemaVersionMismatch {
204 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
205 found: preflight.schema_version,
206 });
207 }
208
209 ensure_preflight_field("plan_id", &preflight.plan_id)?;
210 ensure_preflight_field("safety_report_id", &preflight.safety_report_id)?;
211 ensure_preflight_field("authority_plan_id", &preflight.authority_plan_id)?;
212 ensure_preflight_status_matches_blockers(preflight)?;
213 ensure_unique_capabilities("required_capabilities", &preflight.required_capabilities)?;
214 ensure_unique_capabilities("missing_capabilities", &preflight.missing_capabilities)?;
215 ensure_missing_capabilities_are_required(preflight)?;
216 ensure_missing_capabilities_have_blockers(preflight)?;
217
218 Ok(())
219}
220
221pub fn validate_deployment_execution_preflight_for_check(
222 check: &DeploymentCheckV1,
223 preflight: &DeploymentExecutionPreflightV1,
224) -> Result<(), DeploymentExecutionPreflightError> {
225 validate_deployment_execution_preflight(preflight)?;
226 ensure_preflight_check_match("plan_id", &preflight.plan_id, &check.plan.plan_id)?;
227 ensure_preflight_check_match(
228 "safety_report_id",
229 &preflight.safety_report_id,
230 &check.report.report_id,
231 )?;
232
233 let authority_plan = build_authority_reconciliation_plan(check);
234 ensure_preflight_check_match(
235 "authority_plan_id",
236 &preflight.authority_plan_id,
237 &authority_plan.plan_id,
238 )?;
239
240 Ok(())
241}
242
243fn deployment_execution_blockers(
244 safety_report: &SafetyReportV1,
245 authority_plan: &AuthorityReconciliationPlanV1,
246 missing_capabilities: &[DeploymentExecutorCapabilityV1],
247) -> Vec<SafetyFindingV1> {
248 let mut blockers = Vec::new();
249
250 if matches!(safety_report.status, SafetyStatusV1::Blocked) {
251 blockers.push(SafetyFindingV1 {
252 code: "deployment_safety_blocked".to_string(),
253 message: safety_report.summary.clone(),
254 severity: SafetySeverityV1::HardFailure,
255 subject: Some(safety_report.report_id.clone()),
256 });
257 }
258 blockers.extend(safety_report.hard_failures.clone());
259 blockers.extend(
260 authority_plan
261 .hard_failures
262 .iter()
263 .filter(|failure| failure.code != "authority_unsafe_blocked")
264 .cloned(),
265 );
266
267 for action in &authority_plan.canister_actions {
268 match action.state {
269 AuthorityReconciliationStateV1::AlreadyCorrect => {}
270 AuthorityReconciliationStateV1::CanApplyAutomatically => {
271 blockers.push(SafetyFindingV1 {
272 code: "authority_controller_change_pending".to_string(),
273 message: action.reason.clone(),
274 severity: SafetySeverityV1::HardFailure,
275 subject: action
276 .canister_id
277 .clone()
278 .or_else(|| action.role.clone())
279 .or_else(|| Some("authority".to_string())),
280 });
281 }
282 AuthorityReconciliationStateV1::RequiresExternalAction => {
283 blockers.push(SafetyFindingV1 {
284 code: "authority_external_action_required".to_string(),
285 message: action.reason.clone(),
286 severity: SafetySeverityV1::HardFailure,
287 subject: action
288 .canister_id
289 .clone()
290 .or_else(|| action.role.clone())
291 .or_else(|| Some("authority".to_string())),
292 });
293 }
294 AuthorityReconciliationStateV1::UnsafeBlocked => {
295 blockers.push(SafetyFindingV1 {
296 code: "authority_unsafe_blocked".to_string(),
297 message: action.reason.clone(),
298 severity: SafetySeverityV1::HardFailure,
299 subject: action
300 .canister_id
301 .clone()
302 .or_else(|| action.role.clone())
303 .or_else(|| Some("authority".to_string())),
304 });
305 }
306 AuthorityReconciliationStateV1::Unknown => {
307 blockers.push(SafetyFindingV1 {
308 code: "authority_observation_missing".to_string(),
309 message: action.reason.clone(),
310 severity: SafetySeverityV1::HardFailure,
311 subject: action
312 .canister_id
313 .clone()
314 .or_else(|| action.role.clone())
315 .or_else(|| Some("authority".to_string())),
316 });
317 }
318 }
319 }
320
321 for capability in missing_capabilities {
322 blockers.push(SafetyFindingV1 {
323 code: "executor_capability_missing".to_string(),
324 message: format!("executor backend is missing required capability: {capability:?}"),
325 severity: SafetySeverityV1::HardFailure,
326 subject: Some(format!("{capability:?}")),
327 });
328 }
329
330 blockers
331}
332
333fn ensure_preflight_field(
334 field: &'static str,
335 value: &str,
336) -> Result<(), DeploymentExecutionPreflightError> {
337 if value.trim().is_empty() {
338 return Err(DeploymentExecutionPreflightError::MissingRequiredField { field });
339 }
340 Ok(())
341}
342
343const fn ensure_preflight_status_matches_blockers(
344 preflight: &DeploymentExecutionPreflightV1,
345) -> Result<(), DeploymentExecutionPreflightError> {
346 let blocker_count = preflight.blockers.len();
347 let matches_blockers = match preflight.status {
348 DeploymentExecutionPreflightStatusV1::Ready => blocker_count == 0,
349 DeploymentExecutionPreflightStatusV1::Blocked => blocker_count > 0,
350 };
351 if !matches_blockers {
352 return Err(DeploymentExecutionPreflightError::StatusBlockerMismatch {
353 status: preflight.status,
354 blocker_count,
355 });
356 }
357 Ok(())
358}
359
360fn ensure_unique_capabilities(
361 field: &'static str,
362 capabilities: &[DeploymentExecutorCapabilityV1],
363) -> Result<(), DeploymentExecutionPreflightError> {
364 let mut seen = BTreeSet::new();
365 for capability in capabilities {
366 if !seen.insert(*capability) {
367 return Err(DeploymentExecutionPreflightError::DuplicateCapability {
368 field,
369 capability: *capability,
370 });
371 }
372 }
373 Ok(())
374}
375
376fn ensure_missing_capabilities_are_required(
377 preflight: &DeploymentExecutionPreflightV1,
378) -> Result<(), DeploymentExecutionPreflightError> {
379 let required = preflight
380 .required_capabilities
381 .iter()
382 .copied()
383 .collect::<BTreeSet<_>>();
384 for capability in &preflight.missing_capabilities {
385 if !required.contains(capability) {
386 return Err(
387 DeploymentExecutionPreflightError::MissingCapabilityNotRequired {
388 capability: *capability,
389 },
390 );
391 }
392 }
393 Ok(())
394}
395
396fn ensure_missing_capabilities_have_blockers(
397 preflight: &DeploymentExecutionPreflightV1,
398) -> Result<(), DeploymentExecutionPreflightError> {
399 for capability in &preflight.missing_capabilities {
400 let subject = format!("{capability:?}");
401 if !preflight.blockers.iter().any(|finding| {
402 finding.code == "executor_capability_missing"
403 && finding.subject.as_deref() == Some(subject.as_str())
404 }) {
405 return Err(
406 DeploymentExecutionPreflightError::MissingCapabilityWithoutBlocker {
407 capability: *capability,
408 },
409 );
410 }
411 }
412 Ok(())
413}
414
415fn ensure_preflight_check_match(
416 field: &'static str,
417 preflight_value: &str,
418 check_value: &str,
419) -> Result<(), DeploymentExecutionPreflightError> {
420 if preflight_value != check_value {
421 return Err(DeploymentExecutionPreflightError::SourceCheckMismatch {
422 field,
423 preflight_value: preflight_value.to_string(),
424 check_value: check_value.to_string(),
425 });
426 }
427 Ok(())
428}