1use crate::canister_build::{
2 CanisterBuildProfile, build_current_workspace_canister_artifact,
3 current_workspace_build_context_once,
4};
5use crate::deployment_truth::{
6 ArtifactPromotionExecutionReceiptRequest, ArtifactPromotionExecutionReceiptV1,
7 ArtifactPromotionPlanV1, ArtifactPromotionProvenanceReportRequest, ArtifactTransportV1,
8 CurrentCliDeploymentExecutor, DeploymentCheckV1, DeploymentCommandResultV1,
9 DeploymentExecutionContextV1, DeploymentExecutionPreflightV1, DeploymentExecutionStatusV1,
10 DeploymentExecutor, DeploymentExecutorCapabilityV1, DeploymentPlanV1, DeploymentReceiptV1,
11 LocalDeploymentCheckRequest, LocalInventoryRequest, ObservationStatusV1, SafetyFindingV1,
12 StagingReceiptV1, artifact_gate_phase_receipt, artifact_gate_role_phase_receipts,
13 artifact_promotion_execution_receipt, artifact_promotion_provenance_report,
14 check_local_deployment, collect_local_deployment_inventory, compare_plan_to_inventory,
15 deployment_execution_preflight_from_check, deployment_receipt_from_check_with_status,
16 missing_executor_capabilities, phase_receipt, safety_report_from_diff,
17 staging_receipt_evidence, validate_deployment_execution_preflight_for_check,
18};
19use crate::format::wasm_size_label;
20use crate::icp::{self, CANIC_ICP_LOCAL_NETWORK_URL_ENV, CANIC_ICP_LOCAL_ROOT_KEY_ENV};
21use crate::release_set::{
22 LOCAL_ROOT_MIN_READY_CYCLES, RootReleaseSetManifest, configured_fleet_name,
23 configured_install_targets, configured_local_root_create_cycles,
24 emit_root_release_set_manifest_with_config, icp_query_on_network, icp_root,
25 load_root_release_set_manifest, resolve_artifact_root, resume_root_bootstrap,
26 stage_root_release_set, workspace_root,
27};
28use crate::replica_query;
29use crate::response_parse::parse_cycle_balance_response;
30use crate::table::{ColumnAlign, render_separator, render_table, render_table_row, table_widths};
31use canic_core::cdk::utils::hash::wasm_hash_hex;
32use canic_core::{
33 CANIC_WASM_CHUNK_BYTES,
34 cdk::{types::Principal, utils::hash::wasm_hash},
35 protocol,
36};
37use config_selection::resolve_install_config_path;
38use serde_json::Value as JsonValue;
39use std::{
40 env,
41 ffi::OsString,
42 fs,
43 path::{Path, PathBuf},
44 process::Command,
45 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
46};
47
48mod config_selection;
49mod readiness;
50mod state;
51
52pub use config_selection::{
53 current_canic_project_root, discover_canic_config_choices, discover_canic_project_root_from,
54 discover_project_canic_config_choices, project_fleet_roots,
55};
56use readiness::wait_for_root_ready;
57use state::{
58 INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, validate_network_name, write_install_state,
59};
60pub use state::{
61 InstallState, RootVerificationStatus, read_named_fleet_install_state,
62 read_named_fleet_install_state_from_root,
63};
64
65#[cfg(test)]
66mod tests;
67
68#[cfg(test)]
69use config_selection::config_selection_error;
70#[cfg(test)]
71use readiness::{parse_bootstrap_status_value, parse_root_ready_value};
72#[cfg(test)]
73use state::{deployment_install_state_path, fleet_install_state_path, read_fleet_install_state};
74
75#[derive(Clone, Debug)]
80pub struct InstallRootOptions {
81 pub root_canister: String,
82 pub root_build_target: String,
83 pub network: String,
84 pub icp_root: Option<PathBuf>,
85 pub build_profile: Option<CanisterBuildProfile>,
86 pub ready_timeout_seconds: u64,
87 pub config_path: Option<String>,
88 pub expected_fleet: Option<String>,
89 pub interactive_config_selection: bool,
90 pub deployment_plan_override: Option<DeploymentPlanV1>,
91 pub artifact_promotion_plan_override: Option<ArtifactPromotionPlanV1>,
92}
93
94#[derive(Clone, Debug, Eq, PartialEq)]
99pub struct RegisterDeploymentStateOptions {
100 pub deployment_name: String,
101 pub fleet_template: String,
102 pub root_canister_id: String,
103 pub network: String,
104 pub icp_root: Option<PathBuf>,
105 pub workspace_root: Option<PathBuf>,
106}
107
108#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
113struct InstallTimingSummary {
114 create_canisters: Duration,
115 build_all: Duration,
116 emit_manifest: Duration,
117 install_root: Duration,
118 fund_root: Duration,
119 stage_release_set: Duration,
120 resume_bootstrap: Duration,
121 wait_ready: Duration,
122 finalize_root_funding: Duration,
123}
124
125const CURRENT_INSTALL_REQUIRED_CAPABILITIES: &[DeploymentExecutorCapabilityV1] = &[
126 DeploymentExecutorCapabilityV1::CreateCanister,
127 DeploymentExecutorCapabilityV1::InstallCode,
128 DeploymentExecutorCapabilityV1::Call,
129 DeploymentExecutorCapabilityV1::Query,
130 DeploymentExecutorCapabilityV1::StageArtifact,
131];
132
133pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
135 let project_root = current_canic_project_root()?;
136 let choices = config_selection::discover_workspace_canic_config_choices(&project_root)?;
137 if !choices.is_empty() {
138 return Ok(choices);
139 }
140
141 if let Ok(icp_root) = icp_root()
142 && icp_root != project_root
143 {
144 return config_selection::discover_workspace_canic_config_choices(&icp_root);
145 }
146
147 Ok(choices)
148}
149
150pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
152 let workspace_root = workspace_root()?;
153 let icp_root = match &options.icp_root {
154 Some(path) => path.canonicalize()?,
155 None => icp_root()?,
156 };
157 let config_path = resolve_install_config_path(
158 &icp_root,
159 options.config_path.as_deref(),
160 options.interactive_config_selection,
161 )?;
162 let _install_env = BuildEnvGuard::apply(&options.network, &config_path, &icp_root);
163 let fleet_name = configured_fleet_name(&config_path)?;
164 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
165 validate_fleet_name(&fleet_name)?;
166 let total_started_at = Instant::now();
167 let mut timings = InstallTimingSummary::default();
168 let network = options.network.as_str();
169 let execution_context = current_install_execution_context(&workspace_root, &icp_root, network);
170
171 println!("Installing fleet {fleet_name}");
172 println!();
173 let prepared = prepare_install_deployment_truth(
174 &options,
175 &workspace_root,
176 &icp_root,
177 &config_path,
178 &fleet_name,
179 &execution_context,
180 )?;
181 timings.create_canisters = prepared.timings.create_canisters;
182 timings.build_all = prepared.timings.build_all;
183
184 let (manifest_path, emit_manifest_duration) = emit_manifest_with_deployment_truth_receipt(
185 &workspace_root,
186 &icp_root,
187 &options,
188 &config_path,
189 &fleet_name,
190 &prepared.deployment_truth_check,
191 &execution_context,
192 )?;
193 timings.emit_manifest = emit_manifest_duration;
194 let activation_timings = run_root_activation_phases(
195 InstallReceiptScope {
196 icp_root: &icp_root,
197 network,
198 fleet_name: &fleet_name,
199 check: &prepared.deployment_truth_check,
200 execution_context: Some(&execution_context),
201 },
202 &options,
203 &prepared.root_canister_id,
204 &manifest_path,
205 total_started_at,
206 )?;
207 timings.install_root = activation_timings.install_root;
208 timings.fund_root = activation_timings.fund_root;
209 timings.stage_release_set = activation_timings.stage_release_set;
210 timings.resume_bootstrap = activation_timings.resume_bootstrap;
211 timings.wait_ready = activation_timings.wait_ready;
212 timings.finalize_root_funding = activation_timings.finalize_root_funding;
213
214 print_install_timing_summary(&timings, total_started_at.elapsed());
215 let state = build_install_state(
216 &options,
217 &workspace_root,
218 &icp_root,
219 &config_path,
220 &manifest_path,
221 &fleet_name,
222 &prepared.root_canister_id,
223 )?;
224 let state_path = write_install_state_with_deployment_truth_receipt(
225 InstallReceiptScope {
226 icp_root: &icp_root,
227 network,
228 fleet_name: &fleet_name,
229 check: &prepared.deployment_truth_check,
230 execution_context: Some(&execution_context),
231 },
232 &options.network,
233 &state,
234 )?;
235 write_artifact_promotion_execution_receipt_for_install(
236 &options,
237 &icp_root,
238 network,
239 &fleet_name,
240 &prepared.deployment_truth_check,
241 &execution_context,
242 )?;
243 print_install_result_summary(&options.network, &state.fleet, &state_path);
244 Ok(())
245}
246
247pub fn register_deployment_state(
253 options: RegisterDeploymentStateOptions,
254) -> Result<PathBuf, Box<dyn std::error::Error>> {
255 validate_fleet_name(&options.deployment_name)?;
256 validate_fleet_name(&options.fleet_template)?;
257 validate_network_name(&options.network)?;
258 Principal::from_text(&options.root_canister_id).map_err(|err| {
259 format!(
260 "invalid root principal for deployment {}: {err}",
261 options.deployment_name
262 )
263 })?;
264
265 let workspace_root = match options.workspace_root {
266 Some(path) => path,
267 None => workspace_root()?,
268 };
269 let icp_root = match options.icp_root {
270 Some(path) => path,
271 None => icp_root()?,
272 };
273 let artifact_root = resolve_artifact_root(&icp_root, &options.network).unwrap_or_else(|_| {
274 icp_root
275 .join(".icp")
276 .join(&options.network)
277 .join("canisters")
278 });
279 let release_set_manifest_path =
280 crate::release_set::root_release_set_manifest_path(&artifact_root)
281 .unwrap_or_else(|_| artifact_root.join("root").join("root.release-set.json"));
282 let state = InstallState {
283 schema_version: INSTALL_STATE_SCHEMA_VERSION,
284 deployment_name: options.deployment_name,
285 fleet_template: options.fleet_template.clone(),
286 fleet: options.fleet_template.clone(),
287 installed_at_unix_secs: current_unix_secs()?,
288 network: options.network.clone(),
289 root_target: options.root_canister_id.clone(),
290 root_canister_id: options.root_canister_id,
291 root_verification: RootVerificationStatus::NotVerified,
292 root_build_target: "root".to_string(),
293 workspace_root: workspace_root.display().to_string(),
294 icp_root: icp_root.display().to_string(),
295 config_path: workspace_root
296 .join("fleets")
297 .join(&options.fleet_template)
298 .join("canic.toml")
299 .display()
300 .to_string(),
301 release_set_manifest_path: release_set_manifest_path.display().to_string(),
302 };
303
304 write_install_state(&icp_root, &options.network, &state)
305}
306
307struct PreparedInstallTruth {
308 root_canister_id: String,
309 deployment_truth_check: DeploymentCheckV1,
310 timings: InstallTimingSummary,
311}
312
313struct CurrentInstallTruthInputs {
314 workspace_root: PathBuf,
315 icp_root: PathBuf,
316 config_path: PathBuf,
317 fleet_name: String,
318}
319
320fn prepare_install_deployment_truth(
321 options: &InstallRootOptions,
322 workspace_root: &Path,
323 icp_root: &Path,
324 config_path: &Path,
325 fleet_name: &str,
326 execution_context: &DeploymentExecutionContextV1,
327) -> Result<PreparedInstallTruth, Box<dyn std::error::Error>> {
328 let mut timings = InstallTimingSummary::default();
329 ensure_current_install_executor_capabilities(execution_context)?;
330 ensure_icp_environment_ready(icp_root, &options.network)?;
331 let (root_canister_id, create_phase, create_duration) =
332 resolve_root_canister_with_phase(options, icp_root, config_path)?;
333 timings.create_canisters = create_duration;
334
335 let (build_phase, build_duration) =
336 build_install_targets_with_phase(options, icp_root, config_path)?;
337 timings.build_all = build_duration;
338
339 let deployment_truth_check = run_install_deployment_truth_safety_gate(
340 options,
341 workspace_root,
342 icp_root,
343 config_path,
344 fleet_name,
345 execution_context,
346 )?;
347 let receipt_scope = InstallReceiptScope {
348 icp_root,
349 network: &options.network,
350 fleet_name,
351 check: &deployment_truth_check,
352 execution_context: Some(execution_context),
353 };
354 write_completed_install_phase_receipt(receipt_scope, create_phase)?;
355 write_completed_install_phase_receipt(receipt_scope, build_phase)?;
356
357 Ok(PreparedInstallTruth {
358 root_canister_id,
359 deployment_truth_check,
360 timings,
361 })
362}
363
364fn resolve_root_canister_with_phase(
365 options: &InstallRootOptions,
366 icp_root: &Path,
367 config_path: &Path,
368) -> Result<(String, CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
369 let operation = ResolveRootCanisterOperation::new(
370 icp_root,
371 &options.network,
372 &options.root_canister,
373 config_path,
374 );
375 let started_at = current_unix_timestamp_label()?;
376 let started = Instant::now();
377 let root_canister_id = operation.execute()?;
378 let duration = started.elapsed();
379 let phase = CompletedInstallPhase {
380 phase: "resolve_root_canister",
381 attempted_action: "resolve or create root canister id",
382 started_at,
383 finished_at: Some(current_unix_timestamp_label()?),
384 evidence: operation.evidence(&root_canister_id),
385 role_names: Vec::new(),
386 };
387 Ok((root_canister_id, phase, duration))
388}
389
390fn build_install_targets_with_phase(
391 options: &InstallRootOptions,
392 icp_root: &Path,
393 config_path: &Path,
394) -> Result<(CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
395 if let Some(plan) = &options.deployment_plan_override {
396 return validate_plan_artifacts_with_phase(plan, icp_root, &options.network);
397 }
398
399 let build_targets = configured_install_targets(config_path, &options.root_build_target)?;
400 let operation = BuildInstallTargetsOperation::new(
401 &options.network,
402 build_targets,
403 options.build_profile,
404 config_path,
405 icp_root,
406 );
407 let started_at = current_unix_timestamp_label()?;
408 let started = Instant::now();
409 operation.execute()?;
410 let duration = started.elapsed();
411 let phase = CompletedInstallPhase {
412 phase: "build_artifacts",
413 attempted_action: "build configured install targets",
414 started_at,
415 finished_at: Some(current_unix_timestamp_label()?),
416 evidence: operation.evidence(),
417 role_names: operation.role_names(),
418 };
419 Ok((phase, duration))
420}
421
422fn validate_plan_artifacts_with_phase(
423 plan: &DeploymentPlanV1,
424 icp_root: &Path,
425 network: &str,
426) -> Result<(CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
427 let started_at = current_unix_timestamp_label()?;
428 let started = Instant::now();
429 validate_plan_artifact_paths(plan, icp_root, network)?;
430 let duration = started.elapsed();
431 let role_names = plan
432 .role_artifacts
433 .iter()
434 .map(|artifact| artifact.role.clone())
435 .collect::<Vec<_>>();
436 let phase = CompletedInstallPhase {
437 phase: "materialize_artifacts",
438 attempted_action: "validate supplied deployment plan artifacts",
439 started_at,
440 finished_at: Some(current_unix_timestamp_label()?),
441 evidence: vec![format!("deployment_plan:{}", plan.plan_id)],
442 role_names,
443 };
444 Ok((phase, duration))
445}
446
447fn validate_plan_artifact_paths(
448 plan: &DeploymentPlanV1,
449 icp_root: &Path,
450 network: &str,
451) -> Result<(), Box<dyn std::error::Error>> {
452 let artifact_root = resolve_artifact_root(icp_root, network)?;
453 let root_artifact = plan
454 .role_artifacts
455 .iter()
456 .find(|artifact| artifact.role == "root")
457 .ok_or_else(|| "deployment plan is missing root role artifact".to_string())?;
458 let root_wasm = plan_role_wasm_path(icp_root, &artifact_root, root_artifact);
459 if !root_wasm.is_file() {
460 return Err(format!(
461 "deployment plan root wasm artifact does not exist: {}",
462 root_wasm.display()
463 )
464 .into());
465 }
466
467 for artifact in plan_release_role_artifacts(plan) {
468 let wasm_gz = plan_role_wasm_gz_path(icp_root, &artifact_root, artifact);
469 if !wasm_gz.is_file() {
470 return Err(format!(
471 "deployment plan role {} wasm.gz artifact does not exist: {}",
472 artifact.role,
473 wasm_gz.display()
474 )
475 .into());
476 }
477 }
478 Ok(())
479}
480
481fn emit_manifest_with_deployment_truth_receipt(
482 workspace_root: &Path,
483 icp_root: &Path,
484 options: &InstallRootOptions,
485 config_path: &Path,
486 fleet_name: &str,
487 deployment_truth_check: &DeploymentCheckV1,
488 execution_context: &DeploymentExecutionContextV1,
489) -> Result<(PathBuf, Duration), Box<dyn std::error::Error>> {
490 let operation =
491 EmitRootManifestOperation::new(workspace_root, icp_root, &options.network, config_path);
492 let emit_manifest_started_at_label = current_unix_timestamp_label()?;
493 let emit_manifest_started_at = Instant::now();
494 let manifest_path = if let Some(plan) = &options.deployment_plan_override {
495 emit_root_release_set_manifest_from_plan(icp_root, &options.network, plan)?
496 } else {
497 operation.execute()?
498 };
499 let emit_manifest_duration = emit_manifest_started_at.elapsed();
500 let emit_manifest_receipt = receipt_with_execution_context(
501 install_deployment_truth_phase_receipt(
502 deployment_truth_check,
503 "emit_manifest",
504 emit_manifest_started_at_label,
505 Some(current_unix_timestamp_label()?),
506 "emit root release-set manifest",
507 crate::deployment_truth::ObservationStatusV1::Observed,
508 EmitRootManifestOperation::evidence(&manifest_path),
509 ),
510 execution_context,
511 );
512 let emit_manifest_receipt_path = write_install_deployment_truth_receipt(
513 icp_root,
514 &options.network,
515 fleet_name,
516 &emit_manifest_receipt,
517 )?;
518 println!(
519 "Deployment truth receipt JSON: {}",
520 emit_manifest_receipt_path.display()
521 );
522 Ok((manifest_path, emit_manifest_duration))
523}
524
525fn emit_root_release_set_manifest_from_plan(
526 icp_root: &Path,
527 network: &str,
528 plan: &DeploymentPlanV1,
529) -> Result<PathBuf, Box<dyn std::error::Error>> {
530 let artifact_root = resolve_artifact_root(icp_root, network)?;
531 let manifest_path = crate::release_set::root_release_set_manifest_path(&artifact_root)?;
532 let entries = plan_release_role_artifacts(plan)
533 .map(|artifact| release_set_entry_from_plan_artifact(icp_root, &artifact_root, artifact))
534 .collect::<Result<Vec<_>, _>>()?;
535 let manifest = RootReleaseSetManifest {
536 release_version: plan
537 .deployment_identity
538 .canic_version
539 .clone()
540 .unwrap_or_else(|| plan.plan_id.clone()),
541 entries,
542 };
543
544 fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
545 Ok(manifest_path)
546}
547
548fn release_set_entry_from_plan_artifact(
549 icp_root: &Path,
550 artifact_root: &Path,
551 artifact: &crate::deployment_truth::RoleArtifactV1,
552) -> Result<crate::release_set::ReleaseSetEntry, Box<dyn std::error::Error>> {
553 let artifact_path = plan_role_wasm_gz_path(icp_root, artifact_root, artifact);
554 let artifact_relative_path = artifact_path
555 .strip_prefix(icp_root)
556 .map_err(|_| {
557 format!(
558 "deployment plan artifact {} is not under ICP root {}",
559 artifact_path.display(),
560 icp_root.display()
561 )
562 })?
563 .to_string_lossy()
564 .to_string();
565 let wasm_module = fs::read(&artifact_path)?;
566 let chunk_hashes = wasm_module
567 .chunks(CANIC_WASM_CHUNK_BYTES)
568 .map(wasm_hash_hex)
569 .collect::<Vec<_>>();
570
571 Ok(crate::release_set::ReleaseSetEntry {
572 role: artifact.role.clone(),
573 template_id: format!("embedded:{}", artifact.role),
574 artifact_relative_path,
575 payload_size_bytes: wasm_module.len() as u64,
576 payload_sha256_hex: wasm_hash_hex(&wasm_module),
577 chunk_size_bytes: CANIC_WASM_CHUNK_BYTES as u64,
578 chunk_sha256_hex: chunk_hashes,
579 })
580}
581
582fn plan_release_role_artifacts(
583 plan: &DeploymentPlanV1,
584) -> impl Iterator<Item = &crate::deployment_truth::RoleArtifactV1> {
585 plan.role_artifacts
586 .iter()
587 .filter(|artifact| !matches!(artifact.role.as_str(), "root" | "wasm_store"))
588}
589
590fn plan_role_wasm_path(
591 icp_root: &Path,
592 artifact_root: &Path,
593 artifact: &crate::deployment_truth::RoleArtifactV1,
594) -> PathBuf {
595 artifact.wasm_path.as_ref().map_or_else(
596 || {
597 artifact_root
598 .join(&artifact.role)
599 .join(format!("{}.wasm", artifact.role))
600 },
601 |path| plan_artifact_path(icp_root, path),
602 )
603}
604
605fn plan_role_wasm_gz_path(
606 icp_root: &Path,
607 artifact_root: &Path,
608 artifact: &crate::deployment_truth::RoleArtifactV1,
609) -> PathBuf {
610 artifact.wasm_gz_path.as_ref().map_or_else(
611 || {
612 artifact_root
613 .join(&artifact.role)
614 .join(format!("{}.wasm.gz", artifact.role))
615 },
616 |path| plan_artifact_path(icp_root, path),
617 )
618}
619
620fn plan_artifact_path(icp_root: &Path, path: &str) -> PathBuf {
621 let path = PathBuf::from(path);
622 if path.is_absolute() {
623 path
624 } else {
625 icp_root.join(path)
626 }
627}
628
629fn run_root_activation_phases(
630 receipt_scope: InstallReceiptScope<'_>,
631 options: &InstallRootOptions,
632 root_canister_id: &str,
633 manifest_path: &Path,
634 total_started_at: Instant,
635) -> Result<InstallTimingSummary, Box<dyn std::error::Error>> {
636 let mut timings = InstallTimingSummary::default();
637 let root_wasm = root_wasm_for_install_plan(
638 receipt_scope.icp_root,
639 receipt_scope.network,
640 &options.root_build_target,
641 options.deployment_plan_override.as_ref(),
642 )?;
643 let install_operation = InstallRootWasmOperation::new(
644 receipt_scope.icp_root,
645 receipt_scope.network,
646 root_canister_id,
647 root_wasm,
648 );
649 timings.install_root = receipt_scope.run_operation(&install_operation)?;
650 let pre_bootstrap_funding = EnsureRootCyclesOperation::new(
651 receipt_scope.icp_root,
652 receipt_scope.network,
653 root_canister_id,
654 "fund_root_pre_bootstrap",
655 "ensure local root minimum cycles before bootstrap",
656 "pre-bootstrap",
657 );
658 timings.fund_root = receipt_scope.run_operation(&pre_bootstrap_funding)?;
659 let manifest = load_root_release_set_manifest(manifest_path)?;
660 let stage_operation = StageReleaseSetOperation::new(
661 receipt_scope.icp_root,
662 receipt_scope.network,
663 root_canister_id,
664 manifest_path,
665 manifest,
666 );
667 timings.stage_release_set = receipt_scope.run_operation(&stage_operation)?;
668 let resume_operation = ResumeBootstrapOperation::new(receipt_scope.network, root_canister_id);
669 timings.resume_bootstrap = receipt_scope.run_operation(&resume_operation)?;
670 let wait_ready_operation = WaitRootReadyOperation::new(
671 receipt_scope.network,
672 root_canister_id,
673 options.ready_timeout_seconds,
674 );
675 let wait_ready_result = receipt_scope.run_operation(&wait_ready_operation);
676 match wait_ready_result {
677 Ok(duration) => timings.wait_ready = duration,
678 Err(err) => {
679 print_install_timing_summary(&timings, total_started_at.elapsed());
680 return Err(err);
681 }
682 }
683 let post_ready_funding = EnsureRootCyclesOperation::new(
684 receipt_scope.icp_root,
685 receipt_scope.network,
686 root_canister_id,
687 "fund_root_post_ready",
688 "ensure local root minimum cycles after ready",
689 "post-ready",
690 );
691 timings.finalize_root_funding = receipt_scope.run_operation(&post_ready_funding)?;
692 Ok(timings)
693}
694
695fn root_wasm_for_install_plan(
696 icp_root: &Path,
697 network: &str,
698 root_build_target: &str,
699 plan: Option<&DeploymentPlanV1>,
700) -> Result<PathBuf, Box<dyn std::error::Error>> {
701 let artifact_root = resolve_artifact_root(icp_root, network)?;
702 if let Some(plan) = plan {
703 let root_artifact = plan
704 .role_artifacts
705 .iter()
706 .find(|artifact| artifact.role == "root")
707 .ok_or_else(|| "deployment plan is missing root role artifact".to_string())?;
708 return Ok(plan_role_wasm_path(icp_root, &artifact_root, root_artifact));
709 }
710
711 Ok(artifact_root
712 .join(root_build_target)
713 .join(format!("{root_build_target}.wasm")))
714}
715
716#[derive(Clone, Copy)]
717struct InstallReceiptScope<'a> {
718 icp_root: &'a Path,
719 network: &'a str,
720 fleet_name: &'a str,
721 check: &'a DeploymentCheckV1,
722 execution_context: Option<&'a DeploymentExecutionContextV1>,
723}
724
725struct CompletedInstallPhase {
726 phase: &'static str,
727 attempted_action: &'static str,
728 started_at: String,
729 finished_at: Option<String>,
730 evidence: Vec<String>,
731 role_names: Vec<String>,
732}
733
734trait InstallPhaseOperation {
735 fn phase(&self) -> &'static str;
736 fn attempted_action(&self) -> &'static str;
737 fn evidence(&self) -> Vec<String>;
738 fn execute(&self) -> Result<(), Box<dyn std::error::Error>>;
739}
740
741struct ResolveRootCanisterOperation<'a> {
742 icp_root: &'a Path,
743 network: &'a str,
744 root_canister: &'a str,
745 config_path: &'a Path,
746}
747
748impl<'a> ResolveRootCanisterOperation<'a> {
749 const fn new(
750 icp_root: &'a Path,
751 network: &'a str,
752 root_canister: &'a str,
753 config_path: &'a Path,
754 ) -> Self {
755 Self {
756 icp_root,
757 network,
758 root_canister,
759 config_path,
760 }
761 }
762
763 fn evidence(&self, root_canister_id: &str) -> Vec<String> {
764 vec![
765 format!("root_target:{}", self.root_canister),
766 format!("root_canister:{root_canister_id}"),
767 ]
768 }
769
770 fn execute(&self) -> Result<String, Box<dyn std::error::Error>> {
771 ensure_root_canister_id(
772 self.icp_root,
773 self.network,
774 self.root_canister,
775 self.config_path,
776 )
777 }
778}
779
780struct BuildInstallTargetsOperation<'a> {
781 network: &'a str,
782 build_targets: Vec<String>,
783 build_profile: Option<CanisterBuildProfile>,
784 config_path: &'a Path,
785 icp_root: &'a Path,
786}
787
788impl<'a> BuildInstallTargetsOperation<'a> {
789 const fn new(
790 network: &'a str,
791 build_targets: Vec<String>,
792 build_profile: Option<CanisterBuildProfile>,
793 config_path: &'a Path,
794 icp_root: &'a Path,
795 ) -> Self {
796 Self {
797 network,
798 build_targets,
799 build_profile,
800 config_path,
801 icp_root,
802 }
803 }
804
805 fn evidence(&self) -> Vec<String> {
806 self.build_targets
807 .iter()
808 .map(|target| format!("build_target:{target}"))
809 .collect()
810 }
811
812 fn role_names(&self) -> Vec<String> {
813 self.build_targets.clone()
814 }
815
816 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
817 run_canic_build_targets(
818 self.network,
819 &self.build_targets,
820 self.build_profile,
821 self.config_path,
822 self.icp_root,
823 )
824 }
825}
826
827struct EmitRootManifestOperation<'a> {
828 workspace_root: &'a Path,
829 icp_root: &'a Path,
830 network: &'a str,
831 config_path: &'a Path,
832}
833
834impl<'a> EmitRootManifestOperation<'a> {
835 const fn new(
836 workspace_root: &'a Path,
837 icp_root: &'a Path,
838 network: &'a str,
839 config_path: &'a Path,
840 ) -> Self {
841 Self {
842 workspace_root,
843 icp_root,
844 network,
845 config_path,
846 }
847 }
848
849 fn evidence(manifest_path: &Path) -> Vec<String> {
850 vec![format!("manifest_path:{}", manifest_path.display())]
851 }
852
853 fn execute(&self) -> Result<PathBuf, Box<dyn std::error::Error>> {
854 emit_root_release_set_manifest_with_config(
855 self.workspace_root,
856 self.icp_root,
857 self.network,
858 self.config_path,
859 )
860 }
861}
862
863struct InstallRootWasmOperation<'a> {
864 icp_root: &'a Path,
865 network: &'a str,
866 root_canister_id: &'a str,
867 root_wasm: PathBuf,
868}
869
870impl<'a> InstallRootWasmOperation<'a> {
871 const fn new(
872 icp_root: &'a Path,
873 network: &'a str,
874 root_canister_id: &'a str,
875 root_wasm: PathBuf,
876 ) -> Self {
877 Self {
878 icp_root,
879 network,
880 root_canister_id,
881 root_wasm,
882 }
883 }
884}
885
886impl InstallPhaseOperation for InstallRootWasmOperation<'_> {
887 fn phase(&self) -> &'static str {
888 "install_root"
889 }
890
891 fn attempted_action(&self) -> &'static str {
892 "install root wasm"
893 }
894
895 fn evidence(&self) -> Vec<String> {
896 vec![
897 format!("root_canister:{}", self.root_canister_id),
898 format!("root_wasm:{}", self.root_wasm.display()),
899 ]
900 }
901
902 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
903 reinstall_root_wasm(
904 self.icp_root,
905 self.network,
906 self.root_canister_id,
907 &self.root_wasm,
908 )
909 }
910}
911
912struct EnsureRootCyclesOperation<'a> {
913 icp_root: &'a Path,
914 network: &'a str,
915 root_canister_id: &'a str,
916 phase: &'static str,
917 attempted_action: &'static str,
918 phase_label: &'a str,
919}
920
921impl<'a> EnsureRootCyclesOperation<'a> {
922 const fn new(
923 icp_root: &'a Path,
924 network: &'a str,
925 root_canister_id: &'a str,
926 phase: &'static str,
927 attempted_action: &'static str,
928 phase_label: &'a str,
929 ) -> Self {
930 Self {
931 icp_root,
932 network,
933 root_canister_id,
934 phase,
935 attempted_action,
936 phase_label,
937 }
938 }
939}
940
941impl InstallPhaseOperation for EnsureRootCyclesOperation<'_> {
942 fn phase(&self) -> &'static str {
943 self.phase
944 }
945
946 fn attempted_action(&self) -> &'static str {
947 self.attempted_action
948 }
949
950 fn evidence(&self) -> Vec<String> {
951 vec![
952 format!("root_canister:{}", self.root_canister_id),
953 format!("minimum_cycles:{LOCAL_ROOT_MIN_READY_CYCLES}"),
954 format!("funding_phase:{}", self.phase_label),
955 ]
956 }
957
958 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
959 ensure_local_root_min_cycles(
960 self.icp_root,
961 self.network,
962 self.root_canister_id,
963 self.phase_label,
964 )
965 }
966}
967
968struct ResumeBootstrapOperation<'a> {
969 network: &'a str,
970 root_canister_id: &'a str,
971}
972
973impl<'a> ResumeBootstrapOperation<'a> {
974 const fn new(network: &'a str, root_canister_id: &'a str) -> Self {
975 Self {
976 network,
977 root_canister_id,
978 }
979 }
980}
981
982impl InstallPhaseOperation for ResumeBootstrapOperation<'_> {
983 fn phase(&self) -> &'static str {
984 "resume_bootstrap"
985 }
986
987 fn attempted_action(&self) -> &'static str {
988 "resume root bootstrap"
989 }
990
991 fn evidence(&self) -> Vec<String> {
992 vec![format!("root_canister:{}", self.root_canister_id)]
993 }
994
995 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
996 resume_root_bootstrap(self.network, self.root_canister_id)
997 }
998}
999
1000struct WaitRootReadyOperation<'a> {
1001 network: &'a str,
1002 root_canister_id: &'a str,
1003 timeout_seconds: u64,
1004}
1005
1006impl<'a> WaitRootReadyOperation<'a> {
1007 const fn new(network: &'a str, root_canister_id: &'a str, timeout_seconds: u64) -> Self {
1008 Self {
1009 network,
1010 root_canister_id,
1011 timeout_seconds,
1012 }
1013 }
1014}
1015
1016impl InstallPhaseOperation for WaitRootReadyOperation<'_> {
1017 fn phase(&self) -> &'static str {
1018 "wait_ready"
1019 }
1020
1021 fn attempted_action(&self) -> &'static str {
1022 "wait for root bootstrap readiness"
1023 }
1024
1025 fn evidence(&self) -> Vec<String> {
1026 vec![
1027 format!("root_canister:{}", self.root_canister_id),
1028 format!("timeout_seconds:{}", self.timeout_seconds),
1029 ]
1030 }
1031
1032 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
1033 wait_for_root_ready(self.network, self.root_canister_id, self.timeout_seconds)
1034 }
1035}
1036
1037fn write_completed_install_phase_receipt(
1038 receipt_scope: InstallReceiptScope<'_>,
1039 completed: CompletedInstallPhase,
1040) -> Result<PathBuf, Box<dyn std::error::Error>> {
1041 let role_phase_receipts = completed
1042 .role_names
1043 .iter()
1044 .filter_map(|role| {
1045 completed_phase_role_receipt(
1046 receipt_scope.check,
1047 completed.phase,
1048 role,
1049 crate::deployment_truth::RolePhaseResultV1::Applied,
1050 None,
1051 )
1052 })
1053 .collect();
1054 let receipt =
1055 receipt_scope.with_execution_context(install_deployment_truth_phase_receipt_with_result(
1056 receipt_scope.check,
1057 PhaseReceiptInput {
1058 phase: completed.phase,
1059 started_at: completed.started_at,
1060 finished_at: completed.finished_at,
1061 attempted_action: completed.attempted_action,
1062 status: crate::deployment_truth::ObservationStatusV1::Observed,
1063 evidence: completed.evidence,
1064 role_phase_receipts,
1065 operation_status: DeploymentExecutionStatusV1::Complete,
1066 command_result: DeploymentCommandResultV1::Succeeded,
1067 },
1068 ));
1069 receipt_scope.write_receipt(&receipt)
1070}
1071
1072fn completed_phase_role_receipt(
1073 check: &DeploymentCheckV1,
1074 phase: &str,
1075 role: &str,
1076 result: crate::deployment_truth::RolePhaseResultV1,
1077 error: Option<String>,
1078) -> Option<crate::deployment_truth::RolePhaseReceiptV1> {
1079 let planned = check
1080 .plan
1081 .role_artifacts
1082 .iter()
1083 .find(|artifact| artifact.role == role)?;
1084 let observed = check
1085 .inventory
1086 .observed_artifacts
1087 .iter()
1088 .find(|artifact| artifact.role == role);
1089 let artifact_digest = observed
1090 .and_then(|artifact| artifact.file_sha256.clone())
1091 .or_else(|| observed.and_then(|artifact| artifact.payload_sha256.clone()))
1092 .or_else(|| planned.observed_wasm_gz_file_sha256.clone())
1093 .or_else(|| planned.wasm_gz_sha256.clone());
1094
1095 Some(crate::deployment_truth::RolePhaseReceiptV1 {
1096 role: role.to_string(),
1097 phase: phase.to_string(),
1098 result,
1099 previous_module_hash: None,
1100 target_module_hash: planned.installed_module_hash.clone(),
1101 observed_module_hash_after: None,
1102 artifact_digest,
1103 canonical_embedded_config_sha256: planned.canonical_embedded_config_sha256.clone(),
1104 error,
1105 })
1106}
1107
1108fn write_install_state_with_deployment_truth_receipt(
1109 receipt_scope: InstallReceiptScope<'_>,
1110 network: &str,
1111 state: &InstallState,
1112) -> Result<PathBuf, Box<dyn std::error::Error>> {
1113 let started_at = current_unix_timestamp_label()?;
1114 let state_path = write_install_state(receipt_scope.icp_root, network, state)?;
1115 let completed = CompletedInstallPhase {
1116 phase: "write_install_state",
1117 attempted_action: "write local install state",
1118 started_at,
1119 finished_at: Some(current_unix_timestamp_label()?),
1120 evidence: vec![
1121 format!("install_state:{}", state_path.display()),
1122 format!("deployment:{}", state.deployment_name),
1123 format!("fleet_template:{}", state.fleet_template),
1124 format!("root_canister:{}", state.root_canister_id),
1125 ],
1126 role_names: Vec::new(),
1127 };
1128 write_completed_install_phase_receipt(receipt_scope, completed)?;
1129 Ok(state_path)
1130}
1131
1132fn write_artifact_promotion_execution_receipt_for_install(
1133 options: &InstallRootOptions,
1134 icp_root: &Path,
1135 network: &str,
1136 fleet_name: &str,
1137 check: &DeploymentCheckV1,
1138 execution_context: &DeploymentExecutionContextV1,
1139) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
1140 let Some(promotion_plan) = &options.artifact_promotion_plan_override else {
1141 return Ok(None);
1142 };
1143 let deployment_receipt =
1144 promotion_install_deployment_receipt(check, execution_context, promotion_plan)?;
1145 let provenance_report =
1146 artifact_promotion_provenance_report(ArtifactPromotionProvenanceReportRequest {
1147 report_id: format!("{}:execution-provenance", promotion_plan.plan_id),
1148 artifact_promotion_plan: promotion_plan.clone(),
1149 wasm_store_identity_report: None,
1150 wasm_store_catalog_verification: None,
1151 materialization_identity_report: None,
1152 })?;
1153 let receipt = artifact_promotion_execution_receipt(ArtifactPromotionExecutionReceiptRequest {
1154 receipt_id: format!("{}:execution-receipt", promotion_plan.plan_id),
1155 provenance_report,
1156 deployment_receipt,
1157 })?;
1158 let path = write_artifact_promotion_execution_receipt(icp_root, network, fleet_name, &receipt)?;
1159 println!(
1160 "Artifact promotion execution receipt JSON: {}",
1161 path.display()
1162 );
1163 Ok(Some(path))
1164}
1165
1166fn promotion_install_deployment_receipt(
1167 check: &DeploymentCheckV1,
1168 execution_context: &DeploymentExecutionContextV1,
1169 promotion_plan: &ArtifactPromotionPlanV1,
1170) -> Result<DeploymentReceiptV1, Box<dyn std::error::Error>> {
1171 let started_at = current_unix_timestamp_label()?;
1172 let finished_at = current_unix_timestamp_label()?;
1173 let phase = phase_receipt(
1174 "promoted_plan_install",
1175 started_at.clone(),
1176 Some(finished_at.clone()),
1177 "execute promoted deployment plan through current install runner",
1178 ObservationStatusV1::Observed,
1179 vec![
1180 format!("artifact_promotion_plan:{}", promotion_plan.plan_id),
1181 format!(
1182 "artifact_promotion_plan_digest:{}",
1183 promotion_plan.artifact_promotion_plan_digest
1184 ),
1185 format!(
1186 "promotion_plan_lineage_digest:{}",
1187 promotion_plan.promotion_plan_lineage_digest
1188 ),
1189 ],
1190 );
1191 let role_phase_receipts = promotion_plan
1192 .transform
1193 .roles
1194 .iter()
1195 .map(|role| {
1196 completed_phase_role_receipt(
1197 check,
1198 "promoted_plan_install",
1199 &role.role,
1200 crate::deployment_truth::RolePhaseResultV1::Applied,
1201 None,
1202 )
1203 .ok_or_else(|| {
1204 format!(
1205 "promoted role {} is missing from deployment plan artifacts",
1206 role.role
1207 )
1208 })
1209 })
1210 .collect::<Result<Vec<_>, _>>()?;
1211 Ok(receipt_with_execution_context(
1212 deployment_receipt_from_check_with_status(
1213 check,
1214 format!("{}:promoted_plan_install", check.check_id),
1215 DeploymentExecutionStatusV1::Complete,
1216 started_at,
1217 Some(finished_at),
1218 vec![phase],
1219 role_phase_receipts,
1220 DeploymentCommandResultV1::Succeeded,
1221 ),
1222 execution_context,
1223 ))
1224}
1225
1226impl InstallReceiptScope<'_> {
1227 fn run_operation(
1228 self,
1229 operation: &impl InstallPhaseOperation,
1230 ) -> Result<Duration, Box<dyn std::error::Error>> {
1231 self.run_phase(
1232 operation.phase(),
1233 operation.attempted_action(),
1234 operation.evidence(),
1235 || operation.execute(),
1236 )
1237 }
1238
1239 fn run_phase(
1240 self,
1241 phase: &str,
1242 attempted_action: &str,
1243 evidence: Vec<String>,
1244 run: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
1245 ) -> Result<Duration, Box<dyn std::error::Error>> {
1246 let started_at = current_unix_timestamp_label()?;
1247 let started = Instant::now();
1248 match run() {
1249 Ok(()) => {
1250 let duration = started.elapsed();
1251 let receipt = self.with_execution_context(install_deployment_truth_phase_receipt(
1252 self.check,
1253 phase,
1254 started_at,
1255 Some(current_unix_timestamp_label()?),
1256 attempted_action,
1257 crate::deployment_truth::ObservationStatusV1::Observed,
1258 evidence,
1259 ));
1260 self.write_receipt(&receipt)?;
1261 Ok(duration)
1262 }
1263 Err(err) => {
1264 self.try_write_failed_phase_receipt(
1265 phase,
1266 started_at,
1267 attempted_action,
1268 evidence,
1269 err.as_ref(),
1270 );
1271 Err(err)
1272 }
1273 }
1274 }
1275
1276 fn with_execution_context(self, receipt: DeploymentReceiptV1) -> DeploymentReceiptV1 {
1277 match self.execution_context {
1278 Some(context) => receipt_with_execution_context(receipt, context),
1279 None => receipt,
1280 }
1281 }
1282
1283 fn write_receipt(
1284 self,
1285 receipt: &DeploymentReceiptV1,
1286 ) -> Result<PathBuf, Box<dyn std::error::Error>> {
1287 let path = write_install_deployment_truth_receipt(
1288 self.icp_root,
1289 self.network,
1290 self.fleet_name,
1291 receipt,
1292 )?;
1293 println!("Deployment truth receipt JSON: {}", path.display());
1294 Ok(path)
1295 }
1296
1297 fn try_write_failed_phase_receipt(
1298 self,
1299 phase: &str,
1300 started_at: String,
1301 attempted_action: &str,
1302 evidence: Vec<String>,
1303 err: &dyn std::error::Error,
1304 ) {
1305 let receipt = install_deployment_truth_phase_receipt_with_result(
1306 self.check,
1307 PhaseReceiptInput {
1308 phase,
1309 started_at,
1310 finished_at: Some(
1311 current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string()),
1312 ),
1313 attempted_action,
1314 status: crate::deployment_truth::ObservationStatusV1::Inconclusive,
1315 evidence,
1316 role_phase_receipts: Vec::new(),
1317 operation_status: DeploymentExecutionStatusV1::FailedAfterMutation,
1318 command_result: DeploymentCommandResultV1::Failed {
1319 code: format!("{phase}_failed"),
1320 message: err.to_string(),
1321 },
1322 },
1323 );
1324 let receipt = self.with_execution_context(receipt);
1325 if let Err(write_err) = self.write_receipt(&receipt) {
1326 eprintln!("Deployment truth receipt JSON write failed: {write_err}");
1327 }
1328 }
1329}
1330
1331pub fn check_install_deployment_truth(
1334 options: &InstallRootOptions,
1335 observed_at: impl Into<String>,
1336) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
1337 let inputs = resolve_current_install_truth_inputs(options)?;
1338 current_install_deployment_truth_check_at(
1339 options,
1340 &inputs.workspace_root,
1341 &inputs.icp_root,
1342 &inputs.config_path,
1343 &inputs.fleet_name,
1344 observed_at.into(),
1345 )
1346}
1347
1348pub fn check_install_execution_preflight(
1354 options: &InstallRootOptions,
1355 observed_at: impl Into<String>,
1356) -> Result<DeploymentExecutionPreflightV1, Box<dyn std::error::Error>> {
1357 let inputs = resolve_current_install_truth_inputs(options)?;
1358 let check = current_install_deployment_truth_check_at(
1359 options,
1360 &inputs.workspace_root,
1361 &inputs.icp_root,
1362 &inputs.config_path,
1363 &inputs.fleet_name,
1364 observed_at.into(),
1365 )?;
1366 let execution_context = current_install_execution_context(
1367 &inputs.workspace_root,
1368 &inputs.icp_root,
1369 &options.network,
1370 );
1371 let executor = CurrentCliDeploymentExecutor::new(
1372 execution_context.workspace_root,
1373 execution_context.icp_root,
1374 execution_context.artifact_roots,
1375 );
1376 let preflight = deployment_execution_preflight_from_check(
1377 &check,
1378 &executor,
1379 CURRENT_INSTALL_REQUIRED_CAPABILITIES,
1380 );
1381 validate_deployment_execution_preflight_for_check(&check, &preflight)?;
1382 Ok(preflight)
1383}
1384
1385fn resolve_current_install_truth_inputs(
1386 options: &InstallRootOptions,
1387) -> Result<CurrentInstallTruthInputs, Box<dyn std::error::Error>> {
1388 let workspace_root = workspace_root()?;
1389 let icp_root = match &options.icp_root {
1390 Some(path) => path.canonicalize()?,
1391 None => icp_root()?,
1392 };
1393 let config_path = resolve_install_config_path(
1394 &icp_root,
1395 options.config_path.as_deref(),
1396 options.interactive_config_selection,
1397 )?;
1398 let fleet_name = configured_fleet_name(&config_path)?;
1399 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
1400 validate_fleet_name(&fleet_name)?;
1401 Ok(CurrentInstallTruthInputs {
1402 workspace_root,
1403 icp_root,
1404 config_path,
1405 fleet_name,
1406 })
1407}
1408
1409fn current_install_deployment_truth_check_at(
1410 options: &InstallRootOptions,
1411 workspace_root: &Path,
1412 icp_root: &Path,
1413 config_path: &Path,
1414 fleet_name: &str,
1415 observed_at: String,
1416) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
1417 if let Some(plan) = &options.deployment_plan_override {
1418 validate_current_install_plan_override(plan, &options.network, fleet_name)?;
1419 return current_install_deployment_truth_check_for_plan(
1420 plan,
1421 workspace_root,
1422 icp_root,
1423 config_path,
1424 fleet_name,
1425 observed_at,
1426 &options.network,
1427 );
1428 }
1429
1430 let build_profile = options
1431 .build_profile
1432 .unwrap_or_else(CanisterBuildProfile::current)
1433 .target_dir_name()
1434 .to_string();
1435
1436 check_local_deployment(&LocalDeploymentCheckRequest {
1437 deployment_name: fleet_name.to_string(),
1438 network: options.network.clone(),
1439 workspace_root: workspace_root.to_path_buf(),
1440 icp_root: icp_root.to_path_buf(),
1441 config_path: Some(config_path.to_path_buf()),
1442 observed_at,
1443 runtime_variant: options.network.clone(),
1444 build_profile,
1445 })
1446 .map_err(Into::into)
1447}
1448
1449fn current_install_deployment_truth_check_for_plan(
1450 plan: &DeploymentPlanV1,
1451 workspace_root: &Path,
1452 icp_root: &Path,
1453 config_path: &Path,
1454 fleet_name: &str,
1455 observed_at: String,
1456 network: &str,
1457) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
1458 let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
1459 deployment_name: fleet_name.to_string(),
1460 network: network.to_string(),
1461 workspace_root: workspace_root.to_path_buf(),
1462 icp_root: icp_root.to_path_buf(),
1463 config_path: Some(config_path.to_path_buf()),
1464 observed_at,
1465 })?;
1466 let diff = compare_plan_to_inventory(plan, &inventory);
1467 let report = safety_report_from_diff(
1468 format!("local:{network}:{fleet_name}:report"),
1469 Some(format!("local:{network}:{fleet_name}:diff")),
1470 &diff,
1471 );
1472
1473 Ok(DeploymentCheckV1 {
1474 schema_version: crate::deployment_truth::DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1475 check_id: format!("local:{network}:{fleet_name}:check"),
1476 plan: plan.clone(),
1477 inventory,
1478 diff,
1479 report,
1480 })
1481}
1482
1483fn validate_current_install_plan_override(
1484 plan: &DeploymentPlanV1,
1485 network: &str,
1486 fleet_name: &str,
1487) -> Result<(), Box<dyn std::error::Error>> {
1488 if plan.schema_version != crate::deployment_truth::DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1489 return Err(format!(
1490 "deployment plan schema mismatch: expected {}, found {}",
1491 crate::deployment_truth::DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1492 plan.schema_version
1493 )
1494 .into());
1495 }
1496 if plan.deployment_identity.network != network {
1497 return Err(format!(
1498 "deployment plan network mismatch: install network {network}, plan network {}",
1499 plan.deployment_identity.network
1500 )
1501 .into());
1502 }
1503 if plan.deployment_identity.deployment_name != fleet_name {
1504 return Err(format!(
1505 "deployment plan target mismatch: install deployment {fleet_name}, plan deployment {}",
1506 plan.deployment_identity.deployment_name
1507 )
1508 .into());
1509 }
1510 Ok(())
1511}
1512
1513fn current_install_execution_context(
1514 workspace_root: &Path,
1515 icp_root: &Path,
1516 network: &str,
1517) -> DeploymentExecutionContextV1 {
1518 CurrentCliDeploymentExecutor::new(
1519 Some(workspace_root.display().to_string()),
1520 Some(icp_root.display().to_string()),
1521 current_install_artifact_roots(icp_root, network),
1522 )
1523 .execution_context()
1524}
1525
1526fn ensure_current_install_executor_capabilities(
1527 execution_context: &DeploymentExecutionContextV1,
1528) -> Result<(), Box<dyn std::error::Error>> {
1529 let missing = current_install_executor_missing_capabilities(execution_context);
1530 if missing.is_empty() {
1531 return Ok(());
1532 }
1533
1534 Err(format!(
1535 "current install executor backend {:?} is missing required capabilities: {missing:?}",
1536 execution_context.backend
1537 )
1538 .into())
1539}
1540
1541fn current_install_executor_missing_capabilities(
1542 execution_context: &DeploymentExecutionContextV1,
1543) -> Vec<DeploymentExecutorCapabilityV1> {
1544 missing_executor_capabilities(
1545 &execution_context.backend_capabilities,
1546 CURRENT_INSTALL_REQUIRED_CAPABILITIES,
1547 )
1548}
1549
1550fn current_install_artifact_roots(icp_root: &Path, network: &str) -> Vec<String> {
1551 let planned_root = planned_build_artifact_root(icp_root);
1552 let mut roots = vec![planned_root.display().to_string()];
1553 if let Ok(resolved_root) = resolve_artifact_root(icp_root, network)
1554 && resolved_root != planned_root
1555 {
1556 roots.push(resolved_root.display().to_string());
1557 }
1558 roots
1559}
1560
1561fn run_install_deployment_truth_safety_gate(
1562 options: &InstallRootOptions,
1563 workspace_root: &Path,
1564 icp_root: &Path,
1565 config_path: &Path,
1566 fleet_name: &str,
1567 execution_context: &DeploymentExecutionContextV1,
1568) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
1569 let truth_gate_started_at = current_unix_timestamp_label()?;
1570 let deployment_truth_check = current_install_deployment_truth_check_at(
1571 options,
1572 workspace_root,
1573 icp_root,
1574 config_path,
1575 fleet_name,
1576 truth_gate_started_at.clone(),
1577 )?;
1578 let artifact_gate_receipt = artifact_gate_phase_receipt(
1579 &deployment_truth_check,
1580 truth_gate_started_at.clone(),
1581 Some(current_unix_timestamp_label()?),
1582 );
1583 let role_receipts = artifact_gate_role_phase_receipts(&deployment_truth_check);
1584 let deployment_receipt = receipt_with_execution_context(
1585 install_deployment_truth_gate_receipt(
1586 &deployment_truth_check,
1587 truth_gate_started_at,
1588 vec![artifact_gate_receipt],
1589 role_receipts,
1590 ),
1591 execution_context,
1592 );
1593 let receipt_write = write_install_deployment_truth_receipt(
1594 icp_root,
1595 &options.network,
1596 fleet_name,
1597 &deployment_receipt,
1598 );
1599 match &receipt_write {
1600 Ok(path) => println!("Deployment truth receipt JSON: {}", path.display()),
1601 Err(err) => eprintln!("Deployment truth receipt JSON write failed: {err}"),
1602 }
1603 print_install_deployment_truth_gate(&deployment_truth_check, &deployment_receipt);
1604 enforce_install_deployment_truth_gate(&deployment_truth_check)?;
1605 receipt_write?;
1606 write_current_install_execution_preflight_receipt(
1607 icp_root,
1608 &options.network,
1609 fleet_name,
1610 &deployment_truth_check,
1611 execution_context,
1612 )?;
1613 Ok(deployment_truth_check)
1614}
1615
1616fn enforce_install_deployment_truth_gate(
1617 check: &DeploymentCheckV1,
1618) -> Result<(), Box<dyn std::error::Error>> {
1619 let blockers = install_deployment_truth_gate_blockers(check);
1620 if blockers.is_empty() {
1621 return Ok(());
1622 }
1623
1624 let details = blockers
1625 .iter()
1626 .map(|finding| deployment_truth_finding_label(finding))
1627 .collect::<Vec<_>>()
1628 .join("; ");
1629 Err(format!("deployment truth safety gate blocked install: {details}").into())
1630}
1631
1632fn install_deployment_truth_gate_blockers(check: &DeploymentCheckV1) -> Vec<&SafetyFindingV1> {
1633 check.report.hard_failures.iter().collect()
1634}
1635
1636fn print_install_deployment_truth_gate(check: &DeploymentCheckV1, receipt: &DeploymentReceiptV1) {
1637 for line in install_deployment_truth_gate_lines(check, receipt) {
1638 println!("{line}");
1639 }
1640}
1641
1642fn install_deployment_truth_gate_lines(
1643 check: &DeploymentCheckV1,
1644 receipt: &DeploymentReceiptV1,
1645) -> Vec<String> {
1646 let mut lines = vec![
1647 format!("Deployment truth: {}", check.report.summary),
1648 format!(
1649 "Deployment truth receipt: operation={} status={:?}",
1650 receipt.operation_id, receipt.operation_status
1651 ),
1652 ];
1653 for phase_receipt in &receipt.phase_receipts {
1654 lines.push(format!(
1655 "Deployment truth phase receipt: phase={} postcondition={:?}",
1656 phase_receipt.phase, phase_receipt.verified_postcondition.status
1657 ));
1658 }
1659 if !receipt.role_phase_receipts.is_empty() {
1660 lines.push(format!(
1661 "Deployment truth role receipts: {}",
1662 receipt.role_phase_receipts.len()
1663 ));
1664 }
1665 for role_receipt in &receipt.role_phase_receipts {
1666 lines.push(format!(
1667 "Deployment truth role receipt: phase={} role={} result={:?}",
1668 role_receipt.phase, role_receipt.role, role_receipt.result
1669 ));
1670 }
1671
1672 if !check.report.hard_failures.is_empty() {
1673 lines.push(format!(
1674 "Deployment truth hard failures: {}",
1675 check.report.hard_failures.len()
1676 ));
1677 }
1678 for finding in install_deployment_truth_gate_blockers(check) {
1679 lines.push(format!(
1680 "Deployment truth blocker: {}",
1681 deployment_truth_finding_label(finding)
1682 ));
1683 }
1684 if !check.report.warnings.is_empty() {
1685 lines.push(format!(
1686 "Deployment truth warnings: {}",
1687 check.report.warnings.len()
1688 ));
1689 }
1690 for finding in &check.report.warnings {
1691 lines.push(format!(
1692 "Deployment truth warning: {}",
1693 deployment_truth_finding_label(finding)
1694 ));
1695 }
1696 lines
1697}
1698
1699fn install_deployment_truth_gate_receipt(
1700 check: &DeploymentCheckV1,
1701 started_at: String,
1702 phase_receipts: Vec<crate::deployment_truth::PhaseReceiptV1>,
1703 role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
1704) -> DeploymentReceiptV1 {
1705 let blockers = install_deployment_truth_gate_blockers(check);
1706 let (operation_status, command_result) = if blockers.is_empty() {
1707 (
1708 DeploymentExecutionStatusV1::Complete,
1709 DeploymentCommandResultV1::Succeeded,
1710 )
1711 } else {
1712 (
1713 DeploymentExecutionStatusV1::FailedBeforeMutation,
1714 DeploymentCommandResultV1::Failed {
1715 code: "deployment_truth_blocked".to_string(),
1716 message: check.report.summary.clone(),
1717 },
1718 )
1719 };
1720 deployment_receipt_from_check_with_status(
1721 check,
1722 format!("{}:materialize_artifacts", check.check_id),
1723 operation_status,
1724 started_at,
1725 Some(current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string())),
1726 phase_receipts,
1727 role_phase_receipts,
1728 command_result,
1729 )
1730}
1731
1732fn write_current_install_execution_preflight_receipt(
1733 icp_root: &Path,
1734 network: &str,
1735 fleet_name: &str,
1736 check: &DeploymentCheckV1,
1737 execution_context: &DeploymentExecutionContextV1,
1738) -> Result<PathBuf, Box<dyn std::error::Error>> {
1739 let started_at = current_unix_timestamp_label()?;
1740 let executor = CurrentCliDeploymentExecutor::new(
1741 execution_context.workspace_root.clone(),
1742 execution_context.icp_root.clone(),
1743 execution_context.artifact_roots.clone(),
1744 );
1745 let preflight = deployment_execution_preflight_from_check(
1746 check,
1747 &executor,
1748 CURRENT_INSTALL_REQUIRED_CAPABILITIES,
1749 );
1750 validate_deployment_execution_preflight_for_check(check, &preflight)?;
1751 let blockers = preflight.blockers.clone();
1752 let (operation_status, command_result) = if blockers.is_empty() {
1753 (
1754 DeploymentExecutionStatusV1::Complete,
1755 DeploymentCommandResultV1::Succeeded,
1756 )
1757 } else {
1758 (
1759 DeploymentExecutionStatusV1::FailedBeforeMutation,
1760 DeploymentCommandResultV1::Failed {
1761 code: "execution_preflight_blocked".to_string(),
1762 message: "deployment execution preflight blocked current install".to_string(),
1763 },
1764 )
1765 };
1766 let finished_at = current_unix_timestamp_label()?;
1767 let receipt = receipt_with_execution_context(
1768 deployment_receipt_from_check_with_status(
1769 check,
1770 format!("{}:execution_preflight", check.check_id),
1771 operation_status,
1772 started_at.clone(),
1773 Some(finished_at.clone()),
1774 vec![phase_receipt(
1775 "execution_preflight",
1776 started_at,
1777 Some(finished_at),
1778 "validate deployment plan, authority, and executor capability readiness",
1779 crate::deployment_truth::ObservationStatusV1::Observed,
1780 current_install_execution_preflight_evidence(&preflight),
1781 )],
1782 Vec::new(),
1783 command_result,
1784 ),
1785 execution_context,
1786 );
1787 let path = write_install_deployment_truth_receipt(icp_root, network, fleet_name, &receipt)?;
1788 println!("Deployment truth receipt JSON: {}", path.display());
1789 if !blockers.is_empty() {
1790 let details = blockers
1791 .iter()
1792 .map(deployment_truth_finding_label)
1793 .collect::<Vec<_>>()
1794 .join("; ");
1795 return Err(format!("deployment execution preflight blocked install: {details}").into());
1796 }
1797 Ok(path)
1798}
1799
1800struct StageReleaseSetOperation<'a> {
1801 icp_root: &'a Path,
1802 network: &'a str,
1803 root_canister_id: &'a str,
1804 manifest_path: &'a Path,
1805 manifest: RootReleaseSetManifest,
1806}
1807
1808impl<'a> StageReleaseSetOperation<'a> {
1809 const fn new(
1810 icp_root: &'a Path,
1811 network: &'a str,
1812 root_canister_id: &'a str,
1813 manifest_path: &'a Path,
1814 manifest: RootReleaseSetManifest,
1815 ) -> Self {
1816 Self {
1817 icp_root,
1818 network,
1819 root_canister_id,
1820 manifest_path,
1821 manifest,
1822 }
1823 }
1824}
1825
1826impl InstallPhaseOperation for StageReleaseSetOperation<'_> {
1827 fn phase(&self) -> &'static str {
1828 "stage_release_set"
1829 }
1830
1831 fn attempted_action(&self) -> &'static str {
1832 "stage root release set"
1833 }
1834
1835 fn evidence(&self) -> Vec<String> {
1836 current_install_staging_evidence(self.root_canister_id, self.manifest_path, &self.manifest)
1837 }
1838
1839 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
1840 stage_root_release_set(
1841 self.icp_root,
1842 self.network,
1843 self.root_canister_id,
1844 &self.manifest,
1845 )
1846 }
1847}
1848
1849fn current_install_execution_preflight_evidence(
1850 preflight: &crate::deployment_truth::DeploymentExecutionPreflightV1,
1851) -> Vec<String> {
1852 let mut evidence = vec![
1853 format!("execution_preflight_status:{:?}", preflight.status),
1854 format!("authority_plan:{}", preflight.authority_plan_id),
1855 format!("planned_phases:{}", preflight.planned_phases.len()),
1856 format!(
1857 "required_capabilities:{}",
1858 preflight.required_capabilities.len()
1859 ),
1860 format!(
1861 "missing_capabilities:{}",
1862 preflight.missing_capabilities.len()
1863 ),
1864 format!("blockers:{}", preflight.blockers.len()),
1865 ];
1866 evidence.extend(
1867 preflight
1868 .missing_capabilities
1869 .iter()
1870 .map(|capability| format!("missing_capability:{capability:?}")),
1871 );
1872 evidence.extend(
1873 preflight
1874 .blockers
1875 .iter()
1876 .map(|finding| format!("blocker:{}:{}", finding.code, finding.message)),
1877 );
1878 evidence
1879}
1880
1881fn current_install_staging_evidence(
1882 root_canister_id: &str,
1883 manifest_path: &Path,
1884 manifest: &RootReleaseSetManifest,
1885) -> Vec<String> {
1886 let mut evidence = vec![
1887 format!("root_canister:{root_canister_id}"),
1888 format!("manifest_path:{}", manifest_path.display()),
1889 format!("release_version:{}", manifest.release_version),
1890 ];
1891 let staging_receipts = current_install_staging_receipts(root_canister_id, manifest);
1892 evidence.extend(staging_receipt_evidence(&staging_receipts));
1893 evidence
1894}
1895
1896fn current_install_staging_receipts(
1897 root_canister_id: &str,
1898 manifest: &RootReleaseSetManifest,
1899) -> Vec<StagingReceiptV1> {
1900 manifest
1901 .entries
1902 .iter()
1903 .map(|entry| StagingReceiptV1 {
1904 schema_version: crate::deployment_truth::DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1905 role: entry.role.clone(),
1906 artifact_identity: format!(
1907 "{}:{}:{}",
1908 entry.template_id, manifest.release_version, entry.payload_sha256_hex
1909 ),
1910 transport: ArtifactTransportV1::WasmStore,
1911 wasm_store_locator: Some(format!("root:{root_canister_id}:bootstrap")),
1912 prepared_chunk_hashes: entry.chunk_sha256_hex.clone(),
1913 published_chunk_count: entry.chunk_sha256_hex.len(),
1914 verified_postcondition: crate::deployment_truth::VerifiedPostconditionV1 {
1915 status: ObservationStatusV1::Observed,
1916 evidence: vec![
1917 format!("payload_sha256:{}", entry.payload_sha256_hex),
1918 format!("payload_size_bytes:{}", entry.payload_size_bytes),
1919 format!("chunk_size_bytes:{}", entry.chunk_size_bytes),
1920 format!("chunk_count:{}", entry.chunk_sha256_hex.len()),
1921 ],
1922 },
1923 })
1924 .collect()
1925}
1926
1927fn install_deployment_truth_phase_receipt(
1928 check: &DeploymentCheckV1,
1929 phase: &str,
1930 started_at: String,
1931 finished_at: Option<String>,
1932 attempted_action: &str,
1933 status: crate::deployment_truth::ObservationStatusV1,
1934 evidence: Vec<String>,
1935) -> DeploymentReceiptV1 {
1936 install_deployment_truth_phase_receipt_with_result(
1937 check,
1938 PhaseReceiptInput {
1939 phase,
1940 started_at,
1941 finished_at,
1942 attempted_action,
1943 status,
1944 evidence,
1945 role_phase_receipts: Vec::new(),
1946 operation_status: DeploymentExecutionStatusV1::Complete,
1947 command_result: DeploymentCommandResultV1::Succeeded,
1948 },
1949 )
1950}
1951
1952fn install_deployment_truth_phase_receipt_with_result(
1953 check: &DeploymentCheckV1,
1954 input: PhaseReceiptInput<'_>,
1955) -> DeploymentReceiptV1 {
1956 deployment_receipt_from_check_with_status(
1957 check,
1958 format!("{}:{}", check.check_id, input.phase),
1959 input.operation_status,
1960 input.started_at.clone(),
1961 input.finished_at.clone(),
1962 vec![phase_receipt(
1963 input.phase,
1964 input.started_at,
1965 input.finished_at,
1966 input.attempted_action,
1967 input.status,
1968 input.evidence,
1969 )],
1970 input.role_phase_receipts,
1971 input.command_result,
1972 )
1973}
1974
1975fn receipt_with_execution_context(
1976 mut receipt: DeploymentReceiptV1,
1977 execution_context: &DeploymentExecutionContextV1,
1978) -> DeploymentReceiptV1 {
1979 receipt.execution_context = Some(execution_context.clone());
1980 receipt
1981}
1982
1983struct PhaseReceiptInput<'a> {
1984 phase: &'a str,
1985 started_at: String,
1986 finished_at: Option<String>,
1987 attempted_action: &'a str,
1988 status: crate::deployment_truth::ObservationStatusV1,
1989 evidence: Vec<String>,
1990 role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
1991 operation_status: DeploymentExecutionStatusV1,
1992 command_result: DeploymentCommandResultV1,
1993}
1994
1995fn write_install_deployment_truth_receipt(
1996 icp_root: &Path,
1997 network: &str,
1998 fleet_name: &str,
1999 receipt: &DeploymentReceiptV1,
2000) -> Result<PathBuf, Box<dyn std::error::Error>> {
2001 let path = install_deployment_truth_receipt_path(icp_root, network, fleet_name, receipt)?;
2002 if let Some(parent) = path.parent() {
2003 fs::create_dir_all(parent)?;
2004 }
2005 let mut bytes = serde_json::to_vec_pretty(receipt)?;
2006 bytes.push(b'\n');
2007 fs::write(&path, bytes)?;
2008 Ok(path)
2009}
2010
2011fn write_artifact_promotion_execution_receipt(
2012 icp_root: &Path,
2013 network: &str,
2014 fleet_name: &str,
2015 receipt: &ArtifactPromotionExecutionReceiptV1,
2016) -> Result<PathBuf, Box<dyn std::error::Error>> {
2017 let path = artifact_promotion_execution_receipt_path(icp_root, network, fleet_name, receipt)?;
2018 if let Some(parent) = path.parent() {
2019 fs::create_dir_all(parent)?;
2020 }
2021 let mut bytes = serde_json::to_vec_pretty(receipt)?;
2022 bytes.push(b'\n');
2023 fs::write(&path, bytes)?;
2024 Ok(path)
2025}
2026
2027fn artifact_promotion_execution_receipt_path(
2028 icp_root: &Path,
2029 network: &str,
2030 fleet_name: &str,
2031 receipt: &ArtifactPromotionExecutionReceiptV1,
2032) -> Result<PathBuf, Box<dyn std::error::Error>> {
2033 validate_network_name(network)?;
2034 validate_fleet_name(fleet_name)?;
2035 let file_stem = format!(
2036 "{}-{}",
2037 safe_deployment_truth_path_label(&receipt.started_at),
2038 safe_deployment_truth_path_label(&receipt.receipt_id)
2039 );
2040 Ok(
2041 artifact_promotion_execution_receipts_dir(icp_root, network, fleet_name)?
2042 .join(format!("{file_stem}.json")),
2043 )
2044}
2045
2046fn artifact_promotion_execution_receipts_dir(
2047 icp_root: &Path,
2048 network: &str,
2049 fleet_name: &str,
2050) -> Result<PathBuf, Box<dyn std::error::Error>> {
2051 validate_network_name(network)?;
2052 validate_fleet_name(fleet_name)?;
2053 Ok(icp_root
2054 .join(".canic")
2055 .join(network)
2056 .join("artifact-promotion-execution-receipts")
2057 .join(fleet_name))
2058}
2059
2060fn install_deployment_truth_receipt_path(
2061 icp_root: &Path,
2062 network: &str,
2063 fleet_name: &str,
2064 receipt: &DeploymentReceiptV1,
2065) -> Result<PathBuf, Box<dyn std::error::Error>> {
2066 validate_network_name(network)?;
2067 validate_fleet_name(fleet_name)?;
2068 let file_stem = format!(
2069 "{}-{}",
2070 safe_deployment_truth_path_label(&receipt.started_at),
2071 safe_deployment_truth_path_label(&receipt.operation_id)
2072 );
2073 Ok(
2074 install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?
2075 .join(format!("{file_stem}.json")),
2076 )
2077}
2078
2079pub fn latest_deployment_truth_receipt_path_from_root(
2081 icp_root: &Path,
2082 network: &str,
2083 fleet_name: &str,
2084) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
2085 let dir = install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?;
2086 if !dir.is_dir() {
2087 return Ok(None);
2088 }
2089
2090 let mut latest = None;
2091 for entry in fs::read_dir(dir)? {
2092 let path = entry?.path();
2093 if !path.is_file()
2094 || path
2095 .extension()
2096 .is_none_or(|ext| !ext.eq_ignore_ascii_case("json"))
2097 {
2098 continue;
2099 }
2100 if latest.as_ref().is_none_or(|current| path > *current) {
2101 latest = Some(path);
2102 }
2103 }
2104 Ok(latest)
2105}
2106
2107fn install_deployment_truth_receipts_dir(
2108 icp_root: &Path,
2109 network: &str,
2110 fleet_name: &str,
2111) -> Result<PathBuf, Box<dyn std::error::Error>> {
2112 validate_network_name(network)?;
2113 validate_fleet_name(fleet_name)?;
2114 Ok(icp_root
2115 .join(".canic")
2116 .join(network)
2117 .join("deployment-receipts")
2118 .join(fleet_name))
2119}
2120
2121fn safe_deployment_truth_path_label(value: &str) -> String {
2122 let label = value
2123 .chars()
2124 .map(|ch| {
2125 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
2126 ch
2127 } else {
2128 '_'
2129 }
2130 })
2131 .collect::<String>();
2132 if label.is_empty() {
2133 "unknown".to_string()
2134 } else {
2135 label
2136 }
2137}
2138
2139fn deployment_truth_finding_label(finding: &SafetyFindingV1) -> String {
2140 let subject = finding
2141 .subject
2142 .as_ref()
2143 .map_or_else(|| "<none>".to_string(), Clone::clone);
2144 format!(
2145 "{}:{}:{}: {}",
2146 deployment_truth_finding_source(&finding.code),
2147 finding.code,
2148 subject,
2149 finding.message
2150 )
2151}
2152
2153fn deployment_truth_finding_source(code: &str) -> &'static str {
2154 match code {
2155 "plan_assumption" => "plan",
2156 "observation_gap" => "inventory",
2157 _ => "diff",
2158 }
2159}
2160
2161fn validate_expected_fleet_name(
2162 expected: Option<&str>,
2163 actual: &str,
2164 config_path: &Path,
2165) -> Result<(), Box<dyn std::error::Error>> {
2166 let Some(expected) = expected else {
2167 return Ok(());
2168 };
2169 if expected == actual {
2170 return Ok(());
2171 }
2172 Err(format!(
2173 "install requested fleet {expected}, but {} declares [fleet].name = {actual:?}",
2174 config_path.display()
2175 )
2176 .into())
2177}
2178
2179fn ensure_root_canister_id(
2180 icp_root: &Path,
2181 network: &str,
2182 root_canister: &str,
2183 config_path: &Path,
2184) -> Result<String, Box<dyn std::error::Error>> {
2185 if Principal::from_text(root_canister).is_ok() {
2186 return Ok(root_canister.to_string());
2187 }
2188
2189 match resolve_root_canister_id(icp_root, network, root_canister) {
2190 Ok(canister_id) => return Ok(canister_id),
2191 Err(err) if !is_missing_canister_id_error(&err.to_string()) => return Err(err),
2192 Err(_) => {}
2193 }
2194
2195 let mut create = icp_canister_command_in_network(icp_root);
2196 add_create_root_target(&mut create, root_canister);
2197 add_local_root_create_cycles_arg(&mut create, config_path, network)?;
2198 add_icp_environment_target(&mut create, network);
2199 let output = run_command_stdout(&mut create)?;
2200 if let Some(canister_id) = parse_created_canister_id(&output) {
2201 return Ok(canister_id);
2202 }
2203
2204 resolve_root_canister_id(icp_root, network, root_canister).map_err(|_| {
2205 format!(
2206 "created root canister target '{root_canister}', but ICP CLI still has no canister ID for environment '{network}' under ICP root {}\nExpected project-local state under {}/.icp/{network}. If another foreground replica is reachable, stop it and restart with `canic replica start --background` from this Canic project.",
2207 icp_root.display(),
2208 icp_root.display(),
2209 )
2210 .into()
2211 })
2212}
2213
2214fn parse_created_canister_id(output: &str) -> Option<String> {
2215 if let Ok(value) = serde_json::from_str::<JsonValue>(output) {
2216 return parse_canister_id_json(&value);
2217 }
2218
2219 output
2220 .lines()
2221 .map(str::trim)
2222 .find(|line| Principal::from_text(*line).is_ok())
2223 .map(ToString::to_string)
2224}
2225
2226fn parse_canister_id_json(value: &JsonValue) -> Option<String> {
2227 match value {
2228 JsonValue::String(text) if Principal::from_text(text).is_ok() => Some(text.clone()),
2229 JsonValue::Array(values) => values.iter().find_map(parse_canister_id_json),
2230 JsonValue::Object(object) => ["canister_id", "id", "principal"]
2231 .iter()
2232 .filter_map(|key| object.get(*key))
2233 .find_map(parse_canister_id_json),
2234 _ => None,
2235 }
2236}
2237
2238fn add_create_root_target(command: &mut Command, root_canister: &str) {
2239 if env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV).is_some() {
2240 command.args(["create", "--detached", "--json"]);
2241 } else {
2242 command.args(["create", root_canister, "--json"]);
2243 }
2244}
2245
2246fn is_missing_canister_id_error(message: &str) -> bool {
2247 message.contains("failed to lookup canister ID")
2248 || message.contains("could not find ID for canister")
2249 || message.contains("Canister ID is missing")
2250}
2251
2252fn reinstall_root_wasm(
2253 icp_root: &Path,
2254 network: &str,
2255 root_canister: &str,
2256 root_wasm: &Path,
2257) -> Result<(), Box<dyn std::error::Error>> {
2258 let mut install = icp_canister_command_in_network(icp_root);
2259 install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
2260 install.arg(root_wasm);
2261 install.args(["--args", &root_init_args(root_wasm)?]);
2262 add_icp_environment_target(&mut install, network);
2263 run_command(&mut install)
2264}
2265
2266fn root_init_args(root_wasm: &Path) -> Result<String, Box<dyn std::error::Error>> {
2267 let wasm = std::fs::read(root_wasm)?;
2268 Ok(format!(
2269 "(variant {{ PrimeWithModuleHash = {} }})",
2270 idl_blob(&wasm_hash(&wasm))
2271 ))
2272}
2273
2274fn idl_blob(bytes: &[u8]) -> String {
2275 let mut encoded = String::from("blob \"");
2276 for byte in bytes {
2277 use std::fmt::Write as _;
2278 let _ = write!(encoded, "\\{byte:02X}");
2279 }
2280 encoded.push('"');
2281 encoded
2282}
2283
2284fn build_install_state(
2286 options: &InstallRootOptions,
2287 workspace_root: &Path,
2288 icp_root: &Path,
2289 config_path: &Path,
2290 release_set_manifest_path: &Path,
2291 fleet_name: &str,
2292 root_canister_id: &str,
2293) -> Result<InstallState, Box<dyn std::error::Error>> {
2294 Ok(InstallState {
2295 schema_version: INSTALL_STATE_SCHEMA_VERSION,
2296 deployment_name: fleet_name.to_string(),
2297 fleet_template: fleet_name.to_string(),
2298 fleet: fleet_name.to_string(),
2299 installed_at_unix_secs: current_unix_secs()?,
2300 network: options.network.clone(),
2301 root_target: options.root_canister.clone(),
2302 root_canister_id: root_canister_id.to_string(),
2303 root_verification: RootVerificationStatus::Verified,
2304 root_build_target: options.root_build_target.clone(),
2305 workspace_root: workspace_root.display().to_string(),
2306 icp_root: icp_root.display().to_string(),
2307 config_path: config_path.display().to_string(),
2308 release_set_manifest_path: release_set_manifest_path.display().to_string(),
2309 })
2310}
2311
2312fn resolve_root_canister_id(
2314 icp_root: &Path,
2315 network: &str,
2316 root_canister: &str,
2317) -> Result<String, Box<dyn std::error::Error>> {
2318 if Principal::from_text(root_canister).is_ok() {
2319 return Ok(root_canister.to_string());
2320 }
2321
2322 let mut command = icp_canister_command_in_network(icp_root);
2323 command.args(["status", root_canister, "--json"]);
2324 add_icp_environment_target(&mut command, network);
2325 let output = run_command_stdout(&mut command)?;
2326 parse_created_canister_id(&output).ok_or_else(|| {
2327 format!("could not parse root canister id from ICP status JSON output: {output}").into()
2328 })
2329}
2330
2331fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
2333 Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
2334}
2335
2336fn current_unix_timestamp_label() -> Result<String, Box<dyn std::error::Error>> {
2337 Ok(format!("unix:{}", current_unix_secs()?))
2338}
2339
2340fn run_canic_build_targets(
2342 network: &str,
2343 targets: &[String],
2344 build_profile: Option<CanisterBuildProfile>,
2345 config_path: &Path,
2346 icp_root: &Path,
2347) -> Result<(), Box<dyn std::error::Error>> {
2348 let _env = BuildEnvGuard::apply(network, config_path, icp_root);
2349 let profile = build_profile.unwrap_or_else(CanisterBuildProfile::current);
2350 if let Some(context) = current_workspace_build_context_once(profile)? {
2351 for line in context.lines() {
2352 println!("{line}");
2353 }
2354 println!("config: {}", config_path.display());
2355 println!(
2356 "artifacts: {}",
2357 planned_build_artifact_root(icp_root).display()
2358 );
2359 println!();
2360 }
2361
2362 fs::create_dir_all(planned_build_artifact_root(icp_root))?;
2363 println!("Building {} canisters", targets.len());
2364 println!();
2365 let headers = ["CANISTER", "PROGRESS", "WASM", "ELAPSED"];
2366 let planned_rows = targets
2367 .iter()
2368 .map(|target| {
2369 [
2370 target.clone(),
2371 progress_bar(targets.len(), targets.len(), 10),
2372 "000.00 MiB (gz 000.00 MiB)".to_string(),
2373 "0.00s".to_string(),
2374 ]
2375 })
2376 .collect::<Vec<_>>();
2377 let alignments = [
2378 ColumnAlign::Left,
2379 ColumnAlign::Left,
2380 ColumnAlign::Right,
2381 ColumnAlign::Right,
2382 ];
2383 let widths = table_widths(&headers, &planned_rows);
2384 println!("{}", render_table_row(&headers, &widths, &alignments));
2385 println!("{}", render_separator(&widths));
2386
2387 for (index, target) in targets.iter().enumerate() {
2388 let started_at = Instant::now();
2389 let output = build_current_workspace_canister_artifact(target, profile)
2390 .map_err(|err| format!("artifact build failed for {target}: {err}"))?;
2391 let elapsed = started_at.elapsed();
2392 let artifact_size = wasm_artifact_size(&output.wasm_path, &output.wasm_gz_path)?;
2393
2394 let row = [
2395 target.clone(),
2396 progress_bar(index + 1, targets.len(), 10),
2397 artifact_size,
2398 format!("{:.2}s", elapsed.as_secs_f64()),
2399 ];
2400 println!("{}", render_table_row(&row, &widths, &alignments));
2401 }
2402
2403 println!();
2404 Ok(())
2405}
2406
2407fn planned_build_artifact_root(icp_root: &Path) -> PathBuf {
2408 icp_root.join(".icp/local/canisters")
2409}
2410
2411fn wasm_artifact_size(
2412 wasm_path: &Path,
2413 wasm_gz_path: &Path,
2414) -> Result<String, Box<dyn std::error::Error>> {
2415 let wasm_bytes = Some(std::fs::metadata(wasm_path)?.len());
2416 let gzip_bytes = std::fs::metadata(wasm_gz_path)
2417 .ok()
2418 .map(|metadata| metadata.len());
2419 Ok(wasm_size_label(wasm_bytes, gzip_bytes))
2420}
2421
2422struct BuildEnvGuard {
2423 previous_network: Option<OsString>,
2424 previous_config_path: Option<OsString>,
2425 previous_icp_root: Option<OsString>,
2426 previous_local_network_url: Option<OsString>,
2427 previous_local_root_key: Option<OsString>,
2428}
2429
2430impl BuildEnvGuard {
2431 fn apply(network: &str, config_path: &Path, icp_root: &Path) -> Self {
2432 let guard = Self {
2433 previous_network: env::var_os("ICP_ENVIRONMENT"),
2434 previous_config_path: env::var_os("CANIC_CONFIG_PATH"),
2435 previous_icp_root: env::var_os("CANIC_ICP_ROOT"),
2436 previous_local_network_url: env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV),
2437 previous_local_root_key: env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV),
2438 };
2439 set_env("ICP_ENVIRONMENT", network);
2440 set_env("CANIC_CONFIG_PATH", config_path);
2441 set_env("CANIC_ICP_ROOT", icp_root);
2442 if let Some(target) = local_replica_icp_target(network, icp_root) {
2443 set_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV, target.url);
2444 set_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV, target.root_key);
2445 } else {
2446 remove_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV);
2447 remove_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV);
2448 }
2449 guard
2450 }
2451}
2452
2453impl Drop for BuildEnvGuard {
2454 fn drop(&mut self) {
2455 restore_env("ICP_ENVIRONMENT", self.previous_network.take());
2456 restore_env("CANIC_CONFIG_PATH", self.previous_config_path.take());
2457 restore_env("CANIC_ICP_ROOT", self.previous_icp_root.take());
2458 restore_env(
2459 CANIC_ICP_LOCAL_NETWORK_URL_ENV,
2460 self.previous_local_network_url.take(),
2461 );
2462 restore_env(
2463 CANIC_ICP_LOCAL_ROOT_KEY_ENV,
2464 self.previous_local_root_key.take(),
2465 );
2466 }
2467}
2468
2469struct LocalReplicaIcpTarget {
2470 url: String,
2471 root_key: String,
2472}
2473
2474fn local_replica_icp_target(network: &str, icp_root: &Path) -> Option<LocalReplicaIcpTarget> {
2475 if !replica_query::should_use_local_replica_query(Some(network)) {
2476 return None;
2477 }
2478 if icp_ping(icp_root, network).unwrap_or(false) {
2479 return None;
2480 }
2481 let root_key = replica_query::local_replica_root_key_from_root(Some(network), icp_root)
2482 .ok()
2483 .flatten()?;
2484 Some(LocalReplicaIcpTarget {
2485 url: replica_query::local_replica_endpoint_from_root(Some(network), icp_root),
2486 root_key,
2487 })
2488}
2489
2490fn set_env<K, V>(key: K, value: V)
2491where
2492 K: AsRef<std::ffi::OsStr>,
2493 V: AsRef<std::ffi::OsStr>,
2494{
2495 unsafe {
2498 env::set_var(key, value);
2499 }
2500}
2501
2502fn remove_env<K>(key: K)
2503where
2504 K: AsRef<std::ffi::OsStr>,
2505{
2506 unsafe {
2509 env::remove_var(key);
2510 }
2511}
2512
2513fn restore_env(key: &str, value: Option<OsString>) {
2514 if let Some(value) = value {
2516 set_env(key, value);
2517 } else {
2518 remove_env(key);
2519 }
2520}
2521
2522fn add_local_root_create_cycles_arg(
2523 command: &mut Command,
2524 config_path: &Path,
2525 network: &str,
2526) -> Result<(), Box<dyn std::error::Error>> {
2527 if network != "local" {
2528 return Ok(());
2529 }
2530
2531 let cycles = configured_local_root_create_cycles(config_path)?;
2532 command.args(["--cycles", &cycles.to_string()]);
2533 Ok(())
2534}
2535
2536fn ensure_local_root_min_cycles(
2537 icp_root: &Path,
2538 network: &str,
2539 root_canister: &str,
2540 phase: &str,
2541) -> Result<(), Box<dyn std::error::Error>> {
2542 if network != "local" {
2543 return Ok(());
2544 }
2545
2546 let current = query_root_cycle_balance(network, root_canister)?;
2547 if current >= LOCAL_ROOT_MIN_READY_CYCLES {
2548 return Ok(());
2549 }
2550
2551 let amount = LOCAL_ROOT_MIN_READY_CYCLES.saturating_sub(current);
2552 let mut command = icp_canister_command_in_network(icp_root);
2553 command
2554 .args(["top-up", "--amount"])
2555 .arg(amount.to_string())
2556 .arg(root_canister);
2557 add_icp_environment_target(&mut command, network);
2558 run_command(&mut command)?;
2559 println!(
2560 "Local root cycles ({phase}): topped up {} ({} -> {} target)",
2561 crate::format::cycles_tc(amount),
2562 crate::format::cycles_tc(current),
2563 crate::format::cycles_tc(LOCAL_ROOT_MIN_READY_CYCLES)
2564 );
2565 Ok(())
2566}
2567
2568fn query_root_cycle_balance(
2569 network: &str,
2570 root_canister: &str,
2571) -> Result<u128, Box<dyn std::error::Error>> {
2572 let output = icp_query_on_network(
2573 network,
2574 root_canister,
2575 protocol::CANIC_CYCLE_BALANCE,
2576 None,
2577 Some("json"),
2578 )?;
2579 parse_cycle_balance_response(&output).ok_or_else(|| {
2580 format!(
2581 "could not parse {root_canister} {} response: {output}",
2582 protocol::CANIC_CYCLE_BALANCE
2583 )
2584 .into()
2585 })
2586}
2587
2588fn progress_bar(current: usize, total: usize, width: usize) -> String {
2589 if total == 0 || width == 0 {
2590 return "[] 0/0".to_string();
2591 }
2592
2593 let filled = current.saturating_mul(width).div_ceil(total);
2594 let filled = filled.min(width);
2595 format!(
2596 "[{}{}] {current}/{total}",
2597 "#".repeat(filled),
2598 " ".repeat(width - filled)
2599 )
2600}
2601
2602fn ensure_icp_environment_ready(
2604 icp_root: &Path,
2605 network: &str,
2606) -> Result<(), Box<dyn std::error::Error>> {
2607 if icp_ping(icp_root, network)? {
2608 return Ok(());
2609 }
2610 if replica_query::should_use_local_replica_query(Some(network))
2611 && replica_query::local_replica_status_reachable_from_root(Some(network), icp_root)
2612 {
2613 println!(
2614 "Replica reachable via HTTP status endpoint even though ICP CLI reports network '{network}' stopped; continuing from ICP root {}.",
2615 icp_root.display()
2616 );
2617 return Ok(());
2618 }
2619
2620 Err(format!(
2621 "icp environment is not running for network '{network}'\nStart the target replica in another terminal with `canic replica start` and rerun."
2622 )
2623 .into())
2624}
2625
2626fn icp_ping(icp_root: &Path, network: &str) -> Result<bool, Box<dyn std::error::Error>> {
2628 Ok(icp::default_command_in(icp_root)
2629 .args(["network", "ping", network])
2630 .output()?
2631 .status
2632 .success())
2633}
2634
2635fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
2636 println!("Install timing summary:");
2637 println!("{}", render_install_timing_summary(timings, total));
2638}
2639
2640fn render_install_timing_summary(timings: &InstallTimingSummary, total: Duration) -> String {
2641 let rows = [
2642 timing_row("create_canisters", timings.create_canisters),
2643 timing_row("build_all", timings.build_all),
2644 timing_row("emit_manifest", timings.emit_manifest),
2645 timing_row("install_root", timings.install_root),
2646 timing_row("fund_root", timings.fund_root),
2647 timing_row("stage_release_set", timings.stage_release_set),
2648 timing_row("resume_bootstrap", timings.resume_bootstrap),
2649 timing_row("wait_ready", timings.wait_ready),
2650 timing_row("finalize_root_funding", timings.finalize_root_funding),
2651 timing_row("total", total),
2652 ];
2653 render_table(
2654 &["PHASE", "ELAPSED"],
2655 &rows,
2656 &[ColumnAlign::Left, ColumnAlign::Right],
2657 )
2658}
2659
2660fn timing_row(label: &str, duration: Duration) -> [String; 2] {
2661 [label.to_string(), format!("{:.2}s", duration.as_secs_f64())]
2662}
2663
2664fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
2666 println!("Install result:");
2667 println!("{:<14} success", "status");
2668 println!("{:<14} {}", "fleet", fleet);
2669 println!("{:<14} {}", "install_state", state_path.display());
2670 println!(
2671 "{:<14} canic list {} --network {}",
2672 "smoke_check", fleet, network
2673 );
2674}
2675
2676fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
2678 icp::run_status(command).map_err(Into::into)
2679}
2680
2681fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
2683 icp::run_output(command).map_err(Into::into)
2684}
2685
2686fn icp_command_on_network(network: &str) -> Command {
2689 let mut command = icp::default_command();
2690 command.env("ICP_ENVIRONMENT", network);
2691 command
2692}
2693
2694fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
2696 let mut command = icp::default_command_in(icp_root);
2697 command.env("ICP_ENVIRONMENT", network);
2698 command
2699}
2700
2701fn icp_canister_command_in_network(icp_root: &Path) -> Command {
2703 let mut command = icp::default_command_in(icp_root);
2704 command.arg("canister");
2705 command
2706}
2707
2708fn add_icp_environment_target(command: &mut Command, network: &str) {
2709 icp::add_target_args(command, Some(network), None);
2710}