Skip to main content

canic_host/deployment_truth/
observe.rs

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