1use super::*;
2use crate::{
3 icp::{IcpCanisterStatusReport, IcpCli},
4 install_root::read_named_fleet_install_state_from_root,
5 installed_fleet::{InstalledFleetRequest, resolve_installed_fleet_from_root},
6 registry::RegistryEntry,
7 release_set::{
8 ConfiguredPoolExpectation, ROOT_RELEASE_SET_MANIFEST_FILE, configured_fleet_name,
9 configured_fleet_roles, configured_pool_expectations, load_root_release_set_manifest,
10 },
11};
12use std::{
13 collections::{BTreeMap, BTreeSet},
14 fs,
15 path::{Path, PathBuf},
16};
17use thiserror::Error as ThisError;
18
19#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct LocalInventoryRequest {
24 pub deployment_name: String,
25 pub network: String,
26 pub workspace_root: PathBuf,
27 pub icp_root: PathBuf,
28 pub config_path: Option<PathBuf>,
29 pub observed_at: String,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct LocalArtifactManifestRequest {
37 pub network: String,
38 pub workspace_root: PathBuf,
39 pub icp_root: PathBuf,
40 pub config_path: Option<PathBuf>,
41}
42
43#[derive(Debug, ThisError)]
47pub enum DeploymentTruthError {
48 #[error("failed to read local deployment state: {0}")]
49 LocalState(String),
50}
51
52pub fn collect_local_deployment_inventory(
54 request: &LocalInventoryRequest,
55) -> Result<DeploymentInventoryV1, DeploymentTruthError> {
56 let config = deployment_config_path(&request.workspace_root, request.config_path.as_deref());
57 let mut unresolved_observations = Vec::new();
58 let mut roles = Vec::new();
59
60 let fleet_name = match configured_fleet_name(&config) {
61 Ok(fleet) => fleet,
62 Err(err) => {
63 unresolved_observations.push(observation_gap(
64 "local_config.fleet_name",
65 format!(
66 "could not resolve fleet name from {}: {err}",
67 config.display()
68 ),
69 ));
70 request.deployment_name.clone()
71 }
72 };
73
74 match configured_fleet_roles(&config) {
75 Ok(configured_roles) => {
76 roles = deployment_truth_roles_with_implicit_wasm_store(configured_roles);
77 }
78 Err(err) => unresolved_observations.push(observation_gap(
79 "local_config.roles",
80 format!(
81 "could not resolve configured roles from {}: {err}",
82 config.display()
83 ),
84 )),
85 }
86 let pool_expectations = configured_pool_expectations(&config).unwrap_or_else(|err| {
87 unresolved_observations.push(observation_gap(
88 "local_config.pools",
89 format!(
90 "could not resolve configured pool expectations from {}: {err}",
91 config.display()
92 ),
93 ));
94 Vec::new()
95 });
96
97 let install_state =
98 read_named_fleet_install_state_from_root(&request.icp_root, &request.network, &fleet_name)
99 .map_err(|err| DeploymentTruthError::LocalState(err.to_string()))?;
100 let raw_config_sha256 = observe_config_sha256(&config, &mut unresolved_observations);
101 let canonical_runtime_config_digest =
102 observe_canonical_runtime_config_digest(&config, &mut unresolved_observations);
103 let deployment_manifest_digest = observe_deployment_manifest_digest(
104 &request.icp_root,
105 &request.network,
106 &mut unresolved_observations,
107 );
108 let observed_artifacts = collect_observed_artifacts(
109 &request.icp_root,
110 &request.network,
111 &roles,
112 &mut unresolved_observations,
113 );
114 let (observed_canisters, observed_pool) = install_state_observations(
115 install_state.as_ref(),
116 request,
117 &fleet_name,
118 &pool_expectations,
119 &mut unresolved_observations,
120 );
121 let observed_identity = Some(local_inventory_identity(
122 request,
123 InventoryIdentityFacts {
124 fleet_name: &fleet_name,
125 root_principal: install_state
126 .as_ref()
127 .map(|state| state.root_canister_id.clone()),
128 deployment_manifest_digest,
129 canonical_runtime_config_digest: canonical_runtime_config_digest.clone(),
130 observed_canisters: &observed_canisters,
131 observed_artifacts: &observed_artifacts,
132 observed_pool: &observed_pool,
133 },
134 ));
135
136 Ok(DeploymentInventoryV1 {
137 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
138 inventory_id: format!("local:{}:{fleet_name}", request.network),
139 observed_at: request.observed_at.clone(),
140 observed_identity,
141 local_config: LocalDeploymentConfigV1 {
142 config_path: Some(config.display().to_string()),
143 raw_config_sha256,
144 canonical_embedded_config_sha256: canonical_runtime_config_digest,
145 },
146 observed_canisters,
147 observed_pool,
148 observed_artifacts,
149 observed_verifier_readiness: VerifierReadinessObservationV1 {
150 status: ObservationStatusV1::NotObserved,
151 role_epochs: Vec::new(),
152 },
153 unresolved_observations,
154 })
155}
156
157fn install_state_observations(
158 install_state: Option<&crate::install_root::InstallState>,
159 request: &LocalInventoryRequest,
160 fleet_name: &str,
161 pool_expectations: &[ConfiguredPoolExpectation],
162 unresolved_observations: &mut Vec<DeploymentObservationGapV1>,
163) -> (Vec<ObservedCanisterV1>, Vec<ObservedPoolCanisterV1>) {
164 let Some(state) = install_state else {
165 return (Vec::new(), Vec::new());
166 };
167 let mut observed_canisters = install_state_observed_canisters(
168 state,
169 &request.icp_root,
170 &request.network,
171 unresolved_observations,
172 );
173 let observed_pool = install_state_registry_observations(
174 state,
175 request,
176 fleet_name,
177 pool_expectations,
178 &mut observed_canisters,
179 unresolved_observations,
180 );
181 (observed_canisters, observed_pool)
182}
183
184struct InventoryIdentityFacts<'a> {
185 fleet_name: &'a str,
186 root_principal: Option<String>,
187 deployment_manifest_digest: Option<String>,
188 canonical_runtime_config_digest: Option<String>,
189 observed_canisters: &'a [ObservedCanisterV1],
190 observed_artifacts: &'a [ObservedArtifactV1],
191 observed_pool: &'a [ObservedPoolCanisterV1],
192}
193
194fn local_inventory_identity(
195 request: &LocalInventoryRequest,
196 facts: InventoryIdentityFacts<'_>,
197) -> DeploymentIdentityV1 {
198 local_deployment_identity(
199 request,
200 InventoryIdentityInput {
201 fleet_name: facts.fleet_name,
202 root_principal: facts.root_principal,
203 deployment_manifest_digest: facts.deployment_manifest_digest,
204 canonical_runtime_config_digest: facts.canonical_runtime_config_digest,
205 role_topology_hash: Some(stable_json_sha256_hex(&facts.observed_canisters)),
206 artifact_set_digest: Some(stable_json_sha256_hex(&facts.observed_artifacts)),
207 pool_identity_set_digest: Some(stable_json_sha256_hex(&facts.observed_pool)),
208 },
209 )
210}
211
212pub fn collect_local_role_artifact_manifest(
214 request: &LocalArtifactManifestRequest,
215) -> RoleArtifactManifestV1 {
216 let config = deployment_config_path(&request.workspace_root, request.config_path.as_deref());
217 let mut unresolved_artifacts = Vec::new();
218 let fleet_name = configured_fleet_name(&config).unwrap_or_else(|err| {
219 unresolved_artifacts.push(observation_gap(
220 "local_config.fleet_name",
221 format!(
222 "could not resolve fleet name from {}: {err}",
223 config.display()
224 ),
225 ));
226 "unknown".to_string()
227 });
228 let roles = configured_fleet_roles(&config).map_or_else(
229 |err| {
230 unresolved_artifacts.push(observation_gap(
231 "local_config.roles",
232 format!(
233 "could not resolve configured roles from {}: {err}",
234 config.display()
235 ),
236 ));
237 Vec::new()
238 },
239 deployment_truth_roles_with_implicit_wasm_store,
240 );
241 let artifact_root = match resolve_artifact_root_for_observation(
242 &request.icp_root,
243 &request.network,
244 &mut unresolved_artifacts,
245 ) {
246 Ok(root) => Some(root),
247 Err(err) => {
248 unresolved_artifacts.push(observation_gap(
249 "local_artifacts.root",
250 format!(
251 "could not resolve artifact root for network {}: {err}",
252 request.network
253 ),
254 ));
255 None
256 }
257 };
258 let release_entries = artifact_root
259 .as_ref()
260 .and_then(|root| load_release_entries(root, &mut unresolved_artifacts));
261 let role_artifacts = artifact_root.as_ref().map_or_else(Vec::new, |root| {
262 roles
263 .iter()
264 .map(|role| {
265 role_artifact_from_local_files(
266 root,
267 role,
268 release_entries.as_ref(),
269 &mut unresolved_artifacts,
270 )
271 })
272 .collect()
273 });
274
275 RoleArtifactManifestV1 {
276 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
277 manifest_id: format!("local:{}:{fleet_name}:artifacts", request.network),
278 network: request.network.clone(),
279 artifact_root: artifact_root.map(|root| root.display().to_string()),
280 role_artifacts,
281 unresolved_artifacts,
282 }
283}
284
285fn collect_observed_artifacts(
286 icp_root: &Path,
287 network: &str,
288 roles: &[String],
289 unresolved_observations: &mut Vec<DeploymentObservationGapV1>,
290) -> Vec<ObservedArtifactV1> {
291 let artifact_root =
292 match resolve_artifact_root_for_observation(icp_root, network, unresolved_observations) {
293 Ok(root) => root,
294 Err(err) => {
295 unresolved_observations.push(observation_gap(
296 "local_artifacts.root",
297 format!("could not resolve artifact root for network {network}: {err}"),
298 ));
299 return Vec::new();
300 }
301 };
302
303 roles
304 .iter()
305 .filter_map(|role| {
306 let path = artifact_root.join(role).join(format!("{role}.wasm.gz"));
307 if !path.is_file() {
308 unresolved_observations.push(observation_gap(
309 format!("local_artifacts.{role}"),
310 format!("missing built artifact {}", path.display()),
311 ));
312 return None;
313 }
314 let size = fs::metadata(&path).ok().map(|metadata| metadata.len());
315 let file_sha256 = observe_file_sha256(&path, role, unresolved_observations);
316 let file_sha256_source = file_sha256
317 .as_ref()
318 .map(|_| ArtifactDigestSourceV1::ObservedFileDigest);
319 Some(ObservedArtifactV1 {
320 role: role.clone(),
321 artifact_path: path.display().to_string(),
322 file_sha256,
323 file_sha256_source,
324 payload_sha256: None,
325 payload_size_bytes: size,
326 source: deployment_truth_artifact_source(role),
327 })
328 })
329 .collect()
330}
331
332fn load_release_entries(
333 artifact_root: &Path,
334 unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
335) -> Option<BTreeMap<String, crate::release_set::ReleaseSetEntry>> {
336 let manifest_path = artifact_root
337 .join("root")
338 .join(ROOT_RELEASE_SET_MANIFEST_FILE);
339 if !manifest_path.is_file() {
340 unresolved_artifacts.push(observation_gap(
341 "local_artifacts.release_set_manifest",
342 format!("missing release-set manifest {}", manifest_path.display()),
343 ));
344 return None;
345 }
346 match load_root_release_set_manifest(&manifest_path) {
347 Ok(manifest) => Some(
348 manifest
349 .entries
350 .into_iter()
351 .map(|entry| (entry.role.clone(), entry))
352 .collect(),
353 ),
354 Err(err) => {
355 unresolved_artifacts.push(observation_gap(
356 "local_artifacts.release_set_manifest",
357 format!(
358 "could not read release-set manifest {}: {err}",
359 manifest_path.display()
360 ),
361 ));
362 None
363 }
364 }
365}
366
367fn resolve_artifact_root_for_observation(
368 icp_root: &Path,
369 network: &str,
370 unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
371) -> Result<PathBuf, Box<dyn std::error::Error>> {
372 let preferred = icp_root.join(".icp").join(network).join("canisters");
373 if preferred.is_dir() {
374 return Ok(preferred);
375 }
376
377 let local_fallback = icp_root.join(".icp/local/canisters");
378 if network != "local" && local_fallback.is_dir() {
379 unresolved_artifacts.push(observation_gap(
380 "local_artifacts.network_fallback",
381 format!(
382 "artifact root {} was missing; observing fallback {}",
383 preferred.display(),
384 local_fallback.display()
385 ),
386 ));
387 return Ok(local_fallback);
388 }
389
390 Err(format!("missing built ICP artifacts under {}", preferred.display()).into())
391}
392
393pub(super) fn release_set_manifest_digest(
394 icp_root: &Path,
395 network: &str,
396 gaps: &mut Vec<DeploymentObservationGapV1>,
397) -> Option<String> {
398 let artifact_root = match resolve_artifact_root_for_observation(icp_root, network, gaps) {
399 Ok(root) => root,
400 Err(err) => {
401 gaps.push(observation_gap(
402 "deployment_manifest.path",
403 format!("could not resolve release-set manifest root: {err}"),
404 ));
405 return None;
406 }
407 };
408 let manifest_path = artifact_root
409 .join("root")
410 .join(ROOT_RELEASE_SET_MANIFEST_FILE);
411 if !manifest_path.is_file() {
412 gaps.push(observation_gap(
413 "deployment_manifest.path",
414 format!("missing release-set manifest {}", manifest_path.display()),
415 ));
416 return None;
417 }
418
419 match file_sha256_hex(&manifest_path) {
420 Ok(hash) => Some(hash),
421 Err(err) => {
422 gaps.push(observation_gap(
423 "deployment_manifest.sha256",
424 format!(
425 "could not hash release-set manifest {}: {err}",
426 manifest_path.display()
427 ),
428 ));
429 None
430 }
431 }
432}
433
434fn role_artifact_from_local_files(
435 artifact_root: &Path,
436 role: &str,
437 release_entries: Option<&BTreeMap<String, crate::release_set::ReleaseSetEntry>>,
438 unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
439) -> RoleArtifactV1 {
440 let wasm_gz_path = artifact_root.join(role).join(format!("{role}.wasm.gz"));
441 let (wasm_gz_size_bytes, observed_wasm_gz_file_sha256) = if wasm_gz_path.is_file() {
442 (
443 fs::metadata(&wasm_gz_path)
444 .ok()
445 .map(|metadata| metadata.len()),
446 observe_file_sha256(&wasm_gz_path, role, unresolved_artifacts),
447 )
448 } else {
449 unresolved_artifacts.push(observation_gap(
450 format!("local_artifacts.{role}"),
451 format!("missing built artifact {}", wasm_gz_path.display()),
452 ));
453 (None, None)
454 };
455 let observed_wasm_gz_file_sha256_source = observed_wasm_gz_file_sha256
456 .as_ref()
457 .map(|_| ArtifactDigestSourceV1::ObservedFileDigest);
458 let release_entry = release_entries.and_then(|entries| entries.get(role));
459 RoleArtifactV1 {
460 role: role.to_string(),
461 source: deployment_truth_artifact_source(role),
462 build_profile: "unknown".to_string(),
463 wasm_path: None,
464 wasm_gz_path: Some(wasm_gz_path.display().to_string()),
465 wasm_gz_size_bytes,
466 wasm_sha256: None,
467 wasm_gz_sha256: release_entry.map(|entry| entry.payload_sha256_hex.clone()),
468 wasm_gz_sha256_source: release_entry.map(|_| ArtifactDigestSourceV1::ReleaseSetManifest),
469 observed_wasm_gz_file_sha256,
470 observed_wasm_gz_file_sha256_source,
471 installed_module_hash: None,
472 candid_path: None,
473 candid_sha256: None,
474 raw_config_sha256: None,
475 canonical_embedded_config_sha256: None,
476 embedded_topology_sha256: None,
477 builder_version: Some(env!("CARGO_PKG_VERSION").to_string()),
478 rust_toolchain: None,
479 package_version: None,
480 }
481}
482
483fn observe_file_sha256(
484 path: &Path,
485 role: &str,
486 gaps: &mut Vec<DeploymentObservationGapV1>,
487) -> Option<String> {
488 match file_sha256_hex(path) {
489 Ok(hash) => Some(hash),
490 Err(err) => {
491 gaps.push(observation_gap(
492 format!("local_artifacts.{role}.file_sha256"),
493 format!("could not hash artifact {}: {err}", path.display()),
494 ));
495 None
496 }
497 }
498}
499
500fn observe_config_sha256(
501 path: &Path,
502 gaps: &mut Vec<DeploymentObservationGapV1>,
503) -> Option<String> {
504 match file_sha256_hex(path) {
505 Ok(hash) => Some(hash),
506 Err(err) => {
507 gaps.push(observation_gap(
508 "local_config.raw_sha256",
509 format!("could not hash config {}: {err}", path.display()),
510 ));
511 None
512 }
513 }
514}
515
516fn observe_deployment_manifest_digest(
517 icp_root: &Path,
518 network: &str,
519 gaps: &mut Vec<DeploymentObservationGapV1>,
520) -> Option<String> {
521 release_set_manifest_digest(icp_root, network, gaps)
522}
523
524fn observe_canonical_runtime_config_digest(
525 path: &Path,
526 gaps: &mut Vec<DeploymentObservationGapV1>,
527) -> Option<String> {
528 match canonical_runtime_config_sha256_hex(path) {
529 Ok(hash) => Some(hash),
530 Err(err) => {
531 gaps.push(observation_gap(
532 "local_config.canonical_runtime_config_sha256",
533 format!(
534 "could not hash canonical runtime config {}: {err}",
535 path.display()
536 ),
537 ));
538 None
539 }
540 }
541}
542
543struct InventoryIdentityInput<'a> {
544 fleet_name: &'a str,
545 root_principal: Option<String>,
546 deployment_manifest_digest: Option<String>,
547 canonical_runtime_config_digest: Option<String>,
548 role_topology_hash: Option<String>,
549 artifact_set_digest: Option<String>,
550 pool_identity_set_digest: Option<String>,
551}
552
553fn local_deployment_identity(
554 request: &LocalInventoryRequest,
555 input: InventoryIdentityInput<'_>,
556) -> DeploymentIdentityV1 {
557 DeploymentIdentityV1 {
558 deployment_name: input.fleet_name.to_string(),
559 network: request.network.clone(),
560 root_principal: input.root_principal,
561 authority_profile_hash: None,
562 role_topology_hash: input.role_topology_hash,
563 deployment_manifest_digest: input.deployment_manifest_digest,
564 canonical_runtime_config_digest: input.canonical_runtime_config_digest,
565 role_embedded_config_set_digest: None,
566 artifact_set_digest: input.artifact_set_digest,
567 pool_identity_set_digest: input.pool_identity_set_digest,
568 canic_version: Some(env!("CARGO_PKG_VERSION").to_string()),
569 ic_memory_version: None,
570 }
571}
572
573fn install_state_observed_canisters(
574 state: &crate::install_root::InstallState,
575 icp_root: &Path,
576 network: &str,
577 gaps: &mut Vec<DeploymentObservationGapV1>,
578) -> Vec<ObservedCanisterV1> {
579 match read_live_canister_status(icp_root, network, &state.root_canister_id) {
580 Ok(report) => vec![observed_root_from_status(state, &report)],
581 Err(err) => {
582 gaps.push(observation_gap(
583 "live_canister_status.root",
584 format!(
585 "could not observe live root canister status for {}: {err}",
586 state.root_canister_id
587 ),
588 ));
589 vec![observed_root_from_install_state(state)]
590 }
591 }
592}
593
594fn install_state_registry_observations(
595 state: &crate::install_root::InstallState,
596 request: &LocalInventoryRequest,
597 fleet_name: &str,
598 pool_expectations: &[ConfiguredPoolExpectation],
599 observed_canisters: &mut Vec<ObservedCanisterV1>,
600 gaps: &mut Vec<DeploymentObservationGapV1>,
601) -> Vec<ObservedPoolCanisterV1> {
602 match resolve_installed_fleet_from_root(
603 &InstalledFleetRequest {
604 fleet: fleet_name.to_string(),
605 network: request.network.clone(),
606 icp: "icp".to_string(),
607 detect_lost_local_root: false,
608 },
609 &request.icp_root,
610 ) {
611 Ok(resolution) => {
612 let mut registry_canisters = registry_entries_to_observed_canisters(
613 &state.root_canister_id,
614 &resolution.registry.entries,
615 );
616 enrich_registry_observed_canisters(
617 &mut registry_canisters,
618 &request.icp_root,
619 &request.network,
620 gaps,
621 );
622 let mut observed_pool = registry_entries_to_observed_pool(
623 &state.root_canister_id,
624 &resolution.registry.entries,
625 pool_expectations,
626 gaps,
627 );
628 apply_canister_control_to_observed_pool(&mut observed_pool, ®istry_canisters);
629 observed_canisters.extend(registry_canisters);
630 observed_pool
631 }
632 Err(err) => {
633 gaps.push(observation_gap(
634 "live_subnet_registry",
635 format!(
636 "could not observe live subnet registry for root {}: {err}",
637 state.root_canister_id
638 ),
639 ));
640 Vec::new()
641 }
642 }
643}
644
645pub(super) fn registry_entries_to_observed_canisters(
646 root_canister_id: &str,
647 entries: &[RegistryEntry],
648) -> Vec<ObservedCanisterV1> {
649 entries
650 .iter()
651 .filter(|entry| entry.pid != root_canister_id)
652 .filter_map(registry_entry_to_observed_canister)
653 .collect()
654}
655
656fn registry_entry_to_observed_canister(entry: &RegistryEntry) -> Option<ObservedCanisterV1> {
657 let role = entry.role.clone()?;
658 Some(ObservedCanisterV1 {
659 canister_id: entry.pid.clone(),
660 role: Some(role),
661 control_class: registry_entry_control_class(entry),
662 controllers: Vec::new(),
663 module_hash: entry.module_hash.as_deref().map(normalize_module_hash),
664 status: None,
665 root_trust_anchor: entry.parent_pid.clone(),
666 canonical_embedded_config_digest: None,
667 role_assignment_source: Some("subnet_registry".to_string()),
668 })
669}
670
671pub(super) fn apply_canister_control_to_observed_pool(
672 observed_pool: &mut [ObservedPoolCanisterV1],
673 observed_canisters: &[ObservedCanisterV1],
674) {
675 let control_by_canister = observed_canisters
676 .iter()
677 .map(|canister| (canister.canister_id.as_str(), canister.control_class))
678 .collect::<BTreeMap<_, _>>();
679 for pool in observed_pool {
680 if let Some(control_class) = control_by_canister.get(pool.canister_id.as_str()) {
681 pool.control_class = *control_class;
682 }
683 }
684}
685
686fn enrich_registry_observed_canisters(
687 observed_canisters: &mut [ObservedCanisterV1],
688 icp_root: &Path,
689 network: &str,
690 gaps: &mut Vec<DeploymentObservationGapV1>,
691) {
692 for observed in observed_canisters {
693 match read_live_canister_status(icp_root, network, &observed.canister_id) {
694 Ok(report) => apply_live_status_to_registry_observation(observed, &report),
695 Err(err) => gaps.push(observation_gap(
696 live_status_gap_key(observed),
697 format!(
698 "could not observe live canister status for role {} at {}: {err}",
699 observed.role.as_deref().unwrap_or("unknown"),
700 observed.canister_id
701 ),
702 )),
703 }
704 }
705}
706
707pub(super) fn apply_live_status_to_registry_observation(
708 observed: &mut ObservedCanisterV1,
709 report: &IcpCanisterStatusReport,
710) {
711 let controllers = report
712 .settings
713 .as_ref()
714 .map(|settings| settings.controllers.clone())
715 .unwrap_or_default();
716 observed.canister_id = if report.id.is_empty() {
717 observed.canister_id.clone()
718 } else {
719 report.id.clone()
720 };
721 observed.control_class = classify_registry_observed_control(
722 observed.control_class,
723 &controllers,
724 observed.root_trust_anchor.as_deref(),
725 );
726 observed.controllers = controllers;
727 observed.module_hash = report.module_hash.as_deref().map(normalize_module_hash);
728 observed.status = Some(report.status.clone());
729 observed.role_assignment_source = Some("subnet_registry+icp_canister_status".to_string());
730}
731
732fn live_status_gap_key(observed: &ObservedCanisterV1) -> String {
733 observed.role.as_ref().map_or_else(
734 || format!("live_canister_status.{}", observed.canister_id),
735 |role| format!("live_canister_status.{role}"),
736 )
737}
738
739fn classify_registry_observed_control(
740 fallback: CanisterControlClassV1,
741 controllers: &[String],
742 root_trust_anchor: Option<&str>,
743) -> CanisterControlClassV1 {
744 let Some(anchor) = root_trust_anchor else {
745 return fallback;
746 };
747 if controllers.iter().any(|controller| controller == anchor) {
748 fallback
749 } else {
750 CanisterControlClassV1::UnknownUnsafe
751 }
752}
753
754const fn registry_entry_control_class(entry: &RegistryEntry) -> CanisterControlClassV1 {
755 if entry.parent_pid.is_some() {
756 CanisterControlClassV1::CanicManagedPool
757 } else {
758 CanisterControlClassV1::UnknownUnsafe
759 }
760}
761
762pub(super) fn registry_entries_to_observed_pool(
763 root_canister_id: &str,
764 entries: &[RegistryEntry],
765 pool_expectations: &[ConfiguredPoolExpectation],
766 gaps: &mut Vec<DeploymentObservationGapV1>,
767) -> Vec<ObservedPoolCanisterV1> {
768 let expectations_by_role = pool_expectations_by_role(pool_expectations);
769 let mut seen = BTreeSet::new();
770 let mut observed = Vec::new();
771
772 for entry in entries {
773 if entry.pid == root_canister_id {
774 continue;
775 }
776 let Some(role) = entry.role.as_ref() else {
777 continue;
778 };
779 let Some(expectations) = expectations_by_role.get(role.as_str()) else {
780 continue;
781 };
782 let [expectation] = expectations.as_slice() else {
783 gaps.push(observation_gap(
784 format!("live_subnet_registry.pool.{role}"),
785 format!(
786 "could not assign observed role {role} to one configured pool without ambiguity"
787 ),
788 ));
789 continue;
790 };
791 if !seen.insert(entry.pid.as_str()) {
792 continue;
793 }
794 observed.push(ObservedPoolCanisterV1 {
795 pool: expectation.pool.clone(),
796 canister_id: entry.pid.clone(),
797 role: Some(role.clone()),
798 control_class: pool_control_class(entry),
799 });
800 }
801
802 observed
803}
804
805fn pool_expectations_by_role(
806 pool_expectations: &[ConfiguredPoolExpectation],
807) -> BTreeMap<&str, Vec<&ConfiguredPoolExpectation>> {
808 let mut by_role = BTreeMap::<&str, Vec<&ConfiguredPoolExpectation>>::new();
809 for expectation in pool_expectations {
810 by_role
811 .entry(expectation.canister_role.as_str())
812 .or_default()
813 .push(expectation);
814 }
815 by_role
816}
817
818const fn pool_control_class(entry: &RegistryEntry) -> CanisterControlClassV1 {
819 if entry.parent_pid.is_some() {
820 CanisterControlClassV1::CanicManagedPool
821 } else {
822 CanisterControlClassV1::UnknownUnsafe
823 }
824}
825
826fn read_live_canister_status(
827 icp_root: &Path,
828 network: &str,
829 canister_id: &str,
830) -> Result<IcpCanisterStatusReport, crate::icp::IcpCommandError> {
831 IcpCli::new("icp", Some(network.to_string()), None)
832 .with_cwd(icp_root)
833 .canister_status_report(canister_id)
834}
835
836pub(super) fn observed_root_from_status(
837 state: &crate::install_root::InstallState,
838 report: &IcpCanisterStatusReport,
839) -> ObservedCanisterV1 {
840 let controllers = report
841 .settings
842 .as_ref()
843 .map(|settings| settings.controllers.clone())
844 .unwrap_or_default();
845 ObservedCanisterV1 {
846 canister_id: if report.id.is_empty() {
847 state.root_canister_id.clone()
848 } else {
849 report.id.clone()
850 },
851 role: Some("root".to_string()),
852 control_class: classify_root_control(&controllers, &state.root_canister_id),
853 controllers,
854 module_hash: report.module_hash.as_deref().map(normalize_module_hash),
855 status: Some(report.status.clone()),
856 root_trust_anchor: Some(state.root_canister_id.clone()),
857 canonical_embedded_config_digest: None,
858 role_assignment_source: Some("icp_canister_status".to_string()),
859 }
860}
861
862fn observed_root_from_install_state(
863 state: &crate::install_root::InstallState,
864) -> ObservedCanisterV1 {
865 ObservedCanisterV1 {
866 canister_id: state.root_canister_id.clone(),
867 role: Some("root".to_string()),
868 control_class: CanisterControlClassV1::UnknownUnsafe,
869 controllers: Vec::new(),
870 module_hash: None,
871 status: None,
872 root_trust_anchor: Some(state.root_canister_id.clone()),
873 canonical_embedded_config_digest: None,
874 role_assignment_source: Some("local_install_state".to_string()),
875 }
876}
877
878fn classify_root_control(controllers: &[String], root_canister_id: &str) -> CanisterControlClassV1 {
879 if controllers
880 .iter()
881 .any(|controller| controller == root_canister_id)
882 {
883 CanisterControlClassV1::DeploymentControlled
884 } else {
885 CanisterControlClassV1::UnknownUnsafe
886 }
887}
888
889fn normalize_module_hash(hash: &str) -> String {
890 hash.strip_prefix("0x")
891 .or_else(|| hash.strip_prefix("0X"))
892 .unwrap_or(hash)
893 .to_ascii_lowercase()
894}
895
896fn observation_gap(
897 key: impl Into<String>,
898 description: impl Into<String>,
899) -> DeploymentObservationGapV1 {
900 DeploymentObservationGapV1 {
901 key: key.into(),
902 description: description.into(),
903 }
904}