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    release_set::{
6        ROOT_RELEASE_SET_MANIFEST_FILE, configured_fleet_name, configured_fleet_roles,
7        load_root_release_set_manifest,
8    },
9};
10use std::{
11    collections::BTreeMap,
12    fs,
13    path::{Path, PathBuf},
14};
15use thiserror::Error as ThisError;
16
17///
18/// LocalInventoryRequest
19///
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct LocalInventoryRequest {
22    pub deployment_name: String,
23    pub network: String,
24    pub workspace_root: PathBuf,
25    pub icp_root: PathBuf,
26    pub config_path: Option<PathBuf>,
27    pub observed_at: String,
28}
29
30///
31/// LocalArtifactManifestRequest
32///
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct LocalArtifactManifestRequest {
35    pub network: String,
36    pub workspace_root: PathBuf,
37    pub icp_root: PathBuf,
38    pub config_path: Option<PathBuf>,
39}
40
41///
42/// DeploymentTruthError
43///
44#[derive(Debug, ThisError)]
45pub enum DeploymentTruthError {
46    #[error("failed to read local deployment state: {0}")]
47    LocalState(String),
48}
49
50/// Collect read-only local deployment facts without querying or mutating IC state.
51pub fn collect_local_deployment_inventory(
52    request: &LocalInventoryRequest,
53) -> Result<DeploymentInventoryV1, DeploymentTruthError> {
54    let config = deployment_config_path(&request.workspace_root, request.config_path.as_deref());
55    let mut unresolved_observations = Vec::new();
56    let mut roles = Vec::new();
57
58    let fleet_name = match configured_fleet_name(&config) {
59        Ok(fleet) => fleet,
60        Err(err) => {
61            unresolved_observations.push(observation_gap(
62                "local_config.fleet_name",
63                format!(
64                    "could not resolve fleet name from {}: {err}",
65                    config.display()
66                ),
67            ));
68            request.deployment_name.clone()
69        }
70    };
71
72    match configured_fleet_roles(&config) {
73        Ok(configured_roles) => roles = configured_roles,
74        Err(err) => unresolved_observations.push(observation_gap(
75            "local_config.roles",
76            format!(
77                "could not resolve configured roles from {}: {err}",
78                config.display()
79            ),
80        )),
81    }
82
83    let install_state =
84        read_named_fleet_install_state_from_root(&request.icp_root, &request.network, &fleet_name)
85            .map_err(|err| DeploymentTruthError::LocalState(err.to_string()))?;
86    let raw_config_sha256 = observe_config_sha256(&config, &mut unresolved_observations);
87    let observed_identity = Some(local_deployment_identity(
88        request,
89        &fleet_name,
90        install_state
91            .as_ref()
92            .map(|state| state.root_canister_id.clone()),
93    ));
94    let observed_artifacts = collect_observed_artifacts(
95        &request.icp_root,
96        &request.network,
97        &roles,
98        &mut unresolved_observations,
99    );
100
101    Ok(DeploymentInventoryV1 {
102        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
103        inventory_id: format!("local:{}:{fleet_name}", request.network),
104        observed_at: request.observed_at.clone(),
105        observed_identity,
106        local_config: LocalDeploymentConfigV1 {
107            config_path: Some(config.display().to_string()),
108            raw_config_sha256,
109            canonical_embedded_config_sha256: None,
110        },
111        observed_canisters: install_state.as_ref().map_or_else(Vec::new, |state| {
112            install_state_observed_canisters(
113                state,
114                &request.icp_root,
115                &request.network,
116                &mut unresolved_observations,
117            )
118        }),
119        observed_pool: Vec::new(),
120        observed_artifacts,
121        observed_verifier_readiness: VerifierReadinessObservationV1 {
122            status: ObservationStatusV1::NotObserved,
123            role_epochs: Vec::new(),
124        },
125        unresolved_observations,
126    })
127}
128
129/// Collect a read-only manifest of locally materialized role artifacts.
130pub fn collect_local_role_artifact_manifest(
131    request: &LocalArtifactManifestRequest,
132) -> RoleArtifactManifestV1 {
133    let config = deployment_config_path(&request.workspace_root, request.config_path.as_deref());
134    let mut unresolved_artifacts = Vec::new();
135    let fleet_name = configured_fleet_name(&config).unwrap_or_else(|err| {
136        unresolved_artifacts.push(observation_gap(
137            "local_config.fleet_name",
138            format!(
139                "could not resolve fleet name from {}: {err}",
140                config.display()
141            ),
142        ));
143        "unknown".to_string()
144    });
145    let roles = configured_fleet_roles(&config).unwrap_or_else(|err| {
146        unresolved_artifacts.push(observation_gap(
147            "local_config.roles",
148            format!(
149                "could not resolve configured roles from {}: {err}",
150                config.display()
151            ),
152        ));
153        Vec::new()
154    });
155    let artifact_root = match resolve_artifact_root_for_observation(
156        &request.icp_root,
157        &request.network,
158        &mut unresolved_artifacts,
159    ) {
160        Ok(root) => Some(root),
161        Err(err) => {
162            unresolved_artifacts.push(observation_gap(
163                "local_artifacts.root",
164                format!(
165                    "could not resolve artifact root for network {}: {err}",
166                    request.network
167                ),
168            ));
169            None
170        }
171    };
172    let release_entries = artifact_root
173        .as_ref()
174        .and_then(|root| load_release_entries(root, &mut unresolved_artifacts));
175    let role_artifacts = artifact_root.as_ref().map_or_else(Vec::new, |root| {
176        roles
177            .iter()
178            .map(|role| {
179                role_artifact_from_local_files(
180                    root,
181                    role,
182                    release_entries.as_ref(),
183                    &mut unresolved_artifacts,
184                )
185            })
186            .collect()
187    });
188
189    RoleArtifactManifestV1 {
190        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
191        manifest_id: format!("local:{}:{fleet_name}:artifacts", request.network),
192        network: request.network.clone(),
193        artifact_root: artifact_root.map(|root| root.display().to_string()),
194        role_artifacts,
195        unresolved_artifacts,
196    }
197}
198
199fn collect_observed_artifacts(
200    icp_root: &Path,
201    network: &str,
202    roles: &[String],
203    unresolved_observations: &mut Vec<DeploymentObservationGapV1>,
204) -> Vec<ObservedArtifactV1> {
205    let artifact_root =
206        match resolve_artifact_root_for_observation(icp_root, network, unresolved_observations) {
207            Ok(root) => root,
208            Err(err) => {
209                unresolved_observations.push(observation_gap(
210                    "local_artifacts.root",
211                    format!("could not resolve artifact root for network {network}: {err}"),
212                ));
213                return Vec::new();
214            }
215        };
216
217    roles
218        .iter()
219        .filter_map(|role| {
220            let path = artifact_root.join(role).join(format!("{role}.wasm.gz"));
221            if !path.is_file() {
222                unresolved_observations.push(observation_gap(
223                    format!("local_artifacts.{role}"),
224                    format!("missing built artifact {}", path.display()),
225                ));
226                return None;
227            }
228            let size = fs::metadata(&path).ok().map(|metadata| metadata.len());
229            let file_sha256 = observe_file_sha256(&path, role, unresolved_observations);
230            let file_sha256_source = file_sha256
231                .as_ref()
232                .map(|_| ArtifactDigestSourceV1::ObservedFileDigest);
233            Some(ObservedArtifactV1 {
234                role: role.clone(),
235                artifact_path: path.display().to_string(),
236                file_sha256,
237                file_sha256_source,
238                payload_sha256: None,
239                payload_size_bytes: size,
240                source: ArtifactSourceV1::LocalBuild,
241            })
242        })
243        .collect()
244}
245
246fn load_release_entries(
247    artifact_root: &Path,
248    unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
249) -> Option<BTreeMap<String, crate::release_set::ReleaseSetEntry>> {
250    let manifest_path = artifact_root
251        .join("root")
252        .join(ROOT_RELEASE_SET_MANIFEST_FILE);
253    if !manifest_path.is_file() {
254        unresolved_artifacts.push(observation_gap(
255            "local_artifacts.release_set_manifest",
256            format!("missing release-set manifest {}", manifest_path.display()),
257        ));
258        return None;
259    }
260    match load_root_release_set_manifest(&manifest_path) {
261        Ok(manifest) => Some(
262            manifest
263                .entries
264                .into_iter()
265                .map(|entry| (entry.role.clone(), entry))
266                .collect(),
267        ),
268        Err(err) => {
269            unresolved_artifacts.push(observation_gap(
270                "local_artifacts.release_set_manifest",
271                format!(
272                    "could not read release-set manifest {}: {err}",
273                    manifest_path.display()
274                ),
275            ));
276            None
277        }
278    }
279}
280
281fn resolve_artifact_root_for_observation(
282    icp_root: &Path,
283    network: &str,
284    unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
285) -> Result<PathBuf, Box<dyn std::error::Error>> {
286    let preferred = icp_root.join(".icp").join(network).join("canisters");
287    if preferred.is_dir() {
288        return Ok(preferred);
289    }
290
291    let local_fallback = icp_root.join(".icp/local/canisters");
292    if network != "local" && local_fallback.is_dir() {
293        unresolved_artifacts.push(observation_gap(
294            "local_artifacts.network_fallback",
295            format!(
296                "artifact root {} was missing; observing fallback {}",
297                preferred.display(),
298                local_fallback.display()
299            ),
300        ));
301        return Ok(local_fallback);
302    }
303
304    Err(format!("missing built ICP artifacts under {}", preferred.display()).into())
305}
306
307fn role_artifact_from_local_files(
308    artifact_root: &Path,
309    role: &str,
310    release_entries: Option<&BTreeMap<String, crate::release_set::ReleaseSetEntry>>,
311    unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
312) -> RoleArtifactV1 {
313    let wasm_gz_path = artifact_root.join(role).join(format!("{role}.wasm.gz"));
314    let (wasm_gz_size_bytes, observed_wasm_gz_file_sha256) = if wasm_gz_path.is_file() {
315        (
316            fs::metadata(&wasm_gz_path)
317                .ok()
318                .map(|metadata| metadata.len()),
319            observe_file_sha256(&wasm_gz_path, role, unresolved_artifacts),
320        )
321    } else {
322        unresolved_artifacts.push(observation_gap(
323            format!("local_artifacts.{role}"),
324            format!("missing built artifact {}", wasm_gz_path.display()),
325        ));
326        (None, None)
327    };
328    let observed_wasm_gz_file_sha256_source = observed_wasm_gz_file_sha256
329        .as_ref()
330        .map(|_| ArtifactDigestSourceV1::ObservedFileDigest);
331    let release_entry = release_entries.and_then(|entries| entries.get(role));
332    RoleArtifactV1 {
333        role: role.to_string(),
334        source: ArtifactSourceV1::LocalBuild,
335        build_profile: "unknown".to_string(),
336        wasm_path: None,
337        wasm_gz_path: Some(wasm_gz_path.display().to_string()),
338        wasm_gz_size_bytes,
339        wasm_sha256: None,
340        wasm_gz_sha256: release_entry.map(|entry| entry.payload_sha256_hex.clone()),
341        wasm_gz_sha256_source: release_entry.map(|_| ArtifactDigestSourceV1::ReleaseSetManifest),
342        observed_wasm_gz_file_sha256,
343        observed_wasm_gz_file_sha256_source,
344        installed_module_hash: None,
345        candid_path: None,
346        candid_sha256: None,
347        raw_config_sha256: None,
348        canonical_embedded_config_sha256: None,
349        embedded_topology_sha256: None,
350        builder_version: Some(env!("CARGO_PKG_VERSION").to_string()),
351        rust_toolchain: None,
352        package_version: None,
353    }
354}
355
356fn observe_file_sha256(
357    path: &Path,
358    role: &str,
359    gaps: &mut Vec<DeploymentObservationGapV1>,
360) -> Option<String> {
361    match file_sha256_hex(path) {
362        Ok(hash) => Some(hash),
363        Err(err) => {
364            gaps.push(observation_gap(
365                format!("local_artifacts.{role}.file_sha256"),
366                format!("could not hash artifact {}: {err}", path.display()),
367            ));
368            None
369        }
370    }
371}
372
373fn observe_config_sha256(
374    path: &Path,
375    gaps: &mut Vec<DeploymentObservationGapV1>,
376) -> Option<String> {
377    match file_sha256_hex(path) {
378        Ok(hash) => Some(hash),
379        Err(err) => {
380            gaps.push(observation_gap(
381                "local_config.raw_sha256",
382                format!("could not hash config {}: {err}", path.display()),
383            ));
384            None
385        }
386    }
387}
388
389fn local_deployment_identity(
390    request: &LocalInventoryRequest,
391    fleet_name: &str,
392    root_principal: Option<String>,
393) -> DeploymentIdentityV1 {
394    DeploymentIdentityV1 {
395        deployment_name: fleet_name.to_string(),
396        network: request.network.clone(),
397        root_principal,
398        authority_profile_hash: None,
399        role_topology_hash: None,
400        deployment_manifest_digest: None,
401        canonical_runtime_config_digest: None,
402        role_embedded_config_set_digest: None,
403        artifact_set_digest: None,
404        pool_identity_set_digest: None,
405        canic_version: Some(env!("CARGO_PKG_VERSION").to_string()),
406        ic_memory_version: None,
407    }
408}
409
410fn install_state_observed_canisters(
411    state: &crate::install_root::InstallState,
412    icp_root: &Path,
413    network: &str,
414    gaps: &mut Vec<DeploymentObservationGapV1>,
415) -> Vec<ObservedCanisterV1> {
416    match read_live_root_status(icp_root, network, &state.root_canister_id) {
417        Ok(report) => vec![observed_root_from_status(state, &report)],
418        Err(err) => {
419            gaps.push(observation_gap(
420                "live_canister_status.root",
421                format!(
422                    "could not observe live root canister status for {}: {err}",
423                    state.root_canister_id
424                ),
425            ));
426            vec![observed_root_from_install_state(state)]
427        }
428    }
429}
430
431fn read_live_root_status(
432    icp_root: &Path,
433    network: &str,
434    root_canister_id: &str,
435) -> Result<IcpCanisterStatusReport, crate::icp::IcpCommandError> {
436    IcpCli::new("icp", Some(network.to_string()), None)
437        .with_cwd(icp_root)
438        .canister_status_report(root_canister_id)
439}
440
441pub(super) fn observed_root_from_status(
442    state: &crate::install_root::InstallState,
443    report: &IcpCanisterStatusReport,
444) -> ObservedCanisterV1 {
445    let controllers = report
446        .settings
447        .as_ref()
448        .map(|settings| settings.controllers.clone())
449        .unwrap_or_default();
450    ObservedCanisterV1 {
451        canister_id: if report.id.is_empty() {
452            state.root_canister_id.clone()
453        } else {
454            report.id.clone()
455        },
456        role: Some("root".to_string()),
457        control_class: classify_root_control(&controllers, &state.root_canister_id),
458        controllers,
459        module_hash: report.module_hash.as_deref().map(normalize_module_hash),
460        status: Some(report.status.clone()),
461        root_trust_anchor: Some(state.root_canister_id.clone()),
462        canonical_embedded_config_digest: None,
463        role_assignment_source: Some("icp_canister_status".to_string()),
464    }
465}
466
467fn observed_root_from_install_state(
468    state: &crate::install_root::InstallState,
469) -> ObservedCanisterV1 {
470    ObservedCanisterV1 {
471        canister_id: state.root_canister_id.clone(),
472        role: Some("root".to_string()),
473        control_class: CanisterControlClassV1::UnknownUnsafe,
474        controllers: Vec::new(),
475        module_hash: None,
476        status: None,
477        root_trust_anchor: Some(state.root_canister_id.clone()),
478        canonical_embedded_config_digest: None,
479        role_assignment_source: Some("local_install_state".to_string()),
480    }
481}
482
483fn classify_root_control(controllers: &[String], root_canister_id: &str) -> CanisterControlClassV1 {
484    if controllers
485        .iter()
486        .any(|controller| controller == root_canister_id)
487    {
488        CanisterControlClassV1::DeploymentControlled
489    } else {
490        CanisterControlClassV1::UnknownUnsafe
491    }
492}
493
494fn normalize_module_hash(hash: &str) -> String {
495    hash.strip_prefix("0x")
496        .or_else(|| hash.strip_prefix("0X"))
497        .unwrap_or(hash)
498        .to_ascii_lowercase()
499}
500
501fn observation_gap(
502    key: impl Into<String>,
503    description: impl Into<String>,
504) -> DeploymentObservationGapV1 {
505    DeploymentObservationGapV1 {
506        key: key.into(),
507        description: description.into(),
508    }
509}