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