1use crate::icp;
2use crate::release_set::{
3 LOCAL_ROOT_MIN_READY_CYCLES, configured_fleet_name, configured_install_targets,
4 configured_local_root_create_cycles, emit_root_release_set_manifest_with_config,
5 icp_call_on_network, icp_root, load_root_release_set_manifest, resolve_artifact_root,
6 resume_root_bootstrap, stage_root_release_set, workspace_root,
7};
8use canic_core::{cdk::types::Principal, protocol};
9use config_selection::resolve_install_config_path;
10use std::{
11 env,
12 path::{Path, PathBuf},
13 process::Command,
14 thread,
15 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
16};
17
18mod config_selection;
19mod readiness;
20mod state;
21
22pub use config_selection::discover_canic_config_choices;
23use readiness::wait_for_root_ready;
24use state::{INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, write_install_state};
25pub use state::{InstallState, read_named_fleet_install_state};
26
27#[cfg(test)]
28mod tests;
29
30#[cfg(test)]
31use config_selection::config_selection_error;
32#[cfg(test)]
33use readiness::{parse_bootstrap_status_value, parse_root_ready_value};
34#[cfg(test)]
35use state::{fleet_install_state_path, read_fleet_install_state};
36
37#[derive(Clone, Debug)]
42pub struct InstallRootOptions {
43 pub root_canister: String,
44 pub root_build_target: String,
45 pub network: String,
46 pub ready_timeout_seconds: u64,
47 pub config_path: Option<String>,
48 pub expected_fleet: Option<String>,
49 pub interactive_config_selection: bool,
50}
51
52#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
57struct InstallTimingSummary {
58 create_canisters: Duration,
59 build_all: Duration,
60 emit_manifest: Duration,
61 install_root: Duration,
62 fund_root: Duration,
63 stage_release_set: Duration,
64 resume_bootstrap: Duration,
65 wait_ready: Duration,
66 finalize_root_funding: Duration,
67}
68
69const LOCAL_ICP_READY_TIMEOUT_SECONDS: u64 = 30;
70
71pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
73 let workspace_root = workspace_root()?;
74 config_selection::discover_workspace_canic_config_choices(&workspace_root)
75}
76
77pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
79 let workspace_root = workspace_root()?;
80 let icp_root = icp_root()?;
81 let config_path = resolve_install_config_path(
82 &workspace_root,
83 options.config_path.as_deref(),
84 options.interactive_config_selection,
85 )?;
86 let fleet_name = configured_fleet_name(&config_path)?;
87 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
88 validate_fleet_name(&fleet_name)?;
89 let total_started_at = Instant::now();
90 let mut timings = InstallTimingSummary::default();
91
92 println!(
93 "Installing fleet {} against ICP_ENVIRONMENT={}",
94 fleet_name, options.network
95 );
96 ensure_icp_environment_ready(&icp_root, &options.network)?;
97 let create_started_at = Instant::now();
98 if Principal::from_text(&options.root_canister).is_err() {
99 let mut create = icp_canister_command_in_network(&icp_root);
100 create.args(["create", &options.root_canister, "-q"]);
101 add_local_root_create_cycles_arg(&mut create, &config_path, &options.network)?;
102 add_icp_environment_target(&mut create, &options.network);
103 run_command(&mut create)?;
104 }
105 timings.create_canisters = create_started_at.elapsed();
106
107 let build_targets = configured_install_targets(&config_path, &options.root_build_target)?;
108 let build_session_id = install_build_session_id();
109 let build_started_at = Instant::now();
110 run_canic_build_targets(
111 &icp_root,
112 &options.network,
113 &build_targets,
114 &build_session_id,
115 &config_path,
116 )?;
117 timings.build_all = build_started_at.elapsed();
118
119 let emit_manifest_started_at = Instant::now();
120 let manifest_path = emit_root_release_set_manifest_with_config(
121 &workspace_root,
122 &icp_root,
123 &options.network,
124 &config_path,
125 )?;
126 timings.emit_manifest = emit_manifest_started_at.elapsed();
127
128 let root_wasm = resolve_artifact_root(&icp_root, &options.network)?
129 .join(&options.root_build_target)
130 .join(format!("{}.wasm", options.root_build_target));
131 let install_started_at = Instant::now();
132 reinstall_root_wasm(
133 &icp_root,
134 &options.network,
135 &options.root_canister,
136 &root_wasm,
137 )?;
138 timings.install_root = install_started_at.elapsed();
139 let fund_root_started_at = Instant::now();
140 ensure_local_root_min_cycles(&icp_root, &options.network, &options.root_canister)?;
141 timings.fund_root = fund_root_started_at.elapsed();
142
143 let manifest = load_root_release_set_manifest(&manifest_path)?;
144 let stage_started_at = Instant::now();
145 stage_root_release_set(
146 &icp_root,
147 &options.network,
148 &options.root_canister,
149 &manifest,
150 )?;
151 timings.stage_release_set = stage_started_at.elapsed();
152 let resume_started_at = Instant::now();
153 resume_root_bootstrap(&options.network, &options.root_canister)?;
154 timings.resume_bootstrap = resume_started_at.elapsed();
155 let ready_started_at = Instant::now();
156 let ready_result = wait_for_root_ready(
157 &options.network,
158 &options.root_canister,
159 options.ready_timeout_seconds,
160 );
161 timings.wait_ready = ready_started_at.elapsed();
162 if let Err(err) = ready_result {
163 print_install_timing_summary(&timings, total_started_at.elapsed());
164 return Err(err);
165 }
166 let finalize_funding_started_at = Instant::now();
167 ensure_local_root_min_cycles(&icp_root, &options.network, &options.root_canister)?;
168 timings.finalize_root_funding = finalize_funding_started_at.elapsed();
169
170 print_install_timing_summary(&timings, total_started_at.elapsed());
171 let state = build_install_state(
172 &options,
173 &workspace_root,
174 &icp_root,
175 &config_path,
176 &manifest_path,
177 &fleet_name,
178 )?;
179 let state_path = write_install_state(&icp_root, &options.network, &state)?;
180 print_install_result_summary(&options.network, &state.fleet, &state_path);
181 Ok(())
182}
183
184fn validate_expected_fleet_name(
185 expected: Option<&str>,
186 actual: &str,
187 config_path: &Path,
188) -> Result<(), Box<dyn std::error::Error>> {
189 let Some(expected) = expected else {
190 return Ok(());
191 };
192 if expected == actual {
193 return Ok(());
194 }
195 Err(format!(
196 "install requested fleet {expected}, but {} declares [fleet].name = {actual:?}",
197 config_path.display()
198 )
199 .into())
200}
201
202fn reinstall_root_wasm(
203 icp_root: &Path,
204 network: &str,
205 root_canister: &str,
206 root_wasm: &Path,
207) -> Result<(), Box<dyn std::error::Error>> {
208 let mut install = icp_canister_command_in_network(icp_root);
209 install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
210 install.arg(root_wasm);
211 install.args(["--args", "(variant { Prime })"]);
212 add_icp_environment_target(&mut install, network);
213 run_command(&mut install)
214}
215
216fn build_install_state(
218 options: &InstallRootOptions,
219 workspace_root: &Path,
220 icp_root: &Path,
221 config_path: &Path,
222 release_set_manifest_path: &Path,
223 fleet_name: &str,
224) -> Result<InstallState, Box<dyn std::error::Error>> {
225 Ok(InstallState {
226 schema_version: INSTALL_STATE_SCHEMA_VERSION,
227 fleet: fleet_name.to_string(),
228 installed_at_unix_secs: current_unix_secs()?,
229 network: options.network.clone(),
230 root_target: options.root_canister.clone(),
231 root_canister_id: resolve_root_canister_id(
232 icp_root,
233 &options.network,
234 &options.root_canister,
235 )?,
236 root_build_target: options.root_build_target.clone(),
237 workspace_root: workspace_root.display().to_string(),
238 icp_root: icp_root.display().to_string(),
239 config_path: config_path.display().to_string(),
240 release_set_manifest_path: release_set_manifest_path.display().to_string(),
241 })
242}
243
244fn resolve_root_canister_id(
246 icp_root: &Path,
247 network: &str,
248 root_canister: &str,
249) -> Result<String, Box<dyn std::error::Error>> {
250 if Principal::from_text(root_canister).is_ok() {
251 return Ok(root_canister.to_string());
252 }
253
254 let mut command = icp_canister_command_in_network(icp_root);
255 command.args(["status", root_canister, "-i"]);
256 add_icp_environment_target(&mut command, network);
257 Ok(run_command_stdout(&mut command)?.trim().to_string())
258}
259
260fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
262 Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
263}
264
265fn run_canic_build_targets(
267 icp_root: &Path,
268 network: &str,
269 targets: &[String],
270 build_session_id: &str,
271 config_path: &Path,
272) -> Result<(), Box<dyn std::error::Error>> {
273 println!("Build artifacts:");
274 println!("{:<16} {:<18} {:>10}", "CANISTER", "PROGRESS", "ELAPSED");
275
276 for (index, target) in targets.iter().enumerate() {
277 let mut command = canic_build_target_command(icp_root, network, target, build_session_id);
278 command.env("CANIC_CONFIG_PATH", config_path);
279 let started_at = Instant::now();
280 let output = command.output()?;
281 let elapsed = started_at.elapsed();
282
283 if !output.status.success() {
284 return Err(format!(
285 "canic build failed for {target}: {}\nstdout:\n{}\nstderr:\n{}",
286 output.status,
287 String::from_utf8_lossy(&output.stdout).trim(),
288 String::from_utf8_lossy(&output.stderr).trim()
289 )
290 .into());
291 }
292
293 println!(
294 "{:<16} {:<18} {:>9.2}s",
295 target,
296 progress_bar(index + 1, targets.len(), 10),
297 elapsed.as_secs_f64()
298 );
299 }
300
301 println!();
302 Ok(())
303}
304
305fn canic_build_target_command(
308 _icp_root: &Path,
309 network: &str,
310 target: &str,
311 build_session_id: &str,
312) -> Command {
313 let mut command = canic_command();
314 command
315 .env("CANIC_BUILD_CONTEXT_SESSION", build_session_id)
316 .env("ICP_ENVIRONMENT", network)
317 .args(["build", target]);
318 command
319}
320
321fn canic_command() -> Command {
324 std::env::current_exe().map_or_else(|_| Command::new("canic"), Command::new)
325}
326
327fn install_build_session_id() -> String {
328 let unique = SystemTime::now()
329 .duration_since(UNIX_EPOCH)
330 .map_or(0, |duration| duration.as_nanos());
331 format!("install-root-{}-{unique}", std::process::id())
332}
333
334fn add_local_root_create_cycles_arg(
335 command: &mut Command,
336 config_path: &Path,
337 network: &str,
338) -> Result<(), Box<dyn std::error::Error>> {
339 if network != "local" {
340 return Ok(());
341 }
342
343 let cycles = configured_local_root_create_cycles(config_path)?;
344 command.args(["--cycles", &cycles.to_string()]);
345 Ok(())
346}
347
348fn ensure_local_root_min_cycles(
349 icp_root: &Path,
350 network: &str,
351 root_canister: &str,
352) -> Result<(), Box<dyn std::error::Error>> {
353 if network != "local" {
354 return Ok(());
355 }
356
357 let current = query_root_cycle_balance(network, root_canister)?;
358 if current >= LOCAL_ROOT_MIN_READY_CYCLES {
359 return Ok(());
360 }
361
362 let amount = LOCAL_ROOT_MIN_READY_CYCLES.saturating_sub(current);
363 let mut command = icp_canister_command_in_network(icp_root);
364 command
365 .args(["top-up", "--amount"])
366 .arg(amount.to_string())
367 .arg(root_canister);
368 add_icp_environment_target(&mut command, network);
369 run_command(&mut command)?;
370 println!(
371 "Topped up local root from {} to at least {}",
372 crate::format::cycles_tc(current),
373 crate::format::cycles_tc(LOCAL_ROOT_MIN_READY_CYCLES)
374 );
375 Ok(())
376}
377
378fn query_root_cycle_balance(
379 network: &str,
380 root_canister: &str,
381) -> Result<u128, Box<dyn std::error::Error>> {
382 let output = icp_call_on_network(
383 network,
384 root_canister,
385 protocol::CANIC_CYCLE_BALANCE,
386 None,
387 None,
388 )?;
389 parse_cycle_balance_response(&output).ok_or_else(|| {
390 format!(
391 "could not parse {root_canister} {} response: {output}",
392 protocol::CANIC_CYCLE_BALANCE
393 )
394 .into()
395 })
396}
397
398fn parse_cycle_balance_response(output: &str) -> Option<u128> {
399 output
400 .split_once('=')
401 .map_or(output, |(_, cycles)| cycles)
402 .lines()
403 .find_map(parse_leading_integer)
404}
405
406fn parse_leading_integer(line: &str) -> Option<u128> {
407 let digits = line
408 .trim_start_matches(|ch: char| ch == '(' || ch.is_whitespace())
409 .chars()
410 .take_while(|ch| ch.is_ascii_digit() || *ch == '_' || *ch == ',')
411 .filter(char::is_ascii_digit)
412 .collect::<String>();
413 (!digits.is_empty())
414 .then_some(digits)
415 .and_then(|digits| digits.parse::<u128>().ok())
416}
417
418fn progress_bar(current: usize, total: usize, width: usize) -> String {
419 if total == 0 || width == 0 {
420 return "[] 0/0".to_string();
421 }
422
423 let filled = current.saturating_mul(width).div_ceil(total);
424 let filled = filled.min(width);
425 format!(
426 "[{}{}] {current}/{total}",
427 "#".repeat(filled),
428 " ".repeat(width - filled)
429 )
430}
431
432fn ensure_icp_environment_ready(
434 icp_root: &Path,
435 network: &str,
436) -> Result<(), Box<dyn std::error::Error>> {
437 if icp_ping(network)? {
438 return Ok(());
439 }
440
441 if network == "local" && local_icp_autostart_enabled() {
442 println!("Local icp environment is not reachable; starting a clean local replica");
443 let mut stop = icp_stop_command(icp_root);
444 let _ = run_command_allow_failure(&mut stop)?;
445
446 let mut start = icp_start_local_command(icp_root);
447 run_command(&mut start)?;
448 wait_for_icp_ping(
449 network,
450 Duration::from_secs(LOCAL_ICP_READY_TIMEOUT_SECONDS),
451 )?;
452 return Ok(());
453 }
454
455 Err(format!(
456 "icp environment is not running for network '{network}'\nStart the target replica externally and rerun."
457 )
458 .into())
459}
460
461fn icp_ping(network: &str) -> Result<bool, Box<dyn std::error::Error>> {
463 Ok(icp::default_command()
464 .args(["network", "ping", network])
465 .output()?
466 .status
467 .success())
468}
469
470fn local_icp_autostart_enabled() -> bool {
472 parse_local_icp_autostart(env::var("CANIC_AUTO_START_LOCAL_ICP").ok().as_deref())
473}
474
475fn parse_local_icp_autostart(value: Option<&str>) -> bool {
476 !matches!(
477 value.map(str::trim).map(str::to_ascii_lowercase).as_deref(),
478 Some("0" | "false" | "no" | "off")
479 )
480}
481
482fn icp_stop_command(icp_root: &Path) -> Command {
484 let mut command = icp_command_in_network(icp_root, "local");
485 command.args(["network", "stop", "local"]);
486 command
487}
488
489fn icp_start_local_command(icp_root: &Path) -> Command {
491 let mut command = icp_command_in_network(icp_root, "local");
492 command.args(["network", "start", "local", "--background"]);
493 command
494}
495
496fn wait_for_icp_ping(network: &str, timeout: Duration) -> Result<(), Box<dyn std::error::Error>> {
498 let start = Instant::now();
499 while start.elapsed() < timeout {
500 if icp_ping(network)? {
501 return Ok(());
502 }
503 thread::sleep(Duration::from_millis(500));
504 }
505
506 Err(format!(
507 "icp environment did not become ready for network '{network}' within {}s",
508 timeout.as_secs()
509 )
510 .into())
511}
512
513fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
514 println!("Install timing summary:");
515 println!("{:<20} {:>10}", "phase", "elapsed");
516 println!("{:<20} {:>10}", "--------------------", "----------");
517 print_timing_row("create_canisters", timings.create_canisters);
518 print_timing_row("build_all", timings.build_all);
519 print_timing_row("emit_manifest", timings.emit_manifest);
520 print_timing_row("install_root", timings.install_root);
521 print_timing_row("fund_root", timings.fund_root);
522 print_timing_row("stage_release_set", timings.stage_release_set);
523 print_timing_row("resume_bootstrap", timings.resume_bootstrap);
524 print_timing_row("wait_ready", timings.wait_ready);
525 print_timing_row("finalize_root_funding", timings.finalize_root_funding);
526 print_timing_row("total", total);
527}
528
529fn print_timing_row(label: &str, duration: Duration) {
530 println!("{label:<20} {:>9.2}s", duration.as_secs_f64());
531}
532
533fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
535 println!("Install result:");
536 println!("{:<14} success", "status");
537 println!("{:<14} {}", "fleet", fleet);
538 println!("{:<14} {}", "install_state", state_path.display());
539 println!(
540 "{:<14} canic list {} --network {}",
541 "smoke_check", fleet, network
542 );
543}
544
545fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
547 icp::run_status(command).map_err(Into::into)
548}
549
550fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
552 icp::run_output(command).map_err(Into::into)
553}
554
555fn run_command_allow_failure(
557 command: &mut Command,
558) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> {
559 Ok(command.status()?)
560}
561
562fn icp_command_on_network(network: &str) -> Command {
565 let mut command = icp::default_command();
566 command.env("ICP_ENVIRONMENT", network);
567 command
568}
569
570fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
572 let mut command = icp::default_command_in(icp_root);
573 command.env("ICP_ENVIRONMENT", network);
574 command
575}
576
577fn icp_canister_command_in_network(icp_root: &Path) -> Command {
579 let mut command = icp::default_command_in(icp_root);
580 command.arg("canister");
581 command
582}
583
584fn add_icp_environment_target(command: &mut Command, network: &str) {
585 icp::add_target_args(command, Some(network), None);
586}