Skip to main content

canic_host/install_root/
mod.rs

1use crate::canister_build::{
2    CanisterBuildProfile, build_current_workspace_canister_artifact,
3    current_workspace_build_context_once,
4};
5use crate::format::byte_size;
6use crate::icp;
7use crate::release_set::{
8    LOCAL_ROOT_MIN_READY_CYCLES, configured_fleet_name, configured_install_targets,
9    configured_local_root_create_cycles, emit_root_release_set_manifest_with_config,
10    icp_call_on_network, icp_root, load_root_release_set_manifest, resolve_artifact_root,
11    resume_root_bootstrap, stage_root_release_set, workspace_root,
12};
13use crate::response_parse::parse_cycle_balance_response;
14use crate::table::{ColumnAlign, render_separator, render_table_row, table_widths};
15use canic_core::{
16    cdk::{types::Principal, utils::hash::wasm_hash},
17    protocol,
18};
19use config_selection::resolve_install_config_path;
20use std::{
21    env,
22    ffi::OsString,
23    path::{Path, PathBuf},
24    process::Command,
25    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
26};
27
28mod config_selection;
29mod readiness;
30mod state;
31
32pub use config_selection::discover_canic_config_choices;
33use readiness::wait_for_root_ready;
34use state::{INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, write_install_state};
35pub use state::{InstallState, read_named_fleet_install_state};
36
37#[cfg(test)]
38mod tests;
39
40#[cfg(test)]
41use config_selection::config_selection_error;
42#[cfg(test)]
43use readiness::{parse_bootstrap_status_value, parse_root_ready_value};
44#[cfg(test)]
45use state::{fleet_install_state_path, read_fleet_install_state};
46
47///
48/// InstallRootOptions
49///
50
51#[derive(Clone, Debug)]
52pub struct InstallRootOptions {
53    pub root_canister: String,
54    pub root_build_target: String,
55    pub network: String,
56    pub ready_timeout_seconds: u64,
57    pub config_path: Option<String>,
58    pub expected_fleet: Option<String>,
59    pub interactive_config_selection: bool,
60}
61
62///
63/// InstallTimingSummary
64///
65
66#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
67struct InstallTimingSummary {
68    create_canisters: Duration,
69    build_all: Duration,
70    emit_manifest: Duration,
71    install_root: Duration,
72    fund_root: Duration,
73    stage_release_set: Duration,
74    resume_bootstrap: Duration,
75    wait_ready: Duration,
76    finalize_root_funding: Duration,
77}
78
79/// Discover installable Canic config choices under the current workspace.
80pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
81    let workspace_root = workspace_root()?;
82    config_selection::discover_workspace_canic_config_choices(&workspace_root)
83}
84
85// Execute the local thin-root install flow against an already running replica.
86pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
87    let workspace_root = workspace_root()?;
88    let icp_root = icp_root()?;
89    let config_path = resolve_install_config_path(
90        &workspace_root,
91        options.config_path.as_deref(),
92        options.interactive_config_selection,
93    )?;
94    let fleet_name = configured_fleet_name(&config_path)?;
95    validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
96    validate_fleet_name(&fleet_name)?;
97    let total_started_at = Instant::now();
98    let mut timings = InstallTimingSummary::default();
99    let network = options.network.as_str();
100    let root_canister = options.root_canister.as_str();
101
102    println!("Installing fleet {fleet_name}");
103    println!();
104    ensure_icp_environment_ready(&options.network)?;
105    let create_started_at = Instant::now();
106    if Principal::from_text(&options.root_canister).is_err() {
107        let mut create = icp_canister_command_in_network(&icp_root);
108        create.args(["create", &options.root_canister, "-q"]);
109        add_local_root_create_cycles_arg(&mut create, &config_path, &options.network)?;
110        add_icp_environment_target(&mut create, &options.network);
111        run_command(&mut create)?;
112    }
113    timings.create_canisters = create_started_at.elapsed();
114
115    let build_targets = configured_install_targets(&config_path, &options.root_build_target)?;
116    let build_session_id = install_build_session_id();
117    let build_started_at = Instant::now();
118    run_canic_build_targets(
119        &options.network,
120        &build_targets,
121        &build_session_id,
122        &config_path,
123    )?;
124    timings.build_all = build_started_at.elapsed();
125
126    let emit_manifest_started_at = Instant::now();
127    let manifest_path = emit_root_release_set_manifest_with_config(
128        &workspace_root,
129        &icp_root,
130        &options.network,
131        &config_path,
132    )?;
133    timings.emit_manifest = emit_manifest_started_at.elapsed();
134
135    let root_wasm = resolve_artifact_root(&icp_root, &options.network)?
136        .join(&options.root_build_target)
137        .join(format!("{}.wasm", options.root_build_target));
138    let install_started_at = Instant::now();
139    reinstall_root_wasm(
140        &icp_root,
141        &options.network,
142        &options.root_canister,
143        &root_wasm,
144    )?;
145    timings.install_root = install_started_at.elapsed();
146    let fund_root_started_at = Instant::now();
147    ensure_local_root_min_cycles(&icp_root, network, root_canister, "pre-bootstrap")?;
148    timings.fund_root = fund_root_started_at.elapsed();
149
150    let manifest = load_root_release_set_manifest(&manifest_path)?;
151    let stage_started_at = Instant::now();
152    stage_root_release_set(
153        &icp_root,
154        &options.network,
155        &options.root_canister,
156        &manifest,
157    )?;
158    timings.stage_release_set = stage_started_at.elapsed();
159    let resume_started_at = Instant::now();
160    resume_root_bootstrap(&options.network, &options.root_canister)?;
161    timings.resume_bootstrap = resume_started_at.elapsed();
162    let ready_started_at = Instant::now();
163    let ready_result = wait_for_root_ready(
164        &options.network,
165        &options.root_canister,
166        options.ready_timeout_seconds,
167    );
168    timings.wait_ready = ready_started_at.elapsed();
169    if let Err(err) = ready_result {
170        print_install_timing_summary(&timings, total_started_at.elapsed());
171        return Err(err);
172    }
173    let finalize_funding_started_at = Instant::now();
174    ensure_local_root_min_cycles(&icp_root, network, root_canister, "post-ready")?;
175    timings.finalize_root_funding = finalize_funding_started_at.elapsed();
176
177    print_install_timing_summary(&timings, total_started_at.elapsed());
178    let state = build_install_state(
179        &options,
180        &workspace_root,
181        &icp_root,
182        &config_path,
183        &manifest_path,
184        &fleet_name,
185    )?;
186    let state_path = write_install_state(&icp_root, &options.network, &state)?;
187    print_install_result_summary(&options.network, &state.fleet, &state_path);
188    Ok(())
189}
190
191fn validate_expected_fleet_name(
192    expected: Option<&str>,
193    actual: &str,
194    config_path: &Path,
195) -> Result<(), Box<dyn std::error::Error>> {
196    let Some(expected) = expected else {
197        return Ok(());
198    };
199    if expected == actual {
200        return Ok(());
201    }
202    Err(format!(
203        "install requested fleet {expected}, but {} declares [fleet].name = {actual:?}",
204        config_path.display()
205    )
206    .into())
207}
208
209fn reinstall_root_wasm(
210    icp_root: &Path,
211    network: &str,
212    root_canister: &str,
213    root_wasm: &Path,
214) -> Result<(), Box<dyn std::error::Error>> {
215    let mut install = icp_canister_command_in_network(icp_root);
216    install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
217    install.arg(root_wasm);
218    install.args(["--args", &root_init_args(root_wasm)?]);
219    add_icp_environment_target(&mut install, network);
220    run_command(&mut install)
221}
222
223fn root_init_args(root_wasm: &Path) -> Result<String, Box<dyn std::error::Error>> {
224    let wasm = std::fs::read(root_wasm)?;
225    Ok(format!(
226        "(variant {{ PrimeWithModuleHash = {} }})",
227        idl_blob(&wasm_hash(&wasm))
228    ))
229}
230
231fn idl_blob(bytes: &[u8]) -> String {
232    let mut encoded = String::from("blob \"");
233    for byte in bytes {
234        use std::fmt::Write as _;
235        let _ = write!(encoded, "\\{byte:02X}");
236    }
237    encoded.push('"');
238    encoded
239}
240
241// Build the persisted project-local install state from a completed install.
242fn build_install_state(
243    options: &InstallRootOptions,
244    workspace_root: &Path,
245    icp_root: &Path,
246    config_path: &Path,
247    release_set_manifest_path: &Path,
248    fleet_name: &str,
249) -> Result<InstallState, Box<dyn std::error::Error>> {
250    Ok(InstallState {
251        schema_version: INSTALL_STATE_SCHEMA_VERSION,
252        fleet: fleet_name.to_string(),
253        installed_at_unix_secs: current_unix_secs()?,
254        network: options.network.clone(),
255        root_target: options.root_canister.clone(),
256        root_canister_id: resolve_root_canister_id(
257            icp_root,
258            &options.network,
259            &options.root_canister,
260        )?,
261        root_build_target: options.root_build_target.clone(),
262        workspace_root: workspace_root.display().to_string(),
263        icp_root: icp_root.display().to_string(),
264        config_path: config_path.display().to_string(),
265        release_set_manifest_path: release_set_manifest_path.display().to_string(),
266    })
267}
268
269// Resolve the installed root id, accepting principal targets without a icp lookup.
270fn resolve_root_canister_id(
271    icp_root: &Path,
272    network: &str,
273    root_canister: &str,
274) -> Result<String, Box<dyn std::error::Error>> {
275    if Principal::from_text(root_canister).is_ok() {
276        return Ok(root_canister.to_string());
277    }
278
279    let mut command = icp_canister_command_in_network(icp_root);
280    command.args(["status", root_canister, "-i"]);
281    add_icp_environment_target(&mut command, network);
282    Ok(run_command_stdout(&mut command)?.trim().to_string())
283}
284
285// Read the current host clock as a unix timestamp for install state.
286fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
287    Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
288}
289
290// Build each configured local install target through the host builder.
291fn run_canic_build_targets(
292    network: &str,
293    targets: &[String],
294    build_session_id: &str,
295    config_path: &Path,
296) -> Result<(), Box<dyn std::error::Error>> {
297    let _env = BuildEnvGuard::apply(network, build_session_id, config_path);
298    let profile = CanisterBuildProfile::current();
299    if let Some(context) = current_workspace_build_context_once(profile)? {
300        for line in context.lines() {
301            println!("{line}");
302        }
303        println!("config: {}", config_path.display());
304        println!("artifacts: {}", build_artifact_root(network)?.display());
305        println!();
306    }
307
308    println!("Building {} canisters", targets.len());
309    println!();
310    let headers = ["CANISTER", "PROGRESS", "WASM_GZ", "ELAPSED"];
311    let planned_rows = targets
312        .iter()
313        .map(|target| {
314            [
315                target.clone(),
316                progress_bar(targets.len(), targets.len(), 10),
317                "000.00 MiB".to_string(),
318                "0.00s".to_string(),
319            ]
320        })
321        .collect::<Vec<_>>();
322    let alignments = [
323        ColumnAlign::Left,
324        ColumnAlign::Left,
325        ColumnAlign::Right,
326        ColumnAlign::Right,
327    ];
328    let widths = table_widths(&headers, &planned_rows);
329    println!("{}", render_table_row(&headers, &widths, &alignments));
330    println!("{}", render_separator(&widths));
331
332    for (index, target) in targets.iter().enumerate() {
333        let started_at = Instant::now();
334        let output = build_current_workspace_canister_artifact(target, profile)
335            .map_err(|err| format!("artifact build failed for {target}: {err}"))?;
336        let elapsed = started_at.elapsed();
337        let artifact_size = wasm_gz_size(&output.wasm_gz_path)?;
338
339        let row = [
340            target.clone(),
341            progress_bar(index + 1, targets.len(), 10),
342            artifact_size,
343            format!("{:.2}s", elapsed.as_secs_f64()),
344        ];
345        println!("{}", render_table_row(&row, &widths, &alignments));
346    }
347
348    println!();
349    Ok(())
350}
351
352fn build_artifact_root(network: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
353    resolve_artifact_root(&icp_root()?, network)
354}
355
356fn wasm_gz_size(path: &Path) -> Result<String, Box<dyn std::error::Error>> {
357    Ok(byte_size(std::fs::metadata(path)?.len()))
358}
359
360struct BuildEnvGuard {
361    previous_network: Option<OsString>,
362    previous_session: Option<OsString>,
363    previous_config_path: Option<OsString>,
364}
365
366impl BuildEnvGuard {
367    fn apply(network: &str, build_session_id: &str, config_path: &Path) -> Self {
368        let guard = Self {
369            previous_network: env::var_os("ICP_ENVIRONMENT"),
370            previous_session: env::var_os("CANIC_BUILD_CONTEXT_SESSION"),
371            previous_config_path: env::var_os("CANIC_CONFIG_PATH"),
372        };
373        set_env("ICP_ENVIRONMENT", network);
374        set_env("CANIC_BUILD_CONTEXT_SESSION", build_session_id);
375        set_env("CANIC_CONFIG_PATH", config_path);
376        guard
377    }
378}
379
380impl Drop for BuildEnvGuard {
381    fn drop(&mut self) {
382        restore_env("ICP_ENVIRONMENT", self.previous_network.take());
383        restore_env("CANIC_BUILD_CONTEXT_SESSION", self.previous_session.take());
384        restore_env("CANIC_CONFIG_PATH", self.previous_config_path.take());
385    }
386}
387
388fn set_env<K, V>(key: K, value: V)
389where
390    K: AsRef<std::ffi::OsStr>,
391    V: AsRef<std::ffi::OsStr>,
392{
393    // Install builds are single-threaded host orchestration. The environment is
394    // scoped by BuildEnvGuard so Cargo build scripts see the selected fleet.
395    unsafe {
396        env::set_var(key, value);
397    }
398}
399
400fn restore_env(key: &str, value: Option<OsString>) {
401    // See set_env: this restores the single-threaded install build context.
402    unsafe {
403        match value {
404            Some(value) => env::set_var(key, value),
405            None => env::remove_var(key),
406        }
407    }
408}
409
410fn install_build_session_id() -> String {
411    let unique = SystemTime::now()
412        .duration_since(UNIX_EPOCH)
413        .map_or(0, |duration| duration.as_nanos());
414    format!("install-root-{}-{unique}", std::process::id())
415}
416
417fn add_local_root_create_cycles_arg(
418    command: &mut Command,
419    config_path: &Path,
420    network: &str,
421) -> Result<(), Box<dyn std::error::Error>> {
422    if network != "local" {
423        return Ok(());
424    }
425
426    let cycles = configured_local_root_create_cycles(config_path)?;
427    command.args(["--cycles", &cycles.to_string()]);
428    Ok(())
429}
430
431fn ensure_local_root_min_cycles(
432    icp_root: &Path,
433    network: &str,
434    root_canister: &str,
435    phase: &str,
436) -> Result<(), Box<dyn std::error::Error>> {
437    if network != "local" {
438        return Ok(());
439    }
440
441    let current = query_root_cycle_balance(network, root_canister)?;
442    if current >= LOCAL_ROOT_MIN_READY_CYCLES {
443        return Ok(());
444    }
445
446    let amount = LOCAL_ROOT_MIN_READY_CYCLES.saturating_sub(current);
447    let mut command = icp_canister_command_in_network(icp_root);
448    command
449        .args(["top-up", "--amount"])
450        .arg(amount.to_string())
451        .arg(root_canister);
452    add_icp_environment_target(&mut command, network);
453    run_command(&mut command)?;
454    println!(
455        "Local root cycles ({phase}): topped up {} ({} -> {} target)",
456        crate::format::cycles_tc(amount),
457        crate::format::cycles_tc(current),
458        crate::format::cycles_tc(LOCAL_ROOT_MIN_READY_CYCLES)
459    );
460    Ok(())
461}
462
463fn query_root_cycle_balance(
464    network: &str,
465    root_canister: &str,
466) -> Result<u128, Box<dyn std::error::Error>> {
467    let output = icp_call_on_network(
468        network,
469        root_canister,
470        protocol::CANIC_CYCLE_BALANCE,
471        None,
472        None,
473    )?;
474    parse_cycle_balance_response(&output).ok_or_else(|| {
475        format!(
476            "could not parse {root_canister} {} response: {output}",
477            protocol::CANIC_CYCLE_BALANCE
478        )
479        .into()
480    })
481}
482
483fn progress_bar(current: usize, total: usize, width: usize) -> String {
484    if total == 0 || width == 0 {
485        return "[] 0/0".to_string();
486    }
487
488    let filled = current.saturating_mul(width).div_ceil(total);
489    let filled = filled.min(width);
490    format!(
491        "[{}{}] {current}/{total}",
492        "#".repeat(filled),
493        " ".repeat(width - filled)
494    )
495}
496
497// Ensure the requested replica is reachable before the local install flow begins.
498fn ensure_icp_environment_ready(network: &str) -> Result<(), Box<dyn std::error::Error>> {
499    if icp_ping(network)? {
500        return Ok(());
501    }
502
503    Err(format!(
504        "icp environment is not running for network '{network}'\nStart the target replica in another terminal with `canic replica start` and rerun."
505    )
506    .into())
507}
508
509// Check whether `icp network ping <network>` currently succeeds.
510fn icp_ping(network: &str) -> Result<bool, Box<dyn std::error::Error>> {
511    Ok(icp::default_command()
512        .args(["network", "ping", network])
513        .output()?
514        .status
515        .success())
516}
517
518fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
519    println!("Install timing summary:");
520    println!("{:<20} {:>10}", "phase", "elapsed");
521    println!("{:<20} {:>10}", "--------------------", "----------");
522    print_timing_row("create_canisters", timings.create_canisters);
523    print_timing_row("build_all", timings.build_all);
524    print_timing_row("emit_manifest", timings.emit_manifest);
525    print_timing_row("install_root", timings.install_root);
526    print_timing_row("fund_root", timings.fund_root);
527    print_timing_row("stage_release_set", timings.stage_release_set);
528    print_timing_row("resume_bootstrap", timings.resume_bootstrap);
529    print_timing_row("wait_ready", timings.wait_ready);
530    print_timing_row("finalize_root_funding", timings.finalize_root_funding);
531    print_timing_row("total", total);
532}
533
534fn print_timing_row(label: &str, duration: Duration) {
535    println!("{label:<20} {:>9.2}s", duration.as_secs_f64());
536}
537
538// Print the final install result as a compact whitespace table.
539fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
540    println!("Install result:");
541    println!("{:<14} success", "status");
542    println!("{:<14} {}", "fleet", fleet);
543    println!("{:<14} {}", "install_state", state_path.display());
544    println!(
545        "{:<14} canic list {} --network {}",
546        "smoke_check", fleet, network
547    );
548}
549
550// Run one command and require a zero exit status.
551fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
552    icp::run_status(command).map_err(Into::into)
553}
554
555// Run one command, require success, and return stdout.
556fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
557    icp::run_output(command).map_err(Into::into)
558}
559
560// Build an icp command with the selected install environment exported
561// for Rust build scripts that inspect ICP_ENVIRONMENT at compile time.
562fn icp_command_on_network(network: &str) -> Command {
563    let mut command = icp::default_command();
564    command.env("ICP_ENVIRONMENT", network);
565    command
566}
567
568// Build an icp command in one project directory with ICP_ENVIRONMENT applied.
569fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
570    let mut command = icp::default_command_in(icp_root);
571    command.env("ICP_ENVIRONMENT", network);
572    command
573}
574
575// Build an icp canister command in one project directory.
576fn icp_canister_command_in_network(icp_root: &Path) -> Command {
577    let mut command = icp::default_command_in(icp_root);
578    command.arg("canister");
579    command
580}
581
582fn add_icp_environment_target(command: &mut Command, network: &str) {
583    icp::add_target_args(command, Some(network), None);
584}