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