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#[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#[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#[derive(Debug, ThisError)]
45pub enum DeploymentTruthError {
46 #[error("failed to read local deployment state: {0}")]
47 LocalState(String),
48}
49
50pub 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
123pub 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}