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