1use super::*;
2use crate::{
3 icp::{IcpCanisterStatusReport, IcpCli},
4 install_root::read_named_fleet_install_state_from_root,
5 release_set::{
6 ROOT_RELEASE_SET_MANIFEST_FILE, configured_fleet_name, configured_fleet_roles,
7 load_root_release_set_manifest,
8 },
9};
10use std::{
11 collections::BTreeMap,
12 fs,
13 path::{Path, PathBuf},
14};
15use thiserror::Error as ThisError;
16
17#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct LocalInventoryRequest {
22 pub deployment_name: String,
23 pub network: String,
24 pub workspace_root: PathBuf,
25 pub icp_root: PathBuf,
26 pub config_path: Option<PathBuf>,
27 pub observed_at: String,
28}
29
30#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct LocalArtifactManifestRequest {
35 pub network: String,
36 pub workspace_root: PathBuf,
37 pub icp_root: PathBuf,
38 pub config_path: Option<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 = deployment_config_path(&request.workspace_root, request.config_path.as_deref());
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 raw_config_sha256 = observe_config_sha256(&config, &mut unresolved_observations);
87 let observed_identity = Some(local_deployment_identity(
88 request,
89 &fleet_name,
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.as_ref().map_or_else(Vec::new, |state| {
112 install_state_observed_canisters(
113 state,
114 &request.icp_root,
115 &request.network,
116 &mut unresolved_observations,
117 )
118 }),
119 observed_pool: Vec::new(),
120 observed_artifacts,
121 observed_verifier_readiness: VerifierReadinessObservationV1 {
122 status: ObservationStatusV1::NotObserved,
123 role_epochs: Vec::new(),
124 },
125 unresolved_observations,
126 })
127}
128
129pub fn collect_local_role_artifact_manifest(
131 request: &LocalArtifactManifestRequest,
132) -> RoleArtifactManifestV1 {
133 let config = deployment_config_path(&request.workspace_root, request.config_path.as_deref());
134 let mut unresolved_artifacts = Vec::new();
135 let fleet_name = configured_fleet_name(&config).unwrap_or_else(|err| {
136 unresolved_artifacts.push(observation_gap(
137 "local_config.fleet_name",
138 format!(
139 "could not resolve fleet name from {}: {err}",
140 config.display()
141 ),
142 ));
143 "unknown".to_string()
144 });
145 let roles = configured_fleet_roles(&config).unwrap_or_else(|err| {
146 unresolved_artifacts.push(observation_gap(
147 "local_config.roles",
148 format!(
149 "could not resolve configured roles from {}: {err}",
150 config.display()
151 ),
152 ));
153 Vec::new()
154 });
155 let artifact_root = match resolve_artifact_root_for_observation(
156 &request.icp_root,
157 &request.network,
158 &mut unresolved_artifacts,
159 ) {
160 Ok(root) => Some(root),
161 Err(err) => {
162 unresolved_artifacts.push(observation_gap(
163 "local_artifacts.root",
164 format!(
165 "could not resolve artifact root for network {}: {err}",
166 request.network
167 ),
168 ));
169 None
170 }
171 };
172 let release_entries = artifact_root
173 .as_ref()
174 .and_then(|root| load_release_entries(root, &mut unresolved_artifacts));
175 let role_artifacts = artifact_root.as_ref().map_or_else(Vec::new, |root| {
176 roles
177 .iter()
178 .map(|role| {
179 role_artifact_from_local_files(
180 root,
181 role,
182 release_entries.as_ref(),
183 &mut unresolved_artifacts,
184 )
185 })
186 .collect()
187 });
188
189 RoleArtifactManifestV1 {
190 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
191 manifest_id: format!("local:{}:{fleet_name}:artifacts", request.network),
192 network: request.network.clone(),
193 artifact_root: artifact_root.map(|root| root.display().to_string()),
194 role_artifacts,
195 unresolved_artifacts,
196 }
197}
198
199fn collect_observed_artifacts(
200 icp_root: &Path,
201 network: &str,
202 roles: &[String],
203 unresolved_observations: &mut Vec<DeploymentObservationGapV1>,
204) -> Vec<ObservedArtifactV1> {
205 let artifact_root =
206 match resolve_artifact_root_for_observation(icp_root, network, unresolved_observations) {
207 Ok(root) => root,
208 Err(err) => {
209 unresolved_observations.push(observation_gap(
210 "local_artifacts.root",
211 format!("could not resolve artifact root for network {network}: {err}"),
212 ));
213 return Vec::new();
214 }
215 };
216
217 roles
218 .iter()
219 .filter_map(|role| {
220 let path = artifact_root.join(role).join(format!("{role}.wasm.gz"));
221 if !path.is_file() {
222 unresolved_observations.push(observation_gap(
223 format!("local_artifacts.{role}"),
224 format!("missing built artifact {}", path.display()),
225 ));
226 return None;
227 }
228 let size = fs::metadata(&path).ok().map(|metadata| metadata.len());
229 let file_sha256 = observe_file_sha256(&path, role, unresolved_observations);
230 let file_sha256_source = file_sha256
231 .as_ref()
232 .map(|_| ArtifactDigestSourceV1::ObservedFileDigest);
233 Some(ObservedArtifactV1 {
234 role: role.clone(),
235 artifact_path: path.display().to_string(),
236 file_sha256,
237 file_sha256_source,
238 payload_sha256: None,
239 payload_size_bytes: size,
240 source: ArtifactSourceV1::LocalBuild,
241 })
242 })
243 .collect()
244}
245
246fn load_release_entries(
247 artifact_root: &Path,
248 unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
249) -> Option<BTreeMap<String, crate::release_set::ReleaseSetEntry>> {
250 let manifest_path = artifact_root
251 .join("root")
252 .join(ROOT_RELEASE_SET_MANIFEST_FILE);
253 if !manifest_path.is_file() {
254 unresolved_artifacts.push(observation_gap(
255 "local_artifacts.release_set_manifest",
256 format!("missing release-set manifest {}", manifest_path.display()),
257 ));
258 return None;
259 }
260 match load_root_release_set_manifest(&manifest_path) {
261 Ok(manifest) => Some(
262 manifest
263 .entries
264 .into_iter()
265 .map(|entry| (entry.role.clone(), entry))
266 .collect(),
267 ),
268 Err(err) => {
269 unresolved_artifacts.push(observation_gap(
270 "local_artifacts.release_set_manifest",
271 format!(
272 "could not read release-set manifest {}: {err}",
273 manifest_path.display()
274 ),
275 ));
276 None
277 }
278 }
279}
280
281fn resolve_artifact_root_for_observation(
282 icp_root: &Path,
283 network: &str,
284 unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
285) -> Result<PathBuf, Box<dyn std::error::Error>> {
286 let preferred = icp_root.join(".icp").join(network).join("canisters");
287 if preferred.is_dir() {
288 return Ok(preferred);
289 }
290
291 let local_fallback = icp_root.join(".icp/local/canisters");
292 if network != "local" && local_fallback.is_dir() {
293 unresolved_artifacts.push(observation_gap(
294 "local_artifacts.network_fallback",
295 format!(
296 "artifact root {} was missing; observing fallback {}",
297 preferred.display(),
298 local_fallback.display()
299 ),
300 ));
301 return Ok(local_fallback);
302 }
303
304 Err(format!("missing built ICP artifacts under {}", preferred.display()).into())
305}
306
307fn role_artifact_from_local_files(
308 artifact_root: &Path,
309 role: &str,
310 release_entries: Option<&BTreeMap<String, crate::release_set::ReleaseSetEntry>>,
311 unresolved_artifacts: &mut Vec<DeploymentObservationGapV1>,
312) -> RoleArtifactV1 {
313 let wasm_gz_path = artifact_root.join(role).join(format!("{role}.wasm.gz"));
314 let (wasm_gz_size_bytes, observed_wasm_gz_file_sha256) = if wasm_gz_path.is_file() {
315 (
316 fs::metadata(&wasm_gz_path)
317 .ok()
318 .map(|metadata| metadata.len()),
319 observe_file_sha256(&wasm_gz_path, role, unresolved_artifacts),
320 )
321 } else {
322 unresolved_artifacts.push(observation_gap(
323 format!("local_artifacts.{role}"),
324 format!("missing built artifact {}", wasm_gz_path.display()),
325 ));
326 (None, None)
327 };
328 let observed_wasm_gz_file_sha256_source = observed_wasm_gz_file_sha256
329 .as_ref()
330 .map(|_| ArtifactDigestSourceV1::ObservedFileDigest);
331 let release_entry = release_entries.and_then(|entries| entries.get(role));
332 RoleArtifactV1 {
333 role: role.to_string(),
334 source: ArtifactSourceV1::LocalBuild,
335 build_profile: "unknown".to_string(),
336 wasm_path: None,
337 wasm_gz_path: Some(wasm_gz_path.display().to_string()),
338 wasm_gz_size_bytes,
339 wasm_sha256: None,
340 wasm_gz_sha256: release_entry.map(|entry| entry.payload_sha256_hex.clone()),
341 wasm_gz_sha256_source: release_entry.map(|_| ArtifactDigestSourceV1::ReleaseSetManifest),
342 observed_wasm_gz_file_sha256,
343 observed_wasm_gz_file_sha256_source,
344 installed_module_hash: None,
345 candid_path: None,
346 candid_sha256: None,
347 raw_config_sha256: None,
348 canonical_embedded_config_sha256: None,
349 embedded_topology_sha256: None,
350 builder_version: Some(env!("CARGO_PKG_VERSION").to_string()),
351 rust_toolchain: None,
352 package_version: None,
353 }
354}
355
356fn observe_file_sha256(
357 path: &Path,
358 role: &str,
359 gaps: &mut Vec<DeploymentObservationGapV1>,
360) -> Option<String> {
361 match file_sha256_hex(path) {
362 Ok(hash) => Some(hash),
363 Err(err) => {
364 gaps.push(observation_gap(
365 format!("local_artifacts.{role}.file_sha256"),
366 format!("could not hash artifact {}: {err}", path.display()),
367 ));
368 None
369 }
370 }
371}
372
373fn observe_config_sha256(
374 path: &Path,
375 gaps: &mut Vec<DeploymentObservationGapV1>,
376) -> Option<String> {
377 match file_sha256_hex(path) {
378 Ok(hash) => Some(hash),
379 Err(err) => {
380 gaps.push(observation_gap(
381 "local_config.raw_sha256",
382 format!("could not hash config {}: {err}", path.display()),
383 ));
384 None
385 }
386 }
387}
388
389fn local_deployment_identity(
390 request: &LocalInventoryRequest,
391 fleet_name: &str,
392 root_principal: Option<String>,
393) -> DeploymentIdentityV1 {
394 DeploymentIdentityV1 {
395 deployment_name: fleet_name.to_string(),
396 network: request.network.clone(),
397 root_principal,
398 authority_profile_hash: None,
399 role_topology_hash: None,
400 deployment_manifest_digest: None,
401 canonical_runtime_config_digest: None,
402 role_embedded_config_set_digest: None,
403 artifact_set_digest: None,
404 pool_identity_set_digest: None,
405 canic_version: Some(env!("CARGO_PKG_VERSION").to_string()),
406 ic_memory_version: None,
407 }
408}
409
410fn install_state_observed_canisters(
411 state: &crate::install_root::InstallState,
412 icp_root: &Path,
413 network: &str,
414 gaps: &mut Vec<DeploymentObservationGapV1>,
415) -> Vec<ObservedCanisterV1> {
416 match read_live_root_status(icp_root, network, &state.root_canister_id) {
417 Ok(report) => vec![observed_root_from_status(state, &report)],
418 Err(err) => {
419 gaps.push(observation_gap(
420 "live_canister_status.root",
421 format!(
422 "could not observe live root canister status for {}: {err}",
423 state.root_canister_id
424 ),
425 ));
426 vec![observed_root_from_install_state(state)]
427 }
428 }
429}
430
431fn read_live_root_status(
432 icp_root: &Path,
433 network: &str,
434 root_canister_id: &str,
435) -> Result<IcpCanisterStatusReport, crate::icp::IcpCommandError> {
436 IcpCli::new("icp", Some(network.to_string()), None)
437 .with_cwd(icp_root)
438 .canister_status_report(root_canister_id)
439}
440
441pub(super) fn observed_root_from_status(
442 state: &crate::install_root::InstallState,
443 report: &IcpCanisterStatusReport,
444) -> ObservedCanisterV1 {
445 let controllers = report
446 .settings
447 .as_ref()
448 .map(|settings| settings.controllers.clone())
449 .unwrap_or_default();
450 ObservedCanisterV1 {
451 canister_id: if report.id.is_empty() {
452 state.root_canister_id.clone()
453 } else {
454 report.id.clone()
455 },
456 role: Some("root".to_string()),
457 control_class: classify_root_control(&controllers, &state.root_canister_id),
458 controllers,
459 module_hash: report.module_hash.as_deref().map(normalize_module_hash),
460 status: Some(report.status.clone()),
461 root_trust_anchor: Some(state.root_canister_id.clone()),
462 canonical_embedded_config_digest: None,
463 role_assignment_source: Some("icp_canister_status".to_string()),
464 }
465}
466
467fn observed_root_from_install_state(
468 state: &crate::install_root::InstallState,
469) -> ObservedCanisterV1 {
470 ObservedCanisterV1 {
471 canister_id: state.root_canister_id.clone(),
472 role: Some("root".to_string()),
473 control_class: CanisterControlClassV1::UnknownUnsafe,
474 controllers: Vec::new(),
475 module_hash: None,
476 status: None,
477 root_trust_anchor: Some(state.root_canister_id.clone()),
478 canonical_embedded_config_digest: None,
479 role_assignment_source: Some("local_install_state".to_string()),
480 }
481}
482
483fn classify_root_control(controllers: &[String], root_canister_id: &str) -> CanisterControlClassV1 {
484 if controllers
485 .iter()
486 .any(|controller| controller == root_canister_id)
487 {
488 CanisterControlClassV1::DeploymentControlled
489 } else {
490 CanisterControlClassV1::UnknownUnsafe
491 }
492}
493
494fn normalize_module_hash(hash: &str) -> String {
495 hash.strip_prefix("0x")
496 .or_else(|| hash.strip_prefix("0X"))
497 .unwrap_or(hash)
498 .to_ascii_lowercase()
499}
500
501fn observation_gap(
502 key: impl Into<String>,
503 description: impl Into<String>,
504) -> DeploymentObservationGapV1 {
505 DeploymentObservationGapV1 {
506 key: key.into(),
507 description: description.into(),
508 }
509}