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