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