1use crate::icp;
2use crate::release_set::{
3 configured_fleet_name, configured_install_targets, emit_root_release_set_manifest_with_config,
4 icp_call_on_network, icp_root, load_root_release_set_manifest, resolve_artifact_root,
5 resume_root_bootstrap, stage_root_release_set, workspace_root,
6};
7use canic_core::{cdk::types::Principal, protocol};
8use config_selection::resolve_install_config_path;
9use serde::Deserialize;
10use serde_json::Value;
11use std::{
12 env,
13 path::{Path, PathBuf},
14 process::Command,
15 thread,
16 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
17};
18
19mod config_selection;
20mod state;
21
22pub use config_selection::discover_canic_config_choices;
23use state::{INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, write_install_state};
24pub use state::{InstallState, read_named_fleet_install_state};
25
26#[cfg(test)]
27mod tests;
28
29#[cfg(test)]
30use config_selection::config_selection_error;
31#[cfg(test)]
32use state::{fleet_install_state_path, read_fleet_install_state};
33
34#[derive(Clone, Debug)]
39pub struct InstallRootOptions {
40 pub root_canister: String,
41 pub root_build_target: String,
42 pub network: String,
43 pub ready_timeout_seconds: u64,
44 pub config_path: Option<String>,
45 pub interactive_config_selection: bool,
46}
47
48#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
53struct BootstrapStatusSnapshot {
54 ready: bool,
55 phase: String,
56 last_error: Option<String>,
57}
58
59#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
64struct InstallTimingSummary {
65 create_canisters: Duration,
66 build_all: Duration,
67 emit_manifest: Duration,
68 fabricate_cycles: Duration,
69 install_root: Duration,
70 stage_release_set: Duration,
71 resume_bootstrap: Duration,
72 wait_ready: Duration,
73}
74
75const LOCAL_ROOT_TARGET_CYCLES: u128 = 9_000_000_000_000_000;
76const LOCAL_ICP_READY_TIMEOUT_SECONDS: u64 = 30;
77
78pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
80 let workspace_root = workspace_root()?;
81 config_selection::discover_workspace_canic_config_choices(&workspace_root)
82}
83
84pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
86 let workspace_root = workspace_root()?;
87 let icp_root = icp_root()?;
88 let config_path = resolve_install_config_path(
89 &workspace_root,
90 options.config_path.as_deref(),
91 options.interactive_config_selection,
92 )?;
93 let fleet_name = configured_fleet_name(&config_path)?;
94 validate_fleet_name(&fleet_name)?;
95 let total_started_at = Instant::now();
96 let mut timings = InstallTimingSummary::default();
97
98 println!(
99 "Installing fleet {} against ICP_ENVIRONMENT={}",
100 fleet_name, options.network
101 );
102 ensure_icp_environment_ready(&icp_root, &options.network)?;
103 let create_started_at = Instant::now();
104 if Principal::from_text(&options.root_canister).is_err() {
105 let mut create = icp_canister_command_in_network(&icp_root);
106 create.args(["create", &options.root_canister, "-q"]);
107 add_icp_environment_target(&mut create, &options.network);
108 run_command(&mut create)?;
109 }
110 timings.create_canisters = create_started_at.elapsed();
111
112 let build_targets = configured_install_targets(&config_path, &options.root_build_target)?;
113 let build_session_id = install_build_session_id();
114 let build_started_at = Instant::now();
115 run_canic_build_targets(
116 &icp_root,
117 &options.network,
118 &build_targets,
119 &build_session_id,
120 &config_path,
121 )?;
122 timings.build_all = build_started_at.elapsed();
123
124 let emit_manifest_started_at = Instant::now();
125 let manifest_path = emit_root_release_set_manifest_with_config(
126 &workspace_root,
127 &icp_root,
128 &options.network,
129 &config_path,
130 )?;
131 timings.emit_manifest = emit_manifest_started_at.elapsed();
132
133 timings.fabricate_cycles =
134 maybe_fabricate_local_cycles(&icp_root, &options.root_canister, &options.network)?;
135
136 let root_wasm = resolve_artifact_root(&icp_root, &options.network)?
137 .join(&options.root_build_target)
138 .join(format!("{}.wasm", options.root_build_target));
139 let mut install = icp_canister_command_in_network(&icp_root);
140 install.args([
141 "install",
142 &options.root_canister,
143 "--mode=reinstall",
144 "-y",
145 "--wasm",
146 ]);
147 install.arg(&root_wasm);
148 install.args(["--args", "(variant { Prime })"]);
149 add_icp_environment_target(&mut install, &options.network);
150 let install_started_at = Instant::now();
151 run_command(&mut install)?;
152 timings.install_root = install_started_at.elapsed();
153
154 let manifest = load_root_release_set_manifest(&manifest_path)?;
155 let stage_started_at = Instant::now();
156 stage_root_release_set(
157 &icp_root,
158 &options.network,
159 &options.root_canister,
160 &manifest,
161 )?;
162 timings.stage_release_set = stage_started_at.elapsed();
163 let resume_started_at = Instant::now();
164 resume_root_bootstrap(&options.network, &options.root_canister)?;
165 timings.resume_bootstrap = resume_started_at.elapsed();
166 let ready_started_at = Instant::now();
167 let ready_result = wait_for_root_ready(
168 &options.network,
169 &options.root_canister,
170 options.ready_timeout_seconds,
171 );
172 timings.wait_ready = ready_started_at.elapsed();
173 if let Err(err) = ready_result {
174 print_install_timing_summary(&timings, total_started_at.elapsed());
175 return Err(err);
176 }
177
178 print_install_timing_summary(&timings, total_started_at.elapsed());
179 let state = build_install_state(
180 &options,
181 &workspace_root,
182 &icp_root,
183 &config_path,
184 &manifest_path,
185 &fleet_name,
186 )?;
187 let state_path = write_install_state(&icp_root, &options.network, &state)?;
188 print_install_result_summary(&options.network, &state.fleet, &state_path);
189 Ok(())
190}
191
192fn build_install_state(
194 options: &InstallRootOptions,
195 workspace_root: &Path,
196 icp_root: &Path,
197 config_path: &Path,
198 release_set_manifest_path: &Path,
199 fleet_name: &str,
200) -> Result<InstallState, Box<dyn std::error::Error>> {
201 Ok(InstallState {
202 schema_version: INSTALL_STATE_SCHEMA_VERSION,
203 fleet: fleet_name.to_string(),
204 installed_at_unix_secs: current_unix_secs()?,
205 network: options.network.clone(),
206 root_target: options.root_canister.clone(),
207 root_canister_id: resolve_root_canister_id(
208 icp_root,
209 &options.network,
210 &options.root_canister,
211 )?,
212 root_build_target: options.root_build_target.clone(),
213 workspace_root: workspace_root.display().to_string(),
214 icp_root: icp_root.display().to_string(),
215 config_path: config_path.display().to_string(),
216 release_set_manifest_path: release_set_manifest_path.display().to_string(),
217 })
218}
219
220fn resolve_root_canister_id(
222 icp_root: &Path,
223 network: &str,
224 root_canister: &str,
225) -> Result<String, Box<dyn std::error::Error>> {
226 if Principal::from_text(root_canister).is_ok() {
227 return Ok(root_canister.to_string());
228 }
229
230 let mut command = icp_canister_command_in_network(icp_root);
231 command.args(["status", root_canister, "-i"]);
232 add_icp_environment_target(&mut command, network);
233 Ok(run_command_stdout(&mut command)?.trim().to_string())
234}
235
236fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
238 Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
239}
240
241fn run_canic_build_targets(
243 icp_root: &Path,
244 network: &str,
245 targets: &[String],
246 build_session_id: &str,
247 config_path: &Path,
248) -> Result<(), Box<dyn std::error::Error>> {
249 println!("Build artifacts:");
250 println!("{:<16} {:<18} {:>10}", "CANISTER", "PROGRESS", "ELAPSED");
251
252 for (index, target) in targets.iter().enumerate() {
253 let mut command = canic_build_target_command(icp_root, network, target, build_session_id);
254 command.env("CANIC_CONFIG_PATH", config_path);
255 let started_at = Instant::now();
256 let output = command.output()?;
257 let elapsed = started_at.elapsed();
258
259 if !output.status.success() {
260 return Err(format!(
261 "canic build failed for {target}: {}\nstdout:\n{}\nstderr:\n{}",
262 output.status,
263 String::from_utf8_lossy(&output.stdout).trim(),
264 String::from_utf8_lossy(&output.stderr).trim()
265 )
266 .into());
267 }
268
269 println!(
270 "{:<16} {:<18} {:>9.2}s",
271 target,
272 progress_bar(index + 1, targets.len(), 10),
273 elapsed.as_secs_f64()
274 );
275 }
276
277 println!();
278 Ok(())
279}
280
281fn canic_build_target_command(
284 _icp_root: &Path,
285 network: &str,
286 target: &str,
287 build_session_id: &str,
288) -> Command {
289 let mut command = canic_command();
290 command
291 .env("CANIC_BUILD_CONTEXT_SESSION", build_session_id)
292 .env("ICP_ENVIRONMENT", network)
293 .args(["build", target]);
294 command
295}
296
297fn canic_command() -> Command {
300 std::env::current_exe().map_or_else(|_| Command::new("canic"), Command::new)
301}
302
303fn install_build_session_id() -> String {
304 let unique = SystemTime::now()
305 .duration_since(UNIX_EPOCH)
306 .map_or(0, |duration| duration.as_nanos());
307 format!("install-root-{}-{unique}", std::process::id())
308}
309
310fn maybe_fabricate_local_cycles(
312 icp_root: &Path,
313 root_canister: &str,
314 network: &str,
315) -> Result<Duration, Box<dyn std::error::Error>> {
316 if network != "local" {
317 return Ok(Duration::ZERO);
318 }
319
320 let current_balance = root_cycle_balance(icp_root, network, root_canister)?;
321 let Some(fabricate_cycles) = required_local_cycle_topup(current_balance) else {
322 println!(
323 "Skipping local cycle fabrication for {root_canister}; balance {} already meets target {}",
324 format_cycles(current_balance),
325 format_cycles(LOCAL_ROOT_TARGET_CYCLES)
326 );
327 return Ok(Duration::ZERO);
328 };
329
330 let mut fabricate = icp_canister_command_in_network(icp_root);
331 fabricate.args([
332 "top-up",
333 root_canister,
334 "--amount",
335 &fabricate_cycles.to_string(),
336 ]);
337 add_icp_environment_target(&mut fabricate, network);
338 let fabricate_started_at = Instant::now();
339 let output = fabricate.output()?;
340 print_local_cycle_topup_summary(root_canister, current_balance, fabricate_cycles, &output);
341
342 Ok(fabricate_started_at.elapsed())
343}
344
345fn print_local_cycle_topup_summary(
347 root_canister: &str,
348 current_balance: u128,
349 fabricate_cycles: u128,
350 output: &std::process::Output,
351) {
352 let status = if output.status.success() {
353 "topped up"
354 } else {
355 "top-up requested"
356 };
357 println!(
358 "\n\x1b[33mcycles: {status} local root {root_canister} by {} toward target {} (was {})\x1b[0m\n",
359 format_cycles(fabricate_cycles),
360 format_cycles(LOCAL_ROOT_TARGET_CYCLES),
361 format_cycles(current_balance)
362 );
363}
364
365fn root_cycle_balance(
367 icp_root: &Path,
368 network: &str,
369 root_canister: &str,
370) -> Result<u128, Box<dyn std::error::Error>> {
371 let mut command = icp_canister_command_in_network(icp_root);
372 command.args(["status", root_canister]);
373 add_icp_environment_target(&mut command, network);
374 let stdout = icp::run_output(&mut command)?;
375 parse_canister_status_cycles(&stdout)
376 .ok_or_else(|| "could not parse cycle balance from `icp canister status` output".into())
377}
378
379fn parse_canister_status_cycles(status_output: &str) -> Option<u128> {
381 status_output
382 .lines()
383 .find_map(parse_canister_status_balance_line)
384}
385
386fn parse_canister_status_balance_line(line: &str) -> Option<u128> {
387 let (label, value) = line.trim().split_once(':')?;
388 let label = label.trim().to_ascii_lowercase();
389 if label != "balance" && label != "cycle balance" && label != "cycles" {
390 return None;
391 }
392
393 let digits = value
394 .chars()
395 .filter(char::is_ascii_digit)
396 .collect::<String>();
397 if digits.is_empty() {
398 return None;
399 }
400
401 digits.parse::<u128>().ok()
402}
403
404fn required_local_cycle_topup(current_balance: u128) -> Option<u128> {
406 (current_balance < LOCAL_ROOT_TARGET_CYCLES)
407 .then_some(LOCAL_ROOT_TARGET_CYCLES.saturating_sub(current_balance))
408 .filter(|cycles| *cycles > 0)
409}
410
411fn format_cycles(value: u128) -> String {
412 let digits = value.to_string();
413 let mut out = String::with_capacity(digits.len() + (digits.len().saturating_sub(1) / 3));
414 for (index, ch) in digits.chars().enumerate() {
415 if index > 0 && (digits.len() - index).is_multiple_of(3) {
416 out.push('_');
417 }
418 out.push(ch);
419 }
420 out
421}
422
423fn progress_bar(current: usize, total: usize, width: usize) -> String {
424 if total == 0 || width == 0 {
425 return "[] 0/0".to_string();
426 }
427
428 let filled = current.saturating_mul(width).div_ceil(total);
429 let filled = filled.min(width);
430 format!(
431 "[{}{}] {current}/{total}",
432 "#".repeat(filled),
433 " ".repeat(width - filled)
434 )
435}
436
437fn ensure_icp_environment_ready(
439 icp_root: &Path,
440 network: &str,
441) -> Result<(), Box<dyn std::error::Error>> {
442 if icp_ping(network)? {
443 return Ok(());
444 }
445
446 if network == "local" && local_icp_autostart_enabled() {
447 println!("Local icp environment is not reachable; starting a clean local replica");
448 let mut stop = icp_stop_command(icp_root);
449 let _ = run_command_allow_failure(&mut stop)?;
450
451 let mut start = icp_start_local_command(icp_root);
452 run_command(&mut start)?;
453 wait_for_icp_ping(
454 network,
455 Duration::from_secs(LOCAL_ICP_READY_TIMEOUT_SECONDS),
456 )?;
457 return Ok(());
458 }
459
460 Err(format!(
461 "icp environment is not running for network '{network}'\nStart the target replica externally and rerun."
462 )
463 .into())
464}
465
466fn icp_ping(network: &str) -> Result<bool, Box<dyn std::error::Error>> {
468 Ok(icp::default_command()
469 .args(["network", "ping", network])
470 .output()?
471 .status
472 .success())
473}
474
475fn local_icp_autostart_enabled() -> bool {
477 parse_local_icp_autostart(env::var("CANIC_AUTO_START_LOCAL_ICP").ok().as_deref())
478}
479
480fn parse_local_icp_autostart(value: Option<&str>) -> bool {
481 !matches!(
482 value.map(str::trim).map(str::to_ascii_lowercase).as_deref(),
483 Some("0" | "false" | "no" | "off")
484 )
485}
486
487fn icp_stop_command(icp_root: &Path) -> Command {
489 let mut command = icp_command_in_network(icp_root, "local");
490 command.args(["network", "stop", "local"]);
491 command
492}
493
494fn icp_start_local_command(icp_root: &Path) -> Command {
496 let mut command = icp_command_in_network(icp_root, "local");
497 command.args(["network", "start", "local", "--background"]);
498 command
499}
500
501fn wait_for_icp_ping(network: &str, timeout: Duration) -> Result<(), Box<dyn std::error::Error>> {
503 let start = Instant::now();
504 while start.elapsed() < timeout {
505 if icp_ping(network)? {
506 return Ok(());
507 }
508 thread::sleep(Duration::from_millis(500));
509 }
510
511 Err(format!(
512 "icp environment did not become ready for network '{network}' within {}s",
513 timeout.as_secs()
514 )
515 .into())
516}
517
518fn wait_for_root_ready(
520 network: &str,
521 root_canister: &str,
522 timeout_seconds: u64,
523) -> Result<(), Box<dyn std::error::Error>> {
524 let start = std::time::Instant::now();
525 let mut next_report = 0_u64;
526
527 println!("Waiting for {root_canister} to report canic_ready (timeout {timeout_seconds}s)");
528
529 loop {
530 if root_ready(network, root_canister)? {
531 println!(
532 "{root_canister} reported canic_ready after {}s",
533 start.elapsed().as_secs()
534 );
535 return Ok(());
536 }
537
538 if let Some(status) = root_bootstrap_status(network, root_canister)?
539 && let Some(last_error) = status.last_error.as_deref()
540 {
541 eprintln!(
542 "root bootstrap reported failure during phase '{}' : {}",
543 status.phase, last_error
544 );
545 eprintln!(
546 "Diagnostic: icp canister -n {network} call {root_canister} canic_bootstrap_status"
547 );
548 print_raw_call(network, root_canister, protocol::CANIC_BOOTSTRAP_STATUS);
549 eprintln!(
550 "Diagnostic: icp canister -n {network} call {root_canister} canic_subnet_registry"
551 );
552 print_raw_call(network, root_canister, "canic_subnet_registry");
553 eprintln!(
554 "Diagnostic: icp canister -n {network} call {root_canister} canic_wasm_store_bootstrap_debug"
555 );
556 print_raw_call(network, root_canister, "canic_wasm_store_bootstrap_debug");
557 eprintln!(
558 "Diagnostic: icp canister -n {network} call {root_canister} canic_wasm_store_overview"
559 );
560 print_raw_call(network, root_canister, "canic_wasm_store_overview");
561 eprintln!("Diagnostic: icp canister -n {network} call {root_canister} canic_log");
562 print_recent_root_logs(network, root_canister);
563 return Err(format!(
564 "root bootstrap failed during phase '{}' : {}",
565 status.phase, last_error
566 )
567 .into());
568 }
569
570 let elapsed = start.elapsed().as_secs();
571 if elapsed >= timeout_seconds {
572 eprintln!("root did not report canic_ready within {timeout_seconds}s");
573 eprintln!(
574 "Diagnostic: icp canister -n {network} call {root_canister} canic_bootstrap_status"
575 );
576 print_raw_call(network, root_canister, protocol::CANIC_BOOTSTRAP_STATUS);
577 eprintln!(
578 "Diagnostic: icp canister -n {network} call {root_canister} canic_subnet_registry"
579 );
580 print_raw_call(network, root_canister, "canic_subnet_registry");
581 eprintln!(
582 "Diagnostic: icp canister -n {network} call {root_canister} canic_wasm_store_bootstrap_debug"
583 );
584 print_raw_call(network, root_canister, "canic_wasm_store_bootstrap_debug");
585 eprintln!(
586 "Diagnostic: icp canister -n {network} call {root_canister} canic_wasm_store_overview"
587 );
588 print_raw_call(network, root_canister, "canic_wasm_store_overview");
589 eprintln!("Diagnostic: icp canister -n {network} call {root_canister} canic_log");
590 print_recent_root_logs(network, root_canister);
591 return Err("root did not become ready".into());
592 }
593
594 if elapsed >= next_report {
595 println!("Still waiting for {root_canister} canic_ready ({elapsed}s elapsed)");
596 if let Some(status) = root_bootstrap_status(network, root_canister)? {
597 match status.last_error.as_deref() {
598 Some(last_error) => println!(
599 "Current bootstrap status: phase={} ready={} error={}",
600 status.phase, status.ready, last_error
601 ),
602 None => println!(
603 "Current bootstrap status: phase={} ready={}",
604 status.phase, status.ready
605 ),
606 }
607 }
608 if let Ok(registry_json) = icp_call_on_network(
609 network,
610 root_canister,
611 "canic_subnet_registry",
612 None,
613 Some("json"),
614 ) {
615 println!("Current subnet registry roles:");
616 println!(" {}", registry_roles(®istry_json));
617 }
618 next_report = elapsed + 5;
619 }
620
621 thread::sleep(Duration::from_secs(1));
622 }
623}
624
625fn root_ready(network: &str, root_canister: &str) -> Result<bool, Box<dyn std::error::Error>> {
627 let output = icp_call_on_network(network, root_canister, "canic_ready", None, Some("json"))?;
628 let data = serde_json::from_str::<Value>(&output)?;
629 Ok(parse_root_ready_value(&data))
630}
631
632fn root_bootstrap_status(
634 network: &str,
635 root_canister: &str,
636) -> Result<Option<BootstrapStatusSnapshot>, Box<dyn std::error::Error>> {
637 let output = match icp_call_on_network(
638 network,
639 root_canister,
640 protocol::CANIC_BOOTSTRAP_STATUS,
641 None,
642 Some("json"),
643 ) {
644 Ok(output) => output,
645 Err(err) => {
646 let message = err.to_string();
647 if message.contains("has no query method")
648 || message.contains("method not found")
649 || message.contains("Canister has no query method")
650 {
651 return Ok(None);
652 }
653 return Err(err);
654 }
655 };
656 let data = serde_json::from_str::<Value>(&output)?;
657 Ok(parse_bootstrap_status_value(&data))
658}
659
660fn parse_root_ready_value(data: &Value) -> bool {
662 matches!(data, Value::Bool(true))
663 || matches!(data.get("Ok"), Some(Value::Bool(true)))
664 || data
665 .get("response_candid")
666 .and_then(Value::as_str)
667 .is_some_and(|value| value.trim() == "(true)")
668}
669
670fn parse_bootstrap_status_value(data: &Value) -> Option<BootstrapStatusSnapshot> {
671 serde_json::from_value::<BootstrapStatusSnapshot>(data.clone())
672 .ok()
673 .or_else(|| {
674 data.get("Ok")
675 .cloned()
676 .and_then(|ok| serde_json::from_value::<BootstrapStatusSnapshot>(ok).ok())
677 })
678 .or_else(|| {
679 data.get("response_candid")
680 .and_then(Value::as_str)
681 .and_then(parse_bootstrap_status_candid)
682 })
683}
684
685fn parse_bootstrap_status_candid(candid: &str) -> Option<BootstrapStatusSnapshot> {
686 let ready = if candid.contains("3_870_990_435 = true") || candid.contains("ready = true") {
687 true
688 } else if candid.contains("3_870_990_435 = false") || candid.contains("ready = false") {
689 false
690 } else {
691 return None;
692 };
693
694 let phase = extract_candid_text_field(candid, "3_253_282_875")
695 .or_else(|| extract_candid_text_field(candid, "phase"))
696 .unwrap_or_else(|| {
697 if ready {
698 "ready".to_string()
699 } else {
700 "unknown".to_string()
701 }
702 });
703 let last_error = extract_candid_text_field(candid, "89_620_959")
704 .or_else(|| extract_candid_text_field(candid, "last_error"));
705
706 Some(BootstrapStatusSnapshot {
707 ready,
708 phase,
709 last_error,
710 })
711}
712
713fn extract_candid_text_field(candid: &str, label: &str) -> Option<String> {
714 let (_, tail) = candid.split_once(&format!("{label} = "))?;
715 let tail = tail.trim_start();
716 let quoted = tail
717 .strip_prefix("opt \"")
718 .or_else(|| tail.strip_prefix('"'))?;
719 let mut value = String::new();
720 let mut escaped = false;
721 for ch in quoted.chars() {
722 if escaped {
723 value.push(ch);
724 escaped = false;
725 continue;
726 }
727 if ch == '\\' {
728 escaped = true;
729 continue;
730 }
731 if ch == '"' {
732 return Some(value);
733 }
734 value.push(ch);
735 }
736 None
737}
738
739fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
740 println!("Install timing summary:");
741 println!("{:<20} {:>10}", "phase", "elapsed");
742 println!("{:<20} {:>10}", "--------------------", "----------");
743 print_timing_row("create_canisters", timings.create_canisters);
744 print_timing_row("build_all", timings.build_all);
745 print_timing_row("emit_manifest", timings.emit_manifest);
746 print_timing_row("fabricate_cycles", timings.fabricate_cycles);
747 print_timing_row("install_root", timings.install_root);
748 print_timing_row("stage_release_set", timings.stage_release_set);
749 print_timing_row("resume_bootstrap", timings.resume_bootstrap);
750 print_timing_row("wait_ready", timings.wait_ready);
751 print_timing_row("total", total);
752}
753
754fn print_timing_row(label: &str, duration: Duration) {
755 println!("{label:<20} {:>9.2}s", duration.as_secs_f64());
756}
757
758fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
760 println!("Install result:");
761 println!("{:<14} success", "status");
762 println!("{:<14} {}", "fleet", fleet);
763 println!("{:<14} {}", "install_state", state_path.display());
764 println!(
765 "{:<14} canic list {} --network {}",
766 "smoke_check", fleet, network
767 );
768}
769
770fn print_recent_root_logs(network: &str, root_canister: &str) {
772 let page_args = r"(null, null, null, record { limit = 8; offset = 0 })";
773 let Ok(logs_json) = icp_call_on_network(
774 network,
775 root_canister,
776 "canic_log",
777 Some(page_args),
778 Some("json"),
779 ) else {
780 return;
781 };
782 let Ok(data) = serde_json::from_str::<Value>(&logs_json) else {
783 return;
784 };
785 let entries = data
786 .get("Ok")
787 .and_then(|ok| ok.get("entries"))
788 .and_then(Value::as_array)
789 .cloned()
790 .unwrap_or_default();
791
792 if entries.is_empty() {
793 println!(" <no runtime log entries>");
794 return;
795 }
796
797 for entry in entries.iter().rev() {
798 let level = entry.get("level").and_then(Value::as_str).unwrap_or("Info");
799 let topic = entry.get("topic").and_then(Value::as_str).unwrap_or("");
800 let message = entry
801 .get("message")
802 .and_then(Value::as_str)
803 .unwrap_or("")
804 .replace('\n', "\\n");
805 let topic_prefix = if topic.is_empty() {
806 String::new()
807 } else {
808 format!("[{topic}] ")
809 };
810 println!(" {level} {topic_prefix}{message}");
811 }
812}
813
814fn registry_roles(registry_json: &str) -> String {
816 serde_json::from_str::<Value>(registry_json)
817 .ok()
818 .and_then(|data| {
819 data.get("Ok").and_then(Value::as_array).map(|entries| {
820 entries
821 .iter()
822 .filter_map(|entry| {
823 entry
824 .get("role")
825 .and_then(Value::as_str)
826 .map(str::to_string)
827 })
828 .collect::<Vec<_>>()
829 })
830 })
831 .map_or_else(
832 || "<unavailable>".to_string(),
833 |roles| {
834 if roles.is_empty() {
835 "<empty>".to_string()
836 } else {
837 roles.join(", ")
838 }
839 },
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 run_command_allow_failure(
855 command: &mut Command,
856) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> {
857 Ok(command.status()?)
858}
859
860fn print_raw_call(network: &str, root_canister: &str, method: &str) {
862 let mut command = icp_root().map_or_else(
863 |_| icp_command_on_network(network),
864 |root| icp_command_in_network(&root, network),
865 );
866 let _ = command
867 .arg("canister")
868 .args(["call", root_canister, method, "()", "-e", network])
869 .status();
870}
871
872fn icp_command_on_network(network: &str) -> Command {
875 let mut command = icp::default_command();
876 command.env("ICP_ENVIRONMENT", network);
877 command
878}
879
880fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
882 let mut command = icp::default_command_in(icp_root);
883 command.env("ICP_ENVIRONMENT", network);
884 command
885}
886
887fn icp_canister_command_in_network(icp_root: &Path) -> Command {
889 let mut command = icp::default_command_in(icp_root);
890 command.arg("canister");
891 command
892}
893
894fn add_icp_environment_target(command: &mut Command, network: &str) {
895 icp::add_target_args(command, Some(network), None);
896}