1use crate::canister_build::{
2 CanisterBuildProfile, build_current_workspace_canister_artifact,
3 current_workspace_build_context_once,
4};
5use crate::deployment_truth::{
6 ArtifactPromotionExecutionReceiptRequest, ArtifactPromotionExecutionReceiptV1,
7 ArtifactPromotionPlanV1, ArtifactPromotionProvenanceReportRequest, ArtifactTransportV1,
8 CurrentCliDeploymentExecutor, DeploymentCheckV1, DeploymentCommandResultV1,
9 DeploymentExecutionContextV1, DeploymentExecutionPreflightV1, DeploymentExecutionStatusV1,
10 DeploymentExecutor, DeploymentExecutorCapabilityV1, DeploymentPlanV1, DeploymentReceiptV1,
11 LocalDeploymentCheckRequest, LocalInventoryRequest, ObservationStatusV1, SafetyFindingV1,
12 StagingReceiptV1, artifact_gate_phase_receipt, artifact_gate_role_phase_receipts,
13 artifact_promotion_execution_receipt, artifact_promotion_provenance_report,
14 check_local_deployment, collect_local_deployment_inventory, compare_plan_to_inventory,
15 deployment_execution_preflight_from_check, deployment_receipt_from_check_with_status,
16 missing_executor_capabilities, phase_receipt, safety_report_from_diff,
17 staging_receipt_evidence, validate_deployment_execution_preflight_for_check,
18};
19use crate::format::wasm_size_label;
20use crate::icp::{self, CANIC_ICP_LOCAL_NETWORK_URL_ENV, CANIC_ICP_LOCAL_ROOT_KEY_ENV};
21use crate::release_set::{
22 LOCAL_ROOT_MIN_READY_CYCLES, RootReleaseSetManifest, configured_fleet_name,
23 configured_install_targets, configured_local_root_create_cycles,
24 emit_root_release_set_manifest_with_config, icp_query_on_network, icp_root,
25 load_root_release_set_manifest, resolve_artifact_root, resume_root_bootstrap,
26 stage_root_release_set, workspace_root,
27};
28use crate::replica_query;
29use crate::response_parse::parse_cycle_balance_response;
30use crate::table::{ColumnAlign, render_separator, render_table, render_table_row, table_widths};
31use canic_core::cdk::utils::hash::wasm_hash_hex;
32use canic_core::{
33 CANIC_WASM_CHUNK_BYTES,
34 cdk::{types::Principal, utils::hash::wasm_hash},
35 protocol,
36};
37use config_selection::resolve_install_config_path;
38use serde_json::Value as JsonValue;
39use std::{
40 env,
41 ffi::OsString,
42 fs,
43 path::{Path, PathBuf},
44 process::Command,
45 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
46};
47
48mod config_selection;
49mod readiness;
50mod state;
51
52pub use config_selection::{
53 current_canic_project_root, discover_canic_config_choices, discover_canic_project_root_from,
54 discover_project_canic_config_choices, project_fleet_roots,
55};
56use readiness::wait_for_root_ready;
57use state::{
58 INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, validate_network_name, write_install_state,
59};
60pub use state::{
61 InstallState, RootVerificationStatus, read_named_fleet_install_state,
62 read_named_fleet_install_state_from_root,
63};
64
65#[cfg(test)]
66mod tests;
67
68#[cfg(test)]
69use config_selection::config_selection_error;
70#[cfg(test)]
71use readiness::{parse_bootstrap_status_value, parse_root_ready_value};
72#[cfg(test)]
73use state::{deployment_install_state_path, fleet_install_state_path, read_fleet_install_state};
74
75#[derive(Clone, Debug)]
80pub struct InstallRootOptions {
81 pub root_canister: String,
82 pub root_build_target: String,
83 pub network: String,
84 pub deployment_name: Option<String>,
85 pub icp_root: Option<PathBuf>,
86 pub build_profile: Option<CanisterBuildProfile>,
87 pub ready_timeout_seconds: u64,
88 pub config_path: Option<String>,
89 pub expected_fleet: Option<String>,
90 pub interactive_config_selection: bool,
91 pub deployment_plan_override: Option<DeploymentPlanV1>,
92 pub artifact_promotion_plan_override: Option<ArtifactPromotionPlanV1>,
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct RegisterDeploymentStateOptions {
101 pub deployment_name: String,
102 pub fleet_template: String,
103 pub root_canister_id: String,
104 pub network: String,
105 pub icp_root: Option<PathBuf>,
106 pub workspace_root: Option<PathBuf>,
107}
108
109#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
114struct InstallTimingSummary {
115 create_canisters: Duration,
116 build_all: Duration,
117 emit_manifest: Duration,
118 install_root: Duration,
119 fund_root: Duration,
120 stage_release_set: Duration,
121 resume_bootstrap: Duration,
122 wait_ready: Duration,
123 finalize_root_funding: Duration,
124}
125
126const CURRENT_INSTALL_REQUIRED_CAPABILITIES: &[DeploymentExecutorCapabilityV1] = &[
127 DeploymentExecutorCapabilityV1::CreateCanister,
128 DeploymentExecutorCapabilityV1::InstallCode,
129 DeploymentExecutorCapabilityV1::Call,
130 DeploymentExecutorCapabilityV1::Query,
131 DeploymentExecutorCapabilityV1::StageArtifact,
132];
133
134pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
136 let project_root = current_canic_project_root()?;
137 let choices = config_selection::discover_workspace_canic_config_choices(&project_root)?;
138 if !choices.is_empty() {
139 return Ok(choices);
140 }
141
142 if let Ok(icp_root) = icp_root()
143 && icp_root != project_root
144 {
145 return config_selection::discover_workspace_canic_config_choices(&icp_root);
146 }
147
148 Ok(choices)
149}
150
151pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
153 let workspace_root = workspace_root()?;
154 let icp_root = match &options.icp_root {
155 Some(path) => path.canonicalize()?,
156 None => icp_root()?,
157 };
158 let config_path = resolve_install_config_path(
159 &icp_root,
160 options.config_path.as_deref(),
161 options.interactive_config_selection,
162 )?;
163 let _install_env = BuildEnvGuard::apply(&options.network, &config_path, &icp_root);
164 let fleet_name = configured_fleet_name(&config_path)?;
165 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
166 validate_fleet_name(&fleet_name)?;
167 let deployment_name = options
168 .deployment_name
169 .clone()
170 .unwrap_or_else(|| fleet_name.clone());
171 validate_fleet_name(&deployment_name)?;
172 let total_started_at = Instant::now();
173 let mut timings = InstallTimingSummary::default();
174 let network = options.network.as_str();
175 let execution_context = current_install_execution_context(&workspace_root, &icp_root, network);
176
177 println!("Installing deployment {deployment_name}");
178 println!("Fleet template {fleet_name}");
179 println!();
180 let prepared = prepare_install_deployment_truth(
181 &options,
182 &workspace_root,
183 &icp_root,
184 &config_path,
185 &deployment_name,
186 &execution_context,
187 )?;
188 timings.create_canisters = prepared.timings.create_canisters;
189 timings.build_all = prepared.timings.build_all;
190
191 let (manifest_path, emit_manifest_duration) = emit_manifest_with_deployment_truth_receipt(
192 &workspace_root,
193 &icp_root,
194 &options,
195 &config_path,
196 &deployment_name,
197 &prepared.deployment_truth_check,
198 &execution_context,
199 )?;
200 timings.emit_manifest = emit_manifest_duration;
201 let activation_timings = run_root_activation_phases(
202 InstallReceiptScope {
203 icp_root: &icp_root,
204 network,
205 fleet_name: &deployment_name,
206 check: &prepared.deployment_truth_check,
207 execution_context: Some(&execution_context),
208 },
209 &options,
210 &prepared.root_canister_id,
211 &manifest_path,
212 total_started_at,
213 )?;
214 timings.install_root = activation_timings.install_root;
215 timings.fund_root = activation_timings.fund_root;
216 timings.stage_release_set = activation_timings.stage_release_set;
217 timings.resume_bootstrap = activation_timings.resume_bootstrap;
218 timings.wait_ready = activation_timings.wait_ready;
219 timings.finalize_root_funding = activation_timings.finalize_root_funding;
220
221 print_install_timing_summary(&timings, total_started_at.elapsed());
222 let state = build_install_state(
223 &options,
224 &workspace_root,
225 &icp_root,
226 &config_path,
227 &manifest_path,
228 (&deployment_name, &fleet_name),
229 &prepared.root_canister_id,
230 )?;
231 let state_path = write_install_state_with_deployment_truth_receipt(
232 InstallReceiptScope {
233 icp_root: &icp_root,
234 network,
235 fleet_name: &deployment_name,
236 check: &prepared.deployment_truth_check,
237 execution_context: Some(&execution_context),
238 },
239 &options.network,
240 &state,
241 )?;
242 write_artifact_promotion_execution_receipt_for_install(
243 &options,
244 &icp_root,
245 network,
246 &deployment_name,
247 &prepared.deployment_truth_check,
248 &execution_context,
249 )?;
250 print_install_result_summary(&options.network, &state.fleet, &state_path);
251 Ok(())
252}
253
254pub fn register_deployment_state(
260 options: RegisterDeploymentStateOptions,
261) -> Result<PathBuf, Box<dyn std::error::Error>> {
262 validate_fleet_name(&options.deployment_name)?;
263 validate_fleet_name(&options.fleet_template)?;
264 validate_network_name(&options.network)?;
265 Principal::from_text(&options.root_canister_id).map_err(|err| {
266 format!(
267 "invalid root principal for deployment {}: {err}",
268 options.deployment_name
269 )
270 })?;
271
272 let workspace_root = match options.workspace_root {
273 Some(path) => path,
274 None => workspace_root()?,
275 };
276 let icp_root = match options.icp_root {
277 Some(path) => path,
278 None => icp_root()?,
279 };
280 let artifact_root = resolve_artifact_root(&icp_root, &options.network).unwrap_or_else(|_| {
281 icp_root
282 .join(".icp")
283 .join(&options.network)
284 .join("canisters")
285 });
286 let release_set_manifest_path =
287 crate::release_set::root_release_set_manifest_path(&artifact_root)
288 .unwrap_or_else(|_| artifact_root.join("root").join("root.release-set.json"));
289 let state = InstallState {
290 schema_version: INSTALL_STATE_SCHEMA_VERSION,
291 deployment_name: options.deployment_name,
292 fleet_template: options.fleet_template.clone(),
293 fleet: options.fleet_template.clone(),
294 installed_at_unix_secs: current_unix_secs()?,
295 network: options.network.clone(),
296 root_target: options.root_canister_id.clone(),
297 root_canister_id: options.root_canister_id,
298 root_verification: RootVerificationStatus::NotVerified,
299 root_build_target: "root".to_string(),
300 workspace_root: workspace_root.display().to_string(),
301 icp_root: icp_root.display().to_string(),
302 config_path: workspace_root
303 .join("fleets")
304 .join(&options.fleet_template)
305 .join("canic.toml")
306 .display()
307 .to_string(),
308 release_set_manifest_path: release_set_manifest_path.display().to_string(),
309 };
310
311 write_install_state(&icp_root, &options.network, &state)
312}
313
314struct PreparedInstallTruth {
315 root_canister_id: String,
316 deployment_truth_check: DeploymentCheckV1,
317 timings: InstallTimingSummary,
318}
319
320struct CurrentInstallTruthInputs {
321 workspace_root: PathBuf,
322 icp_root: PathBuf,
323 config_path: PathBuf,
324 deployment_name: String,
325}
326
327fn prepare_install_deployment_truth(
328 options: &InstallRootOptions,
329 workspace_root: &Path,
330 icp_root: &Path,
331 config_path: &Path,
332 deployment_name: &str,
333 execution_context: &DeploymentExecutionContextV1,
334) -> Result<PreparedInstallTruth, Box<dyn std::error::Error>> {
335 let mut timings = InstallTimingSummary::default();
336 ensure_current_install_executor_capabilities(execution_context)?;
337 ensure_icp_environment_ready(icp_root, &options.network)?;
338 let (root_canister_id, create_phase, create_duration) =
339 resolve_root_canister_with_phase(options, icp_root, config_path)?;
340 timings.create_canisters = create_duration;
341
342 let (build_phase, build_duration) =
343 build_install_targets_with_phase(options, icp_root, config_path)?;
344 timings.build_all = build_duration;
345
346 let deployment_truth_check = run_install_deployment_truth_safety_gate(
347 options,
348 workspace_root,
349 icp_root,
350 config_path,
351 deployment_name,
352 execution_context,
353 )?;
354 let receipt_scope = InstallReceiptScope {
355 icp_root,
356 network: &options.network,
357 fleet_name: deployment_name,
358 check: &deployment_truth_check,
359 execution_context: Some(execution_context),
360 };
361 write_completed_install_phase_receipt(receipt_scope, create_phase)?;
362 write_completed_install_phase_receipt(receipt_scope, build_phase)?;
363
364 Ok(PreparedInstallTruth {
365 root_canister_id,
366 deployment_truth_check,
367 timings,
368 })
369}
370
371fn resolve_root_canister_with_phase(
372 options: &InstallRootOptions,
373 icp_root: &Path,
374 config_path: &Path,
375) -> Result<(String, CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
376 let operation = ResolveRootCanisterOperation::new(
377 icp_root,
378 &options.network,
379 &options.root_canister,
380 config_path,
381 );
382 let started_at = current_unix_timestamp_label()?;
383 let started = Instant::now();
384 let root_canister_id = operation.execute()?;
385 let duration = started.elapsed();
386 let phase = CompletedInstallPhase {
387 phase: "resolve_root_canister",
388 attempted_action: "resolve or create root canister id",
389 started_at,
390 finished_at: Some(current_unix_timestamp_label()?),
391 evidence: operation.evidence(&root_canister_id),
392 role_names: Vec::new(),
393 };
394 Ok((root_canister_id, phase, duration))
395}
396
397fn build_install_targets_with_phase(
398 options: &InstallRootOptions,
399 icp_root: &Path,
400 config_path: &Path,
401) -> Result<(CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
402 if let Some(plan) = &options.deployment_plan_override {
403 return validate_plan_artifacts_with_phase(plan, icp_root, &options.network);
404 }
405
406 let build_targets = configured_install_targets(config_path, &options.root_build_target)?;
407 let operation = BuildInstallTargetsOperation::new(
408 &options.network,
409 build_targets,
410 options.build_profile,
411 config_path,
412 icp_root,
413 );
414 let started_at = current_unix_timestamp_label()?;
415 let started = Instant::now();
416 operation.execute()?;
417 let duration = started.elapsed();
418 let phase = CompletedInstallPhase {
419 phase: "build_artifacts",
420 attempted_action: "build configured install targets",
421 started_at,
422 finished_at: Some(current_unix_timestamp_label()?),
423 evidence: operation.evidence(),
424 role_names: operation.role_names(),
425 };
426 Ok((phase, duration))
427}
428
429fn validate_plan_artifacts_with_phase(
430 plan: &DeploymentPlanV1,
431 icp_root: &Path,
432 network: &str,
433) -> Result<(CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
434 let started_at = current_unix_timestamp_label()?;
435 let started = Instant::now();
436 validate_plan_artifact_paths(plan, icp_root, network)?;
437 let duration = started.elapsed();
438 let role_names = plan
439 .role_artifacts
440 .iter()
441 .map(|artifact| artifact.role.clone())
442 .collect::<Vec<_>>();
443 let phase = CompletedInstallPhase {
444 phase: "materialize_artifacts",
445 attempted_action: "validate supplied deployment plan artifacts",
446 started_at,
447 finished_at: Some(current_unix_timestamp_label()?),
448 evidence: vec![format!("deployment_plan:{}", plan.plan_id)],
449 role_names,
450 };
451 Ok((phase, duration))
452}
453
454fn validate_plan_artifact_paths(
455 plan: &DeploymentPlanV1,
456 icp_root: &Path,
457 network: &str,
458) -> Result<(), Box<dyn std::error::Error>> {
459 let artifact_root = resolve_artifact_root(icp_root, network)?;
460 let root_artifact = plan
461 .role_artifacts
462 .iter()
463 .find(|artifact| artifact.role == "root")
464 .ok_or_else(|| "deployment plan is missing root role artifact".to_string())?;
465 let root_wasm = plan_role_wasm_path(icp_root, &artifact_root, root_artifact);
466 if !root_wasm.is_file() {
467 return Err(format!(
468 "deployment plan root wasm artifact does not exist: {}",
469 root_wasm.display()
470 )
471 .into());
472 }
473
474 for artifact in plan_release_role_artifacts(plan) {
475 let wasm_gz = plan_role_wasm_gz_path(icp_root, &artifact_root, artifact);
476 if !wasm_gz.is_file() {
477 return Err(format!(
478 "deployment plan role {} wasm.gz artifact does not exist: {}",
479 artifact.role,
480 wasm_gz.display()
481 )
482 .into());
483 }
484 }
485 Ok(())
486}
487
488fn emit_manifest_with_deployment_truth_receipt(
489 workspace_root: &Path,
490 icp_root: &Path,
491 options: &InstallRootOptions,
492 config_path: &Path,
493 deployment_name: &str,
494 deployment_truth_check: &DeploymentCheckV1,
495 execution_context: &DeploymentExecutionContextV1,
496) -> Result<(PathBuf, Duration), Box<dyn std::error::Error>> {
497 let operation =
498 EmitRootManifestOperation::new(workspace_root, icp_root, &options.network, config_path);
499 let emit_manifest_started_at_label = current_unix_timestamp_label()?;
500 let emit_manifest_started_at = Instant::now();
501 let manifest_path = if let Some(plan) = &options.deployment_plan_override {
502 emit_root_release_set_manifest_from_plan(icp_root, &options.network, plan)?
503 } else {
504 operation.execute()?
505 };
506 let emit_manifest_duration = emit_manifest_started_at.elapsed();
507 let emit_manifest_receipt = receipt_with_execution_context(
508 install_deployment_truth_phase_receipt(
509 deployment_truth_check,
510 "emit_manifest",
511 emit_manifest_started_at_label,
512 Some(current_unix_timestamp_label()?),
513 "emit root release-set manifest",
514 crate::deployment_truth::ObservationStatusV1::Observed,
515 EmitRootManifestOperation::evidence(&manifest_path),
516 ),
517 execution_context,
518 );
519 let emit_manifest_receipt_path = write_install_deployment_truth_receipt(
520 icp_root,
521 &options.network,
522 deployment_name,
523 &emit_manifest_receipt,
524 )?;
525 println!(
526 "Deployment truth receipt JSON: {}",
527 emit_manifest_receipt_path.display()
528 );
529 Ok((manifest_path, emit_manifest_duration))
530}
531
532fn emit_root_release_set_manifest_from_plan(
533 icp_root: &Path,
534 network: &str,
535 plan: &DeploymentPlanV1,
536) -> Result<PathBuf, Box<dyn std::error::Error>> {
537 let artifact_root = resolve_artifact_root(icp_root, network)?;
538 let manifest_path = crate::release_set::root_release_set_manifest_path(&artifact_root)?;
539 let entries = plan_release_role_artifacts(plan)
540 .map(|artifact| release_set_entry_from_plan_artifact(icp_root, &artifact_root, artifact))
541 .collect::<Result<Vec<_>, _>>()?;
542 let manifest = RootReleaseSetManifest {
543 release_version: plan
544 .deployment_identity
545 .canic_version
546 .clone()
547 .unwrap_or_else(|| plan.plan_id.clone()),
548 entries,
549 };
550
551 fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
552 Ok(manifest_path)
553}
554
555fn release_set_entry_from_plan_artifact(
556 icp_root: &Path,
557 artifact_root: &Path,
558 artifact: &crate::deployment_truth::RoleArtifactV1,
559) -> Result<crate::release_set::ReleaseSetEntry, Box<dyn std::error::Error>> {
560 let artifact_path = plan_role_wasm_gz_path(icp_root, artifact_root, artifact);
561 let artifact_relative_path = artifact_path
562 .strip_prefix(icp_root)
563 .map_err(|_| {
564 format!(
565 "deployment plan artifact {} is not under ICP root {}",
566 artifact_path.display(),
567 icp_root.display()
568 )
569 })?
570 .to_string_lossy()
571 .to_string();
572 let wasm_module = fs::read(&artifact_path)?;
573 let chunk_hashes = wasm_module
574 .chunks(CANIC_WASM_CHUNK_BYTES)
575 .map(wasm_hash_hex)
576 .collect::<Vec<_>>();
577
578 Ok(crate::release_set::ReleaseSetEntry {
579 role: artifact.role.clone(),
580 template_id: format!("embedded:{}", artifact.role),
581 artifact_relative_path,
582 payload_size_bytes: wasm_module.len() as u64,
583 payload_sha256_hex: wasm_hash_hex(&wasm_module),
584 chunk_size_bytes: CANIC_WASM_CHUNK_BYTES as u64,
585 chunk_sha256_hex: chunk_hashes,
586 })
587}
588
589fn plan_release_role_artifacts(
590 plan: &DeploymentPlanV1,
591) -> impl Iterator<Item = &crate::deployment_truth::RoleArtifactV1> {
592 plan.role_artifacts
593 .iter()
594 .filter(|artifact| !matches!(artifact.role.as_str(), "root" | "wasm_store"))
595}
596
597fn plan_role_wasm_path(
598 icp_root: &Path,
599 artifact_root: &Path,
600 artifact: &crate::deployment_truth::RoleArtifactV1,
601) -> PathBuf {
602 artifact.wasm_path.as_ref().map_or_else(
603 || {
604 artifact_root
605 .join(&artifact.role)
606 .join(format!("{}.wasm", artifact.role))
607 },
608 |path| plan_artifact_path(icp_root, path),
609 )
610}
611
612fn plan_role_wasm_gz_path(
613 icp_root: &Path,
614 artifact_root: &Path,
615 artifact: &crate::deployment_truth::RoleArtifactV1,
616) -> PathBuf {
617 artifact.wasm_gz_path.as_ref().map_or_else(
618 || {
619 artifact_root
620 .join(&artifact.role)
621 .join(format!("{}.wasm.gz", artifact.role))
622 },
623 |path| plan_artifact_path(icp_root, path),
624 )
625}
626
627fn plan_artifact_path(icp_root: &Path, path: &str) -> PathBuf {
628 let path = PathBuf::from(path);
629 if path.is_absolute() {
630 path
631 } else {
632 icp_root.join(path)
633 }
634}
635
636fn run_root_activation_phases(
637 receipt_scope: InstallReceiptScope<'_>,
638 options: &InstallRootOptions,
639 root_canister_id: &str,
640 manifest_path: &Path,
641 total_started_at: Instant,
642) -> Result<InstallTimingSummary, Box<dyn std::error::Error>> {
643 let mut timings = InstallTimingSummary::default();
644 let root_wasm = root_wasm_for_install_plan(
645 receipt_scope.icp_root,
646 receipt_scope.network,
647 &options.root_build_target,
648 options.deployment_plan_override.as_ref(),
649 )?;
650 let install_operation = InstallRootWasmOperation::new(
651 receipt_scope.icp_root,
652 receipt_scope.network,
653 root_canister_id,
654 root_wasm,
655 );
656 timings.install_root = receipt_scope.run_operation(&install_operation)?;
657 let pre_bootstrap_funding = EnsureRootCyclesOperation::new(
658 receipt_scope.icp_root,
659 receipt_scope.network,
660 root_canister_id,
661 "fund_root_pre_bootstrap",
662 "ensure local root minimum cycles before bootstrap",
663 "pre-bootstrap",
664 );
665 timings.fund_root = receipt_scope.run_operation(&pre_bootstrap_funding)?;
666 let manifest = load_root_release_set_manifest(manifest_path)?;
667 let stage_operation = StageReleaseSetOperation::new(
668 receipt_scope.icp_root,
669 receipt_scope.network,
670 root_canister_id,
671 manifest_path,
672 manifest,
673 );
674 timings.stage_release_set = receipt_scope.run_operation(&stage_operation)?;
675 let resume_operation = ResumeBootstrapOperation::new(receipt_scope.network, root_canister_id);
676 timings.resume_bootstrap = receipt_scope.run_operation(&resume_operation)?;
677 let wait_ready_operation = WaitRootReadyOperation::new(
678 receipt_scope.network,
679 root_canister_id,
680 options.ready_timeout_seconds,
681 );
682 let wait_ready_result = receipt_scope.run_operation(&wait_ready_operation);
683 match wait_ready_result {
684 Ok(duration) => timings.wait_ready = duration,
685 Err(err) => {
686 print_install_timing_summary(&timings, total_started_at.elapsed());
687 return Err(err);
688 }
689 }
690 let post_ready_funding = EnsureRootCyclesOperation::new(
691 receipt_scope.icp_root,
692 receipt_scope.network,
693 root_canister_id,
694 "fund_root_post_ready",
695 "ensure local root minimum cycles after ready",
696 "post-ready",
697 );
698 timings.finalize_root_funding = receipt_scope.run_operation(&post_ready_funding)?;
699 Ok(timings)
700}
701
702fn root_wasm_for_install_plan(
703 icp_root: &Path,
704 network: &str,
705 root_build_target: &str,
706 plan: Option<&DeploymentPlanV1>,
707) -> Result<PathBuf, Box<dyn std::error::Error>> {
708 let artifact_root = resolve_artifact_root(icp_root, network)?;
709 if let Some(plan) = plan {
710 let root_artifact = plan
711 .role_artifacts
712 .iter()
713 .find(|artifact| artifact.role == "root")
714 .ok_or_else(|| "deployment plan is missing root role artifact".to_string())?;
715 return Ok(plan_role_wasm_path(icp_root, &artifact_root, root_artifact));
716 }
717
718 Ok(artifact_root
719 .join(root_build_target)
720 .join(format!("{root_build_target}.wasm")))
721}
722
723#[derive(Clone, Copy)]
724struct InstallReceiptScope<'a> {
725 icp_root: &'a Path,
726 network: &'a str,
727 fleet_name: &'a str,
728 check: &'a DeploymentCheckV1,
729 execution_context: Option<&'a DeploymentExecutionContextV1>,
730}
731
732struct CompletedInstallPhase {
733 phase: &'static str,
734 attempted_action: &'static str,
735 started_at: String,
736 finished_at: Option<String>,
737 evidence: Vec<String>,
738 role_names: Vec<String>,
739}
740
741trait InstallPhaseOperation {
742 fn phase(&self) -> &'static str;
743 fn attempted_action(&self) -> &'static str;
744 fn evidence(&self) -> Vec<String>;
745 fn execute(&self) -> Result<(), Box<dyn std::error::Error>>;
746}
747
748struct ResolveRootCanisterOperation<'a> {
749 icp_root: &'a Path,
750 network: &'a str,
751 root_canister: &'a str,
752 config_path: &'a Path,
753}
754
755impl<'a> ResolveRootCanisterOperation<'a> {
756 const fn new(
757 icp_root: &'a Path,
758 network: &'a str,
759 root_canister: &'a str,
760 config_path: &'a Path,
761 ) -> Self {
762 Self {
763 icp_root,
764 network,
765 root_canister,
766 config_path,
767 }
768 }
769
770 fn evidence(&self, root_canister_id: &str) -> Vec<String> {
771 vec![
772 format!("root_target:{}", self.root_canister),
773 format!("root_canister:{root_canister_id}"),
774 ]
775 }
776
777 fn execute(&self) -> Result<String, Box<dyn std::error::Error>> {
778 ensure_root_canister_id(
779 self.icp_root,
780 self.network,
781 self.root_canister,
782 self.config_path,
783 )
784 }
785}
786
787struct BuildInstallTargetsOperation<'a> {
788 network: &'a str,
789 build_targets: Vec<String>,
790 build_profile: Option<CanisterBuildProfile>,
791 config_path: &'a Path,
792 icp_root: &'a Path,
793}
794
795impl<'a> BuildInstallTargetsOperation<'a> {
796 const fn new(
797 network: &'a str,
798 build_targets: Vec<String>,
799 build_profile: Option<CanisterBuildProfile>,
800 config_path: &'a Path,
801 icp_root: &'a Path,
802 ) -> Self {
803 Self {
804 network,
805 build_targets,
806 build_profile,
807 config_path,
808 icp_root,
809 }
810 }
811
812 fn evidence(&self) -> Vec<String> {
813 self.build_targets
814 .iter()
815 .map(|target| format!("build_target:{target}"))
816 .collect()
817 }
818
819 fn role_names(&self) -> Vec<String> {
820 self.build_targets.clone()
821 }
822
823 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
824 run_canic_build_targets(
825 self.network,
826 &self.build_targets,
827 self.build_profile,
828 self.config_path,
829 self.icp_root,
830 )
831 }
832}
833
834struct EmitRootManifestOperation<'a> {
835 workspace_root: &'a Path,
836 icp_root: &'a Path,
837 network: &'a str,
838 config_path: &'a Path,
839}
840
841impl<'a> EmitRootManifestOperation<'a> {
842 const fn new(
843 workspace_root: &'a Path,
844 icp_root: &'a Path,
845 network: &'a str,
846 config_path: &'a Path,
847 ) -> Self {
848 Self {
849 workspace_root,
850 icp_root,
851 network,
852 config_path,
853 }
854 }
855
856 fn evidence(manifest_path: &Path) -> Vec<String> {
857 vec![format!("manifest_path:{}", manifest_path.display())]
858 }
859
860 fn execute(&self) -> Result<PathBuf, Box<dyn std::error::Error>> {
861 emit_root_release_set_manifest_with_config(
862 self.workspace_root,
863 self.icp_root,
864 self.network,
865 self.config_path,
866 )
867 }
868}
869
870struct InstallRootWasmOperation<'a> {
871 icp_root: &'a Path,
872 network: &'a str,
873 root_canister_id: &'a str,
874 root_wasm: PathBuf,
875}
876
877impl<'a> InstallRootWasmOperation<'a> {
878 const fn new(
879 icp_root: &'a Path,
880 network: &'a str,
881 root_canister_id: &'a str,
882 root_wasm: PathBuf,
883 ) -> Self {
884 Self {
885 icp_root,
886 network,
887 root_canister_id,
888 root_wasm,
889 }
890 }
891}
892
893impl InstallPhaseOperation for InstallRootWasmOperation<'_> {
894 fn phase(&self) -> &'static str {
895 "install_root"
896 }
897
898 fn attempted_action(&self) -> &'static str {
899 "install root wasm"
900 }
901
902 fn evidence(&self) -> Vec<String> {
903 vec![
904 format!("root_canister:{}", self.root_canister_id),
905 format!("root_wasm:{}", self.root_wasm.display()),
906 ]
907 }
908
909 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
910 reinstall_root_wasm(
911 self.icp_root,
912 self.network,
913 self.root_canister_id,
914 &self.root_wasm,
915 )
916 }
917}
918
919struct EnsureRootCyclesOperation<'a> {
920 icp_root: &'a Path,
921 network: &'a str,
922 root_canister_id: &'a str,
923 phase: &'static str,
924 attempted_action: &'static str,
925 phase_label: &'a str,
926}
927
928impl<'a> EnsureRootCyclesOperation<'a> {
929 const fn new(
930 icp_root: &'a Path,
931 network: &'a str,
932 root_canister_id: &'a str,
933 phase: &'static str,
934 attempted_action: &'static str,
935 phase_label: &'a str,
936 ) -> Self {
937 Self {
938 icp_root,
939 network,
940 root_canister_id,
941 phase,
942 attempted_action,
943 phase_label,
944 }
945 }
946}
947
948impl InstallPhaseOperation for EnsureRootCyclesOperation<'_> {
949 fn phase(&self) -> &'static str {
950 self.phase
951 }
952
953 fn attempted_action(&self) -> &'static str {
954 self.attempted_action
955 }
956
957 fn evidence(&self) -> Vec<String> {
958 vec![
959 format!("root_canister:{}", self.root_canister_id),
960 format!("minimum_cycles:{LOCAL_ROOT_MIN_READY_CYCLES}"),
961 format!("funding_phase:{}", self.phase_label),
962 ]
963 }
964
965 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
966 ensure_local_root_min_cycles(
967 self.icp_root,
968 self.network,
969 self.root_canister_id,
970 self.phase_label,
971 )
972 }
973}
974
975struct ResumeBootstrapOperation<'a> {
976 network: &'a str,
977 root_canister_id: &'a str,
978}
979
980impl<'a> ResumeBootstrapOperation<'a> {
981 const fn new(network: &'a str, root_canister_id: &'a str) -> Self {
982 Self {
983 network,
984 root_canister_id,
985 }
986 }
987}
988
989impl InstallPhaseOperation for ResumeBootstrapOperation<'_> {
990 fn phase(&self) -> &'static str {
991 "resume_bootstrap"
992 }
993
994 fn attempted_action(&self) -> &'static str {
995 "resume root bootstrap"
996 }
997
998 fn evidence(&self) -> Vec<String> {
999 vec![format!("root_canister:{}", self.root_canister_id)]
1000 }
1001
1002 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
1003 resume_root_bootstrap(self.network, self.root_canister_id)
1004 }
1005}
1006
1007struct WaitRootReadyOperation<'a> {
1008 network: &'a str,
1009 root_canister_id: &'a str,
1010 timeout_seconds: u64,
1011}
1012
1013impl<'a> WaitRootReadyOperation<'a> {
1014 const fn new(network: &'a str, root_canister_id: &'a str, timeout_seconds: u64) -> Self {
1015 Self {
1016 network,
1017 root_canister_id,
1018 timeout_seconds,
1019 }
1020 }
1021}
1022
1023impl InstallPhaseOperation for WaitRootReadyOperation<'_> {
1024 fn phase(&self) -> &'static str {
1025 "wait_ready"
1026 }
1027
1028 fn attempted_action(&self) -> &'static str {
1029 "wait for root bootstrap readiness"
1030 }
1031
1032 fn evidence(&self) -> Vec<String> {
1033 vec![
1034 format!("root_canister:{}", self.root_canister_id),
1035 format!("timeout_seconds:{}", self.timeout_seconds),
1036 ]
1037 }
1038
1039 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
1040 wait_for_root_ready(self.network, self.root_canister_id, self.timeout_seconds)
1041 }
1042}
1043
1044fn write_completed_install_phase_receipt(
1045 receipt_scope: InstallReceiptScope<'_>,
1046 completed: CompletedInstallPhase,
1047) -> Result<PathBuf, Box<dyn std::error::Error>> {
1048 let role_phase_receipts = completed
1049 .role_names
1050 .iter()
1051 .filter_map(|role| {
1052 completed_phase_role_receipt(
1053 receipt_scope.check,
1054 completed.phase,
1055 role,
1056 crate::deployment_truth::RolePhaseResultV1::Applied,
1057 None,
1058 )
1059 })
1060 .collect();
1061 let receipt =
1062 receipt_scope.with_execution_context(install_deployment_truth_phase_receipt_with_result(
1063 receipt_scope.check,
1064 PhaseReceiptInput {
1065 phase: completed.phase,
1066 started_at: completed.started_at,
1067 finished_at: completed.finished_at,
1068 attempted_action: completed.attempted_action,
1069 status: crate::deployment_truth::ObservationStatusV1::Observed,
1070 evidence: completed.evidence,
1071 role_phase_receipts,
1072 operation_status: DeploymentExecutionStatusV1::Complete,
1073 command_result: DeploymentCommandResultV1::Succeeded,
1074 },
1075 ));
1076 receipt_scope.write_receipt(&receipt)
1077}
1078
1079fn completed_phase_role_receipt(
1080 check: &DeploymentCheckV1,
1081 phase: &str,
1082 role: &str,
1083 result: crate::deployment_truth::RolePhaseResultV1,
1084 error: Option<String>,
1085) -> Option<crate::deployment_truth::RolePhaseReceiptV1> {
1086 let planned = check
1087 .plan
1088 .role_artifacts
1089 .iter()
1090 .find(|artifact| artifact.role == role)?;
1091 let observed = check
1092 .inventory
1093 .observed_artifacts
1094 .iter()
1095 .find(|artifact| artifact.role == role);
1096 let artifact_digest = observed
1097 .and_then(|artifact| artifact.file_sha256.clone())
1098 .or_else(|| observed.and_then(|artifact| artifact.payload_sha256.clone()))
1099 .or_else(|| planned.observed_wasm_gz_file_sha256.clone())
1100 .or_else(|| planned.wasm_gz_sha256.clone());
1101
1102 Some(crate::deployment_truth::RolePhaseReceiptV1 {
1103 role: role.to_string(),
1104 phase: phase.to_string(),
1105 result,
1106 previous_module_hash: None,
1107 target_module_hash: planned.installed_module_hash.clone(),
1108 observed_module_hash_after: None,
1109 artifact_digest,
1110 canonical_embedded_config_sha256: planned.canonical_embedded_config_sha256.clone(),
1111 error,
1112 })
1113}
1114
1115fn write_install_state_with_deployment_truth_receipt(
1116 receipt_scope: InstallReceiptScope<'_>,
1117 network: &str,
1118 state: &InstallState,
1119) -> Result<PathBuf, Box<dyn std::error::Error>> {
1120 let started_at = current_unix_timestamp_label()?;
1121 let state_path = write_install_state(receipt_scope.icp_root, network, state)?;
1122 let completed = CompletedInstallPhase {
1123 phase: "write_install_state",
1124 attempted_action: "write local install state",
1125 started_at,
1126 finished_at: Some(current_unix_timestamp_label()?),
1127 evidence: vec![
1128 format!("install_state:{}", state_path.display()),
1129 format!("deployment:{}", state.deployment_name),
1130 format!("fleet_template:{}", state.fleet_template),
1131 format!("root_canister:{}", state.root_canister_id),
1132 ],
1133 role_names: Vec::new(),
1134 };
1135 write_completed_install_phase_receipt(receipt_scope, completed)?;
1136 Ok(state_path)
1137}
1138
1139fn write_artifact_promotion_execution_receipt_for_install(
1140 options: &InstallRootOptions,
1141 icp_root: &Path,
1142 network: &str,
1143 fleet_name: &str,
1144 check: &DeploymentCheckV1,
1145 execution_context: &DeploymentExecutionContextV1,
1146) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
1147 let Some(promotion_plan) = &options.artifact_promotion_plan_override else {
1148 return Ok(None);
1149 };
1150 let deployment_receipt =
1151 promotion_install_deployment_receipt(check, execution_context, promotion_plan)?;
1152 let provenance_report =
1153 artifact_promotion_provenance_report(ArtifactPromotionProvenanceReportRequest {
1154 report_id: format!("{}:execution-provenance", promotion_plan.plan_id),
1155 artifact_promotion_plan: promotion_plan.clone(),
1156 wasm_store_identity_report: None,
1157 wasm_store_catalog_verification: None,
1158 materialization_identity_report: None,
1159 })?;
1160 let receipt = artifact_promotion_execution_receipt(ArtifactPromotionExecutionReceiptRequest {
1161 receipt_id: format!("{}:execution-receipt", promotion_plan.plan_id),
1162 provenance_report,
1163 deployment_receipt,
1164 })?;
1165 let path = write_artifact_promotion_execution_receipt(icp_root, network, fleet_name, &receipt)?;
1166 println!(
1167 "Artifact promotion execution receipt JSON: {}",
1168 path.display()
1169 );
1170 Ok(Some(path))
1171}
1172
1173fn promotion_install_deployment_receipt(
1174 check: &DeploymentCheckV1,
1175 execution_context: &DeploymentExecutionContextV1,
1176 promotion_plan: &ArtifactPromotionPlanV1,
1177) -> Result<DeploymentReceiptV1, Box<dyn std::error::Error>> {
1178 let started_at = current_unix_timestamp_label()?;
1179 let finished_at = current_unix_timestamp_label()?;
1180 let phase = phase_receipt(
1181 "promoted_plan_install",
1182 started_at.clone(),
1183 Some(finished_at.clone()),
1184 "execute promoted deployment plan through current install runner",
1185 ObservationStatusV1::Observed,
1186 vec![
1187 format!("artifact_promotion_plan:{}", promotion_plan.plan_id),
1188 format!(
1189 "artifact_promotion_plan_digest:{}",
1190 promotion_plan.artifact_promotion_plan_digest
1191 ),
1192 format!(
1193 "promotion_plan_lineage_digest:{}",
1194 promotion_plan.promotion_plan_lineage_digest
1195 ),
1196 ],
1197 );
1198 let role_phase_receipts = promotion_plan
1199 .transform
1200 .roles
1201 .iter()
1202 .map(|role| {
1203 completed_phase_role_receipt(
1204 check,
1205 "promoted_plan_install",
1206 &role.role,
1207 crate::deployment_truth::RolePhaseResultV1::Applied,
1208 None,
1209 )
1210 .ok_or_else(|| {
1211 format!(
1212 "promoted role {} is missing from deployment plan artifacts",
1213 role.role
1214 )
1215 })
1216 })
1217 .collect::<Result<Vec<_>, _>>()?;
1218 Ok(receipt_with_execution_context(
1219 deployment_receipt_from_check_with_status(
1220 check,
1221 format!("{}:promoted_plan_install", check.check_id),
1222 DeploymentExecutionStatusV1::Complete,
1223 started_at,
1224 Some(finished_at),
1225 vec![phase],
1226 role_phase_receipts,
1227 DeploymentCommandResultV1::Succeeded,
1228 ),
1229 execution_context,
1230 ))
1231}
1232
1233impl InstallReceiptScope<'_> {
1234 fn run_operation(
1235 self,
1236 operation: &impl InstallPhaseOperation,
1237 ) -> Result<Duration, Box<dyn std::error::Error>> {
1238 self.run_phase(
1239 operation.phase(),
1240 operation.attempted_action(),
1241 operation.evidence(),
1242 || operation.execute(),
1243 )
1244 }
1245
1246 fn run_phase(
1247 self,
1248 phase: &str,
1249 attempted_action: &str,
1250 evidence: Vec<String>,
1251 run: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
1252 ) -> Result<Duration, Box<dyn std::error::Error>> {
1253 let started_at = current_unix_timestamp_label()?;
1254 let started = Instant::now();
1255 match run() {
1256 Ok(()) => {
1257 let duration = started.elapsed();
1258 let receipt = self.with_execution_context(install_deployment_truth_phase_receipt(
1259 self.check,
1260 phase,
1261 started_at,
1262 Some(current_unix_timestamp_label()?),
1263 attempted_action,
1264 crate::deployment_truth::ObservationStatusV1::Observed,
1265 evidence,
1266 ));
1267 self.write_receipt(&receipt)?;
1268 Ok(duration)
1269 }
1270 Err(err) => {
1271 self.try_write_failed_phase_receipt(
1272 phase,
1273 started_at,
1274 attempted_action,
1275 evidence,
1276 err.as_ref(),
1277 );
1278 Err(err)
1279 }
1280 }
1281 }
1282
1283 fn with_execution_context(self, receipt: DeploymentReceiptV1) -> DeploymentReceiptV1 {
1284 match self.execution_context {
1285 Some(context) => receipt_with_execution_context(receipt, context),
1286 None => receipt,
1287 }
1288 }
1289
1290 fn write_receipt(
1291 self,
1292 receipt: &DeploymentReceiptV1,
1293 ) -> Result<PathBuf, Box<dyn std::error::Error>> {
1294 let path = write_install_deployment_truth_receipt(
1295 self.icp_root,
1296 self.network,
1297 self.fleet_name,
1298 receipt,
1299 )?;
1300 println!("Deployment truth receipt JSON: {}", path.display());
1301 Ok(path)
1302 }
1303
1304 fn try_write_failed_phase_receipt(
1305 self,
1306 phase: &str,
1307 started_at: String,
1308 attempted_action: &str,
1309 evidence: Vec<String>,
1310 err: &dyn std::error::Error,
1311 ) {
1312 let receipt = install_deployment_truth_phase_receipt_with_result(
1313 self.check,
1314 PhaseReceiptInput {
1315 phase,
1316 started_at,
1317 finished_at: Some(
1318 current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string()),
1319 ),
1320 attempted_action,
1321 status: crate::deployment_truth::ObservationStatusV1::Inconclusive,
1322 evidence,
1323 role_phase_receipts: Vec::new(),
1324 operation_status: DeploymentExecutionStatusV1::FailedAfterMutation,
1325 command_result: DeploymentCommandResultV1::Failed {
1326 code: format!("{phase}_failed"),
1327 message: err.to_string(),
1328 },
1329 },
1330 );
1331 let receipt = self.with_execution_context(receipt);
1332 if let Err(write_err) = self.write_receipt(&receipt) {
1333 eprintln!("Deployment truth receipt JSON write failed: {write_err}");
1334 }
1335 }
1336}
1337
1338pub fn check_install_deployment_truth(
1341 options: &InstallRootOptions,
1342 observed_at: impl Into<String>,
1343) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
1344 let inputs = resolve_current_install_truth_inputs(options)?;
1345 current_install_deployment_truth_check_at(
1346 options,
1347 &inputs.workspace_root,
1348 &inputs.icp_root,
1349 &inputs.config_path,
1350 &inputs.deployment_name,
1351 observed_at.into(),
1352 )
1353}
1354
1355pub fn check_install_execution_preflight(
1361 options: &InstallRootOptions,
1362 observed_at: impl Into<String>,
1363) -> Result<DeploymentExecutionPreflightV1, Box<dyn std::error::Error>> {
1364 let inputs = resolve_current_install_truth_inputs(options)?;
1365 let check = current_install_deployment_truth_check_at(
1366 options,
1367 &inputs.workspace_root,
1368 &inputs.icp_root,
1369 &inputs.config_path,
1370 &inputs.deployment_name,
1371 observed_at.into(),
1372 )?;
1373 let execution_context = current_install_execution_context(
1374 &inputs.workspace_root,
1375 &inputs.icp_root,
1376 &options.network,
1377 );
1378 let executor = CurrentCliDeploymentExecutor::new(
1379 execution_context.workspace_root,
1380 execution_context.icp_root,
1381 execution_context.artifact_roots,
1382 );
1383 let preflight = deployment_execution_preflight_from_check(
1384 &check,
1385 &executor,
1386 CURRENT_INSTALL_REQUIRED_CAPABILITIES,
1387 );
1388 validate_deployment_execution_preflight_for_check(&check, &preflight)?;
1389 Ok(preflight)
1390}
1391
1392fn resolve_current_install_truth_inputs(
1393 options: &InstallRootOptions,
1394) -> Result<CurrentInstallTruthInputs, Box<dyn std::error::Error>> {
1395 let workspace_root = workspace_root()?;
1396 let icp_root = match &options.icp_root {
1397 Some(path) => path.canonicalize()?,
1398 None => icp_root()?,
1399 };
1400 let config_path = resolve_install_config_path(
1401 &icp_root,
1402 options.config_path.as_deref(),
1403 options.interactive_config_selection,
1404 )?;
1405 let fleet_name = configured_fleet_name(&config_path)?;
1406 validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
1407 validate_fleet_name(&fleet_name)?;
1408 let deployment_name = options
1409 .deployment_name
1410 .clone()
1411 .unwrap_or_else(|| fleet_name.clone());
1412 validate_fleet_name(&deployment_name)?;
1413 Ok(CurrentInstallTruthInputs {
1414 workspace_root,
1415 icp_root,
1416 config_path,
1417 deployment_name,
1418 })
1419}
1420
1421fn current_install_deployment_truth_check_at(
1422 options: &InstallRootOptions,
1423 workspace_root: &Path,
1424 icp_root: &Path,
1425 config_path: &Path,
1426 deployment_name: &str,
1427 observed_at: String,
1428) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
1429 if let Some(plan) = &options.deployment_plan_override {
1430 validate_current_install_plan_override(plan, &options.network, deployment_name)?;
1431 return current_install_deployment_truth_check_for_plan(
1432 plan,
1433 workspace_root,
1434 icp_root,
1435 config_path,
1436 deployment_name,
1437 observed_at,
1438 &options.network,
1439 );
1440 }
1441
1442 let build_profile = options
1443 .build_profile
1444 .unwrap_or_else(CanisterBuildProfile::current)
1445 .target_dir_name()
1446 .to_string();
1447
1448 check_local_deployment(&LocalDeploymentCheckRequest {
1449 deployment_name: deployment_name.to_string(),
1450 network: options.network.clone(),
1451 workspace_root: workspace_root.to_path_buf(),
1452 icp_root: icp_root.to_path_buf(),
1453 config_path: Some(config_path.to_path_buf()),
1454 observed_at,
1455 runtime_variant: options.network.clone(),
1456 build_profile,
1457 })
1458 .map_err(Into::into)
1459}
1460
1461fn current_install_deployment_truth_check_for_plan(
1462 plan: &DeploymentPlanV1,
1463 workspace_root: &Path,
1464 icp_root: &Path,
1465 config_path: &Path,
1466 deployment_name: &str,
1467 observed_at: String,
1468 network: &str,
1469) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
1470 let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
1471 deployment_name: deployment_name.to_string(),
1472 network: network.to_string(),
1473 workspace_root: workspace_root.to_path_buf(),
1474 icp_root: icp_root.to_path_buf(),
1475 config_path: Some(config_path.to_path_buf()),
1476 observed_at,
1477 })?;
1478 let diff = compare_plan_to_inventory(plan, &inventory);
1479 let report = safety_report_from_diff(
1480 format!("local:{network}:{deployment_name}:report"),
1481 Some(format!("local:{network}:{deployment_name}:diff")),
1482 &diff,
1483 );
1484
1485 Ok(DeploymentCheckV1 {
1486 schema_version: crate::deployment_truth::DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1487 check_id: format!("local:{network}:{deployment_name}:check"),
1488 plan: plan.clone(),
1489 inventory,
1490 diff,
1491 report,
1492 })
1493}
1494
1495fn validate_current_install_plan_override(
1496 plan: &DeploymentPlanV1,
1497 network: &str,
1498 fleet_name: &str,
1499) -> Result<(), Box<dyn std::error::Error>> {
1500 if plan.schema_version != crate::deployment_truth::DEPLOYMENT_TRUTH_SCHEMA_VERSION {
1501 return Err(format!(
1502 "deployment plan schema mismatch: expected {}, found {}",
1503 crate::deployment_truth::DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1504 plan.schema_version
1505 )
1506 .into());
1507 }
1508 if plan.deployment_identity.network != network {
1509 return Err(format!(
1510 "deployment plan network mismatch: install network {network}, plan network {}",
1511 plan.deployment_identity.network
1512 )
1513 .into());
1514 }
1515 if plan.deployment_identity.deployment_name != fleet_name {
1516 return Err(format!(
1517 "deployment plan target mismatch: install deployment {fleet_name}, plan deployment {}",
1518 plan.deployment_identity.deployment_name
1519 )
1520 .into());
1521 }
1522 Ok(())
1523}
1524
1525fn current_install_execution_context(
1526 workspace_root: &Path,
1527 icp_root: &Path,
1528 network: &str,
1529) -> DeploymentExecutionContextV1 {
1530 CurrentCliDeploymentExecutor::new(
1531 Some(workspace_root.display().to_string()),
1532 Some(icp_root.display().to_string()),
1533 current_install_artifact_roots(icp_root, network),
1534 )
1535 .execution_context()
1536}
1537
1538fn ensure_current_install_executor_capabilities(
1539 execution_context: &DeploymentExecutionContextV1,
1540) -> Result<(), Box<dyn std::error::Error>> {
1541 let missing = current_install_executor_missing_capabilities(execution_context);
1542 if missing.is_empty() {
1543 return Ok(());
1544 }
1545
1546 Err(format!(
1547 "current install executor backend {:?} is missing required capabilities: {missing:?}",
1548 execution_context.backend
1549 )
1550 .into())
1551}
1552
1553fn current_install_executor_missing_capabilities(
1554 execution_context: &DeploymentExecutionContextV1,
1555) -> Vec<DeploymentExecutorCapabilityV1> {
1556 missing_executor_capabilities(
1557 &execution_context.backend_capabilities,
1558 CURRENT_INSTALL_REQUIRED_CAPABILITIES,
1559 )
1560}
1561
1562fn current_install_artifact_roots(icp_root: &Path, network: &str) -> Vec<String> {
1563 let planned_root = planned_build_artifact_root(icp_root);
1564 let mut roots = vec![planned_root.display().to_string()];
1565 if let Ok(resolved_root) = resolve_artifact_root(icp_root, network)
1566 && resolved_root != planned_root
1567 {
1568 roots.push(resolved_root.display().to_string());
1569 }
1570 roots
1571}
1572
1573fn run_install_deployment_truth_safety_gate(
1574 options: &InstallRootOptions,
1575 workspace_root: &Path,
1576 icp_root: &Path,
1577 config_path: &Path,
1578 deployment_name: &str,
1579 execution_context: &DeploymentExecutionContextV1,
1580) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
1581 let truth_gate_started_at = current_unix_timestamp_label()?;
1582 let deployment_truth_check = current_install_deployment_truth_check_at(
1583 options,
1584 workspace_root,
1585 icp_root,
1586 config_path,
1587 deployment_name,
1588 truth_gate_started_at.clone(),
1589 )?;
1590 let artifact_gate_receipt = artifact_gate_phase_receipt(
1591 &deployment_truth_check,
1592 truth_gate_started_at.clone(),
1593 Some(current_unix_timestamp_label()?),
1594 );
1595 let role_receipts = artifact_gate_role_phase_receipts(&deployment_truth_check);
1596 let deployment_receipt = receipt_with_execution_context(
1597 install_deployment_truth_gate_receipt(
1598 &deployment_truth_check,
1599 truth_gate_started_at,
1600 vec![artifact_gate_receipt],
1601 role_receipts,
1602 ),
1603 execution_context,
1604 );
1605 let receipt_write = write_install_deployment_truth_receipt(
1606 icp_root,
1607 &options.network,
1608 deployment_name,
1609 &deployment_receipt,
1610 );
1611 match &receipt_write {
1612 Ok(path) => println!("Deployment truth receipt JSON: {}", path.display()),
1613 Err(err) => eprintln!("Deployment truth receipt JSON write failed: {err}"),
1614 }
1615 print_install_deployment_truth_gate(&deployment_truth_check, &deployment_receipt);
1616 enforce_install_deployment_truth_gate(&deployment_truth_check)?;
1617 receipt_write?;
1618 write_current_install_execution_preflight_receipt(
1619 icp_root,
1620 &options.network,
1621 deployment_name,
1622 &deployment_truth_check,
1623 execution_context,
1624 )?;
1625 Ok(deployment_truth_check)
1626}
1627
1628fn enforce_install_deployment_truth_gate(
1629 check: &DeploymentCheckV1,
1630) -> Result<(), Box<dyn std::error::Error>> {
1631 let blockers = install_deployment_truth_gate_blockers(check);
1632 if blockers.is_empty() {
1633 return Ok(());
1634 }
1635
1636 let details = blockers
1637 .iter()
1638 .map(|finding| deployment_truth_finding_label(finding))
1639 .collect::<Vec<_>>()
1640 .join("; ");
1641 Err(format!("deployment truth safety gate blocked install: {details}").into())
1642}
1643
1644fn install_deployment_truth_gate_blockers(check: &DeploymentCheckV1) -> Vec<&SafetyFindingV1> {
1645 check.report.hard_failures.iter().collect()
1646}
1647
1648fn print_install_deployment_truth_gate(check: &DeploymentCheckV1, receipt: &DeploymentReceiptV1) {
1649 for line in install_deployment_truth_gate_lines(check, receipt) {
1650 println!("{line}");
1651 }
1652}
1653
1654fn install_deployment_truth_gate_lines(
1655 check: &DeploymentCheckV1,
1656 receipt: &DeploymentReceiptV1,
1657) -> Vec<String> {
1658 let mut lines = vec![
1659 format!("Deployment truth: {}", check.report.summary),
1660 format!(
1661 "Deployment truth receipt: operation={} status={:?}",
1662 receipt.operation_id, receipt.operation_status
1663 ),
1664 ];
1665 for phase_receipt in &receipt.phase_receipts {
1666 lines.push(format!(
1667 "Deployment truth phase receipt: phase={} postcondition={:?}",
1668 phase_receipt.phase, phase_receipt.verified_postcondition.status
1669 ));
1670 }
1671 if !receipt.role_phase_receipts.is_empty() {
1672 lines.push(format!(
1673 "Deployment truth role receipts: {}",
1674 receipt.role_phase_receipts.len()
1675 ));
1676 }
1677 for role_receipt in &receipt.role_phase_receipts {
1678 lines.push(format!(
1679 "Deployment truth role receipt: phase={} role={} result={:?}",
1680 role_receipt.phase, role_receipt.role, role_receipt.result
1681 ));
1682 }
1683
1684 if !check.report.hard_failures.is_empty() {
1685 lines.push(format!(
1686 "Deployment truth hard failures: {}",
1687 check.report.hard_failures.len()
1688 ));
1689 }
1690 for finding in install_deployment_truth_gate_blockers(check) {
1691 lines.push(format!(
1692 "Deployment truth blocker: {}",
1693 deployment_truth_finding_label(finding)
1694 ));
1695 }
1696 if !check.report.warnings.is_empty() {
1697 lines.push(format!(
1698 "Deployment truth warnings: {}",
1699 check.report.warnings.len()
1700 ));
1701 }
1702 for finding in &check.report.warnings {
1703 lines.push(format!(
1704 "Deployment truth warning: {}",
1705 deployment_truth_finding_label(finding)
1706 ));
1707 }
1708 lines
1709}
1710
1711fn install_deployment_truth_gate_receipt(
1712 check: &DeploymentCheckV1,
1713 started_at: String,
1714 phase_receipts: Vec<crate::deployment_truth::PhaseReceiptV1>,
1715 role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
1716) -> DeploymentReceiptV1 {
1717 let blockers = install_deployment_truth_gate_blockers(check);
1718 let (operation_status, command_result) = if blockers.is_empty() {
1719 (
1720 DeploymentExecutionStatusV1::Complete,
1721 DeploymentCommandResultV1::Succeeded,
1722 )
1723 } else {
1724 (
1725 DeploymentExecutionStatusV1::FailedBeforeMutation,
1726 DeploymentCommandResultV1::Failed {
1727 code: "deployment_truth_blocked".to_string(),
1728 message: check.report.summary.clone(),
1729 },
1730 )
1731 };
1732 deployment_receipt_from_check_with_status(
1733 check,
1734 format!("{}:materialize_artifacts", check.check_id),
1735 operation_status,
1736 started_at,
1737 Some(current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string())),
1738 phase_receipts,
1739 role_phase_receipts,
1740 command_result,
1741 )
1742}
1743
1744fn write_current_install_execution_preflight_receipt(
1745 icp_root: &Path,
1746 network: &str,
1747 fleet_name: &str,
1748 check: &DeploymentCheckV1,
1749 execution_context: &DeploymentExecutionContextV1,
1750) -> Result<PathBuf, Box<dyn std::error::Error>> {
1751 let started_at = current_unix_timestamp_label()?;
1752 let executor = CurrentCliDeploymentExecutor::new(
1753 execution_context.workspace_root.clone(),
1754 execution_context.icp_root.clone(),
1755 execution_context.artifact_roots.clone(),
1756 );
1757 let preflight = deployment_execution_preflight_from_check(
1758 check,
1759 &executor,
1760 CURRENT_INSTALL_REQUIRED_CAPABILITIES,
1761 );
1762 validate_deployment_execution_preflight_for_check(check, &preflight)?;
1763 let blockers = preflight.blockers.clone();
1764 let (operation_status, command_result) = if blockers.is_empty() {
1765 (
1766 DeploymentExecutionStatusV1::Complete,
1767 DeploymentCommandResultV1::Succeeded,
1768 )
1769 } else {
1770 (
1771 DeploymentExecutionStatusV1::FailedBeforeMutation,
1772 DeploymentCommandResultV1::Failed {
1773 code: "execution_preflight_blocked".to_string(),
1774 message: "deployment execution preflight blocked current install".to_string(),
1775 },
1776 )
1777 };
1778 let finished_at = current_unix_timestamp_label()?;
1779 let receipt = receipt_with_execution_context(
1780 deployment_receipt_from_check_with_status(
1781 check,
1782 format!("{}:execution_preflight", check.check_id),
1783 operation_status,
1784 started_at.clone(),
1785 Some(finished_at.clone()),
1786 vec![phase_receipt(
1787 "execution_preflight",
1788 started_at,
1789 Some(finished_at),
1790 "validate deployment plan, authority, and executor capability readiness",
1791 crate::deployment_truth::ObservationStatusV1::Observed,
1792 current_install_execution_preflight_evidence(&preflight),
1793 )],
1794 Vec::new(),
1795 command_result,
1796 ),
1797 execution_context,
1798 );
1799 let path = write_install_deployment_truth_receipt(icp_root, network, fleet_name, &receipt)?;
1800 println!("Deployment truth receipt JSON: {}", path.display());
1801 if !blockers.is_empty() {
1802 let details = blockers
1803 .iter()
1804 .map(deployment_truth_finding_label)
1805 .collect::<Vec<_>>()
1806 .join("; ");
1807 return Err(format!("deployment execution preflight blocked install: {details}").into());
1808 }
1809 Ok(path)
1810}
1811
1812struct StageReleaseSetOperation<'a> {
1813 icp_root: &'a Path,
1814 network: &'a str,
1815 root_canister_id: &'a str,
1816 manifest_path: &'a Path,
1817 manifest: RootReleaseSetManifest,
1818}
1819
1820impl<'a> StageReleaseSetOperation<'a> {
1821 const fn new(
1822 icp_root: &'a Path,
1823 network: &'a str,
1824 root_canister_id: &'a str,
1825 manifest_path: &'a Path,
1826 manifest: RootReleaseSetManifest,
1827 ) -> Self {
1828 Self {
1829 icp_root,
1830 network,
1831 root_canister_id,
1832 manifest_path,
1833 manifest,
1834 }
1835 }
1836}
1837
1838impl InstallPhaseOperation for StageReleaseSetOperation<'_> {
1839 fn phase(&self) -> &'static str {
1840 "stage_release_set"
1841 }
1842
1843 fn attempted_action(&self) -> &'static str {
1844 "stage root release set"
1845 }
1846
1847 fn evidence(&self) -> Vec<String> {
1848 current_install_staging_evidence(self.root_canister_id, self.manifest_path, &self.manifest)
1849 }
1850
1851 fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
1852 stage_root_release_set(
1853 self.icp_root,
1854 self.network,
1855 self.root_canister_id,
1856 &self.manifest,
1857 )
1858 }
1859}
1860
1861fn current_install_execution_preflight_evidence(
1862 preflight: &crate::deployment_truth::DeploymentExecutionPreflightV1,
1863) -> Vec<String> {
1864 let mut evidence = vec![
1865 format!("execution_preflight_status:{:?}", preflight.status),
1866 format!("authority_plan:{}", preflight.authority_plan_id),
1867 format!("planned_phases:{}", preflight.planned_phases.len()),
1868 format!(
1869 "required_capabilities:{}",
1870 preflight.required_capabilities.len()
1871 ),
1872 format!(
1873 "missing_capabilities:{}",
1874 preflight.missing_capabilities.len()
1875 ),
1876 format!("blockers:{}", preflight.blockers.len()),
1877 ];
1878 evidence.extend(
1879 preflight
1880 .missing_capabilities
1881 .iter()
1882 .map(|capability| format!("missing_capability:{capability:?}")),
1883 );
1884 evidence.extend(
1885 preflight
1886 .blockers
1887 .iter()
1888 .map(|finding| format!("blocker:{}:{}", finding.code, finding.message)),
1889 );
1890 evidence
1891}
1892
1893fn current_install_staging_evidence(
1894 root_canister_id: &str,
1895 manifest_path: &Path,
1896 manifest: &RootReleaseSetManifest,
1897) -> Vec<String> {
1898 let mut evidence = vec![
1899 format!("root_canister:{root_canister_id}"),
1900 format!("manifest_path:{}", manifest_path.display()),
1901 format!("release_version:{}", manifest.release_version),
1902 ];
1903 let staging_receipts = current_install_staging_receipts(root_canister_id, manifest);
1904 evidence.extend(staging_receipt_evidence(&staging_receipts));
1905 evidence
1906}
1907
1908fn current_install_staging_receipts(
1909 root_canister_id: &str,
1910 manifest: &RootReleaseSetManifest,
1911) -> Vec<StagingReceiptV1> {
1912 manifest
1913 .entries
1914 .iter()
1915 .map(|entry| StagingReceiptV1 {
1916 schema_version: crate::deployment_truth::DEPLOYMENT_TRUTH_SCHEMA_VERSION,
1917 role: entry.role.clone(),
1918 artifact_identity: format!(
1919 "{}:{}:{}",
1920 entry.template_id, manifest.release_version, entry.payload_sha256_hex
1921 ),
1922 transport: ArtifactTransportV1::WasmStore,
1923 wasm_store_locator: Some(format!("root:{root_canister_id}:bootstrap")),
1924 prepared_chunk_hashes: entry.chunk_sha256_hex.clone(),
1925 published_chunk_count: entry.chunk_sha256_hex.len(),
1926 verified_postcondition: crate::deployment_truth::VerifiedPostconditionV1 {
1927 status: ObservationStatusV1::Observed,
1928 evidence: vec![
1929 format!("payload_sha256:{}", entry.payload_sha256_hex),
1930 format!("payload_size_bytes:{}", entry.payload_size_bytes),
1931 format!("chunk_size_bytes:{}", entry.chunk_size_bytes),
1932 format!("chunk_count:{}", entry.chunk_sha256_hex.len()),
1933 ],
1934 },
1935 })
1936 .collect()
1937}
1938
1939fn install_deployment_truth_phase_receipt(
1940 check: &DeploymentCheckV1,
1941 phase: &str,
1942 started_at: String,
1943 finished_at: Option<String>,
1944 attempted_action: &str,
1945 status: crate::deployment_truth::ObservationStatusV1,
1946 evidence: Vec<String>,
1947) -> DeploymentReceiptV1 {
1948 install_deployment_truth_phase_receipt_with_result(
1949 check,
1950 PhaseReceiptInput {
1951 phase,
1952 started_at,
1953 finished_at,
1954 attempted_action,
1955 status,
1956 evidence,
1957 role_phase_receipts: Vec::new(),
1958 operation_status: DeploymentExecutionStatusV1::Complete,
1959 command_result: DeploymentCommandResultV1::Succeeded,
1960 },
1961 )
1962}
1963
1964fn install_deployment_truth_phase_receipt_with_result(
1965 check: &DeploymentCheckV1,
1966 input: PhaseReceiptInput<'_>,
1967) -> DeploymentReceiptV1 {
1968 deployment_receipt_from_check_with_status(
1969 check,
1970 format!("{}:{}", check.check_id, input.phase),
1971 input.operation_status,
1972 input.started_at.clone(),
1973 input.finished_at.clone(),
1974 vec![phase_receipt(
1975 input.phase,
1976 input.started_at,
1977 input.finished_at,
1978 input.attempted_action,
1979 input.status,
1980 input.evidence,
1981 )],
1982 input.role_phase_receipts,
1983 input.command_result,
1984 )
1985}
1986
1987fn receipt_with_execution_context(
1988 mut receipt: DeploymentReceiptV1,
1989 execution_context: &DeploymentExecutionContextV1,
1990) -> DeploymentReceiptV1 {
1991 receipt.execution_context = Some(execution_context.clone());
1992 receipt
1993}
1994
1995struct PhaseReceiptInput<'a> {
1996 phase: &'a str,
1997 started_at: String,
1998 finished_at: Option<String>,
1999 attempted_action: &'a str,
2000 status: crate::deployment_truth::ObservationStatusV1,
2001 evidence: Vec<String>,
2002 role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
2003 operation_status: DeploymentExecutionStatusV1,
2004 command_result: DeploymentCommandResultV1,
2005}
2006
2007fn write_install_deployment_truth_receipt(
2008 icp_root: &Path,
2009 network: &str,
2010 fleet_name: &str,
2011 receipt: &DeploymentReceiptV1,
2012) -> Result<PathBuf, Box<dyn std::error::Error>> {
2013 let path = install_deployment_truth_receipt_path(icp_root, network, fleet_name, receipt)?;
2014 if let Some(parent) = path.parent() {
2015 fs::create_dir_all(parent)?;
2016 }
2017 let mut bytes = serde_json::to_vec_pretty(receipt)?;
2018 bytes.push(b'\n');
2019 fs::write(&path, bytes)?;
2020 Ok(path)
2021}
2022
2023fn write_artifact_promotion_execution_receipt(
2024 icp_root: &Path,
2025 network: &str,
2026 fleet_name: &str,
2027 receipt: &ArtifactPromotionExecutionReceiptV1,
2028) -> Result<PathBuf, Box<dyn std::error::Error>> {
2029 let path = artifact_promotion_execution_receipt_path(icp_root, network, fleet_name, receipt)?;
2030 if let Some(parent) = path.parent() {
2031 fs::create_dir_all(parent)?;
2032 }
2033 let mut bytes = serde_json::to_vec_pretty(receipt)?;
2034 bytes.push(b'\n');
2035 fs::write(&path, bytes)?;
2036 Ok(path)
2037}
2038
2039fn artifact_promotion_execution_receipt_path(
2040 icp_root: &Path,
2041 network: &str,
2042 fleet_name: &str,
2043 receipt: &ArtifactPromotionExecutionReceiptV1,
2044) -> Result<PathBuf, Box<dyn std::error::Error>> {
2045 validate_network_name(network)?;
2046 validate_fleet_name(fleet_name)?;
2047 let file_stem = format!(
2048 "{}-{}",
2049 safe_deployment_truth_path_label(&receipt.started_at),
2050 safe_deployment_truth_path_label(&receipt.receipt_id)
2051 );
2052 Ok(
2053 artifact_promotion_execution_receipts_dir(icp_root, network, fleet_name)?
2054 .join(format!("{file_stem}.json")),
2055 )
2056}
2057
2058fn artifact_promotion_execution_receipts_dir(
2059 icp_root: &Path,
2060 network: &str,
2061 fleet_name: &str,
2062) -> Result<PathBuf, Box<dyn std::error::Error>> {
2063 validate_network_name(network)?;
2064 validate_fleet_name(fleet_name)?;
2065 Ok(icp_root
2066 .join(".canic")
2067 .join(network)
2068 .join("artifact-promotion-execution-receipts")
2069 .join(fleet_name))
2070}
2071
2072fn install_deployment_truth_receipt_path(
2073 icp_root: &Path,
2074 network: &str,
2075 fleet_name: &str,
2076 receipt: &DeploymentReceiptV1,
2077) -> Result<PathBuf, Box<dyn std::error::Error>> {
2078 validate_network_name(network)?;
2079 validate_fleet_name(fleet_name)?;
2080 let file_stem = format!(
2081 "{}-{}",
2082 safe_deployment_truth_path_label(&receipt.started_at),
2083 safe_deployment_truth_path_label(&receipt.operation_id)
2084 );
2085 Ok(
2086 install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?
2087 .join(format!("{file_stem}.json")),
2088 )
2089}
2090
2091pub fn latest_deployment_truth_receipt_path_from_root(
2093 icp_root: &Path,
2094 network: &str,
2095 fleet_name: &str,
2096) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
2097 let dir = install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?;
2098 if !dir.is_dir() {
2099 return Ok(None);
2100 }
2101
2102 let mut latest = None;
2103 for entry in fs::read_dir(dir)? {
2104 let path = entry?.path();
2105 if !path.is_file()
2106 || path
2107 .extension()
2108 .is_none_or(|ext| !ext.eq_ignore_ascii_case("json"))
2109 {
2110 continue;
2111 }
2112 if latest.as_ref().is_none_or(|current| path > *current) {
2113 latest = Some(path);
2114 }
2115 }
2116 Ok(latest)
2117}
2118
2119fn install_deployment_truth_receipts_dir(
2120 icp_root: &Path,
2121 network: &str,
2122 fleet_name: &str,
2123) -> Result<PathBuf, Box<dyn std::error::Error>> {
2124 validate_network_name(network)?;
2125 validate_fleet_name(fleet_name)?;
2126 Ok(icp_root
2127 .join(".canic")
2128 .join(network)
2129 .join("deployment-receipts")
2130 .join(fleet_name))
2131}
2132
2133fn safe_deployment_truth_path_label(value: &str) -> String {
2134 let label = value
2135 .chars()
2136 .map(|ch| {
2137 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
2138 ch
2139 } else {
2140 '_'
2141 }
2142 })
2143 .collect::<String>();
2144 if label.is_empty() {
2145 "unknown".to_string()
2146 } else {
2147 label
2148 }
2149}
2150
2151fn deployment_truth_finding_label(finding: &SafetyFindingV1) -> String {
2152 let subject = finding
2153 .subject
2154 .as_ref()
2155 .map_or_else(|| "<none>".to_string(), Clone::clone);
2156 format!(
2157 "{}:{}:{}: {}",
2158 deployment_truth_finding_source(&finding.code),
2159 finding.code,
2160 subject,
2161 finding.message
2162 )
2163}
2164
2165fn deployment_truth_finding_source(code: &str) -> &'static str {
2166 match code {
2167 "plan_assumption" => "plan",
2168 "observation_gap" => "inventory",
2169 _ => "diff",
2170 }
2171}
2172
2173fn validate_expected_fleet_name(
2174 expected: Option<&str>,
2175 actual: &str,
2176 config_path: &Path,
2177) -> Result<(), Box<dyn std::error::Error>> {
2178 let Some(expected) = expected else {
2179 return Ok(());
2180 };
2181 if expected == actual {
2182 return Ok(());
2183 }
2184 Err(format!(
2185 "install requested fleet {expected}, but {} declares [fleet].name = {actual:?}",
2186 config_path.display()
2187 )
2188 .into())
2189}
2190
2191fn ensure_root_canister_id(
2192 icp_root: &Path,
2193 network: &str,
2194 root_canister: &str,
2195 config_path: &Path,
2196) -> Result<String, Box<dyn std::error::Error>> {
2197 if Principal::from_text(root_canister).is_ok() {
2198 return Ok(root_canister.to_string());
2199 }
2200
2201 match resolve_root_canister_id(icp_root, network, root_canister) {
2202 Ok(canister_id) => return Ok(canister_id),
2203 Err(err) if !is_missing_canister_id_error(&err.to_string()) => return Err(err),
2204 Err(_) => {}
2205 }
2206
2207 let mut create = icp_canister_command_in_network(icp_root);
2208 add_create_root_target(&mut create, root_canister);
2209 add_local_root_create_cycles_arg(&mut create, config_path, network)?;
2210 add_icp_environment_target(&mut create, network);
2211 let output = run_command_stdout(&mut create)?;
2212 if let Some(canister_id) = parse_created_canister_id(&output) {
2213 return Ok(canister_id);
2214 }
2215
2216 resolve_root_canister_id(icp_root, network, root_canister).map_err(|_| {
2217 format!(
2218 "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.",
2219 icp_root.display(),
2220 icp_root.display(),
2221 )
2222 .into()
2223 })
2224}
2225
2226fn parse_created_canister_id(output: &str) -> Option<String> {
2227 if let Ok(value) = serde_json::from_str::<JsonValue>(output) {
2228 return parse_canister_id_json(&value);
2229 }
2230
2231 output
2232 .lines()
2233 .map(str::trim)
2234 .find(|line| Principal::from_text(*line).is_ok())
2235 .map(ToString::to_string)
2236}
2237
2238fn parse_canister_id_json(value: &JsonValue) -> Option<String> {
2239 match value {
2240 JsonValue::String(text) if Principal::from_text(text).is_ok() => Some(text.clone()),
2241 JsonValue::Array(values) => values.iter().find_map(parse_canister_id_json),
2242 JsonValue::Object(object) => ["canister_id", "id", "principal"]
2243 .iter()
2244 .filter_map(|key| object.get(*key))
2245 .find_map(parse_canister_id_json),
2246 _ => None,
2247 }
2248}
2249
2250fn add_create_root_target(command: &mut Command, root_canister: &str) {
2251 if env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV).is_some() {
2252 command.args(["create", "--detached", "--json"]);
2253 } else {
2254 command.args(["create", root_canister, "--json"]);
2255 }
2256}
2257
2258fn is_missing_canister_id_error(message: &str) -> bool {
2259 message.contains("failed to lookup canister ID")
2260 || message.contains("could not find ID for canister")
2261 || message.contains("Canister ID is missing")
2262}
2263
2264fn reinstall_root_wasm(
2265 icp_root: &Path,
2266 network: &str,
2267 root_canister: &str,
2268 root_wasm: &Path,
2269) -> Result<(), Box<dyn std::error::Error>> {
2270 let mut install = icp_canister_command_in_network(icp_root);
2271 install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
2272 install.arg(root_wasm);
2273 install.args(["--args", &root_init_args(root_wasm)?]);
2274 add_icp_environment_target(&mut install, network);
2275 run_command(&mut install)
2276}
2277
2278fn root_init_args(root_wasm: &Path) -> Result<String, Box<dyn std::error::Error>> {
2279 let wasm = std::fs::read(root_wasm)?;
2280 Ok(format!(
2281 "(variant {{ PrimeWithModuleHash = {} }})",
2282 idl_blob(&wasm_hash(&wasm))
2283 ))
2284}
2285
2286fn idl_blob(bytes: &[u8]) -> String {
2287 let mut encoded = String::from("blob \"");
2288 for byte in bytes {
2289 use std::fmt::Write as _;
2290 let _ = write!(encoded, "\\{byte:02X}");
2291 }
2292 encoded.push('"');
2293 encoded
2294}
2295
2296fn build_install_state(
2298 options: &InstallRootOptions,
2299 workspace_root: &Path,
2300 icp_root: &Path,
2301 config_path: &Path,
2302 release_set_manifest_path: &Path,
2303 identity: (&str, &str),
2304 root_canister_id: &str,
2305) -> Result<InstallState, Box<dyn std::error::Error>> {
2306 let (deployment_name, fleet_name) = identity;
2307 Ok(InstallState {
2308 schema_version: INSTALL_STATE_SCHEMA_VERSION,
2309 deployment_name: deployment_name.to_string(),
2310 fleet_template: fleet_name.to_string(),
2311 fleet: fleet_name.to_string(),
2312 installed_at_unix_secs: current_unix_secs()?,
2313 network: options.network.clone(),
2314 root_target: options.root_canister.clone(),
2315 root_canister_id: root_canister_id.to_string(),
2316 root_verification: RootVerificationStatus::Verified,
2317 root_build_target: options.root_build_target.clone(),
2318 workspace_root: workspace_root.display().to_string(),
2319 icp_root: icp_root.display().to_string(),
2320 config_path: config_path.display().to_string(),
2321 release_set_manifest_path: release_set_manifest_path.display().to_string(),
2322 })
2323}
2324
2325fn resolve_root_canister_id(
2327 icp_root: &Path,
2328 network: &str,
2329 root_canister: &str,
2330) -> Result<String, Box<dyn std::error::Error>> {
2331 if Principal::from_text(root_canister).is_ok() {
2332 return Ok(root_canister.to_string());
2333 }
2334
2335 let mut command = icp_canister_command_in_network(icp_root);
2336 command.args(["status", root_canister, "--json"]);
2337 add_icp_environment_target(&mut command, network);
2338 let output = run_command_stdout(&mut command)?;
2339 parse_created_canister_id(&output).ok_or_else(|| {
2340 format!("could not parse root canister id from ICP status JSON output: {output}").into()
2341 })
2342}
2343
2344fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
2346 Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
2347}
2348
2349fn current_unix_timestamp_label() -> Result<String, Box<dyn std::error::Error>> {
2350 Ok(format!("unix:{}", current_unix_secs()?))
2351}
2352
2353fn run_canic_build_targets(
2355 network: &str,
2356 targets: &[String],
2357 build_profile: Option<CanisterBuildProfile>,
2358 config_path: &Path,
2359 icp_root: &Path,
2360) -> Result<(), Box<dyn std::error::Error>> {
2361 let _env = BuildEnvGuard::apply(network, config_path, icp_root);
2362 let profile = build_profile.unwrap_or_else(CanisterBuildProfile::current);
2363 if let Some(context) = current_workspace_build_context_once(profile)? {
2364 for line in context.lines() {
2365 println!("{line}");
2366 }
2367 println!("config: {}", config_path.display());
2368 println!(
2369 "artifacts: {}",
2370 planned_build_artifact_root(icp_root).display()
2371 );
2372 println!();
2373 }
2374
2375 fs::create_dir_all(planned_build_artifact_root(icp_root))?;
2376 println!("Building {} canisters", targets.len());
2377 println!();
2378 let headers = ["CANISTER", "PROGRESS", "WASM", "ELAPSED"];
2379 let planned_rows = targets
2380 .iter()
2381 .map(|target| {
2382 [
2383 target.clone(),
2384 progress_bar(targets.len(), targets.len(), 10),
2385 "000.00 MiB (gz 000.00 MiB)".to_string(),
2386 "0.00s".to_string(),
2387 ]
2388 })
2389 .collect::<Vec<_>>();
2390 let alignments = [
2391 ColumnAlign::Left,
2392 ColumnAlign::Left,
2393 ColumnAlign::Right,
2394 ColumnAlign::Right,
2395 ];
2396 let widths = table_widths(&headers, &planned_rows);
2397 println!("{}", render_table_row(&headers, &widths, &alignments));
2398 println!("{}", render_separator(&widths));
2399
2400 for (index, target) in targets.iter().enumerate() {
2401 let started_at = Instant::now();
2402 let output = build_current_workspace_canister_artifact(target, profile)
2403 .map_err(|err| format!("artifact build failed for {target}: {err}"))?;
2404 let elapsed = started_at.elapsed();
2405 let artifact_size = wasm_artifact_size(&output.wasm_path, &output.wasm_gz_path)?;
2406
2407 let row = [
2408 target.clone(),
2409 progress_bar(index + 1, targets.len(), 10),
2410 artifact_size,
2411 format!("{:.2}s", elapsed.as_secs_f64()),
2412 ];
2413 println!("{}", render_table_row(&row, &widths, &alignments));
2414 }
2415
2416 println!();
2417 Ok(())
2418}
2419
2420fn planned_build_artifact_root(icp_root: &Path) -> PathBuf {
2421 icp_root.join(".icp/local/canisters")
2422}
2423
2424fn wasm_artifact_size(
2425 wasm_path: &Path,
2426 wasm_gz_path: &Path,
2427) -> Result<String, Box<dyn std::error::Error>> {
2428 let wasm_bytes = Some(std::fs::metadata(wasm_path)?.len());
2429 let gzip_bytes = std::fs::metadata(wasm_gz_path)
2430 .ok()
2431 .map(|metadata| metadata.len());
2432 Ok(wasm_size_label(wasm_bytes, gzip_bytes))
2433}
2434
2435struct BuildEnvGuard {
2436 previous_network: Option<OsString>,
2437 previous_config_path: Option<OsString>,
2438 previous_icp_root: Option<OsString>,
2439 previous_local_network_url: Option<OsString>,
2440 previous_local_root_key: Option<OsString>,
2441}
2442
2443impl BuildEnvGuard {
2444 fn apply(network: &str, config_path: &Path, icp_root: &Path) -> Self {
2445 let guard = Self {
2446 previous_network: env::var_os("ICP_ENVIRONMENT"),
2447 previous_config_path: env::var_os("CANIC_CONFIG_PATH"),
2448 previous_icp_root: env::var_os("CANIC_ICP_ROOT"),
2449 previous_local_network_url: env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV),
2450 previous_local_root_key: env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV),
2451 };
2452 set_env("ICP_ENVIRONMENT", network);
2453 set_env("CANIC_CONFIG_PATH", config_path);
2454 set_env("CANIC_ICP_ROOT", icp_root);
2455 if let Some(target) = local_replica_icp_target(network, icp_root) {
2456 set_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV, target.url);
2457 set_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV, target.root_key);
2458 } else {
2459 remove_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV);
2460 remove_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV);
2461 }
2462 guard
2463 }
2464}
2465
2466impl Drop for BuildEnvGuard {
2467 fn drop(&mut self) {
2468 restore_env("ICP_ENVIRONMENT", self.previous_network.take());
2469 restore_env("CANIC_CONFIG_PATH", self.previous_config_path.take());
2470 restore_env("CANIC_ICP_ROOT", self.previous_icp_root.take());
2471 restore_env(
2472 CANIC_ICP_LOCAL_NETWORK_URL_ENV,
2473 self.previous_local_network_url.take(),
2474 );
2475 restore_env(
2476 CANIC_ICP_LOCAL_ROOT_KEY_ENV,
2477 self.previous_local_root_key.take(),
2478 );
2479 }
2480}
2481
2482struct LocalReplicaIcpTarget {
2483 url: String,
2484 root_key: String,
2485}
2486
2487fn local_replica_icp_target(network: &str, icp_root: &Path) -> Option<LocalReplicaIcpTarget> {
2488 if !replica_query::should_use_local_replica_query(Some(network)) {
2489 return None;
2490 }
2491 if icp_ping(icp_root, network).unwrap_or(false) {
2492 return None;
2493 }
2494 let root_key = replica_query::local_replica_root_key_from_root(Some(network), icp_root)
2495 .ok()
2496 .flatten()?;
2497 Some(LocalReplicaIcpTarget {
2498 url: replica_query::local_replica_endpoint_from_root(Some(network), icp_root),
2499 root_key,
2500 })
2501}
2502
2503fn set_env<K, V>(key: K, value: V)
2504where
2505 K: AsRef<std::ffi::OsStr>,
2506 V: AsRef<std::ffi::OsStr>,
2507{
2508 unsafe {
2511 env::set_var(key, value);
2512 }
2513}
2514
2515fn remove_env<K>(key: K)
2516where
2517 K: AsRef<std::ffi::OsStr>,
2518{
2519 unsafe {
2522 env::remove_var(key);
2523 }
2524}
2525
2526fn restore_env(key: &str, value: Option<OsString>) {
2527 if let Some(value) = value {
2529 set_env(key, value);
2530 } else {
2531 remove_env(key);
2532 }
2533}
2534
2535fn add_local_root_create_cycles_arg(
2536 command: &mut Command,
2537 config_path: &Path,
2538 network: &str,
2539) -> Result<(), Box<dyn std::error::Error>> {
2540 if network != "local" {
2541 return Ok(());
2542 }
2543
2544 let cycles = configured_local_root_create_cycles(config_path)?;
2545 command.args(["--cycles", &cycles.to_string()]);
2546 Ok(())
2547}
2548
2549fn ensure_local_root_min_cycles(
2550 icp_root: &Path,
2551 network: &str,
2552 root_canister: &str,
2553 phase: &str,
2554) -> Result<(), Box<dyn std::error::Error>> {
2555 if network != "local" {
2556 return Ok(());
2557 }
2558
2559 let current = query_root_cycle_balance(network, root_canister)?;
2560 if current >= LOCAL_ROOT_MIN_READY_CYCLES {
2561 return Ok(());
2562 }
2563
2564 let amount = LOCAL_ROOT_MIN_READY_CYCLES.saturating_sub(current);
2565 let mut command = icp_canister_command_in_network(icp_root);
2566 command
2567 .args(["top-up", "--amount"])
2568 .arg(amount.to_string())
2569 .arg(root_canister);
2570 add_icp_environment_target(&mut command, network);
2571 run_command(&mut command)?;
2572 println!(
2573 "Local root cycles ({phase}): topped up {} ({} -> {} target)",
2574 crate::format::cycles_tc(amount),
2575 crate::format::cycles_tc(current),
2576 crate::format::cycles_tc(LOCAL_ROOT_MIN_READY_CYCLES)
2577 );
2578 Ok(())
2579}
2580
2581fn query_root_cycle_balance(
2582 network: &str,
2583 root_canister: &str,
2584) -> Result<u128, Box<dyn std::error::Error>> {
2585 let output = icp_query_on_network(
2586 network,
2587 root_canister,
2588 protocol::CANIC_CYCLE_BALANCE,
2589 None,
2590 Some("json"),
2591 )?;
2592 parse_cycle_balance_response(&output).ok_or_else(|| {
2593 format!(
2594 "could not parse {root_canister} {} response: {output}",
2595 protocol::CANIC_CYCLE_BALANCE
2596 )
2597 .into()
2598 })
2599}
2600
2601fn progress_bar(current: usize, total: usize, width: usize) -> String {
2602 if total == 0 || width == 0 {
2603 return "[] 0/0".to_string();
2604 }
2605
2606 let filled = current.saturating_mul(width).div_ceil(total);
2607 let filled = filled.min(width);
2608 format!(
2609 "[{}{}] {current}/{total}",
2610 "#".repeat(filled),
2611 " ".repeat(width - filled)
2612 )
2613}
2614
2615fn ensure_icp_environment_ready(
2617 icp_root: &Path,
2618 network: &str,
2619) -> Result<(), Box<dyn std::error::Error>> {
2620 if icp_ping(icp_root, network)? {
2621 return Ok(());
2622 }
2623 if replica_query::should_use_local_replica_query(Some(network))
2624 && replica_query::local_replica_status_reachable_from_root(Some(network), icp_root)
2625 {
2626 println!(
2627 "Replica reachable via HTTP status endpoint even though ICP CLI reports network '{network}' stopped; continuing from ICP root {}.",
2628 icp_root.display()
2629 );
2630 return Ok(());
2631 }
2632
2633 Err(format!(
2634 "icp environment is not running for network '{network}'\nStart the target replica in another terminal with `canic replica start` and rerun."
2635 )
2636 .into())
2637}
2638
2639fn icp_ping(icp_root: &Path, network: &str) -> Result<bool, Box<dyn std::error::Error>> {
2641 Ok(icp::default_command_in(icp_root)
2642 .args(["network", "ping", network])
2643 .output()?
2644 .status
2645 .success())
2646}
2647
2648fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
2649 println!("Install timing summary:");
2650 println!("{}", render_install_timing_summary(timings, total));
2651}
2652
2653fn render_install_timing_summary(timings: &InstallTimingSummary, total: Duration) -> String {
2654 let rows = [
2655 timing_row("create_canisters", timings.create_canisters),
2656 timing_row("build_all", timings.build_all),
2657 timing_row("emit_manifest", timings.emit_manifest),
2658 timing_row("install_root", timings.install_root),
2659 timing_row("fund_root", timings.fund_root),
2660 timing_row("stage_release_set", timings.stage_release_set),
2661 timing_row("resume_bootstrap", timings.resume_bootstrap),
2662 timing_row("wait_ready", timings.wait_ready),
2663 timing_row("finalize_root_funding", timings.finalize_root_funding),
2664 timing_row("total", total),
2665 ];
2666 render_table(
2667 &["PHASE", "ELAPSED"],
2668 &rows,
2669 &[ColumnAlign::Left, ColumnAlign::Right],
2670 )
2671}
2672
2673fn timing_row(label: &str, duration: Duration) -> [String; 2] {
2674 [label.to_string(), format!("{:.2}s", duration.as_secs_f64())]
2675}
2676
2677fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
2679 println!("Install result:");
2680 println!("{:<14} success", "status");
2681 println!("{:<14} {}", "fleet", fleet);
2682 println!("{:<14} {}", "install_state", state_path.display());
2683 println!(
2684 "{:<14} canic list {} --network {}",
2685 "smoke_check", fleet, network
2686 );
2687}
2688
2689fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
2691 icp::run_status(command).map_err(Into::into)
2692}
2693
2694fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
2696 icp::run_output(command).map_err(Into::into)
2697}
2698
2699fn icp_command_on_network(network: &str) -> Command {
2702 let mut command = icp::default_command();
2703 command.env("ICP_ENVIRONMENT", network);
2704 command
2705}
2706
2707fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
2709 let mut command = icp::default_command_in(icp_root);
2710 command.env("ICP_ENVIRONMENT", network);
2711 command
2712}
2713
2714fn icp_canister_command_in_network(icp_root: &Path) -> Command {
2716 let mut command = icp::default_command_in(icp_root);
2717 command.arg("canister");
2718 command
2719}
2720
2721fn add_icp_environment_target(command: &mut Command, network: &str) {
2722 icp::add_target_args(command, Some(network), None);
2723}