Skip to main content

canic_host/install_root/
mod.rs

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///
38/// InstallRootOptions
39///
40
41#[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///
53/// InstallTimingSummary
54///
55
56#[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
71/// Discover installable Canic config choices under the current workspace.
72pub 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
77// Execute the local thin-root install flow against an already running replica.
78pub 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
216// Build the persisted project-local install state from a completed install.
217fn 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
244// Resolve the installed root id, accepting principal targets without a icp lookup.
245fn 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
260// Read the current host clock as a unix timestamp for install state.
261fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
262    Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
263}
264
265// Run one `canic build <canister>` call per configured local install target.
266fn 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
305// Spawn one local `canic build <canister>` step without overriding the caller's
306// selected build profile environment.
307fn 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
321// Re-enter the current Canic CLI binary so install builds use the same public
322// build path operators can run directly.
323fn 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
432// Ensure the requested replica is reachable before the local install flow begins.
433fn 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
461// Check whether `icp network ping <network>` currently succeeds.
462fn 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
470// Return true when the local install flow should auto-start a clean local replica.
471fn 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
482// Spawn one local `icp network stop` command for cleanup before a restart.
483fn 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
489// Spawn one background `icp network start` command for local install/test flows.
490fn 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
496// Poll `icp network ping` until the requested network responds or the timeout expires.
497fn 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
533// Print the final install result as a compact whitespace table.
534fn 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
545// Run one command and require a zero exit status.
546fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
547    icp::run_status(command).map_err(Into::into)
548}
549
550// Run one command, require success, and return stdout.
551fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
552    icp::run_output(command).map_err(Into::into)
553}
554
555// Run one command and return its status without failing the caller on non-zero exit.
556fn run_command_allow_failure(
557    command: &mut Command,
558) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> {
559    Ok(command.status()?)
560}
561
562// Build an icp command with the selected install environment exported
563// for Rust build scripts that inspect ICP_ENVIRONMENT at compile time.
564fn icp_command_on_network(network: &str) -> Command {
565    let mut command = icp::default_command();
566    command.env("ICP_ENVIRONMENT", network);
567    command
568}
569
570// Build an icp command in one project directory with ICP_ENVIRONMENT applied.
571fn 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
577// Build an icp canister command in one project directory.
578fn 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}