1use crate::canister_build::{
2 CanisterBuildProfile, build_current_workspace_canister_artifact,
3 current_workspace_build_context_once,
4};
5use crate::deployment_truth::{
6 DeploymentCheckV1, DeploymentCommandResultV1, DeploymentExecutionStatusV1, DeploymentReceiptV1,
7 LocalDeploymentCheckRequest, SafetyFindingV1, artifact_gate_phase_receipt,
8 artifact_gate_role_phase_receipts, check_local_deployment,
9 deployment_receipt_from_check_with_status, phase_receipt,
10};
11use crate::format::wasm_size_label;
12use crate::icp::{self, CANIC_ICP_LOCAL_NETWORK_URL_ENV, CANIC_ICP_LOCAL_ROOT_KEY_ENV};
13use crate::release_set::{
14 LOCAL_ROOT_MIN_READY_CYCLES, configured_fleet_name, configured_install_targets,
15 configured_local_root_create_cycles, emit_root_release_set_manifest_with_config,
16 icp_query_on_network, icp_root, load_root_release_set_manifest, resolve_artifact_root,
17 resume_root_bootstrap, stage_root_release_set, workspace_root,
18};
19use crate::replica_query;
20use crate::response_parse::parse_cycle_balance_response;
21use crate::table::{ColumnAlign, render_separator, render_table, render_table_row, table_widths};
22use canic_core::{
23 cdk::{types::Principal, utils::hash::wasm_hash},
24 protocol,
25};
26use config_selection::resolve_install_config_path;
27use serde_json::Value as JsonValue;
28use std::{
29 env,
30 ffi::OsString,
31 fs,
32 path::{Path, PathBuf},
33 process::Command,
34 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
35};
36
37mod config_selection;
38mod readiness;
39mod state;
40
41pub use config_selection::{
42 current_canic_project_root, discover_canic_config_choices, discover_canic_project_root_from,
43 discover_project_canic_config_choices, project_fleet_roots,
44};
45use readiness::wait_for_root_ready;
46use state::{
47 INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, validate_network_name, write_install_state,
48};
49pub use state::{
50 InstallState, read_named_fleet_install_state, read_named_fleet_install_state_from_root,
51};
52
53#[cfg(test)]
54mod tests;
55
56#[cfg(test)]
57use config_selection::config_selection_error;
58#[cfg(test)]
59use readiness::{parse_bootstrap_status_value, parse_root_ready_value};
60#[cfg(test)]
61use state::{fleet_install_state_path, read_fleet_install_state};
62
63#[derive(Clone, Debug)]
68pub struct InstallRootOptions {
69 pub root_canister: String,
70 pub root_build_target: String,
71 pub network: String,
72 pub icp_root: Option<PathBuf>,
73 pub build_profile: Option<CanisterBuildProfile>,
74 pub ready_timeout_seconds: u64,
75 pub config_path: Option<String>,
76 pub expected_fleet: Option<String>,
77 pub interactive_config_selection: bool,
78}
79
80#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
85struct InstallTimingSummary {
86 create_canisters: Duration,
87 build_all: Duration,
88 emit_manifest: Duration,
89 install_root: Duration,
90 fund_root: Duration,
91 stage_release_set: Duration,
92 resume_bootstrap: Duration,
93 wait_ready: Duration,
94 finalize_root_funding: Duration,
95}
96
97pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
99 let project_root = current_canic_project_root()?;
100 let choices = config_selection::discover_workspace_canic_config_choices(&project_root)?;
101 if !choices.is_empty() {
102 return Ok(choices);
103 }
104
105 if let Ok(icp_root) = icp_root()
106 && icp_root != project_root
107 {
108 return config_selection::discover_workspace_canic_config_choices(&icp_root);
109 }
110
111 Ok(choices)
112}
113
114pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
116 let workspace_root = workspace_root()?;
117 let icp_root = match &options.icp_root {
118 Some(path) => path.canonicalize()?,
119 None => icp_root()?,
120 };
121 let config_path = resolve_install_config_path(
122 &icp_root,
123 options.config_path.as_deref(),
124 options.interactive_config_selection,
125 )?;
126 let _install_env = BuildEnvGuard::apply(&options.network, &config_path, &icp_root);
127 let fleet_name = configured_fleet_name(&config_path)?;
128 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
129 validate_fleet_name(&fleet_name)?;
130 let total_started_at = Instant::now();
131 let mut timings = InstallTimingSummary::default();
132 let network = options.network.as_str();
133
134 println!("Installing fleet {fleet_name}");
135 println!();
136 let prepared = prepare_install_deployment_truth(
137 &options,
138 &workspace_root,
139 &icp_root,
140 &config_path,
141 &fleet_name,
142 )?;
143 timings.create_canisters = prepared.timings.create_canisters;
144 timings.build_all = prepared.timings.build_all;
145
146 let (manifest_path, emit_manifest_duration) = emit_manifest_with_deployment_truth_receipt(
147 &workspace_root,
148 &icp_root,
149 &options,
150 &config_path,
151 &fleet_name,
152 &prepared.deployment_truth_check,
153 )?;
154 timings.emit_manifest = emit_manifest_duration;
155 let activation_timings = run_root_activation_phases(
156 InstallReceiptScope {
157 icp_root: &icp_root,
158 network,
159 fleet_name: &fleet_name,
160 check: &prepared.deployment_truth_check,
161 },
162 &options,
163 &prepared.root_canister_id,
164 &manifest_path,
165 total_started_at,
166 )?;
167 timings.install_root = activation_timings.install_root;
168 timings.fund_root = activation_timings.fund_root;
169 timings.stage_release_set = activation_timings.stage_release_set;
170 timings.resume_bootstrap = activation_timings.resume_bootstrap;
171 timings.wait_ready = activation_timings.wait_ready;
172 timings.finalize_root_funding = activation_timings.finalize_root_funding;
173
174 print_install_timing_summary(&timings, total_started_at.elapsed());
175 let state = build_install_state(
176 &options,
177 &workspace_root,
178 &icp_root,
179 &config_path,
180 &manifest_path,
181 &fleet_name,
182 &prepared.root_canister_id,
183 )?;
184 let state_path = write_install_state_with_deployment_truth_receipt(
185 InstallReceiptScope {
186 icp_root: &icp_root,
187 network,
188 fleet_name: &fleet_name,
189 check: &prepared.deployment_truth_check,
190 },
191 &options.network,
192 &state,
193 )?;
194 print_install_result_summary(&options.network, &state.fleet, &state_path);
195 Ok(())
196}
197
198struct PreparedInstallTruth {
199 root_canister_id: String,
200 deployment_truth_check: DeploymentCheckV1,
201 timings: InstallTimingSummary,
202}
203
204fn prepare_install_deployment_truth(
205 options: &InstallRootOptions,
206 workspace_root: &Path,
207 icp_root: &Path,
208 config_path: &Path,
209 fleet_name: &str,
210) -> Result<PreparedInstallTruth, Box<dyn std::error::Error>> {
211 let mut timings = InstallTimingSummary::default();
212 ensure_icp_environment_ready(icp_root, &options.network)?;
213 let (root_canister_id, create_phase, create_duration) =
214 resolve_root_canister_with_phase(options, icp_root, config_path)?;
215 timings.create_canisters = create_duration;
216
217 let (build_phase, build_duration) =
218 build_install_targets_with_phase(options, icp_root, config_path)?;
219 timings.build_all = build_duration;
220
221 let deployment_truth_check = run_install_deployment_truth_safety_gate(
222 options,
223 workspace_root,
224 icp_root,
225 config_path,
226 fleet_name,
227 )?;
228 let receipt_scope = InstallReceiptScope {
229 icp_root,
230 network: &options.network,
231 fleet_name,
232 check: &deployment_truth_check,
233 };
234 write_completed_install_phase_receipt(receipt_scope, create_phase)?;
235 write_completed_install_phase_receipt(receipt_scope, build_phase)?;
236
237 Ok(PreparedInstallTruth {
238 root_canister_id,
239 deployment_truth_check,
240 timings,
241 })
242}
243
244fn resolve_root_canister_with_phase(
245 options: &InstallRootOptions,
246 icp_root: &Path,
247 config_path: &Path,
248) -> Result<(String, CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
249 let started_at = current_unix_timestamp_label()?;
250 let started = Instant::now();
251 let root_canister_id = ensure_root_canister_id(
252 icp_root,
253 &options.network,
254 &options.root_canister,
255 config_path,
256 )?;
257 let duration = started.elapsed();
258 let phase = CompletedInstallPhase {
259 phase: "resolve_root_canister",
260 attempted_action: "resolve or create root canister id",
261 started_at,
262 finished_at: Some(current_unix_timestamp_label()?),
263 evidence: vec![
264 format!("root_target:{}", options.root_canister),
265 format!("root_canister:{root_canister_id}"),
266 ],
267 role_names: Vec::new(),
268 };
269 Ok((root_canister_id, phase, duration))
270}
271
272fn build_install_targets_with_phase(
273 options: &InstallRootOptions,
274 icp_root: &Path,
275 config_path: &Path,
276) -> Result<(CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
277 let build_targets = configured_install_targets(config_path, &options.root_build_target)?;
278 let started_at = current_unix_timestamp_label()?;
279 let started = Instant::now();
280 run_canic_build_targets(
281 &options.network,
282 &build_targets,
283 options.build_profile,
284 config_path,
285 icp_root,
286 )?;
287 let duration = started.elapsed();
288 let phase = CompletedInstallPhase {
289 phase: "build_artifacts",
290 attempted_action: "build configured install targets",
291 started_at,
292 finished_at: Some(current_unix_timestamp_label()?),
293 evidence: build_targets
294 .iter()
295 .map(|target| format!("build_target:{target}"))
296 .collect(),
297 role_names: build_targets,
298 };
299 Ok((phase, duration))
300}
301
302fn emit_manifest_with_deployment_truth_receipt(
303 workspace_root: &Path,
304 icp_root: &Path,
305 options: &InstallRootOptions,
306 config_path: &Path,
307 fleet_name: &str,
308 deployment_truth_check: &DeploymentCheckV1,
309) -> Result<(PathBuf, Duration), Box<dyn std::error::Error>> {
310 let emit_manifest_started_at_label = current_unix_timestamp_label()?;
311 let emit_manifest_started_at = Instant::now();
312 let manifest_path = emit_root_release_set_manifest_with_config(
313 workspace_root,
314 icp_root,
315 &options.network,
316 config_path,
317 )?;
318 let emit_manifest_duration = emit_manifest_started_at.elapsed();
319 let emit_manifest_receipt = install_deployment_truth_phase_receipt(
320 deployment_truth_check,
321 "emit_manifest",
322 emit_manifest_started_at_label,
323 Some(current_unix_timestamp_label()?),
324 "emit root release-set manifest",
325 crate::deployment_truth::ObservationStatusV1::Observed,
326 vec![format!("manifest_path:{}", manifest_path.display())],
327 );
328 let emit_manifest_receipt_path = write_install_deployment_truth_receipt(
329 icp_root,
330 &options.network,
331 fleet_name,
332 &emit_manifest_receipt,
333 )?;
334 println!(
335 "Deployment truth receipt JSON: {}",
336 emit_manifest_receipt_path.display()
337 );
338 Ok((manifest_path, emit_manifest_duration))
339}
340
341fn run_root_activation_phases(
342 receipt_scope: InstallReceiptScope<'_>,
343 options: &InstallRootOptions,
344 root_canister_id: &str,
345 manifest_path: &Path,
346 total_started_at: Instant,
347) -> Result<InstallTimingSummary, Box<dyn std::error::Error>> {
348 let mut timings = InstallTimingSummary::default();
349 let root_wasm = resolve_artifact_root(receipt_scope.icp_root, receipt_scope.network)?
350 .join(&options.root_build_target)
351 .join(format!("{}.wasm", options.root_build_target));
352 timings.install_root = receipt_scope.run_phase(
353 "install_root",
354 "install root wasm",
355 vec![
356 format!("root_canister:{root_canister_id}"),
357 format!("root_wasm:{}", root_wasm.display()),
358 ],
359 || {
360 reinstall_root_wasm(
361 receipt_scope.icp_root,
362 receipt_scope.network,
363 root_canister_id,
364 &root_wasm,
365 )
366 },
367 )?;
368 timings.fund_root = receipt_scope.run_phase(
369 "fund_root_pre_bootstrap",
370 "ensure local root minimum cycles before bootstrap",
371 vec![
372 format!("root_canister:{root_canister_id}"),
373 format!("minimum_cycles:{LOCAL_ROOT_MIN_READY_CYCLES}"),
374 ],
375 || {
376 ensure_local_root_min_cycles(
377 receipt_scope.icp_root,
378 receipt_scope.network,
379 root_canister_id,
380 "pre-bootstrap",
381 )
382 },
383 )?;
384 let manifest = load_root_release_set_manifest(manifest_path)?;
385 timings.stage_release_set = receipt_scope.run_phase(
386 "stage_release_set",
387 "stage root release set",
388 vec![
389 format!("root_canister:{root_canister_id}"),
390 format!("manifest_path:{}", manifest_path.display()),
391 ],
392 || {
393 stage_root_release_set(
394 receipt_scope.icp_root,
395 receipt_scope.network,
396 root_canister_id,
397 &manifest,
398 )
399 },
400 )?;
401 timings.resume_bootstrap = receipt_scope.run_phase(
402 "resume_bootstrap",
403 "resume root bootstrap",
404 vec![format!("root_canister:{root_canister_id}")],
405 || resume_root_bootstrap(receipt_scope.network, root_canister_id),
406 )?;
407 let wait_ready_result = receipt_scope.run_phase(
408 "wait_ready",
409 "wait for root bootstrap readiness",
410 vec![
411 format!("root_canister:{root_canister_id}"),
412 format!("timeout_seconds:{}", options.ready_timeout_seconds),
413 ],
414 || {
415 wait_for_root_ready(
416 receipt_scope.network,
417 root_canister_id,
418 options.ready_timeout_seconds,
419 )
420 },
421 );
422 match wait_ready_result {
423 Ok(duration) => timings.wait_ready = duration,
424 Err(err) => {
425 print_install_timing_summary(&timings, total_started_at.elapsed());
426 return Err(err);
427 }
428 }
429 timings.finalize_root_funding = receipt_scope.run_phase(
430 "fund_root_post_ready",
431 "ensure local root minimum cycles after ready",
432 vec![
433 format!("root_canister:{root_canister_id}"),
434 format!("minimum_cycles:{LOCAL_ROOT_MIN_READY_CYCLES}"),
435 ],
436 || {
437 ensure_local_root_min_cycles(
438 receipt_scope.icp_root,
439 receipt_scope.network,
440 root_canister_id,
441 "post-ready",
442 )
443 },
444 )?;
445 Ok(timings)
446}
447
448#[derive(Clone, Copy)]
449struct InstallReceiptScope<'a> {
450 icp_root: &'a Path,
451 network: &'a str,
452 fleet_name: &'a str,
453 check: &'a DeploymentCheckV1,
454}
455
456struct CompletedInstallPhase {
457 phase: &'static str,
458 attempted_action: &'static str,
459 started_at: String,
460 finished_at: Option<String>,
461 evidence: Vec<String>,
462 role_names: Vec<String>,
463}
464
465fn write_completed_install_phase_receipt(
466 receipt_scope: InstallReceiptScope<'_>,
467 completed: CompletedInstallPhase,
468) -> Result<PathBuf, Box<dyn std::error::Error>> {
469 let role_phase_receipts = completed
470 .role_names
471 .iter()
472 .filter_map(|role| {
473 completed_phase_role_receipt(
474 receipt_scope.check,
475 completed.phase,
476 role,
477 crate::deployment_truth::RolePhaseResultV1::Applied,
478 None,
479 )
480 })
481 .collect();
482 let receipt = install_deployment_truth_phase_receipt_with_result(
483 receipt_scope.check,
484 PhaseReceiptInput {
485 phase: completed.phase,
486 started_at: completed.started_at,
487 finished_at: completed.finished_at,
488 attempted_action: completed.attempted_action,
489 status: crate::deployment_truth::ObservationStatusV1::Observed,
490 evidence: completed.evidence,
491 role_phase_receipts,
492 operation_status: DeploymentExecutionStatusV1::Complete,
493 command_result: DeploymentCommandResultV1::Succeeded,
494 },
495 );
496 receipt_scope.write_receipt(&receipt)
497}
498
499fn completed_phase_role_receipt(
500 check: &DeploymentCheckV1,
501 phase: &str,
502 role: &str,
503 result: crate::deployment_truth::RolePhaseResultV1,
504 error: Option<String>,
505) -> Option<crate::deployment_truth::RolePhaseReceiptV1> {
506 let planned = check
507 .plan
508 .role_artifacts
509 .iter()
510 .find(|artifact| artifact.role == role)?;
511 let observed = check
512 .inventory
513 .observed_artifacts
514 .iter()
515 .find(|artifact| artifact.role == role);
516 let artifact_digest = observed
517 .and_then(|artifact| artifact.file_sha256.clone())
518 .or_else(|| observed.and_then(|artifact| artifact.payload_sha256.clone()))
519 .or_else(|| planned.observed_wasm_gz_file_sha256.clone())
520 .or_else(|| planned.wasm_gz_sha256.clone());
521
522 Some(crate::deployment_truth::RolePhaseReceiptV1 {
523 role: role.to_string(),
524 phase: phase.to_string(),
525 result,
526 previous_module_hash: None,
527 target_module_hash: planned.installed_module_hash.clone(),
528 observed_module_hash_after: None,
529 artifact_digest,
530 canonical_embedded_config_sha256: planned.canonical_embedded_config_sha256.clone(),
531 error,
532 })
533}
534
535fn write_install_state_with_deployment_truth_receipt(
536 receipt_scope: InstallReceiptScope<'_>,
537 network: &str,
538 state: &InstallState,
539) -> Result<PathBuf, Box<dyn std::error::Error>> {
540 let started_at = current_unix_timestamp_label()?;
541 let state_path = write_install_state(receipt_scope.icp_root, network, state)?;
542 let completed = CompletedInstallPhase {
543 phase: "write_install_state",
544 attempted_action: "write local install state",
545 started_at,
546 finished_at: Some(current_unix_timestamp_label()?),
547 evidence: vec![
548 format!("install_state:{}", state_path.display()),
549 format!("fleet:{}", state.fleet),
550 format!("root_canister:{}", state.root_canister_id),
551 ],
552 role_names: Vec::new(),
553 };
554 write_completed_install_phase_receipt(receipt_scope, completed)?;
555 Ok(state_path)
556}
557
558impl InstallReceiptScope<'_> {
559 fn run_phase(
560 self,
561 phase: &str,
562 attempted_action: &str,
563 evidence: Vec<String>,
564 run: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
565 ) -> Result<Duration, Box<dyn std::error::Error>> {
566 let started_at = current_unix_timestamp_label()?;
567 let started = Instant::now();
568 match run() {
569 Ok(()) => {
570 let duration = started.elapsed();
571 let receipt = install_deployment_truth_phase_receipt(
572 self.check,
573 phase,
574 started_at,
575 Some(current_unix_timestamp_label()?),
576 attempted_action,
577 crate::deployment_truth::ObservationStatusV1::Observed,
578 evidence,
579 );
580 self.write_receipt(&receipt)?;
581 Ok(duration)
582 }
583 Err(err) => {
584 self.try_write_failed_phase_receipt(
585 phase,
586 started_at,
587 attempted_action,
588 evidence,
589 err.as_ref(),
590 );
591 Err(err)
592 }
593 }
594 }
595
596 fn write_receipt(
597 self,
598 receipt: &DeploymentReceiptV1,
599 ) -> Result<PathBuf, Box<dyn std::error::Error>> {
600 let path = write_install_deployment_truth_receipt(
601 self.icp_root,
602 self.network,
603 self.fleet_name,
604 receipt,
605 )?;
606 println!("Deployment truth receipt JSON: {}", path.display());
607 Ok(path)
608 }
609
610 fn try_write_failed_phase_receipt(
611 self,
612 phase: &str,
613 started_at: String,
614 attempted_action: &str,
615 evidence: Vec<String>,
616 err: &dyn std::error::Error,
617 ) {
618 let receipt = install_deployment_truth_phase_receipt_with_result(
619 self.check,
620 PhaseReceiptInput {
621 phase,
622 started_at,
623 finished_at: Some(
624 current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string()),
625 ),
626 attempted_action,
627 status: crate::deployment_truth::ObservationStatusV1::Inconclusive,
628 evidence,
629 role_phase_receipts: Vec::new(),
630 operation_status: DeploymentExecutionStatusV1::FailedAfterMutation,
631 command_result: DeploymentCommandResultV1::Failed {
632 code: format!("{phase}_failed"),
633 message: err.to_string(),
634 },
635 },
636 );
637 if let Err(write_err) = self.write_receipt(&receipt) {
638 eprintln!("Deployment truth receipt JSON write failed: {write_err}");
639 }
640 }
641}
642
643pub fn check_install_deployment_truth(
646 options: &InstallRootOptions,
647 observed_at: impl Into<String>,
648) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
649 let workspace_root = workspace_root()?;
650 let icp_root = match &options.icp_root {
651 Some(path) => path.canonicalize()?,
652 None => icp_root()?,
653 };
654 let config_path = resolve_install_config_path(
655 &icp_root,
656 options.config_path.as_deref(),
657 options.interactive_config_selection,
658 )?;
659 let fleet_name = configured_fleet_name(&config_path)?;
660 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
661 validate_fleet_name(&fleet_name)?;
662 current_install_deployment_truth_check_at(
663 options,
664 &workspace_root,
665 &icp_root,
666 &config_path,
667 &fleet_name,
668 observed_at.into(),
669 )
670}
671
672fn current_install_deployment_truth_check_at(
673 options: &InstallRootOptions,
674 workspace_root: &Path,
675 icp_root: &Path,
676 config_path: &Path,
677 fleet_name: &str,
678 observed_at: String,
679) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
680 let build_profile = options
681 .build_profile
682 .unwrap_or_else(CanisterBuildProfile::current)
683 .target_dir_name()
684 .to_string();
685
686 check_local_deployment(&LocalDeploymentCheckRequest {
687 deployment_name: fleet_name.to_string(),
688 network: options.network.clone(),
689 workspace_root: workspace_root.to_path_buf(),
690 icp_root: icp_root.to_path_buf(),
691 config_path: Some(config_path.to_path_buf()),
692 observed_at,
693 runtime_variant: options.network.clone(),
694 build_profile,
695 })
696 .map_err(Into::into)
697}
698
699fn run_install_deployment_truth_safety_gate(
700 options: &InstallRootOptions,
701 workspace_root: &Path,
702 icp_root: &Path,
703 config_path: &Path,
704 fleet_name: &str,
705) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
706 let truth_gate_started_at = current_unix_timestamp_label()?;
707 let deployment_truth_check = current_install_deployment_truth_check_at(
708 options,
709 workspace_root,
710 icp_root,
711 config_path,
712 fleet_name,
713 truth_gate_started_at.clone(),
714 )?;
715 let artifact_gate_receipt = artifact_gate_phase_receipt(
716 &deployment_truth_check,
717 truth_gate_started_at.clone(),
718 Some(current_unix_timestamp_label()?),
719 );
720 let role_receipts = artifact_gate_role_phase_receipts(&deployment_truth_check);
721 let deployment_receipt = install_deployment_truth_gate_receipt(
722 &deployment_truth_check,
723 truth_gate_started_at,
724 vec![artifact_gate_receipt],
725 role_receipts,
726 );
727 let receipt_write = write_install_deployment_truth_receipt(
728 icp_root,
729 &options.network,
730 fleet_name,
731 &deployment_receipt,
732 );
733 match &receipt_write {
734 Ok(path) => println!("Deployment truth receipt JSON: {}", path.display()),
735 Err(err) => eprintln!("Deployment truth receipt JSON write failed: {err}"),
736 }
737 print_install_deployment_truth_gate(&deployment_truth_check, &deployment_receipt);
738 enforce_install_deployment_truth_gate(&deployment_truth_check)?;
739 receipt_write?;
740 Ok(deployment_truth_check)
741}
742
743fn enforce_install_deployment_truth_gate(
744 check: &DeploymentCheckV1,
745) -> Result<(), Box<dyn std::error::Error>> {
746 let blockers = install_deployment_truth_gate_blockers(check);
747 if blockers.is_empty() {
748 return Ok(());
749 }
750
751 let details = blockers
752 .iter()
753 .map(|finding| deployment_truth_finding_label(finding))
754 .collect::<Vec<_>>()
755 .join("; ");
756 Err(format!("deployment truth safety gate blocked install: {details}").into())
757}
758
759fn install_deployment_truth_gate_blockers(check: &DeploymentCheckV1) -> Vec<&SafetyFindingV1> {
760 check.report.hard_failures.iter().collect()
761}
762
763fn print_install_deployment_truth_gate(check: &DeploymentCheckV1, receipt: &DeploymentReceiptV1) {
764 for line in install_deployment_truth_gate_lines(check, receipt) {
765 println!("{line}");
766 }
767}
768
769fn install_deployment_truth_gate_lines(
770 check: &DeploymentCheckV1,
771 receipt: &DeploymentReceiptV1,
772) -> Vec<String> {
773 let mut lines = vec![
774 format!("Deployment truth: {}", check.report.summary),
775 format!(
776 "Deployment truth receipt: operation={} status={:?}",
777 receipt.operation_id, receipt.operation_status
778 ),
779 ];
780 for phase_receipt in &receipt.phase_receipts {
781 lines.push(format!(
782 "Deployment truth phase receipt: phase={} postcondition={:?}",
783 phase_receipt.phase, phase_receipt.verified_postcondition.status
784 ));
785 }
786 if !receipt.role_phase_receipts.is_empty() {
787 lines.push(format!(
788 "Deployment truth role receipts: {}",
789 receipt.role_phase_receipts.len()
790 ));
791 }
792 for role_receipt in &receipt.role_phase_receipts {
793 lines.push(format!(
794 "Deployment truth role receipt: phase={} role={} result={:?}",
795 role_receipt.phase, role_receipt.role, role_receipt.result
796 ));
797 }
798
799 if !check.report.hard_failures.is_empty() {
800 lines.push(format!(
801 "Deployment truth hard failures: {}",
802 check.report.hard_failures.len()
803 ));
804 }
805 for finding in install_deployment_truth_gate_blockers(check) {
806 lines.push(format!(
807 "Deployment truth blocker: {}",
808 deployment_truth_finding_label(finding)
809 ));
810 }
811 if !check.report.warnings.is_empty() {
812 lines.push(format!(
813 "Deployment truth warnings: {}",
814 check.report.warnings.len()
815 ));
816 }
817 for finding in &check.report.warnings {
818 lines.push(format!(
819 "Deployment truth warning: {}",
820 deployment_truth_finding_label(finding)
821 ));
822 }
823 lines
824}
825
826fn install_deployment_truth_gate_receipt(
827 check: &DeploymentCheckV1,
828 started_at: String,
829 phase_receipts: Vec<crate::deployment_truth::PhaseReceiptV1>,
830 role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
831) -> DeploymentReceiptV1 {
832 let blockers = install_deployment_truth_gate_blockers(check);
833 let (operation_status, command_result) = if blockers.is_empty() {
834 (
835 DeploymentExecutionStatusV1::Complete,
836 DeploymentCommandResultV1::Succeeded,
837 )
838 } else {
839 (
840 DeploymentExecutionStatusV1::FailedBeforeMutation,
841 DeploymentCommandResultV1::Failed {
842 code: "deployment_truth_blocked".to_string(),
843 message: check.report.summary.clone(),
844 },
845 )
846 };
847 deployment_receipt_from_check_with_status(
848 check,
849 format!("{}:materialize_artifacts", check.check_id),
850 operation_status,
851 started_at,
852 Some(current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string())),
853 phase_receipts,
854 role_phase_receipts,
855 command_result,
856 )
857}
858
859fn install_deployment_truth_phase_receipt(
860 check: &DeploymentCheckV1,
861 phase: &str,
862 started_at: String,
863 finished_at: Option<String>,
864 attempted_action: &str,
865 status: crate::deployment_truth::ObservationStatusV1,
866 evidence: Vec<String>,
867) -> DeploymentReceiptV1 {
868 install_deployment_truth_phase_receipt_with_result(
869 check,
870 PhaseReceiptInput {
871 phase,
872 started_at,
873 finished_at,
874 attempted_action,
875 status,
876 evidence,
877 role_phase_receipts: Vec::new(),
878 operation_status: DeploymentExecutionStatusV1::Complete,
879 command_result: DeploymentCommandResultV1::Succeeded,
880 },
881 )
882}
883
884fn install_deployment_truth_phase_receipt_with_result(
885 check: &DeploymentCheckV1,
886 input: PhaseReceiptInput<'_>,
887) -> DeploymentReceiptV1 {
888 deployment_receipt_from_check_with_status(
889 check,
890 format!("{}:{}", check.check_id, input.phase),
891 input.operation_status,
892 input.started_at.clone(),
893 input.finished_at.clone(),
894 vec![phase_receipt(
895 input.phase,
896 input.started_at,
897 input.finished_at,
898 input.attempted_action,
899 input.status,
900 input.evidence,
901 )],
902 input.role_phase_receipts,
903 input.command_result,
904 )
905}
906
907struct PhaseReceiptInput<'a> {
908 phase: &'a str,
909 started_at: String,
910 finished_at: Option<String>,
911 attempted_action: &'a str,
912 status: crate::deployment_truth::ObservationStatusV1,
913 evidence: Vec<String>,
914 role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
915 operation_status: DeploymentExecutionStatusV1,
916 command_result: DeploymentCommandResultV1,
917}
918
919fn write_install_deployment_truth_receipt(
920 icp_root: &Path,
921 network: &str,
922 fleet_name: &str,
923 receipt: &DeploymentReceiptV1,
924) -> Result<PathBuf, Box<dyn std::error::Error>> {
925 let path = install_deployment_truth_receipt_path(icp_root, network, fleet_name, receipt)?;
926 if let Some(parent) = path.parent() {
927 fs::create_dir_all(parent)?;
928 }
929 let mut bytes = serde_json::to_vec_pretty(receipt)?;
930 bytes.push(b'\n');
931 fs::write(&path, bytes)?;
932 Ok(path)
933}
934
935fn install_deployment_truth_receipt_path(
936 icp_root: &Path,
937 network: &str,
938 fleet_name: &str,
939 receipt: &DeploymentReceiptV1,
940) -> Result<PathBuf, Box<dyn std::error::Error>> {
941 validate_network_name(network)?;
942 validate_fleet_name(fleet_name)?;
943 let file_stem = format!(
944 "{}-{}",
945 safe_deployment_truth_path_label(&receipt.started_at),
946 safe_deployment_truth_path_label(&receipt.operation_id)
947 );
948 Ok(
949 install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?
950 .join(format!("{file_stem}.json")),
951 )
952}
953
954pub fn latest_deployment_truth_receipt_path_from_root(
956 icp_root: &Path,
957 network: &str,
958 fleet_name: &str,
959) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
960 let dir = install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?;
961 if !dir.is_dir() {
962 return Ok(None);
963 }
964
965 let mut latest = None;
966 for entry in fs::read_dir(dir)? {
967 let path = entry?.path();
968 if !path.is_file()
969 || path
970 .extension()
971 .is_none_or(|ext| !ext.eq_ignore_ascii_case("json"))
972 {
973 continue;
974 }
975 if latest.as_ref().is_none_or(|current| path > *current) {
976 latest = Some(path);
977 }
978 }
979 Ok(latest)
980}
981
982fn install_deployment_truth_receipts_dir(
983 icp_root: &Path,
984 network: &str,
985 fleet_name: &str,
986) -> Result<PathBuf, Box<dyn std::error::Error>> {
987 validate_network_name(network)?;
988 validate_fleet_name(fleet_name)?;
989 Ok(icp_root
990 .join(".canic")
991 .join(network)
992 .join("deployment-receipts")
993 .join(fleet_name))
994}
995
996fn safe_deployment_truth_path_label(value: &str) -> String {
997 let label = value
998 .chars()
999 .map(|ch| {
1000 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
1001 ch
1002 } else {
1003 '_'
1004 }
1005 })
1006 .collect::<String>();
1007 if label.is_empty() {
1008 "unknown".to_string()
1009 } else {
1010 label
1011 }
1012}
1013
1014fn deployment_truth_finding_label(finding: &SafetyFindingV1) -> String {
1015 let subject = finding
1016 .subject
1017 .as_ref()
1018 .map_or_else(|| "<none>".to_string(), Clone::clone);
1019 format!(
1020 "{}:{}:{}: {}",
1021 deployment_truth_finding_source(&finding.code),
1022 finding.code,
1023 subject,
1024 finding.message
1025 )
1026}
1027
1028fn deployment_truth_finding_source(code: &str) -> &'static str {
1029 match code {
1030 "plan_assumption" => "plan",
1031 "observation_gap" => "inventory",
1032 _ => "diff",
1033 }
1034}
1035
1036fn validate_expected_fleet_name(
1037 expected: Option<&str>,
1038 actual: &str,
1039 config_path: &Path,
1040) -> Result<(), Box<dyn std::error::Error>> {
1041 let Some(expected) = expected else {
1042 return Ok(());
1043 };
1044 if expected == actual {
1045 return Ok(());
1046 }
1047 Err(format!(
1048 "install requested fleet {expected}, but {} declares [fleet].name = {actual:?}",
1049 config_path.display()
1050 )
1051 .into())
1052}
1053
1054fn ensure_root_canister_id(
1055 icp_root: &Path,
1056 network: &str,
1057 root_canister: &str,
1058 config_path: &Path,
1059) -> Result<String, Box<dyn std::error::Error>> {
1060 if Principal::from_text(root_canister).is_ok() {
1061 return Ok(root_canister.to_string());
1062 }
1063
1064 match resolve_root_canister_id(icp_root, network, root_canister) {
1065 Ok(canister_id) => return Ok(canister_id),
1066 Err(err) if !is_missing_canister_id_error(&err.to_string()) => return Err(err),
1067 Err(_) => {}
1068 }
1069
1070 let mut create = icp_canister_command_in_network(icp_root);
1071 add_create_root_target(&mut create, root_canister);
1072 add_local_root_create_cycles_arg(&mut create, config_path, network)?;
1073 add_icp_environment_target(&mut create, network);
1074 let output = run_command_stdout(&mut create)?;
1075 if let Some(canister_id) = parse_created_canister_id(&output) {
1076 return Ok(canister_id);
1077 }
1078
1079 resolve_root_canister_id(icp_root, network, root_canister).map_err(|_| {
1080 format!(
1081 "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.",
1082 icp_root.display(),
1083 icp_root.display(),
1084 )
1085 .into()
1086 })
1087}
1088
1089fn parse_created_canister_id(output: &str) -> Option<String> {
1090 if let Ok(value) = serde_json::from_str::<JsonValue>(output) {
1091 return parse_canister_id_json(&value);
1092 }
1093
1094 output
1095 .lines()
1096 .map(str::trim)
1097 .find(|line| Principal::from_text(*line).is_ok())
1098 .map(ToString::to_string)
1099}
1100
1101fn parse_canister_id_json(value: &JsonValue) -> Option<String> {
1102 match value {
1103 JsonValue::String(text) if Principal::from_text(text).is_ok() => Some(text.clone()),
1104 JsonValue::Array(values) => values.iter().find_map(parse_canister_id_json),
1105 JsonValue::Object(object) => ["canister_id", "id", "principal"]
1106 .iter()
1107 .filter_map(|key| object.get(*key))
1108 .find_map(parse_canister_id_json),
1109 _ => None,
1110 }
1111}
1112
1113fn add_create_root_target(command: &mut Command, root_canister: &str) {
1114 if env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV).is_some() {
1115 command.args(["create", "--detached", "--json"]);
1116 } else {
1117 command.args(["create", root_canister, "--json"]);
1118 }
1119}
1120
1121fn is_missing_canister_id_error(message: &str) -> bool {
1122 message.contains("failed to lookup canister ID")
1123 || message.contains("could not find ID for canister")
1124 || message.contains("Canister ID is missing")
1125}
1126
1127fn reinstall_root_wasm(
1128 icp_root: &Path,
1129 network: &str,
1130 root_canister: &str,
1131 root_wasm: &Path,
1132) -> Result<(), Box<dyn std::error::Error>> {
1133 let mut install = icp_canister_command_in_network(icp_root);
1134 install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
1135 install.arg(root_wasm);
1136 install.args(["--args", &root_init_args(root_wasm)?]);
1137 add_icp_environment_target(&mut install, network);
1138 run_command(&mut install)
1139}
1140
1141fn root_init_args(root_wasm: &Path) -> Result<String, Box<dyn std::error::Error>> {
1142 let wasm = std::fs::read(root_wasm)?;
1143 Ok(format!(
1144 "(variant {{ PrimeWithModuleHash = {} }})",
1145 idl_blob(&wasm_hash(&wasm))
1146 ))
1147}
1148
1149fn idl_blob(bytes: &[u8]) -> String {
1150 let mut encoded = String::from("blob \"");
1151 for byte in bytes {
1152 use std::fmt::Write as _;
1153 let _ = write!(encoded, "\\{byte:02X}");
1154 }
1155 encoded.push('"');
1156 encoded
1157}
1158
1159fn build_install_state(
1161 options: &InstallRootOptions,
1162 workspace_root: &Path,
1163 icp_root: &Path,
1164 config_path: &Path,
1165 release_set_manifest_path: &Path,
1166 fleet_name: &str,
1167 root_canister_id: &str,
1168) -> Result<InstallState, Box<dyn std::error::Error>> {
1169 Ok(InstallState {
1170 schema_version: INSTALL_STATE_SCHEMA_VERSION,
1171 fleet: fleet_name.to_string(),
1172 installed_at_unix_secs: current_unix_secs()?,
1173 network: options.network.clone(),
1174 root_target: options.root_canister.clone(),
1175 root_canister_id: root_canister_id.to_string(),
1176 root_build_target: options.root_build_target.clone(),
1177 workspace_root: workspace_root.display().to_string(),
1178 icp_root: icp_root.display().to_string(),
1179 config_path: config_path.display().to_string(),
1180 release_set_manifest_path: release_set_manifest_path.display().to_string(),
1181 })
1182}
1183
1184fn resolve_root_canister_id(
1186 icp_root: &Path,
1187 network: &str,
1188 root_canister: &str,
1189) -> Result<String, Box<dyn std::error::Error>> {
1190 if Principal::from_text(root_canister).is_ok() {
1191 return Ok(root_canister.to_string());
1192 }
1193
1194 let mut command = icp_canister_command_in_network(icp_root);
1195 command.args(["status", root_canister, "--json"]);
1196 add_icp_environment_target(&mut command, network);
1197 let output = run_command_stdout(&mut command)?;
1198 parse_created_canister_id(&output).ok_or_else(|| {
1199 format!("could not parse root canister id from ICP status JSON output: {output}").into()
1200 })
1201}
1202
1203fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
1205 Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
1206}
1207
1208fn current_unix_timestamp_label() -> Result<String, Box<dyn std::error::Error>> {
1209 Ok(format!("unix:{}", current_unix_secs()?))
1210}
1211
1212fn run_canic_build_targets(
1214 network: &str,
1215 targets: &[String],
1216 build_profile: Option<CanisterBuildProfile>,
1217 config_path: &Path,
1218 icp_root: &Path,
1219) -> Result<(), Box<dyn std::error::Error>> {
1220 let _env = BuildEnvGuard::apply(network, config_path, icp_root);
1221 let profile = build_profile.unwrap_or_else(CanisterBuildProfile::current);
1222 if let Some(context) = current_workspace_build_context_once(profile)? {
1223 for line in context.lines() {
1224 println!("{line}");
1225 }
1226 println!("config: {}", config_path.display());
1227 println!(
1228 "artifacts: {}",
1229 planned_build_artifact_root(icp_root).display()
1230 );
1231 println!();
1232 }
1233
1234 fs::create_dir_all(planned_build_artifact_root(icp_root))?;
1235 println!("Building {} canisters", targets.len());
1236 println!();
1237 let headers = ["CANISTER", "PROGRESS", "WASM", "ELAPSED"];
1238 let planned_rows = targets
1239 .iter()
1240 .map(|target| {
1241 [
1242 target.clone(),
1243 progress_bar(targets.len(), targets.len(), 10),
1244 "000.00 MiB (gz 000.00 MiB)".to_string(),
1245 "0.00s".to_string(),
1246 ]
1247 })
1248 .collect::<Vec<_>>();
1249 let alignments = [
1250 ColumnAlign::Left,
1251 ColumnAlign::Left,
1252 ColumnAlign::Right,
1253 ColumnAlign::Right,
1254 ];
1255 let widths = table_widths(&headers, &planned_rows);
1256 println!("{}", render_table_row(&headers, &widths, &alignments));
1257 println!("{}", render_separator(&widths));
1258
1259 for (index, target) in targets.iter().enumerate() {
1260 let started_at = Instant::now();
1261 let output = build_current_workspace_canister_artifact(target, profile)
1262 .map_err(|err| format!("artifact build failed for {target}: {err}"))?;
1263 let elapsed = started_at.elapsed();
1264 let artifact_size = wasm_artifact_size(&output.wasm_path, &output.wasm_gz_path)?;
1265
1266 let row = [
1267 target.clone(),
1268 progress_bar(index + 1, targets.len(), 10),
1269 artifact_size,
1270 format!("{:.2}s", elapsed.as_secs_f64()),
1271 ];
1272 println!("{}", render_table_row(&row, &widths, &alignments));
1273 }
1274
1275 println!();
1276 Ok(())
1277}
1278
1279fn planned_build_artifact_root(icp_root: &Path) -> PathBuf {
1280 icp_root.join(".icp/local/canisters")
1281}
1282
1283fn wasm_artifact_size(
1284 wasm_path: &Path,
1285 wasm_gz_path: &Path,
1286) -> Result<String, Box<dyn std::error::Error>> {
1287 let wasm_bytes = Some(std::fs::metadata(wasm_path)?.len());
1288 let gzip_bytes = std::fs::metadata(wasm_gz_path)
1289 .ok()
1290 .map(|metadata| metadata.len());
1291 Ok(wasm_size_label(wasm_bytes, gzip_bytes))
1292}
1293
1294struct BuildEnvGuard {
1295 previous_network: Option<OsString>,
1296 previous_config_path: Option<OsString>,
1297 previous_icp_root: Option<OsString>,
1298 previous_local_network_url: Option<OsString>,
1299 previous_local_root_key: Option<OsString>,
1300}
1301
1302impl BuildEnvGuard {
1303 fn apply(network: &str, config_path: &Path, icp_root: &Path) -> Self {
1304 let guard = Self {
1305 previous_network: env::var_os("ICP_ENVIRONMENT"),
1306 previous_config_path: env::var_os("CANIC_CONFIG_PATH"),
1307 previous_icp_root: env::var_os("CANIC_ICP_ROOT"),
1308 previous_local_network_url: env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV),
1309 previous_local_root_key: env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV),
1310 };
1311 set_env("ICP_ENVIRONMENT", network);
1312 set_env("CANIC_CONFIG_PATH", config_path);
1313 set_env("CANIC_ICP_ROOT", icp_root);
1314 if let Some(target) = local_replica_icp_target(network, icp_root) {
1315 set_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV, target.url);
1316 set_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV, target.root_key);
1317 } else {
1318 remove_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV);
1319 remove_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV);
1320 }
1321 guard
1322 }
1323}
1324
1325impl Drop for BuildEnvGuard {
1326 fn drop(&mut self) {
1327 restore_env("ICP_ENVIRONMENT", self.previous_network.take());
1328 restore_env("CANIC_CONFIG_PATH", self.previous_config_path.take());
1329 restore_env("CANIC_ICP_ROOT", self.previous_icp_root.take());
1330 restore_env(
1331 CANIC_ICP_LOCAL_NETWORK_URL_ENV,
1332 self.previous_local_network_url.take(),
1333 );
1334 restore_env(
1335 CANIC_ICP_LOCAL_ROOT_KEY_ENV,
1336 self.previous_local_root_key.take(),
1337 );
1338 }
1339}
1340
1341struct LocalReplicaIcpTarget {
1342 url: String,
1343 root_key: String,
1344}
1345
1346fn local_replica_icp_target(network: &str, icp_root: &Path) -> Option<LocalReplicaIcpTarget> {
1347 if !replica_query::should_use_local_replica_query(Some(network)) {
1348 return None;
1349 }
1350 if icp_ping(icp_root, network).unwrap_or(false) {
1351 return None;
1352 }
1353 let root_key = replica_query::local_replica_root_key_from_root(Some(network), icp_root)
1354 .ok()
1355 .flatten()?;
1356 Some(LocalReplicaIcpTarget {
1357 url: replica_query::local_replica_endpoint_from_root(Some(network), icp_root),
1358 root_key,
1359 })
1360}
1361
1362fn set_env<K, V>(key: K, value: V)
1363where
1364 K: AsRef<std::ffi::OsStr>,
1365 V: AsRef<std::ffi::OsStr>,
1366{
1367 unsafe {
1370 env::set_var(key, value);
1371 }
1372}
1373
1374fn remove_env<K>(key: K)
1375where
1376 K: AsRef<std::ffi::OsStr>,
1377{
1378 unsafe {
1381 env::remove_var(key);
1382 }
1383}
1384
1385fn restore_env(key: &str, value: Option<OsString>) {
1386 if let Some(value) = value {
1388 set_env(key, value);
1389 } else {
1390 remove_env(key);
1391 }
1392}
1393
1394fn add_local_root_create_cycles_arg(
1395 command: &mut Command,
1396 config_path: &Path,
1397 network: &str,
1398) -> Result<(), Box<dyn std::error::Error>> {
1399 if network != "local" {
1400 return Ok(());
1401 }
1402
1403 let cycles = configured_local_root_create_cycles(config_path)?;
1404 command.args(["--cycles", &cycles.to_string()]);
1405 Ok(())
1406}
1407
1408fn ensure_local_root_min_cycles(
1409 icp_root: &Path,
1410 network: &str,
1411 root_canister: &str,
1412 phase: &str,
1413) -> Result<(), Box<dyn std::error::Error>> {
1414 if network != "local" {
1415 return Ok(());
1416 }
1417
1418 let current = query_root_cycle_balance(network, root_canister)?;
1419 if current >= LOCAL_ROOT_MIN_READY_CYCLES {
1420 return Ok(());
1421 }
1422
1423 let amount = LOCAL_ROOT_MIN_READY_CYCLES.saturating_sub(current);
1424 let mut command = icp_canister_command_in_network(icp_root);
1425 command
1426 .args(["top-up", "--amount"])
1427 .arg(amount.to_string())
1428 .arg(root_canister);
1429 add_icp_environment_target(&mut command, network);
1430 run_command(&mut command)?;
1431 println!(
1432 "Local root cycles ({phase}): topped up {} ({} -> {} target)",
1433 crate::format::cycles_tc(amount),
1434 crate::format::cycles_tc(current),
1435 crate::format::cycles_tc(LOCAL_ROOT_MIN_READY_CYCLES)
1436 );
1437 Ok(())
1438}
1439
1440fn query_root_cycle_balance(
1441 network: &str,
1442 root_canister: &str,
1443) -> Result<u128, Box<dyn std::error::Error>> {
1444 let output = icp_query_on_network(
1445 network,
1446 root_canister,
1447 protocol::CANIC_CYCLE_BALANCE,
1448 None,
1449 Some("json"),
1450 )?;
1451 parse_cycle_balance_response(&output).ok_or_else(|| {
1452 format!(
1453 "could not parse {root_canister} {} response: {output}",
1454 protocol::CANIC_CYCLE_BALANCE
1455 )
1456 .into()
1457 })
1458}
1459
1460fn progress_bar(current: usize, total: usize, width: usize) -> String {
1461 if total == 0 || width == 0 {
1462 return "[] 0/0".to_string();
1463 }
1464
1465 let filled = current.saturating_mul(width).div_ceil(total);
1466 let filled = filled.min(width);
1467 format!(
1468 "[{}{}] {current}/{total}",
1469 "#".repeat(filled),
1470 " ".repeat(width - filled)
1471 )
1472}
1473
1474fn ensure_icp_environment_ready(
1476 icp_root: &Path,
1477 network: &str,
1478) -> Result<(), Box<dyn std::error::Error>> {
1479 if icp_ping(icp_root, network)? {
1480 return Ok(());
1481 }
1482 if replica_query::should_use_local_replica_query(Some(network))
1483 && replica_query::local_replica_status_reachable_from_root(Some(network), icp_root)
1484 {
1485 println!(
1486 "Replica reachable via HTTP status endpoint even though ICP CLI reports network '{network}' stopped; continuing from ICP root {}.",
1487 icp_root.display()
1488 );
1489 return Ok(());
1490 }
1491
1492 Err(format!(
1493 "icp environment is not running for network '{network}'\nStart the target replica in another terminal with `canic replica start` and rerun."
1494 )
1495 .into())
1496}
1497
1498fn icp_ping(icp_root: &Path, network: &str) -> Result<bool, Box<dyn std::error::Error>> {
1500 Ok(icp::default_command_in(icp_root)
1501 .args(["network", "ping", network])
1502 .output()?
1503 .status
1504 .success())
1505}
1506
1507fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
1508 println!("Install timing summary:");
1509 println!("{}", render_install_timing_summary(timings, total));
1510}
1511
1512fn render_install_timing_summary(timings: &InstallTimingSummary, total: Duration) -> String {
1513 let rows = [
1514 timing_row("create_canisters", timings.create_canisters),
1515 timing_row("build_all", timings.build_all),
1516 timing_row("emit_manifest", timings.emit_manifest),
1517 timing_row("install_root", timings.install_root),
1518 timing_row("fund_root", timings.fund_root),
1519 timing_row("stage_release_set", timings.stage_release_set),
1520 timing_row("resume_bootstrap", timings.resume_bootstrap),
1521 timing_row("wait_ready", timings.wait_ready),
1522 timing_row("finalize_root_funding", timings.finalize_root_funding),
1523 timing_row("total", total),
1524 ];
1525 render_table(
1526 &["PHASE", "ELAPSED"],
1527 &rows,
1528 &[ColumnAlign::Left, ColumnAlign::Right],
1529 )
1530}
1531
1532fn timing_row(label: &str, duration: Duration) -> [String; 2] {
1533 [label.to_string(), format!("{:.2}s", duration.as_secs_f64())]
1534}
1535
1536fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
1538 println!("Install result:");
1539 println!("{:<14} success", "status");
1540 println!("{:<14} {}", "fleet", fleet);
1541 println!("{:<14} {}", "install_state", state_path.display());
1542 println!(
1543 "{:<14} canic list {} --network {}",
1544 "smoke_check", fleet, network
1545 );
1546}
1547
1548fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
1550 icp::run_status(command).map_err(Into::into)
1551}
1552
1553fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
1555 icp::run_output(command).map_err(Into::into)
1556}
1557
1558fn icp_command_on_network(network: &str) -> Command {
1561 let mut command = icp::default_command();
1562 command.env("ICP_ENVIRONMENT", network);
1563 command
1564}
1565
1566fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
1568 let mut command = icp::default_command_in(icp_root);
1569 command.env("ICP_ENVIRONMENT", network);
1570 command
1571}
1572
1573fn icp_canister_command_in_network(icp_root: &Path) -> Command {
1575 let mut command = icp::default_command_in(icp_root);
1576 command.arg("canister");
1577 command
1578}
1579
1580fn add_icp_environment_target(command: &mut Command, network: &str) {
1581 icp::add_target_args(command, Some(network), None);
1582}