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#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct LocalInventoryRequest {
24 pub deployment_name: String,
25 pub network: String,
26 pub workspace_root: PathBuf,
27 pub icp_root: PathBuf,
28 pub config_path: Option<PathBuf>,
29 pub observed_at: String,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct LocalArtifactManifestRequest {
37 pub network: String,
38 pub workspace_root: PathBuf,
39 pub icp_root: PathBuf,
40 pub config_path: Option<PathBuf>,
41}
42
43#[derive(Debug, ThisError)]
47pub enum DeploymentTruthError {
48 #[error("failed to read local deployment state: {0}")]
49 LocalState(String),
50}
51
52pub fn collect_local_deployment_inventory(
54 request: &LocalInventoryRequest,
55) -> Result<DeploymentInventoryV1, DeploymentTruthError> {
56 let config = deployment_config_path(&request.workspace_root, request.config_path.as_deref());
57 let mut unresolved_observations = Vec::new();
58 let 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
210pub 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}