Skip to main content

canic_host/deployment_truth/observe/artifacts/
mod.rs

1use super::super::*;
2use super::shared::observation_gap;
3use crate::release_set::{
4    ROOT_RELEASE_SET_MANIFEST_FILE, configured_deployable_roles, configured_fleet_name,
5    load_root_release_set_manifest,
6};
7use std::{
8    collections::BTreeMap,
9    fs,
10    path::{Path, PathBuf},
11};
12
13///
14/// LocalArtifactManifestRequest
15///
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct LocalArtifactManifestRequest {
18    pub network: String,
19    pub workspace_root: PathBuf,
20    pub icp_root: PathBuf,
21    pub config_path: Option<PathBuf>,
22}
23
24/// Collect a read-only manifest of locally materialized role artifacts.
25pub fn collect_local_role_artifact_manifest(
26    request: &LocalArtifactManifestRequest,
27) -> RoleArtifactManifestV1 {
28    let config = deployment_config_path(&request.workspace_root, request.config_path.as_deref());
29    let mut unresolved_artifacts = Vec::new();
30    let fleet_name = configured_fleet_name(&config).unwrap_or_else(|err| {
31        unresolved_artifacts.push(observation_gap(
32            "local_config.fleet_name",
33            format!(
34                "could not resolve fleet name from {}: {err}",
35                config.display()
36            ),
37        ));
38        "unknown".to_string()
39    });
40    let roles = configured_deployable_roles(&config).map_or_else(
41        |err| {
42            unresolved_artifacts.push(observation_gap(
43                "local_config.roles",
44                format!(
45                    "could not resolve configured roles from {}: {err}",
46                    config.display()
47                ),
48            ));
49            Vec::new()
50        },
51        deployment_truth_roles_with_implicit_wasm_store,
52    );
53    let artifact_root_result =
54        resolve_artifact_root_for_observation(&request.icp_root, &request.network);
55    let artifact_root = match artifact_root_result {
56        Ok(root) => Some(root),
57        Err(err) => {
58            unresolved_artifacts.push(observation_gap(
59                "local_artifacts.root",
60                format!(
61                    "could not resolve artifact root for network {}: {err}",
62                    request.network
63                ),
64            ));
65            None
66        }
67    };
68    let release_entries = artifact_root
69        .as_ref()
70        .and_then(|root| load_release_entries(root, &mut unresolved_artifacts));
71    let role_artifacts = artifact_root.as_ref().map_or_else(Vec::new, |root| {
72        roles
73            .iter()
74            .map(|role| {
75                role_artifact_from_local_files(
76                    root,
77                    role,
78                    release_entries.as_ref(),
79                    &mut unresolved_artifacts,
80                )
81            })
82            .collect()
83    });
84
85    RoleArtifactManifestV1 {
86        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
87        manifest_id: format!("local:{}:{fleet_name}:artifacts", request.network),
88        network: request.network.clone(),
89        artifact_root: artifact_root.map(|root| root.display().to_string()),
90        role_artifacts,
91        unresolved_artifacts,
92    }
93}
94
95pub(super) fn collect_observed_artifacts(
96    icp_root: &Path,
97    network: &str,
98    roles: &[String],
99    unresolved_observations: &mut Vec<DeploymentObservationGapV1>,
100) -> Vec<ObservedArtifactV1> {
101    let artifact_root = match resolve_artifact_root_for_observation(icp_root, network) {
102        Ok(root) => root,
103        Err(err) => {
104            unresolved_observations.push(observation_gap(
105                "local_artifacts.root",
106                format!("could not resolve artifact root for network {network}: {err}"),
107            ));
108            return Vec::new();
109        }
110    };
111
112    roles
113        .iter()
114        .filter_map(|role| {
115            let path = artifact_root.join(role).join(format!("{role}.wasm.gz"));
116            if !path.is_file() {
117                unresolved_observations.push(observation_gap(
118                    format!("local_artifacts.{role}"),
119                    format!("missing built artifact {}", path.display()),
120                ));
121                return None;
122            }
123            let size = fs::metadata(&path).ok().map(|metadata| metadata.len());
124            let file_sha256 = observe_file_sha256(&path, role, unresolved_observations);
125            let file_sha256_source = file_sha256
126                .as_ref()
127                .map(|_| ArtifactDigestSourceV1::ObservedFileDigest);
128            Some(ObservedArtifactV1 {
129                role: role.clone(),
130                artifact_path: path.display().to_string(),
131                file_sha256,
132                file_sha256_source,
133                payload_sha256: None,
134                payload_size_bytes: size,
135                source: deployment_truth_artifact_source(role),
136            })
137        })
138        .collect()
139}
140
141fn load_release_entries(
142    artifact_root: &Path,
143    unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
144) -> Option<BTreeMap<String, crate::release_set::ReleaseSetEntry>> {
145    let manifest_path = artifact_root
146        .join("root")
147        .join(ROOT_RELEASE_SET_MANIFEST_FILE);
148    if !manifest_path.is_file() {
149        unresolved_artifacts.push(observation_gap(
150            "local_artifacts.release_set_manifest",
151            format!("missing release-set manifest {}", manifest_path.display()),
152        ));
153        return None;
154    }
155    match load_root_release_set_manifest(&manifest_path) {
156        Ok(manifest) => Some(
157            manifest
158                .entries
159                .into_iter()
160                .map(|entry| (entry.role.clone(), entry))
161                .collect(),
162        ),
163        Err(err) => {
164            unresolved_artifacts.push(observation_gap(
165                "local_artifacts.release_set_manifest",
166                format!(
167                    "could not read release-set manifest {}: {err}",
168                    manifest_path.display()
169                ),
170            ));
171            None
172        }
173    }
174}
175
176fn resolve_artifact_root_for_observation(
177    icp_root: &Path,
178    network: &str,
179) -> Result<PathBuf, Box<dyn std::error::Error>> {
180    let preferred = icp_root.join(".icp").join(network).join("canisters");
181    if preferred.is_dir() {
182        return Ok(preferred);
183    }
184
185    Err(format!("missing built ICP artifacts under {}", preferred.display()).into())
186}
187
188pub(in crate::deployment_truth) fn release_set_manifest_digest(
189    icp_root: &Path,
190    network: &str,
191    gaps: &mut Vec<DeploymentObservationGapV1>,
192) -> Option<String> {
193    let artifact_root = match resolve_artifact_root_for_observation(icp_root, network) {
194        Ok(root) => root,
195        Err(err) => {
196            gaps.push(observation_gap(
197                "deployment_manifest.path",
198                format!("could not resolve release-set manifest root: {err}"),
199            ));
200            return None;
201        }
202    };
203    let manifest_path = artifact_root
204        .join("root")
205        .join(ROOT_RELEASE_SET_MANIFEST_FILE);
206    if !manifest_path.is_file() {
207        gaps.push(observation_gap(
208            "deployment_manifest.path",
209            format!("missing release-set manifest {}", manifest_path.display()),
210        ));
211        return None;
212    }
213
214    match file_sha256_hex(&manifest_path) {
215        Ok(hash) => Some(hash),
216        Err(err) => {
217            gaps.push(observation_gap(
218                "deployment_manifest.sha256",
219                format!(
220                    "could not hash release-set manifest {}: {err}",
221                    manifest_path.display()
222                ),
223            ));
224            None
225        }
226    }
227}
228
229fn role_artifact_from_local_files(
230    artifact_root: &Path,
231    role: &str,
232    release_entries: Option<&BTreeMap<String, crate::release_set::ReleaseSetEntry>>,
233    unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
234) -> RoleArtifactV1 {
235    let wasm_gz_path = artifact_root.join(role).join(format!("{role}.wasm.gz"));
236    let (wasm_gz_size_bytes, observed_wasm_gz_file_sha256) = if wasm_gz_path.is_file() {
237        (
238            fs::metadata(&wasm_gz_path)
239                .ok()
240                .map(|metadata| metadata.len()),
241            observe_file_sha256(&wasm_gz_path, role, unresolved_artifacts),
242        )
243    } else {
244        unresolved_artifacts.push(observation_gap(
245            format!("local_artifacts.{role}"),
246            format!("missing built artifact {}", wasm_gz_path.display()),
247        ));
248        (None, None)
249    };
250    let observed_wasm_gz_file_sha256_source = observed_wasm_gz_file_sha256
251        .as_ref()
252        .map(|_| ArtifactDigestSourceV1::ObservedFileDigest);
253    let release_entry = release_entries.and_then(|entries| entries.get(role));
254    RoleArtifactV1 {
255        role: role.to_string(),
256        source: deployment_truth_artifact_source(role),
257        build_profile: "unknown".to_string(),
258        wasm_path: None,
259        wasm_gz_path: Some(wasm_gz_path.display().to_string()),
260        wasm_gz_size_bytes,
261        wasm_sha256: None,
262        wasm_gz_sha256: release_entry.map(|entry| entry.payload_sha256_hex.clone()),
263        wasm_gz_sha256_source: release_entry.map(|_| ArtifactDigestSourceV1::ReleaseSetManifest),
264        observed_wasm_gz_file_sha256,
265        observed_wasm_gz_file_sha256_source,
266        installed_module_hash: None,
267        candid_path: None,
268        candid_sha256: None,
269        raw_config_sha256: None,
270        canonical_embedded_config_sha256: None,
271        embedded_topology_sha256: None,
272        builder_version: Some(env!("CARGO_PKG_VERSION").to_string()),
273        rust_toolchain: None,
274        package_version: None,
275    }
276}
277
278fn observe_file_sha256(
279    path: &Path,
280    role: &str,
281    gaps: &mut Vec<DeploymentObservationGapV1>,
282) -> Option<String> {
283    match file_sha256_hex(path) {
284        Ok(hash) => Some(hash),
285        Err(err) => {
286            gaps.push(observation_gap(
287                format!("local_artifacts.{role}.file_sha256"),
288                format!("could not hash artifact {}: {err}", path.display()),
289            ));
290            None
291        }
292    }
293}
294
295pub(super) fn observe_config_sha256(
296    path: &Path,
297    gaps: &mut Vec<DeploymentObservationGapV1>,
298) -> Option<String> {
299    match file_sha256_hex(path) {
300        Ok(hash) => Some(hash),
301        Err(err) => {
302            gaps.push(observation_gap(
303                "local_config.raw_sha256",
304                format!("could not hash config {}: {err}", path.display()),
305            ));
306            None
307        }
308    }
309}
310
311pub(super) fn observe_deployment_manifest_digest(
312    icp_root: &Path,
313    network: &str,
314    gaps: &mut Vec<DeploymentObservationGapV1>,
315) -> Option<String> {
316    release_set_manifest_digest(icp_root, network, gaps)
317}
318
319pub(super) fn observe_canonical_runtime_config_digest(
320    path: &Path,
321    gaps: &mut Vec<DeploymentObservationGapV1>,
322) -> Option<String> {
323    match canonical_runtime_config_sha256_hex(path) {
324        Ok(hash) => Some(hash),
325        Err(err) => {
326            gaps.push(observation_gap(
327                "local_config.canonical_runtime_config_sha256",
328                format!(
329                    "could not hash canonical runtime config {}: {err}",
330                    path.display()
331                ),
332            ));
333            None
334        }
335    }
336}