Skip to main content

canic_host/deployment_truth/
observe.rs

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