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