1use crate::dfx;
2use crate::release_set::{
3 configured_install_targets, dfx_call, dfx_root, emit_root_release_set_manifest_with_config,
4 load_root_release_set_manifest, resume_root_bootstrap, stage_root_release_set, workspace_root,
5};
6use canic_core::{cdk::types::Principal, protocol};
7use config_selection::resolve_install_config_path;
8use serde::Deserialize;
9use serde_json::Value;
10use std::{
11 env,
12 path::Path,
13 process::Command,
14 thread,
15 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
16};
17
18mod config_selection;
19mod state;
20
21pub use state::{
22 DEFAULT_FLEET_NAME, FleetSummary, InstallState, list_current_fleets,
23 read_current_install_state, read_current_or_fleet_install_state, select_current_fleet,
24};
25use state::{INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, write_install_state};
26
27#[cfg(test)]
28mod tests;
29
30#[cfg(test)]
31use config_selection::{config_selection_error, discover_canic_config_choices};
32#[cfg(test)]
33use state::{
34 current_fleet_path, fleet_install_state_path, list_fleets, read_fleet_install_state,
35 read_install_state,
36};
37
38#[derive(Clone, Debug)]
43pub struct InstallRootOptions {
44 pub fleet_name: String,
45 pub root_canister: String,
46 pub root_build_target: String,
47 pub network: String,
48 pub ready_timeout_seconds: u64,
49 pub config_path: Option<String>,
50 pub interactive_config_selection: bool,
51}
52
53#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
58struct BootstrapStatusSnapshot {
59 ready: bool,
60 phase: String,
61 last_error: Option<String>,
62}
63
64#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
69struct InstallTimingSummary {
70 create_canisters: Duration,
71 build_all: Duration,
72 emit_manifest: Duration,
73 fabricate_cycles: Duration,
74 install_root: Duration,
75 stage_release_set: Duration,
76 resume_bootstrap: Duration,
77 wait_ready: Duration,
78}
79
80const LOCAL_ROOT_TARGET_CYCLES: u128 = 9_000_000_000_000_000;
81const LOCAL_DFX_READY_TIMEOUT_SECONDS: u64 = 30;
82
83pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
85 validate_fleet_name(&options.fleet_name)?;
86 let workspace_root = workspace_root()?;
87 let dfx_root = dfx_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 total_started_at = Instant::now();
94 let mut timings = InstallTimingSummary::default();
95
96 println!(
97 "Installing fleet {} against DFX_NETWORK={}",
98 options.fleet_name, options.network
99 );
100 ensure_dfx_running(&dfx_root, &options.network)?;
101 let mut create = Command::new("dfx");
102 create
103 .current_dir(&dfx_root)
104 .args(["canister", "create", "--all", "-qq"]);
105 let create_started_at = Instant::now();
106 run_command(&mut create)?;
107 timings.create_canisters = create_started_at.elapsed();
108
109 let build_targets = configured_install_targets(&config_path, &options.root_build_target)?;
110 let build_session_id = install_build_session_id();
111 let build_started_at = Instant::now();
112 run_dfx_build_targets(&dfx_root, &build_targets, &build_session_id, &config_path)?;
113 timings.build_all = build_started_at.elapsed();
114
115 let emit_manifest_started_at = Instant::now();
116 let manifest_path = emit_root_release_set_manifest_with_config(
117 &workspace_root,
118 &dfx_root,
119 &options.network,
120 &config_path,
121 )?;
122 timings.emit_manifest = emit_manifest_started_at.elapsed();
123
124 timings.fabricate_cycles =
125 maybe_fabricate_local_cycles(&dfx_root, &options.root_canister, &options.network)?;
126
127 let mut install = Command::new("dfx");
128 install.current_dir(&dfx_root).args([
129 "canister",
130 "install",
131 &options.root_canister,
132 "--mode=reinstall",
133 "-y",
134 "--argument",
135 "(variant { Prime })",
136 ]);
137 let install_started_at = Instant::now();
138 run_command(&mut install)?;
139 timings.install_root = install_started_at.elapsed();
140
141 let manifest = load_root_release_set_manifest(&manifest_path)?;
142 let stage_started_at = Instant::now();
143 stage_root_release_set(&dfx_root, &options.root_canister, &manifest)?;
144 timings.stage_release_set = stage_started_at.elapsed();
145 let resume_started_at = Instant::now();
146 resume_root_bootstrap(&options.root_canister)?;
147 timings.resume_bootstrap = resume_started_at.elapsed();
148 let ready_started_at = Instant::now();
149 let ready_result = wait_for_root_ready(&options.root_canister, options.ready_timeout_seconds);
150 timings.wait_ready = ready_started_at.elapsed();
151 if let Err(err) = ready_result {
152 print_install_timing_summary(&timings, total_started_at.elapsed());
153 return Err(err);
154 }
155
156 print_install_timing_summary(&timings, total_started_at.elapsed());
157 let state = build_install_state(
158 &options,
159 &workspace_root,
160 &dfx_root,
161 &config_path,
162 &manifest_path,
163 )?;
164 let state_path = write_install_state(&dfx_root, &options.network, &state)?;
165 print_install_result_summary(&options.network, &state.fleet, &state_path);
166 Ok(())
167}
168
169fn build_install_state(
171 options: &InstallRootOptions,
172 workspace_root: &Path,
173 dfx_root: &Path,
174 config_path: &Path,
175 release_set_manifest_path: &Path,
176) -> Result<InstallState, Box<dyn std::error::Error>> {
177 Ok(InstallState {
178 schema_version: INSTALL_STATE_SCHEMA_VERSION,
179 fleet: options.fleet_name.clone(),
180 installed_at_unix_secs: current_unix_secs()?,
181 network: options.network.clone(),
182 root_target: options.root_canister.clone(),
183 root_canister_id: resolve_root_canister_id(dfx_root, &options.root_canister)?,
184 root_build_target: options.root_build_target.clone(),
185 workspace_root: workspace_root.display().to_string(),
186 dfx_root: dfx_root.display().to_string(),
187 config_path: config_path.display().to_string(),
188 release_set_manifest_path: release_set_manifest_path.display().to_string(),
189 })
190}
191
192fn resolve_root_canister_id(
194 dfx_root: &Path,
195 root_canister: &str,
196) -> Result<String, Box<dyn std::error::Error>> {
197 if Principal::from_text(root_canister).is_ok() {
198 return Ok(root_canister.to_string());
199 }
200
201 let mut command = Command::new("dfx");
202 command
203 .current_dir(dfx_root)
204 .args(["canister", "id", root_canister]);
205 Ok(run_command_stdout(&mut command)?.trim().to_string())
206}
207
208fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
210 Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
211}
212
213fn run_dfx_build_targets(
215 dfx_root: &Path,
216 targets: &[String],
217 build_session_id: &str,
218 config_path: &Path,
219) -> Result<(), Box<dyn std::error::Error>> {
220 println!("Build artifacts:");
221 println!("{:<16} {:<18} {:>10}", "CANISTER", "PROGRESS", "ELAPSED");
222
223 for (index, target) in targets.iter().enumerate() {
224 let mut command = dfx_build_target_command(dfx_root, target, build_session_id);
225 command.env("CANIC_CONFIG_PATH", config_path);
226 let started_at = Instant::now();
227 let output = command.output()?;
228 let elapsed = started_at.elapsed();
229
230 if !output.status.success() {
231 return Err(format!(
232 "dfx build failed for {target}: {}\nstdout:\n{}\nstderr:\n{}",
233 output.status,
234 String::from_utf8_lossy(&output.stdout).trim(),
235 String::from_utf8_lossy(&output.stderr).trim()
236 )
237 .into());
238 }
239
240 println!(
241 "{:<16} {:<18} {:>9.2}s",
242 target,
243 progress_bar(index + 1, targets.len(), 10),
244 elapsed.as_secs_f64()
245 );
246 }
247
248 println!();
249 Ok(())
250}
251
252fn dfx_build_target_command(dfx_root: &Path, target: &str, build_session_id: &str) -> Command {
255 let mut command = Command::new("dfx");
256 command
257 .current_dir(dfx_root)
258 .env("CANIC_BUILD_CONTEXT_SESSION", build_session_id)
259 .args(["build", "-qq", target]);
260 command
261}
262
263fn install_build_session_id() -> String {
264 let unique = SystemTime::now()
265 .duration_since(UNIX_EPOCH)
266 .map_or(0, |duration| duration.as_nanos());
267 format!("install-root-{}-{unique}", std::process::id())
268}
269
270fn maybe_fabricate_local_cycles(
272 dfx_root: &Path,
273 root_canister: &str,
274 network: &str,
275) -> Result<Duration, Box<dyn std::error::Error>> {
276 if network != "local" {
277 return Ok(Duration::ZERO);
278 }
279
280 let current_balance = root_cycle_balance(dfx_root, root_canister)?;
281 let Some(fabricate_cycles) = required_local_cycle_topup(current_balance) else {
282 println!(
283 "Skipping local cycle fabrication for {root_canister}; balance {} already meets target {}",
284 format_cycles(current_balance),
285 format_cycles(LOCAL_ROOT_TARGET_CYCLES)
286 );
287 return Ok(Duration::ZERO);
288 };
289
290 println!(
291 "Fabricating {} cycles locally for {root_canister} to reach target {} (current balance {})",
292 format_cycles(fabricate_cycles),
293 format_cycles(LOCAL_ROOT_TARGET_CYCLES),
294 format_cycles(current_balance)
295 );
296
297 let mut fabricate = Command::new("dfx");
298 fabricate.current_dir(dfx_root);
299 fabricate.args([
300 "ledger",
301 "fabricate-cycles",
302 "--canister",
303 root_canister,
304 "--cycles",
305 &fabricate_cycles.to_string(),
306 ]);
307 let fabricate_started_at = Instant::now();
308 let _ = run_command_allow_failure(&mut fabricate)?;
309
310 Ok(fabricate_started_at.elapsed())
311}
312
313fn root_cycle_balance(
315 dfx_root: &Path,
316 root_canister: &str,
317) -> Result<u128, Box<dyn std::error::Error>> {
318 let mut command = Command::new("dfx");
319 command
320 .current_dir(dfx_root)
321 .args(["canister", "status", root_canister]);
322 let stdout = dfx::run_output(&mut command)?;
323 parse_canister_status_cycles(&stdout)
324 .ok_or_else(|| "could not parse cycle balance from `dfx canister status` output".into())
325}
326
327fn parse_canister_status_cycles(status_output: &str) -> Option<u128> {
329 status_output
330 .lines()
331 .find_map(parse_canister_status_balance_line)
332}
333
334fn parse_canister_status_balance_line(line: &str) -> Option<u128> {
335 let (label, value) = line.trim().split_once(':')?;
336 let label = label.trim().to_ascii_lowercase();
337 if label != "balance" && label != "cycle balance" {
338 return None;
339 }
340
341 let digits = value
342 .chars()
343 .filter(char::is_ascii_digit)
344 .collect::<String>();
345 if digits.is_empty() {
346 return None;
347 }
348
349 digits.parse::<u128>().ok()
350}
351
352fn required_local_cycle_topup(current_balance: u128) -> Option<u128> {
354 (current_balance < LOCAL_ROOT_TARGET_CYCLES)
355 .then_some(LOCAL_ROOT_TARGET_CYCLES.saturating_sub(current_balance))
356 .filter(|cycles| *cycles > 0)
357}
358
359fn format_cycles(value: u128) -> String {
360 let digits = value.to_string();
361 let mut out = String::with_capacity(digits.len() + (digits.len().saturating_sub(1) / 3));
362 for (index, ch) in digits.chars().enumerate() {
363 if index > 0 && (digits.len() - index).is_multiple_of(3) {
364 out.push('_');
365 }
366 out.push(ch);
367 }
368 out
369}
370
371fn progress_bar(current: usize, total: usize, width: usize) -> String {
372 if total == 0 || width == 0 {
373 return "[] 0/0".to_string();
374 }
375
376 let filled = current.saturating_mul(width).div_ceil(total);
377 let filled = filled.min(width);
378 format!(
379 "[{}{}] {current}/{total}",
380 "#".repeat(filled),
381 " ".repeat(width - filled)
382 )
383}
384
385fn ensure_dfx_running(dfx_root: &Path, network: &str) -> Result<(), Box<dyn std::error::Error>> {
387 if dfx_ping(network)? {
388 return Ok(());
389 }
390
391 if network == "local" && local_dfx_autostart_enabled() {
392 println!("Local dfx replica is not reachable; starting a clean local replica");
393 let mut stop = dfx_stop_command(dfx_root);
394 let _ = run_command_allow_failure(&mut stop)?;
395
396 let mut start = dfx_start_local_command(dfx_root);
397 run_command(&mut start)?;
398 wait_for_dfx_ping(
399 network,
400 Duration::from_secs(LOCAL_DFX_READY_TIMEOUT_SECONDS),
401 )?;
402 return Ok(());
403 }
404
405 Err(format!(
406 "dfx replica is not running for network '{network}'\nStart the target replica externally and rerun."
407 )
408 .into())
409}
410
411fn dfx_ping(network: &str) -> Result<bool, Box<dyn std::error::Error>> {
413 Ok(Command::new("dfx")
414 .args(["ping", network])
415 .output()?
416 .status
417 .success())
418}
419
420fn local_dfx_autostart_enabled() -> bool {
422 parse_local_dfx_autostart(env::var("CANIC_AUTO_START_LOCAL_DFX").ok().as_deref())
423}
424
425fn parse_local_dfx_autostart(value: Option<&str>) -> bool {
426 !matches!(
427 value.map(str::trim).map(str::to_ascii_lowercase).as_deref(),
428 Some("0" | "false" | "no" | "off")
429 )
430}
431
432fn dfx_stop_command(dfx_root: &Path) -> Command {
434 let mut command = Command::new("dfx");
435 command.current_dir(dfx_root).arg("stop");
436 command
437}
438
439fn dfx_start_local_command(dfx_root: &Path) -> Command {
441 let mut command = Command::new("dfx");
442 command
443 .current_dir(dfx_root)
444 .args(["start", "--background", "--clean", "--system-canisters"]);
445 command
446}
447
448fn wait_for_dfx_ping(network: &str, timeout: Duration) -> Result<(), Box<dyn std::error::Error>> {
450 let start = Instant::now();
451 while start.elapsed() < timeout {
452 if dfx_ping(network)? {
453 return Ok(());
454 }
455 thread::sleep(Duration::from_millis(500));
456 }
457
458 Err(format!(
459 "dfx replica did not become ready for network '{network}' within {}s",
460 timeout.as_secs()
461 )
462 .into())
463}
464
465fn wait_for_root_ready(
467 root_canister: &str,
468 timeout_seconds: u64,
469) -> Result<(), Box<dyn std::error::Error>> {
470 let start = std::time::Instant::now();
471 let mut next_report = 0_u64;
472
473 println!("Waiting for {root_canister} to report canic_ready (timeout {timeout_seconds}s)");
474
475 loop {
476 if root_ready(root_canister)? {
477 println!(
478 "{root_canister} reported canic_ready after {}s",
479 start.elapsed().as_secs()
480 );
481 return Ok(());
482 }
483
484 if let Some(status) = root_bootstrap_status(root_canister)?
485 && let Some(last_error) = status.last_error.as_deref()
486 {
487 eprintln!(
488 "root bootstrap reported failure during phase '{}' : {}",
489 status.phase, last_error
490 );
491 eprintln!("Diagnostic: dfx canister call {root_canister} canic_bootstrap_status");
492 print_raw_call(root_canister, protocol::CANIC_BOOTSTRAP_STATUS);
493 eprintln!("Diagnostic: dfx canister call {root_canister} canic_subnet_registry");
494 print_raw_call(root_canister, "canic_subnet_registry");
495 eprintln!(
496 "Diagnostic: dfx canister call {root_canister} canic_wasm_store_bootstrap_debug"
497 );
498 print_raw_call(root_canister, "canic_wasm_store_bootstrap_debug");
499 eprintln!("Diagnostic: dfx canister call {root_canister} canic_wasm_store_overview");
500 print_raw_call(root_canister, "canic_wasm_store_overview");
501 eprintln!("Diagnostic: dfx canister call {root_canister} canic_log");
502 print_recent_root_logs(root_canister);
503 return Err(format!(
504 "root bootstrap failed during phase '{}' : {}",
505 status.phase, last_error
506 )
507 .into());
508 }
509
510 let elapsed = start.elapsed().as_secs();
511 if elapsed >= timeout_seconds {
512 eprintln!("root did not report canic_ready within {timeout_seconds}s");
513 eprintln!("Diagnostic: dfx canister call {root_canister} canic_bootstrap_status");
514 print_raw_call(root_canister, protocol::CANIC_BOOTSTRAP_STATUS);
515 eprintln!("Diagnostic: dfx canister call {root_canister} canic_subnet_registry");
516 print_raw_call(root_canister, "canic_subnet_registry");
517 eprintln!(
518 "Diagnostic: dfx canister call {root_canister} canic_wasm_store_bootstrap_debug"
519 );
520 print_raw_call(root_canister, "canic_wasm_store_bootstrap_debug");
521 eprintln!("Diagnostic: dfx canister call {root_canister} canic_wasm_store_overview");
522 print_raw_call(root_canister, "canic_wasm_store_overview");
523 eprintln!("Diagnostic: dfx canister call {root_canister} canic_log");
524 print_recent_root_logs(root_canister);
525 return Err("root did not become ready".into());
526 }
527
528 if elapsed >= next_report {
529 println!("Still waiting for {root_canister} canic_ready ({elapsed}s elapsed)");
530 if let Some(status) = root_bootstrap_status(root_canister)? {
531 match status.last_error.as_deref() {
532 Some(last_error) => println!(
533 "Current bootstrap status: phase={} ready={} error={}",
534 status.phase, status.ready, last_error
535 ),
536 None => println!(
537 "Current bootstrap status: phase={} ready={}",
538 status.phase, status.ready
539 ),
540 }
541 }
542 if let Ok(registry_json) =
543 dfx_call(root_canister, "canic_subnet_registry", None, Some("json"))
544 {
545 println!("Current subnet registry roles:");
546 println!(" {}", registry_roles(®istry_json));
547 }
548 next_report = elapsed + 5;
549 }
550
551 thread::sleep(Duration::from_secs(1));
552 }
553}
554
555fn root_ready(root_canister: &str) -> Result<bool, Box<dyn std::error::Error>> {
557 let output = dfx_call(root_canister, "canic_ready", None, Some("json"))?;
558 let data = serde_json::from_str::<Value>(&output)?;
559 Ok(parse_root_ready_value(&data))
560}
561
562fn root_bootstrap_status(
564 root_canister: &str,
565) -> Result<Option<BootstrapStatusSnapshot>, Box<dyn std::error::Error>> {
566 let output = match dfx_call(
567 root_canister,
568 protocol::CANIC_BOOTSTRAP_STATUS,
569 None,
570 Some("json"),
571 ) {
572 Ok(output) => output,
573 Err(err) => {
574 let message = err.to_string();
575 if message.contains("has no query method")
576 || message.contains("method not found")
577 || message.contains("Canister has no query method")
578 {
579 return Ok(None);
580 }
581 return Err(err);
582 }
583 };
584 let data = serde_json::from_str::<Value>(&output)?;
585 Ok(parse_bootstrap_status_value(&data))
586}
587
588fn parse_root_ready_value(data: &Value) -> bool {
590 matches!(data, Value::Bool(true)) || matches!(data.get("Ok"), Some(Value::Bool(true)))
591}
592
593fn parse_bootstrap_status_value(data: &Value) -> Option<BootstrapStatusSnapshot> {
594 serde_json::from_value::<BootstrapStatusSnapshot>(data.clone())
595 .ok()
596 .or_else(|| {
597 data.get("Ok")
598 .cloned()
599 .and_then(|ok| serde_json::from_value::<BootstrapStatusSnapshot>(ok).ok())
600 })
601}
602
603fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
604 println!("Install timing summary:");
605 println!("{:<20} {:>10}", "phase", "elapsed");
606 println!("{:<20} {:>10}", "--------------------", "----------");
607 print_timing_row("create_canisters", timings.create_canisters);
608 print_timing_row("build_all", timings.build_all);
609 print_timing_row("emit_manifest", timings.emit_manifest);
610 print_timing_row("fabricate_cycles", timings.fabricate_cycles);
611 print_timing_row("install_root", timings.install_root);
612 print_timing_row("stage_release_set", timings.stage_release_set);
613 print_timing_row("resume_bootstrap", timings.resume_bootstrap);
614 print_timing_row("wait_ready", timings.wait_ready);
615 print_timing_row("total", total);
616}
617
618fn print_timing_row(label: &str, duration: Duration) {
619 println!("{label:<20} {:>9.2}s", duration.as_secs_f64());
620}
621
622fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
624 println!("Install result:");
625 println!("{:<14} success", "status");
626 println!("{:<14} {}", "fleet", fleet);
627 println!("{:<14} {}", "install_state", state_path.display());
628 println!("{:<14} canic list --network {}", "smoke_check", network);
629}
630
631fn print_recent_root_logs(root_canister: &str) {
633 let page_args = r"(null, null, null, record { limit = 8; offset = 0 })";
634 let Ok(logs_json) = dfx_call(root_canister, "canic_log", Some(page_args), Some("json")) else {
635 return;
636 };
637 let Ok(data) = serde_json::from_str::<Value>(&logs_json) else {
638 return;
639 };
640 let entries = data
641 .get("Ok")
642 .and_then(|ok| ok.get("entries"))
643 .and_then(Value::as_array)
644 .cloned()
645 .unwrap_or_default();
646
647 if entries.is_empty() {
648 println!(" <no runtime log entries>");
649 return;
650 }
651
652 for entry in entries.iter().rev() {
653 let level = entry.get("level").and_then(Value::as_str).unwrap_or("Info");
654 let topic = entry.get("topic").and_then(Value::as_str).unwrap_or("");
655 let message = entry
656 .get("message")
657 .and_then(Value::as_str)
658 .unwrap_or("")
659 .replace('\n', "\\n");
660 let topic_prefix = if topic.is_empty() {
661 String::new()
662 } else {
663 format!("[{topic}] ")
664 };
665 println!(" {level} {topic_prefix}{message}");
666 }
667}
668
669fn registry_roles(registry_json: &str) -> String {
671 serde_json::from_str::<Value>(registry_json)
672 .ok()
673 .and_then(|data| {
674 data.get("Ok").and_then(Value::as_array).map(|entries| {
675 entries
676 .iter()
677 .filter_map(|entry| {
678 entry
679 .get("role")
680 .and_then(Value::as_str)
681 .map(str::to_string)
682 })
683 .collect::<Vec<_>>()
684 })
685 })
686 .map_or_else(
687 || "<unavailable>".to_string(),
688 |roles| {
689 if roles.is_empty() {
690 "<empty>".to_string()
691 } else {
692 roles.join(", ")
693 }
694 },
695 )
696}
697
698fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
700 dfx::run_status(command).map_err(Into::into)
701}
702
703fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
705 dfx::run_output(command).map_err(Into::into)
706}
707
708fn run_command_allow_failure(
710 command: &mut Command,
711) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> {
712 Ok(command.status()?)
713}
714
715fn print_raw_call(root_canister: &str, method: &str) {
717 let mut command = Command::new("dfx");
718 if let Ok(root) = dfx_root() {
719 command.current_dir(root);
720 }
721 let _ = command
722 .args(["canister", "call", root_canister, method])
723 .status();
724}