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 =
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
212/// Collect a read-only manifest of locally materialized role artifacts.
213pub 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, &registry_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}