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