Skip to main content

canic_host/deployment_truth/
observe.rs

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///
20/// LocalInventoryRequest
21///
22#[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///
33/// LocalArtifactManifestRequest
34///
35#[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///
44/// DeploymentTruthError
45///
46#[derive(Debug, ThisError)]
47pub enum DeploymentTruthError {
48    #[error("failed to read local deployment state: {0}")]
49    LocalState(String),
50}
51
52/// Collect read-only local deployment facts without querying or mutating IC state.
53pub 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
215/// Collect a read-only manifest of locally materialized role artifacts.
216pub 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, &registry_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}