1use crate::canister_build::{
2 CanisterBuildProfile, build_current_workspace_canister_artifact,
3 current_workspace_build_context_once,
4};
5use crate::deployment_truth::{
6 CurrentCliDeploymentExecutor, DeploymentCheckV1, DeploymentCommandResultV1,
7 DeploymentExecutionContextV1, DeploymentExecutionStatusV1, DeploymentExecutor,
8 DeploymentExecutorCapabilityV1, DeploymentReceiptV1, LocalDeploymentCheckRequest,
9 SafetyFindingV1, artifact_gate_phase_receipt, artifact_gate_role_phase_receipts,
10 check_local_deployment, deployment_receipt_from_check_with_status,
11 missing_executor_capabilities, phase_receipt,
12};
13use crate::format::wasm_size_label;
14use crate::icp::{self, CANIC_ICP_LOCAL_NETWORK_URL_ENV, CANIC_ICP_LOCAL_ROOT_KEY_ENV};
15use crate::release_set::{
16 LOCAL_ROOT_MIN_READY_CYCLES, configured_fleet_name, configured_install_targets,
17 configured_local_root_create_cycles, emit_root_release_set_manifest_with_config,
18 icp_query_on_network, icp_root, load_root_release_set_manifest, resolve_artifact_root,
19 resume_root_bootstrap, stage_root_release_set, workspace_root,
20};
21use crate::replica_query;
22use crate::response_parse::parse_cycle_balance_response;
23use crate::table::{ColumnAlign, render_separator, render_table, render_table_row, table_widths};
24use canic_core::{
25 cdk::{types::Principal, utils::hash::wasm_hash},
26 protocol,
27};
28use config_selection::resolve_install_config_path;
29use serde_json::Value as JsonValue;
30use std::{
31 env,
32 ffi::OsString,
33 fs,
34 path::{Path, PathBuf},
35 process::Command,
36 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
37};
38
39mod config_selection;
40mod readiness;
41mod state;
42
43pub use config_selection::{
44 current_canic_project_root, discover_canic_config_choices, discover_canic_project_root_from,
45 discover_project_canic_config_choices, project_fleet_roots,
46};
47use readiness::wait_for_root_ready;
48use state::{
49 INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, validate_network_name, write_install_state,
50};
51pub use state::{
52 InstallState, read_named_fleet_install_state, read_named_fleet_install_state_from_root,
53};
54
55#[cfg(test)]
56mod tests;
57
58#[cfg(test)]
59use config_selection::config_selection_error;
60#[cfg(test)]
61use readiness::{parse_bootstrap_status_value, parse_root_ready_value};
62#[cfg(test)]
63use state::{fleet_install_state_path, read_fleet_install_state};
64
65#[derive(Clone, Debug)]
70pub struct InstallRootOptions {
71 pub root_canister: String,
72 pub root_build_target: String,
73 pub network: String,
74 pub icp_root: Option<PathBuf>,
75 pub build_profile: Option<CanisterBuildProfile>,
76 pub ready_timeout_seconds: u64,
77 pub config_path: Option<String>,
78 pub expected_fleet: Option<String>,
79 pub interactive_config_selection: bool,
80}
81
82#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
87struct InstallTimingSummary {
88 create_canisters: Duration,
89 build_all: Duration,
90 emit_manifest: Duration,
91 install_root: Duration,
92 fund_root: Duration,
93 stage_release_set: Duration,
94 resume_bootstrap: Duration,
95 wait_ready: Duration,
96 finalize_root_funding: Duration,
97}
98
99const CURRENT_INSTALL_REQUIRED_CAPABILITIES: &[DeploymentExecutorCapabilityV1] = &[
100 DeploymentExecutorCapabilityV1::CreateCanister,
101 DeploymentExecutorCapabilityV1::InstallCode,
102 DeploymentExecutorCapabilityV1::Call,
103 DeploymentExecutorCapabilityV1::Query,
104 DeploymentExecutorCapabilityV1::StageArtifact,
105];
106
107pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
109 let project_root = current_canic_project_root()?;
110 let choices = config_selection::discover_workspace_canic_config_choices(&project_root)?;
111 if !choices.is_empty() {
112 return Ok(choices);
113 }
114
115 if let Ok(icp_root) = icp_root()
116 && icp_root != project_root
117 {
118 return config_selection::discover_workspace_canic_config_choices(&icp_root);
119 }
120
121 Ok(choices)
122}
123
124pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
126 let workspace_root = workspace_root()?;
127 let icp_root = match &options.icp_root {
128 Some(path) => path.canonicalize()?,
129 None => icp_root()?,
130 };
131 let config_path = resolve_install_config_path(
132 &icp_root,
133 options.config_path.as_deref(),
134 options.interactive_config_selection,
135 )?;
136 let _install_env = BuildEnvGuard::apply(&options.network, &config_path, &icp_root);
137 let fleet_name = configured_fleet_name(&config_path)?;
138 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
139 validate_fleet_name(&fleet_name)?;
140 let total_started_at = Instant::now();
141 let mut timings = InstallTimingSummary::default();
142 let network = options.network.as_str();
143 let execution_context = current_install_execution_context(&workspace_root, &icp_root, network);
144
145 println!("Installing fleet {fleet_name}");
146 println!();
147 let prepared = prepare_install_deployment_truth(
148 &options,
149 &workspace_root,
150 &icp_root,
151 &config_path,
152 &fleet_name,
153 &execution_context,
154 )?;
155 timings.create_canisters = prepared.timings.create_canisters;
156 timings.build_all = prepared.timings.build_all;
157
158 let (manifest_path, emit_manifest_duration) = emit_manifest_with_deployment_truth_receipt(
159 &workspace_root,
160 &icp_root,
161 &options,
162 &config_path,
163 &fleet_name,
164 &prepared.deployment_truth_check,
165 &execution_context,
166 )?;
167 timings.emit_manifest = emit_manifest_duration;
168 let activation_timings = run_root_activation_phases(
169 InstallReceiptScope {
170 icp_root: &icp_root,
171 network,
172 fleet_name: &fleet_name,
173 check: &prepared.deployment_truth_check,
174 execution_context: Some(&execution_context),
175 },
176 &options,
177 &prepared.root_canister_id,
178 &manifest_path,
179 total_started_at,
180 )?;
181 timings.install_root = activation_timings.install_root;
182 timings.fund_root = activation_timings.fund_root;
183 timings.stage_release_set = activation_timings.stage_release_set;
184 timings.resume_bootstrap = activation_timings.resume_bootstrap;
185 timings.wait_ready = activation_timings.wait_ready;
186 timings.finalize_root_funding = activation_timings.finalize_root_funding;
187
188 print_install_timing_summary(&timings, total_started_at.elapsed());
189 let state = build_install_state(
190 &options,
191 &workspace_root,
192 &icp_root,
193 &config_path,
194 &manifest_path,
195 &fleet_name,
196 &prepared.root_canister_id,
197 )?;
198 let state_path = write_install_state_with_deployment_truth_receipt(
199 InstallReceiptScope {
200 icp_root: &icp_root,
201 network,
202 fleet_name: &fleet_name,
203 check: &prepared.deployment_truth_check,
204 execution_context: Some(&execution_context),
205 },
206 &options.network,
207 &state,
208 )?;
209 print_install_result_summary(&options.network, &state.fleet, &state_path);
210 Ok(())
211}
212
213struct PreparedInstallTruth {
214 root_canister_id: String,
215 deployment_truth_check: DeploymentCheckV1,
216 timings: InstallTimingSummary,
217}
218
219fn prepare_install_deployment_truth(
220 options: &InstallRootOptions,
221 workspace_root: &Path,
222 icp_root: &Path,
223 config_path: &Path,
224 fleet_name: &str,
225 execution_context: &DeploymentExecutionContextV1,
226) -> Result<PreparedInstallTruth, Box<dyn std::error::Error>> {
227 let mut timings = InstallTimingSummary::default();
228 ensure_current_install_executor_capabilities(execution_context)?;
229 ensure_icp_environment_ready(icp_root, &options.network)?;
230 let (root_canister_id, create_phase, create_duration) =
231 resolve_root_canister_with_phase(options, icp_root, config_path)?;
232 timings.create_canisters = create_duration;
233
234 let (build_phase, build_duration) =
235 build_install_targets_with_phase(options, icp_root, config_path)?;
236 timings.build_all = build_duration;
237
238 let deployment_truth_check = run_install_deployment_truth_safety_gate(
239 options,
240 workspace_root,
241 icp_root,
242 config_path,
243 fleet_name,
244 execution_context,
245 )?;
246 let receipt_scope = InstallReceiptScope {
247 icp_root,
248 network: &options.network,
249 fleet_name,
250 check: &deployment_truth_check,
251 execution_context: Some(execution_context),
252 };
253 write_completed_install_phase_receipt(receipt_scope, create_phase)?;
254 write_completed_install_phase_receipt(receipt_scope, build_phase)?;
255
256 Ok(PreparedInstallTruth {
257 root_canister_id,
258 deployment_truth_check,
259 timings,
260 })
261}
262
263fn resolve_root_canister_with_phase(
264 options: &InstallRootOptions,
265 icp_root: &Path,
266 config_path: &Path,
267) -> Result<(String, CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
268 let started_at = current_unix_timestamp_label()?;
269 let started = Instant::now();
270 let root_canister_id = ensure_root_canister_id(
271 icp_root,
272 &options.network,
273 &options.root_canister,
274 config_path,
275 )?;
276 let duration = started.elapsed();
277 let phase = CompletedInstallPhase {
278 phase: "resolve_root_canister",
279 attempted_action: "resolve or create root canister id",
280 started_at,
281 finished_at: Some(current_unix_timestamp_label()?),
282 evidence: vec![
283 format!("root_target:{}", options.root_canister),
284 format!("root_canister:{root_canister_id}"),
285 ],
286 role_names: Vec::new(),
287 };
288 Ok((root_canister_id, phase, duration))
289}
290
291fn build_install_targets_with_phase(
292 options: &InstallRootOptions,
293 icp_root: &Path,
294 config_path: &Path,
295) -> Result<(CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
296 let build_targets = configured_install_targets(config_path, &options.root_build_target)?;
297 let started_at = current_unix_timestamp_label()?;
298 let started = Instant::now();
299 run_canic_build_targets(
300 &options.network,
301 &build_targets,
302 options.build_profile,
303 config_path,
304 icp_root,
305 )?;
306 let duration = started.elapsed();
307 let phase = CompletedInstallPhase {
308 phase: "build_artifacts",
309 attempted_action: "build configured install targets",
310 started_at,
311 finished_at: Some(current_unix_timestamp_label()?),
312 evidence: build_targets
313 .iter()
314 .map(|target| format!("build_target:{target}"))
315 .collect(),
316 role_names: build_targets,
317 };
318 Ok((phase, duration))
319}
320
321fn emit_manifest_with_deployment_truth_receipt(
322 workspace_root: &Path,
323 icp_root: &Path,
324 options: &InstallRootOptions,
325 config_path: &Path,
326 fleet_name: &str,
327 deployment_truth_check: &DeploymentCheckV1,
328 execution_context: &DeploymentExecutionContextV1,
329) -> Result<(PathBuf, Duration), Box<dyn std::error::Error>> {
330 let emit_manifest_started_at_label = current_unix_timestamp_label()?;
331 let emit_manifest_started_at = Instant::now();
332 let manifest_path = emit_root_release_set_manifest_with_config(
333 workspace_root,
334 icp_root,
335 &options.network,
336 config_path,
337 )?;
338 let emit_manifest_duration = emit_manifest_started_at.elapsed();
339 let emit_manifest_receipt = receipt_with_execution_context(
340 install_deployment_truth_phase_receipt(
341 deployment_truth_check,
342 "emit_manifest",
343 emit_manifest_started_at_label,
344 Some(current_unix_timestamp_label()?),
345 "emit root release-set manifest",
346 crate::deployment_truth::ObservationStatusV1::Observed,
347 vec![format!("manifest_path:{}", manifest_path.display())],
348 ),
349 execution_context,
350 );
351 let emit_manifest_receipt_path = write_install_deployment_truth_receipt(
352 icp_root,
353 &options.network,
354 fleet_name,
355 &emit_manifest_receipt,
356 )?;
357 println!(
358 "Deployment truth receipt JSON: {}",
359 emit_manifest_receipt_path.display()
360 );
361 Ok((manifest_path, emit_manifest_duration))
362}
363
364fn run_root_activation_phases(
365 receipt_scope: InstallReceiptScope<'_>,
366 options: &InstallRootOptions,
367 root_canister_id: &str,
368 manifest_path: &Path,
369 total_started_at: Instant,
370) -> Result<InstallTimingSummary, Box<dyn std::error::Error>> {
371 let mut timings = InstallTimingSummary::default();
372 let root_wasm = resolve_artifact_root(receipt_scope.icp_root, receipt_scope.network)?
373 .join(&options.root_build_target)
374 .join(format!("{}.wasm", options.root_build_target));
375 timings.install_root = receipt_scope.run_phase(
376 "install_root",
377 "install root wasm",
378 vec![
379 format!("root_canister:{root_canister_id}"),
380 format!("root_wasm:{}", root_wasm.display()),
381 ],
382 || {
383 reinstall_root_wasm(
384 receipt_scope.icp_root,
385 receipt_scope.network,
386 root_canister_id,
387 &root_wasm,
388 )
389 },
390 )?;
391 timings.fund_root = receipt_scope.run_phase(
392 "fund_root_pre_bootstrap",
393 "ensure local root minimum cycles before bootstrap",
394 vec![
395 format!("root_canister:{root_canister_id}"),
396 format!("minimum_cycles:{LOCAL_ROOT_MIN_READY_CYCLES}"),
397 ],
398 || {
399 ensure_local_root_min_cycles(
400 receipt_scope.icp_root,
401 receipt_scope.network,
402 root_canister_id,
403 "pre-bootstrap",
404 )
405 },
406 )?;
407 let manifest = load_root_release_set_manifest(manifest_path)?;
408 timings.stage_release_set = receipt_scope.run_phase(
409 "stage_release_set",
410 "stage root release set",
411 vec![
412 format!("root_canister:{root_canister_id}"),
413 format!("manifest_path:{}", manifest_path.display()),
414 ],
415 || {
416 stage_root_release_set(
417 receipt_scope.icp_root,
418 receipt_scope.network,
419 root_canister_id,
420 &manifest,
421 )
422 },
423 )?;
424 timings.resume_bootstrap = receipt_scope.run_phase(
425 "resume_bootstrap",
426 "resume root bootstrap",
427 vec![format!("root_canister:{root_canister_id}")],
428 || resume_root_bootstrap(receipt_scope.network, root_canister_id),
429 )?;
430 let wait_ready_result = receipt_scope.run_phase(
431 "wait_ready",
432 "wait for root bootstrap readiness",
433 vec![
434 format!("root_canister:{root_canister_id}"),
435 format!("timeout_seconds:{}", options.ready_timeout_seconds),
436 ],
437 || {
438 wait_for_root_ready(
439 receipt_scope.network,
440 root_canister_id,
441 options.ready_timeout_seconds,
442 )
443 },
444 );
445 match wait_ready_result {
446 Ok(duration) => timings.wait_ready = duration,
447 Err(err) => {
448 print_install_timing_summary(&timings, total_started_at.elapsed());
449 return Err(err);
450 }
451 }
452 timings.finalize_root_funding = receipt_scope.run_phase(
453 "fund_root_post_ready",
454 "ensure local root minimum cycles after ready",
455 vec![
456 format!("root_canister:{root_canister_id}"),
457 format!("minimum_cycles:{LOCAL_ROOT_MIN_READY_CYCLES}"),
458 ],
459 || {
460 ensure_local_root_min_cycles(
461 receipt_scope.icp_root,
462 receipt_scope.network,
463 root_canister_id,
464 "post-ready",
465 )
466 },
467 )?;
468 Ok(timings)
469}
470
471#[derive(Clone, Copy)]
472struct InstallReceiptScope<'a> {
473 icp_root: &'a Path,
474 network: &'a str,
475 fleet_name: &'a str,
476 check: &'a DeploymentCheckV1,
477 execution_context: Option<&'a DeploymentExecutionContextV1>,
478}
479
480struct CompletedInstallPhase {
481 phase: &'static str,
482 attempted_action: &'static str,
483 started_at: String,
484 finished_at: Option<String>,
485 evidence: Vec<String>,
486 role_names: Vec<String>,
487}
488
489fn write_completed_install_phase_receipt(
490 receipt_scope: InstallReceiptScope<'_>,
491 completed: CompletedInstallPhase,
492) -> Result<PathBuf, Box<dyn std::error::Error>> {
493 let role_phase_receipts = completed
494 .role_names
495 .iter()
496 .filter_map(|role| {
497 completed_phase_role_receipt(
498 receipt_scope.check,
499 completed.phase,
500 role,
501 crate::deployment_truth::RolePhaseResultV1::Applied,
502 None,
503 )
504 })
505 .collect();
506 let receipt =
507 receipt_scope.with_execution_context(install_deployment_truth_phase_receipt_with_result(
508 receipt_scope.check,
509 PhaseReceiptInput {
510 phase: completed.phase,
511 started_at: completed.started_at,
512 finished_at: completed.finished_at,
513 attempted_action: completed.attempted_action,
514 status: crate::deployment_truth::ObservationStatusV1::Observed,
515 evidence: completed.evidence,
516 role_phase_receipts,
517 operation_status: DeploymentExecutionStatusV1::Complete,
518 command_result: DeploymentCommandResultV1::Succeeded,
519 },
520 ));
521 receipt_scope.write_receipt(&receipt)
522}
523
524fn completed_phase_role_receipt(
525 check: &DeploymentCheckV1,
526 phase: &str,
527 role: &str,
528 result: crate::deployment_truth::RolePhaseResultV1,
529 error: Option<String>,
530) -> Option<crate::deployment_truth::RolePhaseReceiptV1> {
531 let planned = check
532 .plan
533 .role_artifacts
534 .iter()
535 .find(|artifact| artifact.role == role)?;
536 let observed = check
537 .inventory
538 .observed_artifacts
539 .iter()
540 .find(|artifact| artifact.role == role);
541 let artifact_digest = observed
542 .and_then(|artifact| artifact.file_sha256.clone())
543 .or_else(|| observed.and_then(|artifact| artifact.payload_sha256.clone()))
544 .or_else(|| planned.observed_wasm_gz_file_sha256.clone())
545 .or_else(|| planned.wasm_gz_sha256.clone());
546
547 Some(crate::deployment_truth::RolePhaseReceiptV1 {
548 role: role.to_string(),
549 phase: phase.to_string(),
550 result,
551 previous_module_hash: None,
552 target_module_hash: planned.installed_module_hash.clone(),
553 observed_module_hash_after: None,
554 artifact_digest,
555 canonical_embedded_config_sha256: planned.canonical_embedded_config_sha256.clone(),
556 error,
557 })
558}
559
560fn write_install_state_with_deployment_truth_receipt(
561 receipt_scope: InstallReceiptScope<'_>,
562 network: &str,
563 state: &InstallState,
564) -> Result<PathBuf, Box<dyn std::error::Error>> {
565 let started_at = current_unix_timestamp_label()?;
566 let state_path = write_install_state(receipt_scope.icp_root, network, state)?;
567 let completed = CompletedInstallPhase {
568 phase: "write_install_state",
569 attempted_action: "write local install state",
570 started_at,
571 finished_at: Some(current_unix_timestamp_label()?),
572 evidence: vec![
573 format!("install_state:{}", state_path.display()),
574 format!("fleet:{}", state.fleet),
575 format!("root_canister:{}", state.root_canister_id),
576 ],
577 role_names: Vec::new(),
578 };
579 write_completed_install_phase_receipt(receipt_scope, completed)?;
580 Ok(state_path)
581}
582
583impl InstallReceiptScope<'_> {
584 fn run_phase(
585 self,
586 phase: &str,
587 attempted_action: &str,
588 evidence: Vec<String>,
589 run: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
590 ) -> Result<Duration, Box<dyn std::error::Error>> {
591 let started_at = current_unix_timestamp_label()?;
592 let started = Instant::now();
593 match run() {
594 Ok(()) => {
595 let duration = started.elapsed();
596 let receipt = self.with_execution_context(install_deployment_truth_phase_receipt(
597 self.check,
598 phase,
599 started_at,
600 Some(current_unix_timestamp_label()?),
601 attempted_action,
602 crate::deployment_truth::ObservationStatusV1::Observed,
603 evidence,
604 ));
605 self.write_receipt(&receipt)?;
606 Ok(duration)
607 }
608 Err(err) => {
609 self.try_write_failed_phase_receipt(
610 phase,
611 started_at,
612 attempted_action,
613 evidence,
614 err.as_ref(),
615 );
616 Err(err)
617 }
618 }
619 }
620
621 fn with_execution_context(self, receipt: DeploymentReceiptV1) -> DeploymentReceiptV1 {
622 match self.execution_context {
623 Some(context) => receipt_with_execution_context(receipt, context),
624 None => receipt,
625 }
626 }
627
628 fn write_receipt(
629 self,
630 receipt: &DeploymentReceiptV1,
631 ) -> Result<PathBuf, Box<dyn std::error::Error>> {
632 let path = write_install_deployment_truth_receipt(
633 self.icp_root,
634 self.network,
635 self.fleet_name,
636 receipt,
637 )?;
638 println!("Deployment truth receipt JSON: {}", path.display());
639 Ok(path)
640 }
641
642 fn try_write_failed_phase_receipt(
643 self,
644 phase: &str,
645 started_at: String,
646 attempted_action: &str,
647 evidence: Vec<String>,
648 err: &dyn std::error::Error,
649 ) {
650 let receipt = install_deployment_truth_phase_receipt_with_result(
651 self.check,
652 PhaseReceiptInput {
653 phase,
654 started_at,
655 finished_at: Some(
656 current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string()),
657 ),
658 attempted_action,
659 status: crate::deployment_truth::ObservationStatusV1::Inconclusive,
660 evidence,
661 role_phase_receipts: Vec::new(),
662 operation_status: DeploymentExecutionStatusV1::FailedAfterMutation,
663 command_result: DeploymentCommandResultV1::Failed {
664 code: format!("{phase}_failed"),
665 message: err.to_string(),
666 },
667 },
668 );
669 let receipt = self.with_execution_context(receipt);
670 if let Err(write_err) = self.write_receipt(&receipt) {
671 eprintln!("Deployment truth receipt JSON write failed: {write_err}");
672 }
673 }
674}
675
676pub fn check_install_deployment_truth(
679 options: &InstallRootOptions,
680 observed_at: impl Into<String>,
681) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
682 let workspace_root = workspace_root()?;
683 let icp_root = match &options.icp_root {
684 Some(path) => path.canonicalize()?,
685 None => icp_root()?,
686 };
687 let config_path = resolve_install_config_path(
688 &icp_root,
689 options.config_path.as_deref(),
690 options.interactive_config_selection,
691 )?;
692 let fleet_name = configured_fleet_name(&config_path)?;
693 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
694 validate_fleet_name(&fleet_name)?;
695 current_install_deployment_truth_check_at(
696 options,
697 &workspace_root,
698 &icp_root,
699 &config_path,
700 &fleet_name,
701 observed_at.into(),
702 )
703}
704
705fn current_install_deployment_truth_check_at(
706 options: &InstallRootOptions,
707 workspace_root: &Path,
708 icp_root: &Path,
709 config_path: &Path,
710 fleet_name: &str,
711 observed_at: String,
712) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
713 let build_profile = options
714 .build_profile
715 .unwrap_or_else(CanisterBuildProfile::current)
716 .target_dir_name()
717 .to_string();
718
719 check_local_deployment(&LocalDeploymentCheckRequest {
720 deployment_name: fleet_name.to_string(),
721 network: options.network.clone(),
722 workspace_root: workspace_root.to_path_buf(),
723 icp_root: icp_root.to_path_buf(),
724 config_path: Some(config_path.to_path_buf()),
725 observed_at,
726 runtime_variant: options.network.clone(),
727 build_profile,
728 })
729 .map_err(Into::into)
730}
731
732fn current_install_execution_context(
733 workspace_root: &Path,
734 icp_root: &Path,
735 network: &str,
736) -> DeploymentExecutionContextV1 {
737 CurrentCliDeploymentExecutor::new(
738 Some(workspace_root.display().to_string()),
739 Some(icp_root.display().to_string()),
740 current_install_artifact_roots(icp_root, network),
741 )
742 .execution_context()
743}
744
745fn ensure_current_install_executor_capabilities(
746 execution_context: &DeploymentExecutionContextV1,
747) -> Result<(), Box<dyn std::error::Error>> {
748 let missing = current_install_executor_missing_capabilities(execution_context);
749 if missing.is_empty() {
750 return Ok(());
751 }
752
753 Err(format!(
754 "current install executor backend {:?} is missing required capabilities: {missing:?}",
755 execution_context.backend
756 )
757 .into())
758}
759
760fn current_install_executor_missing_capabilities(
761 execution_context: &DeploymentExecutionContextV1,
762) -> Vec<DeploymentExecutorCapabilityV1> {
763 missing_executor_capabilities(
764 &execution_context.backend_capabilities,
765 CURRENT_INSTALL_REQUIRED_CAPABILITIES,
766 )
767}
768
769fn current_install_artifact_roots(icp_root: &Path, network: &str) -> Vec<String> {
770 let planned_root = planned_build_artifact_root(icp_root);
771 let mut roots = vec![planned_root.display().to_string()];
772 if let Ok(resolved_root) = resolve_artifact_root(icp_root, network)
773 && resolved_root != planned_root
774 {
775 roots.push(resolved_root.display().to_string());
776 }
777 roots
778}
779
780fn run_install_deployment_truth_safety_gate(
781 options: &InstallRootOptions,
782 workspace_root: &Path,
783 icp_root: &Path,
784 config_path: &Path,
785 fleet_name: &str,
786 execution_context: &DeploymentExecutionContextV1,
787) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
788 let truth_gate_started_at = current_unix_timestamp_label()?;
789 let deployment_truth_check = current_install_deployment_truth_check_at(
790 options,
791 workspace_root,
792 icp_root,
793 config_path,
794 fleet_name,
795 truth_gate_started_at.clone(),
796 )?;
797 let artifact_gate_receipt = artifact_gate_phase_receipt(
798 &deployment_truth_check,
799 truth_gate_started_at.clone(),
800 Some(current_unix_timestamp_label()?),
801 );
802 let role_receipts = artifact_gate_role_phase_receipts(&deployment_truth_check);
803 let deployment_receipt = receipt_with_execution_context(
804 install_deployment_truth_gate_receipt(
805 &deployment_truth_check,
806 truth_gate_started_at,
807 vec![artifact_gate_receipt],
808 role_receipts,
809 ),
810 execution_context,
811 );
812 let receipt_write = write_install_deployment_truth_receipt(
813 icp_root,
814 &options.network,
815 fleet_name,
816 &deployment_receipt,
817 );
818 match &receipt_write {
819 Ok(path) => println!("Deployment truth receipt JSON: {}", path.display()),
820 Err(err) => eprintln!("Deployment truth receipt JSON write failed: {err}"),
821 }
822 print_install_deployment_truth_gate(&deployment_truth_check, &deployment_receipt);
823 enforce_install_deployment_truth_gate(&deployment_truth_check)?;
824 receipt_write?;
825 Ok(deployment_truth_check)
826}
827
828fn enforce_install_deployment_truth_gate(
829 check: &DeploymentCheckV1,
830) -> Result<(), Box<dyn std::error::Error>> {
831 let blockers = install_deployment_truth_gate_blockers(check);
832 if blockers.is_empty() {
833 return Ok(());
834 }
835
836 let details = blockers
837 .iter()
838 .map(|finding| deployment_truth_finding_label(finding))
839 .collect::<Vec<_>>()
840 .join("; ");
841 Err(format!("deployment truth safety gate blocked install: {details}").into())
842}
843
844fn install_deployment_truth_gate_blockers(check: &DeploymentCheckV1) -> Vec<&SafetyFindingV1> {
845 check.report.hard_failures.iter().collect()
846}
847
848fn print_install_deployment_truth_gate(check: &DeploymentCheckV1, receipt: &DeploymentReceiptV1) {
849 for line in install_deployment_truth_gate_lines(check, receipt) {
850 println!("{line}");
851 }
852}
853
854fn install_deployment_truth_gate_lines(
855 check: &DeploymentCheckV1,
856 receipt: &DeploymentReceiptV1,
857) -> Vec<String> {
858 let mut lines = vec![
859 format!("Deployment truth: {}", check.report.summary),
860 format!(
861 "Deployment truth receipt: operation={} status={:?}",
862 receipt.operation_id, receipt.operation_status
863 ),
864 ];
865 for phase_receipt in &receipt.phase_receipts {
866 lines.push(format!(
867 "Deployment truth phase receipt: phase={} postcondition={:?}",
868 phase_receipt.phase, phase_receipt.verified_postcondition.status
869 ));
870 }
871 if !receipt.role_phase_receipts.is_empty() {
872 lines.push(format!(
873 "Deployment truth role receipts: {}",
874 receipt.role_phase_receipts.len()
875 ));
876 }
877 for role_receipt in &receipt.role_phase_receipts {
878 lines.push(format!(
879 "Deployment truth role receipt: phase={} role={} result={:?}",
880 role_receipt.phase, role_receipt.role, role_receipt.result
881 ));
882 }
883
884 if !check.report.hard_failures.is_empty() {
885 lines.push(format!(
886 "Deployment truth hard failures: {}",
887 check.report.hard_failures.len()
888 ));
889 }
890 for finding in install_deployment_truth_gate_blockers(check) {
891 lines.push(format!(
892 "Deployment truth blocker: {}",
893 deployment_truth_finding_label(finding)
894 ));
895 }
896 if !check.report.warnings.is_empty() {
897 lines.push(format!(
898 "Deployment truth warnings: {}",
899 check.report.warnings.len()
900 ));
901 }
902 for finding in &check.report.warnings {
903 lines.push(format!(
904 "Deployment truth warning: {}",
905 deployment_truth_finding_label(finding)
906 ));
907 }
908 lines
909}
910
911fn install_deployment_truth_gate_receipt(
912 check: &DeploymentCheckV1,
913 started_at: String,
914 phase_receipts: Vec<crate::deployment_truth::PhaseReceiptV1>,
915 role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
916) -> DeploymentReceiptV1 {
917 let blockers = install_deployment_truth_gate_blockers(check);
918 let (operation_status, command_result) = if blockers.is_empty() {
919 (
920 DeploymentExecutionStatusV1::Complete,
921 DeploymentCommandResultV1::Succeeded,
922 )
923 } else {
924 (
925 DeploymentExecutionStatusV1::FailedBeforeMutation,
926 DeploymentCommandResultV1::Failed {
927 code: "deployment_truth_blocked".to_string(),
928 message: check.report.summary.clone(),
929 },
930 )
931 };
932 deployment_receipt_from_check_with_status(
933 check,
934 format!("{}:materialize_artifacts", check.check_id),
935 operation_status,
936 started_at,
937 Some(current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string())),
938 phase_receipts,
939 role_phase_receipts,
940 command_result,
941 )
942}
943
944fn install_deployment_truth_phase_receipt(
945 check: &DeploymentCheckV1,
946 phase: &str,
947 started_at: String,
948 finished_at: Option<String>,
949 attempted_action: &str,
950 status: crate::deployment_truth::ObservationStatusV1,
951 evidence: Vec<String>,
952) -> DeploymentReceiptV1 {
953 install_deployment_truth_phase_receipt_with_result(
954 check,
955 PhaseReceiptInput {
956 phase,
957 started_at,
958 finished_at,
959 attempted_action,
960 status,
961 evidence,
962 role_phase_receipts: Vec::new(),
963 operation_status: DeploymentExecutionStatusV1::Complete,
964 command_result: DeploymentCommandResultV1::Succeeded,
965 },
966 )
967}
968
969fn install_deployment_truth_phase_receipt_with_result(
970 check: &DeploymentCheckV1,
971 input: PhaseReceiptInput<'_>,
972) -> DeploymentReceiptV1 {
973 deployment_receipt_from_check_with_status(
974 check,
975 format!("{}:{}", check.check_id, input.phase),
976 input.operation_status,
977 input.started_at.clone(),
978 input.finished_at.clone(),
979 vec![phase_receipt(
980 input.phase,
981 input.started_at,
982 input.finished_at,
983 input.attempted_action,
984 input.status,
985 input.evidence,
986 )],
987 input.role_phase_receipts,
988 input.command_result,
989 )
990}
991
992fn receipt_with_execution_context(
993 mut receipt: DeploymentReceiptV1,
994 execution_context: &DeploymentExecutionContextV1,
995) -> DeploymentReceiptV1 {
996 receipt.execution_context = Some(execution_context.clone());
997 receipt
998}
999
1000struct PhaseReceiptInput<'a> {
1001 phase: &'a str,
1002 started_at: String,
1003 finished_at: Option<String>,
1004 attempted_action: &'a str,
1005 status: crate::deployment_truth::ObservationStatusV1,
1006 evidence: Vec<String>,
1007 role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
1008 operation_status: DeploymentExecutionStatusV1,
1009 command_result: DeploymentCommandResultV1,
1010}
1011
1012fn write_install_deployment_truth_receipt(
1013 icp_root: &Path,
1014 network: &str,
1015 fleet_name: &str,
1016 receipt: &DeploymentReceiptV1,
1017) -> Result<PathBuf, Box<dyn std::error::Error>> {
1018 let path = install_deployment_truth_receipt_path(icp_root, network, fleet_name, receipt)?;
1019 if let Some(parent) = path.parent() {
1020 fs::create_dir_all(parent)?;
1021 }
1022 let mut bytes = serde_json::to_vec_pretty(receipt)?;
1023 bytes.push(b'\n');
1024 fs::write(&path, bytes)?;
1025 Ok(path)
1026}
1027
1028fn install_deployment_truth_receipt_path(
1029 icp_root: &Path,
1030 network: &str,
1031 fleet_name: &str,
1032 receipt: &DeploymentReceiptV1,
1033) -> Result<PathBuf, Box<dyn std::error::Error>> {
1034 validate_network_name(network)?;
1035 validate_fleet_name(fleet_name)?;
1036 let file_stem = format!(
1037 "{}-{}",
1038 safe_deployment_truth_path_label(&receipt.started_at),
1039 safe_deployment_truth_path_label(&receipt.operation_id)
1040 );
1041 Ok(
1042 install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?
1043 .join(format!("{file_stem}.json")),
1044 )
1045}
1046
1047pub fn latest_deployment_truth_receipt_path_from_root(
1049 icp_root: &Path,
1050 network: &str,
1051 fleet_name: &str,
1052) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
1053 let dir = install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?;
1054 if !dir.is_dir() {
1055 return Ok(None);
1056 }
1057
1058 let mut latest = None;
1059 for entry in fs::read_dir(dir)? {
1060 let path = entry?.path();
1061 if !path.is_file()
1062 || path
1063 .extension()
1064 .is_none_or(|ext| !ext.eq_ignore_ascii_case("json"))
1065 {
1066 continue;
1067 }
1068 if latest.as_ref().is_none_or(|current| path > *current) {
1069 latest = Some(path);
1070 }
1071 }
1072 Ok(latest)
1073}
1074
1075fn install_deployment_truth_receipts_dir(
1076 icp_root: &Path,
1077 network: &str,
1078 fleet_name: &str,
1079) -> Result<PathBuf, Box<dyn std::error::Error>> {
1080 validate_network_name(network)?;
1081 validate_fleet_name(fleet_name)?;
1082 Ok(icp_root
1083 .join(".canic")
1084 .join(network)
1085 .join("deployment-receipts")
1086 .join(fleet_name))
1087}
1088
1089fn safe_deployment_truth_path_label(value: &str) -> String {
1090 let label = value
1091 .chars()
1092 .map(|ch| {
1093 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
1094 ch
1095 } else {
1096 '_'
1097 }
1098 })
1099 .collect::<String>();
1100 if label.is_empty() {
1101 "unknown".to_string()
1102 } else {
1103 label
1104 }
1105}
1106
1107fn deployment_truth_finding_label(finding: &SafetyFindingV1) -> String {
1108 let subject = finding
1109 .subject
1110 .as_ref()
1111 .map_or_else(|| "<none>".to_string(), Clone::clone);
1112 format!(
1113 "{}:{}:{}: {}",
1114 deployment_truth_finding_source(&finding.code),
1115 finding.code,
1116 subject,
1117 finding.message
1118 )
1119}
1120
1121fn deployment_truth_finding_source(code: &str) -> &'static str {
1122 match code {
1123 "plan_assumption" => "plan",
1124 "observation_gap" => "inventory",
1125 _ => "diff",
1126 }
1127}
1128
1129fn validate_expected_fleet_name(
1130 expected: Option<&str>,
1131 actual: &str,
1132 config_path: &Path,
1133) -> Result<(), Box<dyn std::error::Error>> {
1134 let Some(expected) = expected else {
1135 return Ok(());
1136 };
1137 if expected == actual {
1138 return Ok(());
1139 }
1140 Err(format!(
1141 "install requested fleet {expected}, but {} declares [fleet].name = {actual:?}",
1142 config_path.display()
1143 )
1144 .into())
1145}
1146
1147fn ensure_root_canister_id(
1148 icp_root: &Path,
1149 network: &str,
1150 root_canister: &str,
1151 config_path: &Path,
1152) -> Result<String, Box<dyn std::error::Error>> {
1153 if Principal::from_text(root_canister).is_ok() {
1154 return Ok(root_canister.to_string());
1155 }
1156
1157 match resolve_root_canister_id(icp_root, network, root_canister) {
1158 Ok(canister_id) => return Ok(canister_id),
1159 Err(err) if !is_missing_canister_id_error(&err.to_string()) => return Err(err),
1160 Err(_) => {}
1161 }
1162
1163 let mut create = icp_canister_command_in_network(icp_root);
1164 add_create_root_target(&mut create, root_canister);
1165 add_local_root_create_cycles_arg(&mut create, config_path, network)?;
1166 add_icp_environment_target(&mut create, network);
1167 let output = run_command_stdout(&mut create)?;
1168 if let Some(canister_id) = parse_created_canister_id(&output) {
1169 return Ok(canister_id);
1170 }
1171
1172 resolve_root_canister_id(icp_root, network, root_canister).map_err(|_| {
1173 format!(
1174 "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.",
1175 icp_root.display(),
1176 icp_root.display(),
1177 )
1178 .into()
1179 })
1180}
1181
1182fn parse_created_canister_id(output: &str) -> Option<String> {
1183 if let Ok(value) = serde_json::from_str::<JsonValue>(output) {
1184 return parse_canister_id_json(&value);
1185 }
1186
1187 output
1188 .lines()
1189 .map(str::trim)
1190 .find(|line| Principal::from_text(*line).is_ok())
1191 .map(ToString::to_string)
1192}
1193
1194fn parse_canister_id_json(value: &JsonValue) -> Option<String> {
1195 match value {
1196 JsonValue::String(text) if Principal::from_text(text).is_ok() => Some(text.clone()),
1197 JsonValue::Array(values) => values.iter().find_map(parse_canister_id_json),
1198 JsonValue::Object(object) => ["canister_id", "id", "principal"]
1199 .iter()
1200 .filter_map(|key| object.get(*key))
1201 .find_map(parse_canister_id_json),
1202 _ => None,
1203 }
1204}
1205
1206fn add_create_root_target(command: &mut Command, root_canister: &str) {
1207 if env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV).is_some() {
1208 command.args(["create", "--detached", "--json"]);
1209 } else {
1210 command.args(["create", root_canister, "--json"]);
1211 }
1212}
1213
1214fn is_missing_canister_id_error(message: &str) -> bool {
1215 message.contains("failed to lookup canister ID")
1216 || message.contains("could not find ID for canister")
1217 || message.contains("Canister ID is missing")
1218}
1219
1220fn reinstall_root_wasm(
1221 icp_root: &Path,
1222 network: &str,
1223 root_canister: &str,
1224 root_wasm: &Path,
1225) -> Result<(), Box<dyn std::error::Error>> {
1226 let mut install = icp_canister_command_in_network(icp_root);
1227 install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
1228 install.arg(root_wasm);
1229 install.args(["--args", &root_init_args(root_wasm)?]);
1230 add_icp_environment_target(&mut install, network);
1231 run_command(&mut install)
1232}
1233
1234fn root_init_args(root_wasm: &Path) -> Result<String, Box<dyn std::error::Error>> {
1235 let wasm = std::fs::read(root_wasm)?;
1236 Ok(format!(
1237 "(variant {{ PrimeWithModuleHash = {} }})",
1238 idl_blob(&wasm_hash(&wasm))
1239 ))
1240}
1241
1242fn idl_blob(bytes: &[u8]) -> String {
1243 let mut encoded = String::from("blob \"");
1244 for byte in bytes {
1245 use std::fmt::Write as _;
1246 let _ = write!(encoded, "\\{byte:02X}");
1247 }
1248 encoded.push('"');
1249 encoded
1250}
1251
1252fn build_install_state(
1254 options: &InstallRootOptions,
1255 workspace_root: &Path,
1256 icp_root: &Path,
1257 config_path: &Path,
1258 release_set_manifest_path: &Path,
1259 fleet_name: &str,
1260 root_canister_id: &str,
1261) -> Result<InstallState, Box<dyn std::error::Error>> {
1262 Ok(InstallState {
1263 schema_version: INSTALL_STATE_SCHEMA_VERSION,
1264 fleet: fleet_name.to_string(),
1265 installed_at_unix_secs: current_unix_secs()?,
1266 network: options.network.clone(),
1267 root_target: options.root_canister.clone(),
1268 root_canister_id: root_canister_id.to_string(),
1269 root_build_target: options.root_build_target.clone(),
1270 workspace_root: workspace_root.display().to_string(),
1271 icp_root: icp_root.display().to_string(),
1272 config_path: config_path.display().to_string(),
1273 release_set_manifest_path: release_set_manifest_path.display().to_string(),
1274 })
1275}
1276
1277fn resolve_root_canister_id(
1279 icp_root: &Path,
1280 network: &str,
1281 root_canister: &str,
1282) -> Result<String, Box<dyn std::error::Error>> {
1283 if Principal::from_text(root_canister).is_ok() {
1284 return Ok(root_canister.to_string());
1285 }
1286
1287 let mut command = icp_canister_command_in_network(icp_root);
1288 command.args(["status", root_canister, "--json"]);
1289 add_icp_environment_target(&mut command, network);
1290 let output = run_command_stdout(&mut command)?;
1291 parse_created_canister_id(&output).ok_or_else(|| {
1292 format!("could not parse root canister id from ICP status JSON output: {output}").into()
1293 })
1294}
1295
1296fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
1298 Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
1299}
1300
1301fn current_unix_timestamp_label() -> Result<String, Box<dyn std::error::Error>> {
1302 Ok(format!("unix:{}", current_unix_secs()?))
1303}
1304
1305fn run_canic_build_targets(
1307 network: &str,
1308 targets: &[String],
1309 build_profile: Option<CanisterBuildProfile>,
1310 config_path: &Path,
1311 icp_root: &Path,
1312) -> Result<(), Box<dyn std::error::Error>> {
1313 let _env = BuildEnvGuard::apply(network, config_path, icp_root);
1314 let profile = build_profile.unwrap_or_else(CanisterBuildProfile::current);
1315 if let Some(context) = current_workspace_build_context_once(profile)? {
1316 for line in context.lines() {
1317 println!("{line}");
1318 }
1319 println!("config: {}", config_path.display());
1320 println!(
1321 "artifacts: {}",
1322 planned_build_artifact_root(icp_root).display()
1323 );
1324 println!();
1325 }
1326
1327 fs::create_dir_all(planned_build_artifact_root(icp_root))?;
1328 println!("Building {} canisters", targets.len());
1329 println!();
1330 let headers = ["CANISTER", "PROGRESS", "WASM", "ELAPSED"];
1331 let planned_rows = targets
1332 .iter()
1333 .map(|target| {
1334 [
1335 target.clone(),
1336 progress_bar(targets.len(), targets.len(), 10),
1337 "000.00 MiB (gz 000.00 MiB)".to_string(),
1338 "0.00s".to_string(),
1339 ]
1340 })
1341 .collect::<Vec<_>>();
1342 let alignments = [
1343 ColumnAlign::Left,
1344 ColumnAlign::Left,
1345 ColumnAlign::Right,
1346 ColumnAlign::Right,
1347 ];
1348 let widths = table_widths(&headers, &planned_rows);
1349 println!("{}", render_table_row(&headers, &widths, &alignments));
1350 println!("{}", render_separator(&widths));
1351
1352 for (index, target) in targets.iter().enumerate() {
1353 let started_at = Instant::now();
1354 let output = build_current_workspace_canister_artifact(target, profile)
1355 .map_err(|err| format!("artifact build failed for {target}: {err}"))?;
1356 let elapsed = started_at.elapsed();
1357 let artifact_size = wasm_artifact_size(&output.wasm_path, &output.wasm_gz_path)?;
1358
1359 let row = [
1360 target.clone(),
1361 progress_bar(index + 1, targets.len(), 10),
1362 artifact_size,
1363 format!("{:.2}s", elapsed.as_secs_f64()),
1364 ];
1365 println!("{}", render_table_row(&row, &widths, &alignments));
1366 }
1367
1368 println!();
1369 Ok(())
1370}
1371
1372fn planned_build_artifact_root(icp_root: &Path) -> PathBuf {
1373 icp_root.join(".icp/local/canisters")
1374}
1375
1376fn wasm_artifact_size(
1377 wasm_path: &Path,
1378 wasm_gz_path: &Path,
1379) -> Result<String, Box<dyn std::error::Error>> {
1380 let wasm_bytes = Some(std::fs::metadata(wasm_path)?.len());
1381 let gzip_bytes = std::fs::metadata(wasm_gz_path)
1382 .ok()
1383 .map(|metadata| metadata.len());
1384 Ok(wasm_size_label(wasm_bytes, gzip_bytes))
1385}
1386
1387struct BuildEnvGuard {
1388 previous_network: Option<OsString>,
1389 previous_config_path: Option<OsString>,
1390 previous_icp_root: Option<OsString>,
1391 previous_local_network_url: Option<OsString>,
1392 previous_local_root_key: Option<OsString>,
1393}
1394
1395impl BuildEnvGuard {
1396 fn apply(network: &str, config_path: &Path, icp_root: &Path) -> Self {
1397 let guard = Self {
1398 previous_network: env::var_os("ICP_ENVIRONMENT"),
1399 previous_config_path: env::var_os("CANIC_CONFIG_PATH"),
1400 previous_icp_root: env::var_os("CANIC_ICP_ROOT"),
1401 previous_local_network_url: env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV),
1402 previous_local_root_key: env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV),
1403 };
1404 set_env("ICP_ENVIRONMENT", network);
1405 set_env("CANIC_CONFIG_PATH", config_path);
1406 set_env("CANIC_ICP_ROOT", icp_root);
1407 if let Some(target) = local_replica_icp_target(network, icp_root) {
1408 set_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV, target.url);
1409 set_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV, target.root_key);
1410 } else {
1411 remove_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV);
1412 remove_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV);
1413 }
1414 guard
1415 }
1416}
1417
1418impl Drop for BuildEnvGuard {
1419 fn drop(&mut self) {
1420 restore_env("ICP_ENVIRONMENT", self.previous_network.take());
1421 restore_env("CANIC_CONFIG_PATH", self.previous_config_path.take());
1422 restore_env("CANIC_ICP_ROOT", self.previous_icp_root.take());
1423 restore_env(
1424 CANIC_ICP_LOCAL_NETWORK_URL_ENV,
1425 self.previous_local_network_url.take(),
1426 );
1427 restore_env(
1428 CANIC_ICP_LOCAL_ROOT_KEY_ENV,
1429 self.previous_local_root_key.take(),
1430 );
1431 }
1432}
1433
1434struct LocalReplicaIcpTarget {
1435 url: String,
1436 root_key: String,
1437}
1438
1439fn local_replica_icp_target(network: &str, icp_root: &Path) -> Option<LocalReplicaIcpTarget> {
1440 if !replica_query::should_use_local_replica_query(Some(network)) {
1441 return None;
1442 }
1443 if icp_ping(icp_root, network).unwrap_or(false) {
1444 return None;
1445 }
1446 let root_key = replica_query::local_replica_root_key_from_root(Some(network), icp_root)
1447 .ok()
1448 .flatten()?;
1449 Some(LocalReplicaIcpTarget {
1450 url: replica_query::local_replica_endpoint_from_root(Some(network), icp_root),
1451 root_key,
1452 })
1453}
1454
1455fn set_env<K, V>(key: K, value: V)
1456where
1457 K: AsRef<std::ffi::OsStr>,
1458 V: AsRef<std::ffi::OsStr>,
1459{
1460 unsafe {
1463 env::set_var(key, value);
1464 }
1465}
1466
1467fn remove_env<K>(key: K)
1468where
1469 K: AsRef<std::ffi::OsStr>,
1470{
1471 unsafe {
1474 env::remove_var(key);
1475 }
1476}
1477
1478fn restore_env(key: &str, value: Option<OsString>) {
1479 if let Some(value) = value {
1481 set_env(key, value);
1482 } else {
1483 remove_env(key);
1484 }
1485}
1486
1487fn add_local_root_create_cycles_arg(
1488 command: &mut Command,
1489 config_path: &Path,
1490 network: &str,
1491) -> Result<(), Box<dyn std::error::Error>> {
1492 if network != "local" {
1493 return Ok(());
1494 }
1495
1496 let cycles = configured_local_root_create_cycles(config_path)?;
1497 command.args(["--cycles", &cycles.to_string()]);
1498 Ok(())
1499}
1500
1501fn ensure_local_root_min_cycles(
1502 icp_root: &Path,
1503 network: &str,
1504 root_canister: &str,
1505 phase: &str,
1506) -> Result<(), Box<dyn std::error::Error>> {
1507 if network != "local" {
1508 return Ok(());
1509 }
1510
1511 let current = query_root_cycle_balance(network, root_canister)?;
1512 if current >= LOCAL_ROOT_MIN_READY_CYCLES {
1513 return Ok(());
1514 }
1515
1516 let amount = LOCAL_ROOT_MIN_READY_CYCLES.saturating_sub(current);
1517 let mut command = icp_canister_command_in_network(icp_root);
1518 command
1519 .args(["top-up", "--amount"])
1520 .arg(amount.to_string())
1521 .arg(root_canister);
1522 add_icp_environment_target(&mut command, network);
1523 run_command(&mut command)?;
1524 println!(
1525 "Local root cycles ({phase}): topped up {} ({} -> {} target)",
1526 crate::format::cycles_tc(amount),
1527 crate::format::cycles_tc(current),
1528 crate::format::cycles_tc(LOCAL_ROOT_MIN_READY_CYCLES)
1529 );
1530 Ok(())
1531}
1532
1533fn query_root_cycle_balance(
1534 network: &str,
1535 root_canister: &str,
1536) -> Result<u128, Box<dyn std::error::Error>> {
1537 let output = icp_query_on_network(
1538 network,
1539 root_canister,
1540 protocol::CANIC_CYCLE_BALANCE,
1541 None,
1542 Some("json"),
1543 )?;
1544 parse_cycle_balance_response(&output).ok_or_else(|| {
1545 format!(
1546 "could not parse {root_canister} {} response: {output}",
1547 protocol::CANIC_CYCLE_BALANCE
1548 )
1549 .into()
1550 })
1551}
1552
1553fn progress_bar(current: usize, total: usize, width: usize) -> String {
1554 if total == 0 || width == 0 {
1555 return "[] 0/0".to_string();
1556 }
1557
1558 let filled = current.saturating_mul(width).div_ceil(total);
1559 let filled = filled.min(width);
1560 format!(
1561 "[{}{}] {current}/{total}",
1562 "#".repeat(filled),
1563 " ".repeat(width - filled)
1564 )
1565}
1566
1567fn ensure_icp_environment_ready(
1569 icp_root: &Path,
1570 network: &str,
1571) -> Result<(), Box<dyn std::error::Error>> {
1572 if icp_ping(icp_root, network)? {
1573 return Ok(());
1574 }
1575 if replica_query::should_use_local_replica_query(Some(network))
1576 && replica_query::local_replica_status_reachable_from_root(Some(network), icp_root)
1577 {
1578 println!(
1579 "Replica reachable via HTTP status endpoint even though ICP CLI reports network '{network}' stopped; continuing from ICP root {}.",
1580 icp_root.display()
1581 );
1582 return Ok(());
1583 }
1584
1585 Err(format!(
1586 "icp environment is not running for network '{network}'\nStart the target replica in another terminal with `canic replica start` and rerun."
1587 )
1588 .into())
1589}
1590
1591fn icp_ping(icp_root: &Path, network: &str) -> Result<bool, Box<dyn std::error::Error>> {
1593 Ok(icp::default_command_in(icp_root)
1594 .args(["network", "ping", network])
1595 .output()?
1596 .status
1597 .success())
1598}
1599
1600fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
1601 println!("Install timing summary:");
1602 println!("{}", render_install_timing_summary(timings, total));
1603}
1604
1605fn render_install_timing_summary(timings: &InstallTimingSummary, total: Duration) -> String {
1606 let rows = [
1607 timing_row("create_canisters", timings.create_canisters),
1608 timing_row("build_all", timings.build_all),
1609 timing_row("emit_manifest", timings.emit_manifest),
1610 timing_row("install_root", timings.install_root),
1611 timing_row("fund_root", timings.fund_root),
1612 timing_row("stage_release_set", timings.stage_release_set),
1613 timing_row("resume_bootstrap", timings.resume_bootstrap),
1614 timing_row("wait_ready", timings.wait_ready),
1615 timing_row("finalize_root_funding", timings.finalize_root_funding),
1616 timing_row("total", total),
1617 ];
1618 render_table(
1619 &["PHASE", "ELAPSED"],
1620 &rows,
1621 &[ColumnAlign::Left, ColumnAlign::Right],
1622 )
1623}
1624
1625fn timing_row(label: &str, duration: Duration) -> [String; 2] {
1626 [label.to_string(), format!("{:.2}s", duration.as_secs_f64())]
1627}
1628
1629fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
1631 println!("Install result:");
1632 println!("{:<14} success", "status");
1633 println!("{:<14} {}", "fleet", fleet);
1634 println!("{:<14} {}", "install_state", state_path.display());
1635 println!(
1636 "{:<14} canic list {} --network {}",
1637 "smoke_check", fleet, network
1638 );
1639}
1640
1641fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
1643 icp::run_status(command).map_err(Into::into)
1644}
1645
1646fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
1648 icp::run_output(command).map_err(Into::into)
1649}
1650
1651fn icp_command_on_network(network: &str) -> Command {
1654 let mut command = icp::default_command();
1655 command.env("ICP_ENVIRONMENT", network);
1656 command
1657}
1658
1659fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
1661 let mut command = icp::default_command_in(icp_root);
1662 command.env("ICP_ENVIRONMENT", network);
1663 command
1664}
1665
1666fn icp_canister_command_in_network(icp_root: &Path) -> Command {
1668 let mut command = icp::default_command_in(icp_root);
1669 command.arg("canister");
1670 command
1671}
1672
1673fn add_icp_environment_target(command: &mut Command, network: &str) {
1674 icp::add_target_args(command, Some(network), None);
1675}