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