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