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