1use crate::canister_build::{
2 CanisterBuildProfile, build_current_workspace_canister_artifact,
3 current_workspace_build_context_once,
4};
5use crate::deployment_truth::{
6 DeploymentCheckV1, LocalDeploymentCheckRequest, PhaseReceiptV1, artifact_gate_phase_receipt,
7 check_local_deployment,
8};
9use crate::format::wasm_size_label;
10use crate::icp::{self, CANIC_ICP_LOCAL_NETWORK_URL_ENV, CANIC_ICP_LOCAL_ROOT_KEY_ENV};
11use crate::release_set::{
12 LOCAL_ROOT_MIN_READY_CYCLES, configured_fleet_name, configured_install_targets,
13 configured_local_root_create_cycles, emit_root_release_set_manifest_with_config,
14 icp_query_on_network, icp_root, load_root_release_set_manifest, resolve_artifact_root,
15 resume_root_bootstrap, stage_root_release_set, workspace_root,
16};
17use crate::replica_query;
18use crate::response_parse::parse_cycle_balance_response;
19use crate::table::{ColumnAlign, render_separator, render_table, render_table_row, table_widths};
20use canic_core::{
21 cdk::{types::Principal, utils::hash::wasm_hash},
22 protocol,
23};
24use config_selection::resolve_install_config_path;
25use serde_json::Value as JsonValue;
26use std::{
27 env,
28 ffi::OsString,
29 fs,
30 path::{Path, PathBuf},
31 process::Command,
32 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
33};
34
35mod config_selection;
36mod readiness;
37mod state;
38
39pub use config_selection::{
40 current_canic_project_root, discover_canic_config_choices, discover_canic_project_root_from,
41 discover_project_canic_config_choices, project_fleet_roots,
42};
43use readiness::wait_for_root_ready;
44use state::{INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, write_install_state};
45pub use state::{
46 InstallState, read_named_fleet_install_state, read_named_fleet_install_state_from_root,
47};
48
49#[cfg(test)]
50mod tests;
51
52#[cfg(test)]
53use config_selection::config_selection_error;
54#[cfg(test)]
55use readiness::{parse_bootstrap_status_value, parse_root_ready_value};
56#[cfg(test)]
57use state::{fleet_install_state_path, read_fleet_install_state};
58
59#[derive(Clone, Debug)]
64pub struct InstallRootOptions {
65 pub root_canister: String,
66 pub root_build_target: String,
67 pub network: String,
68 pub icp_root: Option<PathBuf>,
69 pub build_profile: Option<CanisterBuildProfile>,
70 pub ready_timeout_seconds: u64,
71 pub config_path: Option<String>,
72 pub expected_fleet: Option<String>,
73 pub interactive_config_selection: bool,
74}
75
76#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
81struct InstallTimingSummary {
82 create_canisters: Duration,
83 build_all: Duration,
84 emit_manifest: Duration,
85 install_root: Duration,
86 fund_root: Duration,
87 stage_release_set: Duration,
88 resume_bootstrap: Duration,
89 wait_ready: Duration,
90 finalize_root_funding: Duration,
91}
92
93pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
95 let project_root = current_canic_project_root()?;
96 let choices = config_selection::discover_workspace_canic_config_choices(&project_root)?;
97 if !choices.is_empty() {
98 return Ok(choices);
99 }
100
101 if let Ok(icp_root) = icp_root()
102 && icp_root != project_root
103 {
104 return config_selection::discover_workspace_canic_config_choices(&icp_root);
105 }
106
107 Ok(choices)
108}
109
110pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
112 let workspace_root = workspace_root()?;
113 let icp_root = match &options.icp_root {
114 Some(path) => path.canonicalize()?,
115 None => icp_root()?,
116 };
117 let config_path = resolve_install_config_path(
118 &icp_root,
119 options.config_path.as_deref(),
120 options.interactive_config_selection,
121 )?;
122 let _install_env = BuildEnvGuard::apply(&options.network, &config_path, &icp_root);
123 let fleet_name = configured_fleet_name(&config_path)?;
124 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
125 validate_fleet_name(&fleet_name)?;
126 let total_started_at = Instant::now();
127 let mut timings = InstallTimingSummary::default();
128 let network = options.network.as_str();
129
130 println!("Installing fleet {fleet_name}");
131 println!();
132 ensure_icp_environment_ready(&icp_root, &options.network)?;
133 let create_started_at = Instant::now();
134 let root_canister_id = ensure_root_canister_id(
135 &icp_root,
136 &options.network,
137 &options.root_canister,
138 &config_path,
139 )?;
140 timings.create_canisters = create_started_at.elapsed();
141
142 let build_targets = configured_install_targets(&config_path, &options.root_build_target)?;
143 let build_started_at = Instant::now();
144 run_canic_build_targets(
145 &options.network,
146 &build_targets,
147 options.build_profile,
148 &config_path,
149 &icp_root,
150 )?;
151 timings.build_all = build_started_at.elapsed();
152
153 run_install_deployment_truth_artifact_gate(
154 &options,
155 &workspace_root,
156 &icp_root,
157 &config_path,
158 &fleet_name,
159 )?;
160
161 let emit_manifest_started_at = Instant::now();
162 let manifest_path = emit_root_release_set_manifest_with_config(
163 &workspace_root,
164 &icp_root,
165 &options.network,
166 &config_path,
167 )?;
168 timings.emit_manifest = emit_manifest_started_at.elapsed();
169
170 let root_wasm = resolve_artifact_root(&icp_root, &options.network)?
171 .join(&options.root_build_target)
172 .join(format!("{}.wasm", options.root_build_target));
173 let install_started_at = Instant::now();
174 reinstall_root_wasm(&icp_root, &options.network, &root_canister_id, &root_wasm)?;
175 timings.install_root = install_started_at.elapsed();
176 let fund_root_started_at = Instant::now();
177 ensure_local_root_min_cycles(&icp_root, network, &root_canister_id, "pre-bootstrap")?;
178 timings.fund_root = fund_root_started_at.elapsed();
179
180 let manifest = load_root_release_set_manifest(&manifest_path)?;
181 let stage_started_at = Instant::now();
182 stage_root_release_set(&icp_root, &options.network, &root_canister_id, &manifest)?;
183 timings.stage_release_set = stage_started_at.elapsed();
184 let resume_started_at = Instant::now();
185 resume_root_bootstrap(&options.network, &root_canister_id)?;
186 timings.resume_bootstrap = resume_started_at.elapsed();
187 let ready_started_at = Instant::now();
188 let ready_result = wait_for_root_ready(
189 &options.network,
190 &root_canister_id,
191 options.ready_timeout_seconds,
192 );
193 timings.wait_ready = ready_started_at.elapsed();
194 if let Err(err) = ready_result {
195 print_install_timing_summary(&timings, total_started_at.elapsed());
196 return Err(err);
197 }
198 let finalize_funding_started_at = Instant::now();
199 ensure_local_root_min_cycles(&icp_root, network, &root_canister_id, "post-ready")?;
200 timings.finalize_root_funding = finalize_funding_started_at.elapsed();
201
202 print_install_timing_summary(&timings, total_started_at.elapsed());
203 let state = build_install_state(
204 &options,
205 &workspace_root,
206 &icp_root,
207 &config_path,
208 &manifest_path,
209 &fleet_name,
210 &root_canister_id,
211 )?;
212 let state_path = write_install_state(&icp_root, &options.network, &state)?;
213 print_install_result_summary(&options.network, &state.fleet, &state_path);
214 Ok(())
215}
216
217pub fn check_install_deployment_truth(
220 options: &InstallRootOptions,
221 observed_at: impl Into<String>,
222) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
223 let workspace_root = workspace_root()?;
224 let icp_root = match &options.icp_root {
225 Some(path) => path.canonicalize()?,
226 None => icp_root()?,
227 };
228 let config_path = resolve_install_config_path(
229 &icp_root,
230 options.config_path.as_deref(),
231 options.interactive_config_selection,
232 )?;
233 let fleet_name = configured_fleet_name(&config_path)?;
234 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
235 validate_fleet_name(&fleet_name)?;
236 current_install_deployment_truth_check_at(
237 options,
238 &workspace_root,
239 &icp_root,
240 &config_path,
241 &fleet_name,
242 observed_at.into(),
243 )
244}
245
246fn current_install_deployment_truth_check_at(
247 options: &InstallRootOptions,
248 workspace_root: &Path,
249 icp_root: &Path,
250 config_path: &Path,
251 fleet_name: &str,
252 observed_at: String,
253) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
254 let build_profile = options
255 .build_profile
256 .unwrap_or_else(CanisterBuildProfile::current)
257 .target_dir_name()
258 .to_string();
259
260 check_local_deployment(&LocalDeploymentCheckRequest {
261 deployment_name: fleet_name.to_string(),
262 network: options.network.clone(),
263 workspace_root: workspace_root.to_path_buf(),
264 icp_root: icp_root.to_path_buf(),
265 config_path: Some(config_path.to_path_buf()),
266 observed_at,
267 runtime_variant: options.network.clone(),
268 build_profile,
269 })
270 .map_err(Into::into)
271}
272
273fn run_install_deployment_truth_artifact_gate(
274 options: &InstallRootOptions,
275 workspace_root: &Path,
276 icp_root: &Path,
277 config_path: &Path,
278 fleet_name: &str,
279) -> Result<(), Box<dyn std::error::Error>> {
280 let truth_gate_started_at = current_unix_timestamp_label()?;
281 let deployment_truth_check = current_install_deployment_truth_check_at(
282 options,
283 workspace_root,
284 icp_root,
285 config_path,
286 fleet_name,
287 truth_gate_started_at.clone(),
288 )?;
289 let artifact_gate_receipt = artifact_gate_phase_receipt(
290 &deployment_truth_check,
291 truth_gate_started_at,
292 Some(current_unix_timestamp_label()?),
293 );
294 enforce_install_artifact_gate(&deployment_truth_check)?;
295 print_install_deployment_truth_gate(&deployment_truth_check, &artifact_gate_receipt);
296 Ok(())
297}
298
299fn enforce_install_artifact_gate(
300 check: &DeploymentCheckV1,
301) -> Result<(), Box<dyn std::error::Error>> {
302 let blockers = check
303 .report
304 .hard_failures
305 .iter()
306 .filter(|finding| finding.code == "artifact_missing")
307 .collect::<Vec<_>>();
308 if blockers.is_empty() {
309 return Ok(());
310 }
311
312 let details = blockers
313 .iter()
314 .map(|finding| finding.message.as_str())
315 .collect::<Vec<_>>()
316 .join("; ");
317 Err(format!("deployment truth artifact gate blocked install: {details}").into())
318}
319
320fn print_install_deployment_truth_gate(check: &DeploymentCheckV1, receipt: &PhaseReceiptV1) {
321 println!("Deployment truth: {}", check.report.summary);
322 println!(
323 "Deployment truth receipt: phase={} postcondition={:?}",
324 receipt.phase, receipt.verified_postcondition.status
325 );
326 if !check.report.warnings.is_empty() {
327 println!("Deployment truth warnings: {}", check.report.warnings.len());
328 }
329}
330
331fn validate_expected_fleet_name(
332 expected: Option<&str>,
333 actual: &str,
334 config_path: &Path,
335) -> Result<(), Box<dyn std::error::Error>> {
336 let Some(expected) = expected else {
337 return Ok(());
338 };
339 if expected == actual {
340 return Ok(());
341 }
342 Err(format!(
343 "install requested fleet {expected}, but {} declares [fleet].name = {actual:?}",
344 config_path.display()
345 )
346 .into())
347}
348
349fn ensure_root_canister_id(
350 icp_root: &Path,
351 network: &str,
352 root_canister: &str,
353 config_path: &Path,
354) -> Result<String, Box<dyn std::error::Error>> {
355 if Principal::from_text(root_canister).is_ok() {
356 return Ok(root_canister.to_string());
357 }
358
359 match resolve_root_canister_id(icp_root, network, root_canister) {
360 Ok(canister_id) => return Ok(canister_id),
361 Err(err) if !is_missing_canister_id_error(&err.to_string()) => return Err(err),
362 Err(_) => {}
363 }
364
365 let mut create = icp_canister_command_in_network(icp_root);
366 add_create_root_target(&mut create, root_canister);
367 add_local_root_create_cycles_arg(&mut create, config_path, network)?;
368 add_icp_environment_target(&mut create, network);
369 let output = run_command_stdout(&mut create)?;
370 if let Some(canister_id) = parse_created_canister_id(&output) {
371 return Ok(canister_id);
372 }
373
374 resolve_root_canister_id(icp_root, network, root_canister).map_err(|_| {
375 format!(
376 "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.",
377 icp_root.display(),
378 icp_root.display(),
379 )
380 .into()
381 })
382}
383
384fn parse_created_canister_id(output: &str) -> Option<String> {
385 if let Ok(value) = serde_json::from_str::<JsonValue>(output) {
386 return parse_canister_id_json(&value);
387 }
388
389 output
390 .lines()
391 .map(str::trim)
392 .find(|line| Principal::from_text(*line).is_ok())
393 .map(ToString::to_string)
394}
395
396fn parse_canister_id_json(value: &JsonValue) -> Option<String> {
397 match value {
398 JsonValue::String(text) if Principal::from_text(text).is_ok() => Some(text.clone()),
399 JsonValue::Array(values) => values.iter().find_map(parse_canister_id_json),
400 JsonValue::Object(object) => ["canister_id", "id", "principal"]
401 .iter()
402 .filter_map(|key| object.get(*key))
403 .find_map(parse_canister_id_json),
404 _ => None,
405 }
406}
407
408fn add_create_root_target(command: &mut Command, root_canister: &str) {
409 if env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV).is_some() {
410 command.args(["create", "--detached", "--json"]);
411 } else {
412 command.args(["create", root_canister, "--json"]);
413 }
414}
415
416fn is_missing_canister_id_error(message: &str) -> bool {
417 message.contains("failed to lookup canister ID")
418 || message.contains("could not find ID for canister")
419 || message.contains("Canister ID is missing")
420}
421
422fn reinstall_root_wasm(
423 icp_root: &Path,
424 network: &str,
425 root_canister: &str,
426 root_wasm: &Path,
427) -> Result<(), Box<dyn std::error::Error>> {
428 let mut install = icp_canister_command_in_network(icp_root);
429 install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
430 install.arg(root_wasm);
431 install.args(["--args", &root_init_args(root_wasm)?]);
432 add_icp_environment_target(&mut install, network);
433 run_command(&mut install)
434}
435
436fn root_init_args(root_wasm: &Path) -> Result<String, Box<dyn std::error::Error>> {
437 let wasm = std::fs::read(root_wasm)?;
438 Ok(format!(
439 "(variant {{ PrimeWithModuleHash = {} }})",
440 idl_blob(&wasm_hash(&wasm))
441 ))
442}
443
444fn idl_blob(bytes: &[u8]) -> String {
445 let mut encoded = String::from("blob \"");
446 for byte in bytes {
447 use std::fmt::Write as _;
448 let _ = write!(encoded, "\\{byte:02X}");
449 }
450 encoded.push('"');
451 encoded
452}
453
454fn build_install_state(
456 options: &InstallRootOptions,
457 workspace_root: &Path,
458 icp_root: &Path,
459 config_path: &Path,
460 release_set_manifest_path: &Path,
461 fleet_name: &str,
462 root_canister_id: &str,
463) -> Result<InstallState, Box<dyn std::error::Error>> {
464 Ok(InstallState {
465 schema_version: INSTALL_STATE_SCHEMA_VERSION,
466 fleet: fleet_name.to_string(),
467 installed_at_unix_secs: current_unix_secs()?,
468 network: options.network.clone(),
469 root_target: options.root_canister.clone(),
470 root_canister_id: root_canister_id.to_string(),
471 root_build_target: options.root_build_target.clone(),
472 workspace_root: workspace_root.display().to_string(),
473 icp_root: icp_root.display().to_string(),
474 config_path: config_path.display().to_string(),
475 release_set_manifest_path: release_set_manifest_path.display().to_string(),
476 })
477}
478
479fn resolve_root_canister_id(
481 icp_root: &Path,
482 network: &str,
483 root_canister: &str,
484) -> Result<String, Box<dyn std::error::Error>> {
485 if Principal::from_text(root_canister).is_ok() {
486 return Ok(root_canister.to_string());
487 }
488
489 let mut command = icp_canister_command_in_network(icp_root);
490 command.args(["status", root_canister, "--json"]);
491 add_icp_environment_target(&mut command, network);
492 let output = run_command_stdout(&mut command)?;
493 parse_created_canister_id(&output).ok_or_else(|| {
494 format!("could not parse root canister id from ICP status JSON output: {output}").into()
495 })
496}
497
498fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
500 Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
501}
502
503fn current_unix_timestamp_label() -> Result<String, Box<dyn std::error::Error>> {
504 Ok(format!("unix:{}", current_unix_secs()?))
505}
506
507fn run_canic_build_targets(
509 network: &str,
510 targets: &[String],
511 build_profile: Option<CanisterBuildProfile>,
512 config_path: &Path,
513 icp_root: &Path,
514) -> Result<(), Box<dyn std::error::Error>> {
515 let _env = BuildEnvGuard::apply(network, config_path, icp_root);
516 let profile = build_profile.unwrap_or_else(CanisterBuildProfile::current);
517 if let Some(context) = current_workspace_build_context_once(profile)? {
518 for line in context.lines() {
519 println!("{line}");
520 }
521 println!("config: {}", config_path.display());
522 println!(
523 "artifacts: {}",
524 planned_build_artifact_root(icp_root).display()
525 );
526 println!();
527 }
528
529 fs::create_dir_all(planned_build_artifact_root(icp_root))?;
530 println!("Building {} canisters", targets.len());
531 println!();
532 let headers = ["CANISTER", "PROGRESS", "WASM", "ELAPSED"];
533 let planned_rows = targets
534 .iter()
535 .map(|target| {
536 [
537 target.clone(),
538 progress_bar(targets.len(), targets.len(), 10),
539 "000.00 MiB (gz 000.00 MiB)".to_string(),
540 "0.00s".to_string(),
541 ]
542 })
543 .collect::<Vec<_>>();
544 let alignments = [
545 ColumnAlign::Left,
546 ColumnAlign::Left,
547 ColumnAlign::Right,
548 ColumnAlign::Right,
549 ];
550 let widths = table_widths(&headers, &planned_rows);
551 println!("{}", render_table_row(&headers, &widths, &alignments));
552 println!("{}", render_separator(&widths));
553
554 for (index, target) in targets.iter().enumerate() {
555 let started_at = Instant::now();
556 let output = build_current_workspace_canister_artifact(target, profile)
557 .map_err(|err| format!("artifact build failed for {target}: {err}"))?;
558 let elapsed = started_at.elapsed();
559 let artifact_size = wasm_artifact_size(&output.wasm_path, &output.wasm_gz_path)?;
560
561 let row = [
562 target.clone(),
563 progress_bar(index + 1, targets.len(), 10),
564 artifact_size,
565 format!("{:.2}s", elapsed.as_secs_f64()),
566 ];
567 println!("{}", render_table_row(&row, &widths, &alignments));
568 }
569
570 println!();
571 Ok(())
572}
573
574fn planned_build_artifact_root(icp_root: &Path) -> PathBuf {
575 icp_root.join(".icp/local/canisters")
576}
577
578fn wasm_artifact_size(
579 wasm_path: &Path,
580 wasm_gz_path: &Path,
581) -> Result<String, Box<dyn std::error::Error>> {
582 let wasm_bytes = Some(std::fs::metadata(wasm_path)?.len());
583 let gzip_bytes = std::fs::metadata(wasm_gz_path)
584 .ok()
585 .map(|metadata| metadata.len());
586 Ok(wasm_size_label(wasm_bytes, gzip_bytes))
587}
588
589struct BuildEnvGuard {
590 previous_network: Option<OsString>,
591 previous_config_path: Option<OsString>,
592 previous_icp_root: Option<OsString>,
593 previous_local_network_url: Option<OsString>,
594 previous_local_root_key: Option<OsString>,
595}
596
597impl BuildEnvGuard {
598 fn apply(network: &str, config_path: &Path, icp_root: &Path) -> Self {
599 let guard = Self {
600 previous_network: env::var_os("ICP_ENVIRONMENT"),
601 previous_config_path: env::var_os("CANIC_CONFIG_PATH"),
602 previous_icp_root: env::var_os("CANIC_ICP_ROOT"),
603 previous_local_network_url: env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV),
604 previous_local_root_key: env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV),
605 };
606 set_env("ICP_ENVIRONMENT", network);
607 set_env("CANIC_CONFIG_PATH", config_path);
608 set_env("CANIC_ICP_ROOT", icp_root);
609 if let Some(target) = local_replica_icp_target(network, icp_root) {
610 set_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV, target.url);
611 set_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV, target.root_key);
612 } else {
613 remove_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV);
614 remove_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV);
615 }
616 guard
617 }
618}
619
620impl Drop for BuildEnvGuard {
621 fn drop(&mut self) {
622 restore_env("ICP_ENVIRONMENT", self.previous_network.take());
623 restore_env("CANIC_CONFIG_PATH", self.previous_config_path.take());
624 restore_env("CANIC_ICP_ROOT", self.previous_icp_root.take());
625 restore_env(
626 CANIC_ICP_LOCAL_NETWORK_URL_ENV,
627 self.previous_local_network_url.take(),
628 );
629 restore_env(
630 CANIC_ICP_LOCAL_ROOT_KEY_ENV,
631 self.previous_local_root_key.take(),
632 );
633 }
634}
635
636struct LocalReplicaIcpTarget {
637 url: String,
638 root_key: String,
639}
640
641fn local_replica_icp_target(network: &str, icp_root: &Path) -> Option<LocalReplicaIcpTarget> {
642 if !replica_query::should_use_local_replica_query(Some(network)) {
643 return None;
644 }
645 if icp_ping(icp_root, network).unwrap_or(false) {
646 return None;
647 }
648 let root_key = replica_query::local_replica_root_key_from_root(Some(network), icp_root)
649 .ok()
650 .flatten()?;
651 Some(LocalReplicaIcpTarget {
652 url: replica_query::local_replica_endpoint_from_root(Some(network), icp_root),
653 root_key,
654 })
655}
656
657fn set_env<K, V>(key: K, value: V)
658where
659 K: AsRef<std::ffi::OsStr>,
660 V: AsRef<std::ffi::OsStr>,
661{
662 unsafe {
665 env::set_var(key, value);
666 }
667}
668
669fn remove_env<K>(key: K)
670where
671 K: AsRef<std::ffi::OsStr>,
672{
673 unsafe {
676 env::remove_var(key);
677 }
678}
679
680fn restore_env(key: &str, value: Option<OsString>) {
681 if let Some(value) = value {
683 set_env(key, value);
684 } else {
685 remove_env(key);
686 }
687}
688
689fn add_local_root_create_cycles_arg(
690 command: &mut Command,
691 config_path: &Path,
692 network: &str,
693) -> Result<(), Box<dyn std::error::Error>> {
694 if network != "local" {
695 return Ok(());
696 }
697
698 let cycles = configured_local_root_create_cycles(config_path)?;
699 command.args(["--cycles", &cycles.to_string()]);
700 Ok(())
701}
702
703fn ensure_local_root_min_cycles(
704 icp_root: &Path,
705 network: &str,
706 root_canister: &str,
707 phase: &str,
708) -> Result<(), Box<dyn std::error::Error>> {
709 if network != "local" {
710 return Ok(());
711 }
712
713 let current = query_root_cycle_balance(network, root_canister)?;
714 if current >= LOCAL_ROOT_MIN_READY_CYCLES {
715 return Ok(());
716 }
717
718 let amount = LOCAL_ROOT_MIN_READY_CYCLES.saturating_sub(current);
719 let mut command = icp_canister_command_in_network(icp_root);
720 command
721 .args(["top-up", "--amount"])
722 .arg(amount.to_string())
723 .arg(root_canister);
724 add_icp_environment_target(&mut command, network);
725 run_command(&mut command)?;
726 println!(
727 "Local root cycles ({phase}): topped up {} ({} -> {} target)",
728 crate::format::cycles_tc(amount),
729 crate::format::cycles_tc(current),
730 crate::format::cycles_tc(LOCAL_ROOT_MIN_READY_CYCLES)
731 );
732 Ok(())
733}
734
735fn query_root_cycle_balance(
736 network: &str,
737 root_canister: &str,
738) -> Result<u128, Box<dyn std::error::Error>> {
739 let output = icp_query_on_network(
740 network,
741 root_canister,
742 protocol::CANIC_CYCLE_BALANCE,
743 None,
744 Some("json"),
745 )?;
746 parse_cycle_balance_response(&output).ok_or_else(|| {
747 format!(
748 "could not parse {root_canister} {} response: {output}",
749 protocol::CANIC_CYCLE_BALANCE
750 )
751 .into()
752 })
753}
754
755fn progress_bar(current: usize, total: usize, width: usize) -> String {
756 if total == 0 || width == 0 {
757 return "[] 0/0".to_string();
758 }
759
760 let filled = current.saturating_mul(width).div_ceil(total);
761 let filled = filled.min(width);
762 format!(
763 "[{}{}] {current}/{total}",
764 "#".repeat(filled),
765 " ".repeat(width - filled)
766 )
767}
768
769fn ensure_icp_environment_ready(
771 icp_root: &Path,
772 network: &str,
773) -> Result<(), Box<dyn std::error::Error>> {
774 if icp_ping(icp_root, network)? {
775 return Ok(());
776 }
777 if replica_query::should_use_local_replica_query(Some(network))
778 && replica_query::local_replica_status_reachable_from_root(Some(network), icp_root)
779 {
780 println!(
781 "Replica reachable via HTTP status endpoint even though ICP CLI reports network '{network}' stopped; continuing from ICP root {}.",
782 icp_root.display()
783 );
784 return Ok(());
785 }
786
787 Err(format!(
788 "icp environment is not running for network '{network}'\nStart the target replica in another terminal with `canic replica start` and rerun."
789 )
790 .into())
791}
792
793fn icp_ping(icp_root: &Path, network: &str) -> Result<bool, Box<dyn std::error::Error>> {
795 Ok(icp::default_command_in(icp_root)
796 .args(["network", "ping", network])
797 .output()?
798 .status
799 .success())
800}
801
802fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
803 println!("Install timing summary:");
804 println!("{}", render_install_timing_summary(timings, total));
805}
806
807fn render_install_timing_summary(timings: &InstallTimingSummary, total: Duration) -> String {
808 let rows = [
809 timing_row("create_canisters", timings.create_canisters),
810 timing_row("build_all", timings.build_all),
811 timing_row("emit_manifest", timings.emit_manifest),
812 timing_row("install_root", timings.install_root),
813 timing_row("fund_root", timings.fund_root),
814 timing_row("stage_release_set", timings.stage_release_set),
815 timing_row("resume_bootstrap", timings.resume_bootstrap),
816 timing_row("wait_ready", timings.wait_ready),
817 timing_row("finalize_root_funding", timings.finalize_root_funding),
818 timing_row("total", total),
819 ];
820 render_table(
821 &["PHASE", "ELAPSED"],
822 &rows,
823 &[ColumnAlign::Left, ColumnAlign::Right],
824 )
825}
826
827fn timing_row(label: &str, duration: Duration) -> [String; 2] {
828 [label.to_string(), format!("{:.2}s", duration.as_secs_f64())]
829}
830
831fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
833 println!("Install result:");
834 println!("{:<14} success", "status");
835 println!("{:<14} {}", "fleet", fleet);
836 println!("{:<14} {}", "install_state", state_path.display());
837 println!(
838 "{:<14} canic list {} --network {}",
839 "smoke_check", fleet, network
840 );
841}
842
843fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
845 icp::run_status(command).map_err(Into::into)
846}
847
848fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
850 icp::run_output(command).map_err(Into::into)
851}
852
853fn icp_command_on_network(network: &str) -> Command {
856 let mut command = icp::default_command();
857 command.env("ICP_ENVIRONMENT", network);
858 command
859}
860
861fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
863 let mut command = icp::default_command_in(icp_root);
864 command.env("ICP_ENVIRONMENT", network);
865 command
866}
867
868fn icp_canister_command_in_network(icp_root: &Path) -> Command {
870 let mut command = icp::default_command_in(icp_root);
871 command.arg("canister");
872 command
873}
874
875fn add_icp_environment_target(command: &mut Command, network: &str) {
876 icp::add_target_args(command, Some(network), None);
877}