1use crate::deployment_truth::{
4 ArtifactSourceV1, CanisterControlClassV1, DeploymentInventoryV1, RoleArtifactManifestV1,
5};
6use canic_core::{bootstrap::parse_config_model, ids::CanisterRole};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use thiserror::Error;
10
11pub const ADOPTION_REPORT_SCHEMA_VERSION: u32 = 1;
12
13#[derive(Clone, Debug)]
17pub struct AdoptionReportRequest<'a> {
18 pub report_id: &'a str,
19 pub generated_at: &'a str,
20 pub profile: AdoptionProfileV1,
21 pub config_source: &'a str,
22 pub inventory: Option<&'a DeploymentInventoryV1>,
23 pub artifact_manifest: Option<&'a RoleArtifactManifestV1>,
24 pub package_metadata: Vec<AdoptionPackageMetadataV1>,
25}
26
27#[derive(Debug, Eq, Error, PartialEq)]
31pub enum AdoptionReportError {
32 #[error("invalid config: {0}")]
33 InvalidConfig(String),
34
35 #[error("missing required [fleet].name in canic.toml")]
36 MissingFleetName,
37}
38
39#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
43pub enum AdoptionProfileV1 {
44 Brownfield,
45 Partial,
46 Standalone,
47 LeafOnly,
48 HybridExternalWasm,
49 Minimal,
50}
51
52#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56pub struct AdoptionReportV1 {
57 pub schema_version: u32,
58 pub report_id: String,
59 pub generated_at: String,
60 pub fleet: String,
61 pub profile: AdoptionProfileV1,
62 pub inputs: AdoptionReportInputsV1,
63 pub summary: AdoptionReportSummaryV1,
64 pub role_findings: Vec<AdoptionRoleFindingV1>,
65 pub observed_canisters: Vec<AdoptionObservedCanisterFindingV1>,
66 pub recommendations: Vec<AdoptionRecommendationV1>,
67 pub blocked_actions: Vec<String>,
68 pub warnings: Vec<String>,
69}
70
71#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75pub struct AdoptionReportInputsV1 {
76 pub config_present: bool,
77 pub inventory_id: Option<String>,
78 pub artifact_manifest_id: Option<String>,
79 pub package_metadata_count: usize,
80 pub missing_or_stale_evidence: Vec<String>,
81}
82
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
87pub struct AdoptionReportSummaryV1 {
88 pub managed_configured_roles: usize,
89 pub declared_only_roles: usize,
90 pub attached_unobserved_roles: usize,
91 pub observed_only_canisters: usize,
92 pub user_controlled_canisters: usize,
93 pub external_controller_required: usize,
94 pub evidence_conflicts: usize,
95 pub mutating_actions_performed: usize,
96}
97
98#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub struct AdoptionRoleFindingV1 {
103 pub fleet: String,
104 pub role: String,
105 pub classifications: Vec<AdoptionClassificationV1>,
106 pub declaration_state: AdoptionDeclarationStateV1,
107 pub topology_state: AdoptionTopologyStateV1,
108 pub package_state: AdoptionPackageStateV1,
109 pub observation_state: AdoptionObservationStateV1,
110 pub authority_state: AdoptionAuthorityStateV1,
111 pub artifact_state: AdoptionArtifactStateV1,
112 pub evidence: Vec<String>,
113 pub recommendations: Vec<AdoptionRecommendationV1>,
114 pub warnings: Vec<String>,
115}
116
117#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
121pub struct AdoptionObservedCanisterFindingV1 {
122 pub canister_id: String,
123 pub matched_fleet: Option<String>,
124 pub matched_role: Option<String>,
125 pub confidence: AdoptionMatchConfidenceV1,
126 pub classifications: Vec<AdoptionClassificationV1>,
127 pub controllers: Vec<String>,
128 pub wasm_evidence: Option<String>,
129 pub deployment_target_evidence: Option<String>,
130 pub recommendations: Vec<AdoptionRecommendationV1>,
131 pub warnings: Vec<String>,
132}
133
134#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
138pub struct AdoptionRecommendationV1 {
139 pub kind: String,
140 pub severity: AdoptionRecommendationSeverityV1,
141 pub description: String,
142 pub suggested_action: Option<String>,
143 pub suggested_action_effect: AdoptionSuggestedActionEffectV1,
144 pub suggested_action_support: AdoptionSuggestedActionSupportV1,
145 pub suggested_action_availability: AdoptionSuggestedActionAvailabilityV1,
146 pub operator_action_requirement: AdoptionOperatorActionRequirementV1,
147}
148
149#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
153pub struct AdoptionPackageMetadataV1 {
154 pub package: String,
155 pub fleet: Option<String>,
156 pub role: Option<String>,
157}
158
159#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
163pub enum AdoptionClassificationV1 {
164 Managed,
165 DeclaredOnly,
166 ObservedOnly,
167 AttachedUnobserved,
168 UserControlled,
169 ExternalControllerRequired,
170 ImportedPoolCandidate,
171 EvidenceConflict,
172}
173
174#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
178pub enum AdoptionDeclarationStateV1 {
179 Undeclared,
180 Declared,
181}
182
183#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
187pub enum AdoptionTopologyStateV1 {
188 Unattached,
189 Attached,
190}
191
192#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
196pub enum AdoptionObservationStateV1 {
197 Unobserved,
198 Observed,
199 CandidateMatch,
200 ConflictingMatch,
201}
202
203#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
207pub enum AdoptionAuthorityStateV1 {
208 CanicAuthorized,
209 UserControlled,
210 External,
211 Unknown,
212}
213
214#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
218pub enum AdoptionArtifactStateV1 {
219 CanicBuilt,
220 ExternalWasm,
221 Unknown,
222}
223
224#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
228pub enum AdoptionPackageStateV1 {
229 UndeclaredRole,
230 NotChecked,
231 Matches,
232 MissingFleet,
233 MissingRole,
234 Mismatch,
235}
236
237#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
241pub enum AdoptionMatchConfidenceV1 {
242 None,
243 Candidate,
244 ExplicitEvidence,
245 Conflict,
246}
247
248#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
252pub enum AdoptionRecommendationSeverityV1 {
253 Info,
254 Warning,
255 Blocked,
256}
257
258#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
262pub enum AdoptionSuggestedActionEffectV1 {
263 ReadOnly,
264 MutatesState,
265}
266
267#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
271pub enum AdoptionSuggestedActionSupportV1 {
272 SupportedByAdoption,
273 UnsupportedByAdoption,
274}
275
276#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
280pub enum AdoptionSuggestedActionAvailabilityV1 {
281 AllowedIn0500,
282 BlockedIn0500,
283}
284
285#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
289pub enum AdoptionOperatorActionRequirementV1 {
290 Required,
291 NotRequired,
292}
293
294struct DeclaredRoleFindingInput<'a> {
295 profile: AdoptionProfileV1,
296 fleet: &'a str,
297 role: &'a CanisterRole,
298 package: &'a str,
299 attached: bool,
300 observed: Option<&'a [&'a crate::deployment_truth::ObservedCanisterV1]>,
301 duplicate_observation: bool,
302 packages_by_path: &'a BTreeMap<String, AdoptionPackageMetadataV1>,
303 artifact_state: Option<AdoptionArtifactStateV1>,
304 artifact_conflict: bool,
305 artifact_evidence: Option<&'a [String]>,
306}
307
308struct ObservedOnlyRoleFindingInput<'a> {
309 profile: AdoptionProfileV1,
310 fleet: &'a str,
311 role: &'a str,
312 observed: &'a [&'a crate::deployment_truth::ObservedCanisterV1],
313 duplicate_observation: bool,
314 artifact_state: Option<AdoptionArtifactStateV1>,
315 artifact_conflict: bool,
316 artifact_evidence: Option<&'a [String]>,
317}
318
319pub fn adoption_report_from_config_source(
323 request: AdoptionReportRequest<'_>,
324) -> Result<AdoptionReportV1, AdoptionReportError> {
325 let config = parse_config_model(request.config_source)
326 .map_err(|err| AdoptionReportError::InvalidConfig(err.to_string()))?;
327 let fleet = config
328 .fleet_name()
329 .ok_or(AdoptionReportError::MissingFleetName)?
330 .to_string();
331 let attached_roles = config.attached_roles();
332 let observed_by_role = observed_canisters_by_role(request.inventory);
333 let observed_duplicate_roles = duplicate_observed_roles(&observed_by_role);
334 let packages_by_path = package_metadata_by_path(request.package_metadata);
335 let artifacts_by_role = artifact_states_by_role(request.artifact_manifest, request.inventory);
336 let artifact_conflict_roles =
337 artifact_conflict_roles(request.artifact_manifest, request.inventory);
338 let artifact_evidence_by_role =
339 artifact_evidence_by_role(request.artifact_manifest, request.inventory);
340 let declared_roles = config.roles.keys().cloned().collect::<BTreeSet<_>>();
341
342 let mut role_findings = Vec::new();
343 let mut seen_roles = BTreeSet::new();
344
345 for (role, declaration) in &config.roles {
346 seen_roles.insert(role.as_str().to_string());
347 role_findings.push(role_finding_for_declared_role(DeclaredRoleFindingInput {
348 profile: request.profile,
349 fleet: &fleet,
350 role,
351 package: declaration.package.as_str(),
352 attached: attached_roles.contains(role),
353 observed: observed_by_role.get(role.as_str()).map(Vec::as_slice),
354 duplicate_observation: observed_duplicate_roles.contains(role.as_str()),
355 packages_by_path: &packages_by_path,
356 artifact_state: artifacts_by_role.get(role.as_str()).copied(),
357 artifact_conflict: artifact_conflict_roles.contains(role.as_str()),
358 artifact_evidence: artifact_evidence_by_role
359 .get(role.as_str())
360 .map(Vec::as_slice),
361 }));
362 }
363
364 for (role, observed) in &observed_by_role {
365 if seen_roles.contains(role) {
366 continue;
367 }
368 role_findings.push(role_finding_for_observed_only_role(
369 ObservedOnlyRoleFindingInput {
370 profile: request.profile,
371 fleet: &fleet,
372 role,
373 observed,
374 duplicate_observation: observed_duplicate_roles.contains(role),
375 artifact_state: artifacts_by_role.get(role.as_str()).copied(),
376 artifact_conflict: artifact_conflict_roles.contains(role),
377 artifact_evidence: artifact_evidence_by_role
378 .get(role.as_str())
379 .map(Vec::as_slice),
380 },
381 ));
382 }
383
384 role_findings.sort_by(|left, right| left.role.cmp(&right.role));
385
386 let observed_canisters = observed_canister_findings(
387 request.profile,
388 &fleet,
389 request.inventory,
390 &declared_roles,
391 &attached_roles,
392 );
393 let summary = report_summary(&role_findings, &observed_canisters);
394 let mut recommendations = Vec::new();
395 for finding in &role_findings {
396 recommendations.extend(finding.recommendations.clone());
397 }
398 for finding in &observed_canisters {
399 recommendations.extend(finding.recommendations.clone());
400 }
401
402 Ok(AdoptionReportV1 {
403 schema_version: ADOPTION_REPORT_SCHEMA_VERSION,
404 report_id: request.report_id.to_string(),
405 generated_at: request.generated_at.to_string(),
406 fleet,
407 profile: request.profile,
408 inputs: AdoptionReportInputsV1 {
409 config_present: true,
410 inventory_id: request
411 .inventory
412 .map(|inventory| inventory.inventory_id.clone()),
413 artifact_manifest_id: request
414 .artifact_manifest
415 .map(|manifest| manifest.manifest_id.clone()),
416 package_metadata_count: packages_by_path.len(),
417 missing_or_stale_evidence: missing_evidence(
418 request.inventory,
419 request.artifact_manifest,
420 ),
421 },
422 summary,
423 role_findings,
424 observed_canisters,
425 recommendations,
426 blocked_actions: blocked_actions(),
427 warnings: Vec::new(),
428 })
429}
430
431fn role_finding_for_declared_role(input: DeclaredRoleFindingInput<'_>) -> AdoptionRoleFindingV1 {
432 let role_name = input.role.as_str().to_string();
433 let observed = input.observed.unwrap_or_default();
434 let observed_any = !observed.is_empty();
435 let mut classifications = BTreeSet::new();
436 let mut evidence = Vec::new();
437 let mut warnings = Vec::new();
438 let mut recommendations = Vec::new();
439
440 evidence.push("role declaration exists".to_string());
441 if input.attached {
442 evidence.push("topology attachment exists".to_string());
443 classifications.insert(AdoptionClassificationV1::Managed);
444 } else {
445 evidence.push("no topology attachment exists".to_string());
446 classifications.insert(AdoptionClassificationV1::DeclaredOnly);
447 if input.profile == AdoptionProfileV1::LeafOnly
448 && is_leaf_only_authority_sensitive_role(&role_name)
449 {
450 warnings.push(
451 "leaf-only profile leaves authority-sensitive declared roles unattached"
452 .to_string(),
453 );
454 } else {
455 recommendations.push(attach_later_recommendation(input.fleet, &role_name));
456 }
457 }
458
459 if input.attached && !observed_any {
460 classifications.insert(AdoptionClassificationV1::AttachedUnobserved);
461 warnings.push("deployment-truth evidence does not confirm this attached role".to_string());
462 }
463
464 for canister in observed {
465 evidence.push(format!("observed canister {}", canister.canister_id));
466 if let Some(hash) = &canister.module_hash {
467 evidence.push(format!("observed canister module_hash={hash}"));
468 }
469 }
470 if let Some(artifact_evidence) = input.artifact_evidence {
471 evidence.extend(artifact_evidence.iter().cloned());
472 }
473
474 let authority_state = combined_authority_state(observed);
475 if matches!(
476 authority_state,
477 AdoptionAuthorityStateV1::UserControlled | AdoptionAuthorityStateV1::External
478 ) {
479 classifications.insert(AdoptionClassificationV1::ExternalControllerRequired);
480 }
481 if matches!(authority_state, AdoptionAuthorityStateV1::UserControlled) {
482 classifications.insert(AdoptionClassificationV1::UserControlled);
483 }
484
485 let package_state = package_state(
486 input.package,
487 input.fleet,
488 &role_name,
489 input.packages_by_path,
490 );
491 if matches!(
492 package_state,
493 AdoptionPackageStateV1::MissingFleet
494 | AdoptionPackageStateV1::MissingRole
495 | AdoptionPackageStateV1::Mismatch
496 ) {
497 classifications.insert(AdoptionClassificationV1::EvidenceConflict);
498 warnings.push("package metadata does not match declared fleet role".to_string());
499 }
500
501 if input.duplicate_observation {
502 classifications.insert(AdoptionClassificationV1::EvidenceConflict);
503 warnings.push("deployment evidence contains conflicting role facts".to_string());
504 }
505
506 if input.artifact_conflict {
507 classifications.insert(AdoptionClassificationV1::EvidenceConflict);
508 warnings.push("artifact evidence contains conflicting role facts".to_string());
509 }
510
511 let artifact_state = input
512 .artifact_state
513 .unwrap_or_else(|| artifact_state_from_observed(observed));
514 if input.profile == AdoptionProfileV1::HybridExternalWasm
515 && artifact_state == AdoptionArtifactStateV1::ExternalWasm
516 {
517 warnings.push(
518 "external Wasm evidence is reported only; artifact registry import is outside adoption reporting"
519 .to_string(),
520 );
521 }
522
523 AdoptionRoleFindingV1 {
524 fleet: input.fleet.to_string(),
525 role: role_name,
526 classifications: classifications.into_iter().collect(),
527 declaration_state: AdoptionDeclarationStateV1::Declared,
528 topology_state: if input.attached {
529 AdoptionTopologyStateV1::Attached
530 } else {
531 AdoptionTopologyStateV1::Unattached
532 },
533 package_state,
534 observation_state: observation_state(observed_any, input.duplicate_observation),
535 authority_state,
536 artifact_state,
537 evidence,
538 recommendations,
539 warnings,
540 }
541}
542
543fn role_finding_for_observed_only_role(
544 input: ObservedOnlyRoleFindingInput<'_>,
545) -> AdoptionRoleFindingV1 {
546 let mut classifications = BTreeSet::new();
547 classifications.insert(AdoptionClassificationV1::ObservedOnly);
548 if input.duplicate_observation {
549 classifications.insert(AdoptionClassificationV1::EvidenceConflict);
550 }
551 if input.artifact_conflict {
552 classifications.insert(AdoptionClassificationV1::EvidenceConflict);
553 }
554
555 let authority_state = combined_authority_state(input.observed);
556 if matches!(authority_state, AdoptionAuthorityStateV1::UserControlled) {
557 classifications.insert(AdoptionClassificationV1::UserControlled);
558 }
559 if matches!(
560 authority_state,
561 AdoptionAuthorityStateV1::UserControlled | AdoptionAuthorityStateV1::External
562 ) {
563 classifications.insert(AdoptionClassificationV1::ExternalControllerRequired);
564 }
565
566 let artifact_state = input
567 .artifact_state
568 .unwrap_or_else(|| artifact_state_from_observed(input.observed));
569 let mut evidence = input
570 .observed
571 .iter()
572 .flat_map(|canister| {
573 let mut evidence = vec![format!("observed canister {}", canister.canister_id)];
574 if let Some(hash) = &canister.module_hash {
575 evidence.push(format!("observed canister module_hash={hash}"));
576 }
577 evidence
578 })
579 .collect::<Vec<_>>();
580 if let Some(artifact_evidence) = input.artifact_evidence {
581 evidence.extend(artifact_evidence.iter().cloned());
582 }
583
584 let mut warnings = observed_only_warnings(input.profile, input.role);
585 if input.artifact_conflict {
586 warnings.push("artifact evidence contains conflicting role facts".to_string());
587 }
588 if input.profile == AdoptionProfileV1::HybridExternalWasm
589 && artifact_state == AdoptionArtifactStateV1::ExternalWasm
590 {
591 warnings.push(
592 "external Wasm evidence is reported only; artifact registry import is outside adoption reporting"
593 .to_string(),
594 );
595 }
596
597 AdoptionRoleFindingV1 {
598 fleet: input.fleet.to_string(),
599 role: input.role.to_string(),
600 classifications: classifications.into_iter().collect(),
601 declaration_state: AdoptionDeclarationStateV1::Undeclared,
602 topology_state: AdoptionTopologyStateV1::Unattached,
603 package_state: AdoptionPackageStateV1::UndeclaredRole,
604 observation_state: observation_state(true, input.duplicate_observation),
605 authority_state,
606 artifact_state,
607 evidence,
608 recommendations: observed_only_recommendations(
609 input.profile,
610 input.fleet,
611 input.role,
612 authority_state,
613 ),
614 warnings,
615 }
616}
617
618fn observed_canister_findings(
619 profile: AdoptionProfileV1,
620 fleet: &str,
621 inventory: Option<&DeploymentInventoryV1>,
622 declarations: &BTreeSet<CanisterRole>,
623 attached_roles: &BTreeSet<CanisterRole>,
624) -> Vec<AdoptionObservedCanisterFindingV1> {
625 let Some(inventory) = inventory else {
626 return Vec::new();
627 };
628
629 let mut findings = Vec::new();
630 for canister in &inventory.observed_canisters {
631 let role = canister.role.as_deref();
632 let declared =
633 role.is_some_and(|role| declarations.contains(&CanisterRole::owned(role.to_string())));
634 let attached = role
635 .is_some_and(|role| attached_roles.contains(&CanisterRole::owned(role.to_string())));
636 let mut classifications = BTreeSet::new();
637 if role.is_none() || !declared {
638 classifications.insert(AdoptionClassificationV1::ObservedOnly);
639 }
640 if matches!(
641 authority_state_for_control_class(canister.control_class),
642 AdoptionAuthorityStateV1::UserControlled
643 ) {
644 classifications.insert(AdoptionClassificationV1::UserControlled);
645 }
646 if matches!(
647 authority_state_for_control_class(canister.control_class),
648 AdoptionAuthorityStateV1::UserControlled | AdoptionAuthorityStateV1::External
649 ) {
650 classifications.insert(AdoptionClassificationV1::ExternalControllerRequired);
651 }
652
653 findings.push(AdoptionObservedCanisterFindingV1 {
654 canister_id: canister.canister_id.clone(),
655 matched_fleet: role.map(|_| fleet.to_string()),
656 matched_role: role.map(str::to_string),
657 confidence: match (role, declared, attached) {
658 (Some(_), true, true) => AdoptionMatchConfidenceV1::ExplicitEvidence,
659 (Some(_), _, _) => AdoptionMatchConfidenceV1::Candidate,
660 (None, _, _) => AdoptionMatchConfidenceV1::None,
661 },
662 classifications: classifications.into_iter().collect(),
663 controllers: canister.controllers.clone(),
664 wasm_evidence: canister
665 .module_hash
666 .as_ref()
667 .map(|hash| format!("module_hash={hash}")),
668 deployment_target_evidence: Some(inventory.inventory_id.clone()),
669 recommendations: match (role, declared) {
670 (Some(role), false) => observed_only_recommendations(
671 profile,
672 fleet,
673 role,
674 authority_state_for_control_class(canister.control_class),
675 ),
676 _ => Vec::new(),
677 },
678 warnings: role
679 .map(|role| observed_only_warnings(profile, role))
680 .unwrap_or_default(),
681 });
682 }
683
684 for pool in &inventory.observed_pool {
685 findings.push(AdoptionObservedCanisterFindingV1 {
686 canister_id: pool.canister_id.clone(),
687 matched_fleet: pool.role.as_ref().map(|_| fleet.to_string()),
688 matched_role: pool.role.clone(),
689 confidence: AdoptionMatchConfidenceV1::Candidate,
690 classifications: vec![AdoptionClassificationV1::ImportedPoolCandidate],
691 controllers: Vec::new(),
692 wasm_evidence: None,
693 deployment_target_evidence: Some(format!("pool={}", pool.pool)),
694 recommendations: Vec::new(),
695 warnings: vec!["pool import is outside 0.50.0".to_string()],
696 });
697 }
698
699 findings.sort_by(|left, right| left.canister_id.cmp(&right.canister_id));
700 findings
701}
702
703fn observed_only_recommendations(
704 profile: AdoptionProfileV1,
705 fleet: &str,
706 role: &str,
707 authority_state: AdoptionAuthorityStateV1,
708) -> Vec<AdoptionRecommendationV1> {
709 if profile == AdoptionProfileV1::LeafOnly && is_leaf_only_authority_sensitive_role(role) {
710 return Vec::new();
711 }
712
713 if authority_state != AdoptionAuthorityStateV1::CanicAuthorized {
714 return vec![review_authority_before_declaration_recommendation(
715 fleet,
716 role,
717 authority_state,
718 )];
719 }
720
721 vec![declare_role_recommendation(fleet, role)]
722}
723
724fn observed_only_warnings(profile: AdoptionProfileV1, role: &str) -> Vec<String> {
725 if profile == AdoptionProfileV1::LeafOnly && is_leaf_only_authority_sensitive_role(role) {
726 return vec![
727 "leaf-only profile leaves authority-sensitive observed roles external".to_string(),
728 ];
729 }
730
731 Vec::new()
732}
733
734fn is_leaf_only_authority_sensitive_role(role: &str) -> bool {
735 matches!(role, "root" | "governance" | "governance_root")
736}
737
738fn package_state(
739 package: &str,
740 fleet: &str,
741 role: &str,
742 packages_by_path: &BTreeMap<String, AdoptionPackageMetadataV1>,
743) -> AdoptionPackageStateV1 {
744 let Some(metadata) = packages_by_path.get(package) else {
745 return AdoptionPackageStateV1::NotChecked;
746 };
747 if metadata.fleet.is_none() {
748 return AdoptionPackageStateV1::MissingFleet;
749 }
750 if metadata.role.is_none() {
751 return AdoptionPackageStateV1::MissingRole;
752 }
753 if metadata.fleet.as_deref() == Some(fleet) && metadata.role.as_deref() == Some(role) {
754 AdoptionPackageStateV1::Matches
755 } else {
756 AdoptionPackageStateV1::Mismatch
757 }
758}
759
760fn observed_canisters_by_role(
761 inventory: Option<&DeploymentInventoryV1>,
762) -> BTreeMap<String, Vec<&crate::deployment_truth::ObservedCanisterV1>> {
763 let mut observed = BTreeMap::<String, Vec<&crate::deployment_truth::ObservedCanisterV1>>::new();
764 let Some(inventory) = inventory else {
765 return observed;
766 };
767
768 for canister in &inventory.observed_canisters {
769 if let Some(role) = &canister.role {
770 observed.entry(role.clone()).or_default().push(canister);
771 }
772 }
773 observed
774}
775
776fn duplicate_observed_roles(
777 observed_by_role: &BTreeMap<String, Vec<&crate::deployment_truth::ObservedCanisterV1>>,
778) -> BTreeSet<String> {
779 observed_by_role
780 .iter()
781 .filter(|(_, canisters)| canisters.len() > 1)
782 .map(|(role, _)| role.clone())
783 .collect()
784}
785
786fn package_metadata_by_path(
787 metadata: Vec<AdoptionPackageMetadataV1>,
788) -> BTreeMap<String, AdoptionPackageMetadataV1> {
789 metadata
790 .into_iter()
791 .map(|metadata| (metadata.package.clone(), metadata))
792 .collect()
793}
794
795fn artifact_states_by_role(
796 manifest: Option<&RoleArtifactManifestV1>,
797 inventory: Option<&DeploymentInventoryV1>,
798) -> BTreeMap<String, AdoptionArtifactStateV1> {
799 let mut states = BTreeMap::new();
800
801 if let Some(manifest) = manifest {
802 for artifact in &manifest.role_artifacts {
803 states.insert(
804 artifact.role.clone(),
805 artifact_state_for_source(artifact.source),
806 );
807 }
808 }
809
810 if let Some(inventory) = inventory {
811 for artifact in &inventory.observed_artifacts {
812 states
813 .entry(artifact.role.clone())
814 .or_insert_with(|| artifact_state_for_source(artifact.source));
815 }
816 }
817
818 states
819}
820
821fn artifact_conflict_roles(
822 manifest: Option<&RoleArtifactManifestV1>,
823 inventory: Option<&DeploymentInventoryV1>,
824) -> BTreeSet<String> {
825 let mut manifest_states = BTreeMap::new();
826 let mut conflict_roles = BTreeSet::new();
827
828 if let Some(manifest) = manifest {
829 for artifact in &manifest.role_artifacts {
830 let state = artifact_state_for_source(artifact.source);
831 if manifest_states
832 .insert(artifact.role.clone(), state)
833 .is_some_and(|previous| previous != state)
834 {
835 conflict_roles.insert(artifact.role.clone());
836 }
837 }
838 }
839
840 if let Some(inventory) = inventory {
841 for artifact in &inventory.observed_artifacts {
842 let state = artifact_state_for_source(artifact.source);
843 if manifest_states
844 .get(&artifact.role)
845 .is_some_and(|previous| *previous != state)
846 {
847 conflict_roles.insert(artifact.role.clone());
848 }
849 }
850 }
851
852 conflict_roles
853}
854
855fn artifact_evidence_by_role(
856 manifest: Option<&RoleArtifactManifestV1>,
857 inventory: Option<&DeploymentInventoryV1>,
858) -> BTreeMap<String, Vec<String>> {
859 let mut evidence = BTreeMap::<String, Vec<String>>::new();
860
861 if let Some(manifest) = manifest {
862 for artifact in &manifest.role_artifacts {
863 let role_evidence = evidence.entry(artifact.role.clone()).or_default();
864 role_evidence.push(format!(
865 "artifact manifest source={}",
866 artifact_source_label(artifact.source)
867 ));
868 if let Some(hash) = &artifact.installed_module_hash {
869 role_evidence.push(format!("artifact manifest installed_module_hash={hash}"));
870 }
871 if let Some(hash) = &artifact.wasm_sha256 {
872 role_evidence.push(format!("artifact manifest wasm_sha256={hash}"));
873 }
874 if let Some(hash) = &artifact.wasm_gz_sha256 {
875 role_evidence.push(format!("artifact manifest wasm_gz_sha256={hash}"));
876 }
877 }
878 }
879
880 if let Some(inventory) = inventory {
881 for artifact in &inventory.observed_artifacts {
882 let role_evidence = evidence.entry(artifact.role.clone()).or_default();
883 role_evidence.push(format!(
884 "observed artifact source={} path={}",
885 artifact_source_label(artifact.source),
886 artifact.artifact_path
887 ));
888 if let Some(hash) = &artifact.file_sha256 {
889 role_evidence.push(format!("observed artifact file_sha256={hash}"));
890 }
891 if let Some(hash) = &artifact.payload_sha256 {
892 role_evidence.push(format!("observed artifact payload_sha256={hash}"));
893 }
894 if let Some(size) = artifact.payload_size_bytes {
895 role_evidence.push(format!("observed artifact payload_size_bytes={size}"));
896 }
897 }
898 }
899
900 evidence
901}
902
903const fn artifact_state_for_source(source: ArtifactSourceV1) -> AdoptionArtifactStateV1 {
904 match source {
905 ArtifactSourceV1::External | ArtifactSourceV1::Unknown => {
906 AdoptionArtifactStateV1::ExternalWasm
907 }
908 ArtifactSourceV1::LocalBuild
909 | ArtifactSourceV1::ReleaseSet
910 | ArtifactSourceV1::WasmStore => AdoptionArtifactStateV1::CanicBuilt,
911 }
912}
913
914const fn artifact_source_label(source: ArtifactSourceV1) -> &'static str {
915 match source {
916 ArtifactSourceV1::LocalBuild => "local-build",
917 ArtifactSourceV1::ReleaseSet => "release-set",
918 ArtifactSourceV1::WasmStore => "wasm-store",
919 ArtifactSourceV1::External => "external",
920 ArtifactSourceV1::Unknown => "unknown",
921 }
922}
923
924fn combined_authority_state(
925 observed: &[&crate::deployment_truth::ObservedCanisterV1],
926) -> AdoptionAuthorityStateV1 {
927 let mut states = observed
928 .iter()
929 .map(|canister| authority_state_for_control_class(canister.control_class))
930 .collect::<BTreeSet<_>>();
931 if states.is_empty() {
932 return AdoptionAuthorityStateV1::Unknown;
933 }
934 if states.remove(&AdoptionAuthorityStateV1::UserControlled) {
935 return AdoptionAuthorityStateV1::UserControlled;
936 }
937 if states.remove(&AdoptionAuthorityStateV1::External) {
938 return AdoptionAuthorityStateV1::External;
939 }
940 if states.remove(&AdoptionAuthorityStateV1::Unknown) {
941 return AdoptionAuthorityStateV1::Unknown;
942 }
943 AdoptionAuthorityStateV1::CanicAuthorized
944}
945
946const fn authority_state_for_control_class(
947 control_class: CanisterControlClassV1,
948) -> AdoptionAuthorityStateV1 {
949 match control_class {
950 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::CanicManagedPool => {
951 AdoptionAuthorityStateV1::CanicAuthorized
952 }
953 CanisterControlClassV1::UserControlled => AdoptionAuthorityStateV1::UserControlled,
954 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
955 AdoptionAuthorityStateV1::External
956 }
957 CanisterControlClassV1::UnknownUnsafe => AdoptionAuthorityStateV1::Unknown,
958 }
959}
960
961const fn observation_state(observed: bool, conflict: bool) -> AdoptionObservationStateV1 {
962 match (observed, conflict) {
963 (_, true) => AdoptionObservationStateV1::ConflictingMatch,
964 (true, false) => AdoptionObservationStateV1::Observed,
965 (false, false) => AdoptionObservationStateV1::Unobserved,
966 }
967}
968
969fn artifact_state_from_observed(
970 observed: &[&crate::deployment_truth::ObservedCanisterV1],
971) -> AdoptionArtifactStateV1 {
972 if observed
973 .iter()
974 .any(|canister| canister.module_hash.is_some())
975 {
976 AdoptionArtifactStateV1::ExternalWasm
977 } else {
978 AdoptionArtifactStateV1::Unknown
979 }
980}
981
982fn missing_evidence(
983 inventory: Option<&DeploymentInventoryV1>,
984 artifact_manifest: Option<&RoleArtifactManifestV1>,
985) -> Vec<String> {
986 let mut evidence = Vec::new();
987
988 if let Some(inventory) = inventory {
989 evidence.extend(inventory.unresolved_observations.iter().map(|gap| {
990 format!(
991 "unresolved inventory observation {}: {}",
992 gap.key, gap.description
993 )
994 }));
995 } else {
996 evidence.push("deployment inventory was not supplied".to_string());
997 }
998
999 if let Some(manifest) = artifact_manifest {
1000 evidence.extend(manifest.unresolved_artifacts.iter().map(|gap| {
1001 format!(
1002 "unresolved artifact evidence {}: {}",
1003 gap.key, gap.description
1004 )
1005 }));
1006 }
1007
1008 evidence
1009}
1010
1011fn report_summary(
1012 role_findings: &[AdoptionRoleFindingV1],
1013 observed_findings: &[AdoptionObservedCanisterFindingV1],
1014) -> AdoptionReportSummaryV1 {
1015 AdoptionReportSummaryV1 {
1016 managed_configured_roles: role_findings
1017 .iter()
1018 .filter(|finding| {
1019 finding
1020 .classifications
1021 .contains(&AdoptionClassificationV1::Managed)
1022 })
1023 .count(),
1024 declared_only_roles: role_findings
1025 .iter()
1026 .filter(|finding| {
1027 finding
1028 .classifications
1029 .contains(&AdoptionClassificationV1::DeclaredOnly)
1030 })
1031 .count(),
1032 attached_unobserved_roles: role_findings
1033 .iter()
1034 .filter(|finding| {
1035 finding
1036 .classifications
1037 .contains(&AdoptionClassificationV1::AttachedUnobserved)
1038 })
1039 .count(),
1040 observed_only_canisters: observed_findings
1041 .iter()
1042 .filter(|finding| {
1043 finding
1044 .classifications
1045 .contains(&AdoptionClassificationV1::ObservedOnly)
1046 })
1047 .count(),
1048 user_controlled_canisters: observed_findings
1049 .iter()
1050 .filter(|finding| {
1051 finding
1052 .classifications
1053 .contains(&AdoptionClassificationV1::UserControlled)
1054 })
1055 .count(),
1056 external_controller_required: role_findings
1057 .iter()
1058 .filter(|finding| {
1059 finding
1060 .classifications
1061 .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1062 })
1063 .count(),
1064 evidence_conflicts: role_findings
1065 .iter()
1066 .filter(|finding| {
1067 finding
1068 .classifications
1069 .contains(&AdoptionClassificationV1::EvidenceConflict)
1070 })
1071 .count(),
1072 mutating_actions_performed: 0,
1073 }
1074}
1075
1076fn declare_role_recommendation(fleet: &str, role: &str) -> AdoptionRecommendationV1 {
1077 AdoptionRecommendationV1 {
1078 kind: "declare_role".to_string(),
1079 severity: AdoptionRecommendationSeverityV1::Info,
1080 description: format!("declare observed role candidate {fleet}.{role} before attachment"),
1081 suggested_action: Some(format!(
1082 "canic fleet role declare {fleet} {role} --package <path>"
1083 )),
1084 suggested_action_effect: AdoptionSuggestedActionEffectV1::MutatesState,
1085 suggested_action_support: AdoptionSuggestedActionSupportV1::UnsupportedByAdoption,
1086 suggested_action_availability: AdoptionSuggestedActionAvailabilityV1::BlockedIn0500,
1087 operator_action_requirement: AdoptionOperatorActionRequirementV1::Required,
1088 }
1089}
1090
1091fn review_authority_before_declaration_recommendation(
1092 fleet: &str,
1093 role: &str,
1094 authority_state: AdoptionAuthorityStateV1,
1095) -> AdoptionRecommendationV1 {
1096 AdoptionRecommendationV1 {
1097 kind: "review_authority_before_declaration".to_string(),
1098 severity: AdoptionRecommendationSeverityV1::Warning,
1099 description: format!(
1100 "review {fleet}.{role} authority before declaring observed role candidate ({})",
1101 adoption_authority_state_label(authority_state)
1102 ),
1103 suggested_action: None,
1104 suggested_action_effect: AdoptionSuggestedActionEffectV1::ReadOnly,
1105 suggested_action_support: AdoptionSuggestedActionSupportV1::SupportedByAdoption,
1106 suggested_action_availability: AdoptionSuggestedActionAvailabilityV1::AllowedIn0500,
1107 operator_action_requirement: AdoptionOperatorActionRequirementV1::Required,
1108 }
1109}
1110
1111const fn adoption_authority_state_label(authority_state: AdoptionAuthorityStateV1) -> &'static str {
1112 match authority_state {
1113 AdoptionAuthorityStateV1::CanicAuthorized => "canic-authorized",
1114 AdoptionAuthorityStateV1::UserControlled => "user-controlled",
1115 AdoptionAuthorityStateV1::External => "external",
1116 AdoptionAuthorityStateV1::Unknown => "unknown",
1117 }
1118}
1119
1120fn attach_later_recommendation(fleet: &str, role: &str) -> AdoptionRecommendationV1 {
1121 AdoptionRecommendationV1 {
1122 kind: "attach_role_later".to_string(),
1123 severity: AdoptionRecommendationSeverityV1::Info,
1124 description: format!("attach {fleet}.{role} explicitly only when topology is ready"),
1125 suggested_action: Some(format!(
1126 "canic fleet role attach {fleet} {role} --subnet <subnet>"
1127 )),
1128 suggested_action_effect: AdoptionSuggestedActionEffectV1::MutatesState,
1129 suggested_action_support: AdoptionSuggestedActionSupportV1::UnsupportedByAdoption,
1130 suggested_action_availability: AdoptionSuggestedActionAvailabilityV1::BlockedIn0500,
1131 operator_action_requirement: AdoptionOperatorActionRequirementV1::Required,
1132 }
1133}
1134
1135fn blocked_actions() -> Vec<String> {
1136 [
1137 "controller changes",
1138 "topology attachment",
1139 "pool import",
1140 "install",
1141 "upgrade",
1142 "reinstall",
1143 "stop",
1144 "start",
1145 "delete",
1146 "deploy",
1147 "promote",
1148 "rollback",
1149 "artifact registry import",
1150 ]
1151 .into_iter()
1152 .map(str::to_string)
1153 .collect()
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
1159 use crate::deployment_truth::{
1160 ArtifactDigestSourceV1, DeploymentInventoryV1, DeploymentObservationGapV1,
1161 DeploymentRootObservationSourceV1, DeploymentRootObservationV1, LocalDeploymentConfigV1,
1162 ObservationStatusV1, ObservedArtifactV1, ObservedCanisterV1, ObservedPoolCanisterV1,
1163 RoleArtifactManifestV1, RoleArtifactV1, VerifierReadinessObservationV1,
1164 };
1165
1166 const CONFIG: &str = r#"
1167controllers = []
1168app_index = []
1169
1170[fleet]
1171name = "demo"
1172
1173[roles.root]
1174kind = "root"
1175package = "root"
1176
1177[roles.api]
1178kind = "canister"
1179package = "api"
1180
1181[roles.store]
1182kind = "canister"
1183package = "store"
1184
1185[subnets.prime.canisters.root]
1186kind = "root"
1187
1188[subnets.prime.canisters.api]
1189kind = "singleton"
1190"#;
1191
1192 const BROWNFIELD_CONFIG: &str = r#"
1193controllers = []
1194app_index = []
1195
1196[fleet]
1197name = "demo"
1198
1199[roles.root]
1200kind = "root"
1201package = "root"
1202
1203[subnets.prime.canisters.root]
1204kind = "root"
1205"#;
1206
1207 const STANDALONE_CONFIG: &str = r#"
1208controllers = []
1209app_index = []
1210
1211[fleet]
1212name = "demo"
1213
1214[roles.worker]
1215kind = "canister"
1216package = "worker"
1217"#;
1218
1219 const LEAF_ONLY_CONFIG: &str = r#"
1220controllers = []
1221app_index = []
1222
1223[fleet]
1224name = "demo"
1225
1226[roles.root]
1227kind = "root"
1228package = "root"
1229
1230[roles.app]
1231kind = "canister"
1232package = "app"
1233
1234[subnets.prime.canisters.app]
1235kind = "singleton"
1236
1237[subnets.prime.canisters.root]
1238kind = "root"
1239"#;
1240
1241 #[test]
1242 fn adoption_report_preserves_declared_only_as_non_deployable() {
1243 let report = report(CONFIG, None, Vec::new());
1244 let store = role(&report, "store");
1245
1246 assert_eq!(
1247 store.declaration_state,
1248 AdoptionDeclarationStateV1::Declared
1249 );
1250 assert_eq!(store.topology_state, AdoptionTopologyStateV1::Unattached);
1251 assert!(
1252 store
1253 .classifications
1254 .contains(&AdoptionClassificationV1::DeclaredOnly)
1255 );
1256 assert_eq!(report.summary.declared_only_roles, 1);
1257 assert_eq!(report.summary.mutating_actions_performed, 0);
1258 assert!(store.recommendations.iter().all(|recommendation| {
1259 recommendation.suggested_action_availability
1260 == AdoptionSuggestedActionAvailabilityV1::BlockedIn0500
1261 }));
1262 assert!(report.blocked_actions.contains(&"install".to_string()));
1263 }
1264
1265 #[test]
1266 fn brownfield_fixture_reports_observed_roles_without_claiming_them() {
1267 let inventory = inventory(vec![
1268 observed_canister(
1269 "aaaaa-aa",
1270 Some("root"),
1271 CanisterControlClassV1::DeploymentControlled,
1272 None,
1273 ),
1274 observed_canister(
1275 "bbbbb-bb",
1276 Some("api"),
1277 CanisterControlClassV1::UserControlled,
1278 Some("api-hash"),
1279 ),
1280 observed_canister(
1281 "ccccc-cc",
1282 Some("store"),
1283 CanisterControlClassV1::ExternallyImported,
1284 Some("store-hash"),
1285 ),
1286 observed_canister(
1287 "ddddd-dd",
1288 None,
1289 CanisterControlClassV1::UnknownUnsafe,
1290 Some("unknown-hash"),
1291 ),
1292 ]);
1293 let report = report_with_profile(
1294 AdoptionProfileV1::Brownfield,
1295 BROWNFIELD_CONFIG,
1296 Some(&inventory),
1297 Vec::new(),
1298 );
1299 let api = role(&report, "api");
1300 let store = role(&report, "store");
1301
1302 assert_eq!(report.profile, AdoptionProfileV1::Brownfield);
1303 assert_eq!(report.summary.managed_configured_roles, 1);
1304 assert_eq!(report.summary.observed_only_canisters, 3);
1305 assert_eq!(report.summary.user_controlled_canisters, 1);
1306 assert_eq!(report.summary.external_controller_required, 2);
1307 assert_eq!(report.summary.mutating_actions_performed, 0);
1308 assert!(
1309 api.classifications
1310 .contains(&AdoptionClassificationV1::ObservedOnly)
1311 );
1312 assert_eq!(
1313 api.authority_state,
1314 AdoptionAuthorityStateV1::UserControlled
1315 );
1316 assert!(
1317 store
1318 .classifications
1319 .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1320 );
1321 assert_eq!(store.artifact_state, AdoptionArtifactStateV1::ExternalWasm);
1322 assert!(
1323 report
1324 .recommendations
1325 .iter()
1326 .filter(|recommendation| recommendation.kind == "declare_role")
1327 .all(|recommendation| recommendation.suggested_action_support
1328 == AdoptionSuggestedActionSupportV1::UnsupportedByAdoption
1329 && recommendation.suggested_action_availability
1330 == AdoptionSuggestedActionAvailabilityV1::BlockedIn0500)
1331 );
1332 }
1333
1334 #[test]
1335 fn partial_fixture_preserves_managed_and_external_roles_separately() {
1336 let inventory = inventory(vec![
1337 observed_canister(
1338 "aaaaa-aa",
1339 Some("root"),
1340 CanisterControlClassV1::DeploymentControlled,
1341 None,
1342 ),
1343 observed_canister(
1344 "bbbbb-bb",
1345 Some("api"),
1346 CanisterControlClassV1::DeploymentControlled,
1347 Some("api-hash"),
1348 ),
1349 observed_canister(
1350 "ccccc-cc",
1351 Some("external_app"),
1352 CanisterControlClassV1::UserControlled,
1353 Some("external_app-hash"),
1354 ),
1355 ]);
1356 let report = report_with_profile(
1357 AdoptionProfileV1::Partial,
1358 CONFIG,
1359 Some(&inventory),
1360 Vec::new(),
1361 );
1362 let api = role(&report, "api");
1363 let store = role(&report, "store");
1364 let external_app = role(&report, "external_app");
1365
1366 assert_eq!(report.profile, AdoptionProfileV1::Partial);
1367 assert_eq!(report.summary.managed_configured_roles, 2);
1368 assert_eq!(report.summary.declared_only_roles, 1);
1369 assert_eq!(report.summary.attached_unobserved_roles, 0);
1370 assert_eq!(report.summary.observed_only_canisters, 1);
1371 assert_eq!(api.observation_state, AdoptionObservationStateV1::Observed);
1372 assert!(
1373 api.classifications
1374 .contains(&AdoptionClassificationV1::Managed)
1375 );
1376 assert!(
1377 store
1378 .classifications
1379 .contains(&AdoptionClassificationV1::DeclaredOnly)
1380 );
1381 assert_eq!(
1382 external_app.authority_state,
1383 AdoptionAuthorityStateV1::UserControlled
1384 );
1385 }
1386
1387 #[test]
1388 fn external_controller_fixture_reports_external_action_boundary() {
1389 let inventory = inventory(vec![observed_canister(
1390 "bbbbb-bb",
1391 Some("api"),
1392 CanisterControlClassV1::JointlyControlled,
1393 Some("api-hash"),
1394 )]);
1395 let report = report(CONFIG, Some(&inventory), Vec::new());
1396 let api = role(&report, "api");
1397
1398 assert!(
1399 api.classifications
1400 .contains(&AdoptionClassificationV1::Managed)
1401 );
1402 assert!(
1403 api.classifications
1404 .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1405 );
1406 assert_eq!(api.authority_state, AdoptionAuthorityStateV1::External);
1407 assert_eq!(report.summary.external_controller_required, 1);
1408 assert!(
1409 report
1410 .blocked_actions
1411 .contains(&"controller changes".to_string())
1412 );
1413 }
1414
1415 #[test]
1416 fn observed_only_fixture_without_role_stays_unmatched() {
1417 let inventory = inventory(vec![observed_canister(
1418 "zzzzz-zz",
1419 None,
1420 CanisterControlClassV1::UnknownUnsafe,
1421 Some("unknown-hash"),
1422 )]);
1423 let report = report(BROWNFIELD_CONFIG, Some(&inventory), Vec::new());
1424 let observed = report
1425 .observed_canisters
1426 .iter()
1427 .find(|finding| finding.canister_id == "zzzzz-zz")
1428 .expect("observed-only canister finding");
1429
1430 assert_eq!(report.summary.observed_only_canisters, 1);
1431 assert_eq!(observed.matched_role, None);
1432 assert_eq!(observed.confidence, AdoptionMatchConfidenceV1::None);
1433 assert!(
1434 observed
1435 .classifications
1436 .contains(&AdoptionClassificationV1::ObservedOnly)
1437 );
1438 assert!(
1439 observed.recommendations.is_empty(),
1440 "name-free observations must not invent declaration actions"
1441 );
1442 }
1443
1444 #[test]
1445 fn declared_only_fixture_reports_compile_only_role() {
1446 let report = report_with_profile(
1447 AdoptionProfileV1::Partial,
1448 CONFIG,
1449 None,
1450 matching_metadata(),
1451 );
1452 let store = role(&report, "store");
1453
1454 assert_eq!(store.package_state, AdoptionPackageStateV1::Matches);
1455 assert_eq!(store.topology_state, AdoptionTopologyStateV1::Unattached);
1456 assert!(
1457 store
1458 .classifications
1459 .contains(&AdoptionClassificationV1::DeclaredOnly)
1460 );
1461 assert!(
1462 store
1463 .recommendations
1464 .iter()
1465 .any(|recommendation| recommendation.kind == "attach_role_later"
1466 && recommendation.suggested_action_effect
1467 == AdoptionSuggestedActionEffectV1::MutatesState
1468 && recommendation.suggested_action_support
1469 == AdoptionSuggestedActionSupportV1::UnsupportedByAdoption)
1470 );
1471 }
1472
1473 #[test]
1474 fn standalone_fixture_keeps_compile_only_role_unattached() {
1475 let report = report_with_profile(
1476 AdoptionProfileV1::Standalone,
1477 STANDALONE_CONFIG,
1478 None,
1479 vec![AdoptionPackageMetadataV1 {
1480 package: "worker".to_string(),
1481 fleet: Some("demo".to_string()),
1482 role: Some("worker".to_string()),
1483 }],
1484 );
1485 let worker = role(&report, "worker");
1486
1487 assert_eq!(report.profile, AdoptionProfileV1::Standalone);
1488 assert_eq!(report.summary.managed_configured_roles, 0);
1489 assert_eq!(report.summary.declared_only_roles, 1);
1490 assert_eq!(report.summary.attached_unobserved_roles, 0);
1491 assert_eq!(worker.package_state, AdoptionPackageStateV1::Matches);
1492 assert_eq!(worker.topology_state, AdoptionTopologyStateV1::Unattached);
1493 assert_eq!(
1494 worker.observation_state,
1495 AdoptionObservationStateV1::Unobserved
1496 );
1497 assert!(
1498 worker
1499 .classifications
1500 .contains(&AdoptionClassificationV1::DeclaredOnly)
1501 );
1502 assert!(
1503 worker
1504 .evidence
1505 .iter()
1506 .any(|evidence| evidence == "no topology attachment exists")
1507 );
1508 assert!(
1509 report
1510 .blocked_actions
1511 .contains(&"topology attachment".to_string())
1512 );
1513 }
1514
1515 #[test]
1516 fn leaf_only_fixture_does_not_recommend_authority_hub_adoption() {
1517 let inventory = inventory(vec![
1518 observed_canister(
1519 "aaaaa-aa",
1520 Some("governance"),
1521 CanisterControlClassV1::UserControlled,
1522 Some("governance-hash"),
1523 ),
1524 observed_canister(
1525 "bbbbb-bb",
1526 Some("app"),
1527 CanisterControlClassV1::DeploymentControlled,
1528 Some("app-hash"),
1529 ),
1530 ]);
1531 let report = report_with_profile(
1532 AdoptionProfileV1::LeafOnly,
1533 LEAF_ONLY_CONFIG,
1534 Some(&inventory),
1535 Vec::new(),
1536 );
1537 let app = role(&report, "app");
1538 let governance = role(&report, "governance");
1539 let governance_observation = report
1540 .observed_canisters
1541 .iter()
1542 .find(|finding| finding.matched_role.as_deref() == Some("governance"))
1543 .expect("governance observation");
1544
1545 assert_eq!(report.profile, AdoptionProfileV1::LeafOnly);
1546 assert!(
1547 app.classifications
1548 .contains(&AdoptionClassificationV1::Managed)
1549 );
1550 assert_eq!(app.observation_state, AdoptionObservationStateV1::Observed);
1551 assert!(
1552 governance
1553 .classifications
1554 .contains(&AdoptionClassificationV1::ObservedOnly)
1555 );
1556 assert!(
1557 governance
1558 .classifications
1559 .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1560 );
1561 assert!(governance.recommendations.is_empty());
1562 assert!(
1563 governance
1564 .warnings
1565 .iter()
1566 .any(|warning| warning.contains("leaf-only profile"))
1567 );
1568 assert!(governance_observation.recommendations.is_empty());
1569 assert!(
1570 governance_observation
1571 .warnings
1572 .iter()
1573 .any(|warning| warning.contains("leaf-only profile"))
1574 );
1575 assert!(
1576 !report
1577 .recommendations
1578 .iter()
1579 .any(|recommendation| recommendation.suggested_action.as_deref()
1580 == Some("canic fleet role declare demo governance --package <path>"))
1581 );
1582 }
1583
1584 #[test]
1585 fn adoption_report_reports_attached_unobserved_without_teardown_inference() {
1586 let report = report(CONFIG, None, Vec::new());
1587 let api = role(&report, "api");
1588
1589 assert!(
1590 api.classifications
1591 .contains(&AdoptionClassificationV1::Managed)
1592 );
1593 assert!(
1594 api.classifications
1595 .contains(&AdoptionClassificationV1::AttachedUnobserved)
1596 );
1597 assert_eq!(
1598 api.observation_state,
1599 AdoptionObservationStateV1::Unobserved
1600 );
1601 assert!(
1602 api.warnings
1603 .iter()
1604 .any(|warning| warning.contains("does not confirm"))
1605 );
1606 }
1607
1608 #[test]
1609 fn adoption_report_preserves_unresolved_evidence_gaps() {
1610 let mut inventory = inventory(Vec::new());
1611 inventory
1612 .unresolved_observations
1613 .push(DeploymentObservationGapV1 {
1614 key: "canister-status:api".to_string(),
1615 description: "status query failed".to_string(),
1616 });
1617 let mut manifest = external_api_artifact_manifest();
1618 manifest
1619 .unresolved_artifacts
1620 .push(DeploymentObservationGapV1 {
1621 key: "artifact:api".to_string(),
1622 description: "artifact file missing".to_string(),
1623 });
1624
1625 let report = adoption_report_from_config_source(AdoptionReportRequest {
1626 report_id: "adoption-1",
1627 generated_at: "2026-05-30T00:00:00Z",
1628 profile: AdoptionProfileV1::Partial,
1629 config_source: CONFIG,
1630 inventory: Some(&inventory),
1631 artifact_manifest: Some(&manifest),
1632 package_metadata: Vec::new(),
1633 })
1634 .expect("adoption report");
1635
1636 assert!(report.inputs.missing_or_stale_evidence.iter().any(|evidence| {
1637 evidence == "unresolved inventory observation canister-status:api: status query failed"
1638 }));
1639 assert!(
1640 report
1641 .inputs
1642 .missing_or_stale_evidence
1643 .iter()
1644 .any(|evidence| {
1645 evidence == "unresolved artifact evidence artifact:api: artifact file missing"
1646 })
1647 );
1648 }
1649
1650 #[test]
1651 fn adoption_report_classifies_observed_only_user_controlled_canister() {
1652 let inventory = inventory(vec![observed_canister(
1653 "aaaaa-aa",
1654 Some("external_app"),
1655 CanisterControlClassV1::UserControlled,
1656 Some("external_app-hash"),
1657 )]);
1658 let report = report(CONFIG, Some(&inventory), Vec::new());
1659 let external_app = role(&report, "external_app");
1660
1661 assert_eq!(
1662 external_app.declaration_state,
1663 AdoptionDeclarationStateV1::Undeclared
1664 );
1665 assert!(
1666 external_app
1667 .classifications
1668 .contains(&AdoptionClassificationV1::ObservedOnly)
1669 );
1670 assert!(
1671 external_app
1672 .classifications
1673 .contains(&AdoptionClassificationV1::UserControlled)
1674 );
1675 assert!(
1676 external_app
1677 .classifications
1678 .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1679 );
1680 assert_eq!(report.summary.observed_only_canisters, 1);
1681 assert_eq!(report.summary.user_controlled_canisters, 1);
1682 assert!(
1683 report
1684 .recommendations
1685 .iter()
1686 .any(
1687 |recommendation| recommendation.kind == "review_authority_before_declaration"
1688 && recommendation.suggested_action.is_none()
1689 && recommendation.suggested_action_effect
1690 == AdoptionSuggestedActionEffectV1::ReadOnly
1691 && recommendation.suggested_action_support
1692 == AdoptionSuggestedActionSupportV1::SupportedByAdoption
1693 )
1694 );
1695 assert!(
1696 report
1697 .recommendations
1698 .iter()
1699 .all(|recommendation| recommendation.kind != "declare_role")
1700 );
1701 }
1702
1703 #[test]
1704 fn adoption_report_recommends_declaration_only_for_canic_authorized_observed_role() {
1705 let inventory = inventory(vec![observed_canister(
1706 "aaaaa-aa",
1707 Some("candidate"),
1708 CanisterControlClassV1::DeploymentControlled,
1709 Some("candidate-hash"),
1710 )]);
1711 let report = report(BROWNFIELD_CONFIG, Some(&inventory), Vec::new());
1712 let candidate = role(&report, "candidate");
1713
1714 assert_eq!(
1715 candidate.authority_state,
1716 AdoptionAuthorityStateV1::CanicAuthorized
1717 );
1718 assert!(
1719 report
1720 .recommendations
1721 .iter()
1722 .any(|recommendation| recommendation.kind == "declare_role"
1723 && recommendation.suggested_action.as_deref()
1724 == Some("canic fleet role declare demo candidate --package <path>")
1725 && recommendation.suggested_action_effect
1726 == AdoptionSuggestedActionEffectV1::MutatesState
1727 && recommendation.suggested_action_support
1728 == AdoptionSuggestedActionSupportV1::UnsupportedByAdoption)
1729 );
1730 }
1731
1732 #[test]
1733 fn adoption_report_authority_gates_observed_only_declaration_recommendations() {
1734 for (
1735 control_class,
1736 expected_authority,
1737 expected_recommendation_kind,
1738 expected_suggested_action,
1739 ) in [
1740 (
1741 CanisterControlClassV1::DeploymentControlled,
1742 AdoptionAuthorityStateV1::CanicAuthorized,
1743 "declare_role",
1744 Some("canic fleet role declare demo candidate --package <path>"),
1745 ),
1746 (
1747 CanisterControlClassV1::UserControlled,
1748 AdoptionAuthorityStateV1::UserControlled,
1749 "review_authority_before_declaration",
1750 None,
1751 ),
1752 (
1753 CanisterControlClassV1::ExternallyImported,
1754 AdoptionAuthorityStateV1::External,
1755 "review_authority_before_declaration",
1756 None,
1757 ),
1758 (
1759 CanisterControlClassV1::UnknownUnsafe,
1760 AdoptionAuthorityStateV1::Unknown,
1761 "review_authority_before_declaration",
1762 None,
1763 ),
1764 ] {
1765 let inventory = inventory(vec![observed_canister(
1766 "aaaaa-aa",
1767 Some("candidate"),
1768 control_class,
1769 Some("candidate-hash"),
1770 )]);
1771 let report = report(BROWNFIELD_CONFIG, Some(&inventory), Vec::new());
1772 let candidate = role(&report, "candidate");
1773 let recommendation = candidate
1774 .recommendations
1775 .first()
1776 .expect("authority-gated recommendation");
1777
1778 assert_eq!(candidate.authority_state, expected_authority);
1779 assert_eq!(recommendation.kind, expected_recommendation_kind);
1780 assert_eq!(
1781 recommendation.suggested_action.as_deref(),
1782 expected_suggested_action
1783 );
1784 if expected_authority == AdoptionAuthorityStateV1::CanicAuthorized {
1785 assert_eq!(
1786 recommendation.suggested_action_availability,
1787 AdoptionSuggestedActionAvailabilityV1::BlockedIn0500
1788 );
1789 assert_eq!(
1790 recommendation.suggested_action_support,
1791 AdoptionSuggestedActionSupportV1::UnsupportedByAdoption
1792 );
1793 } else {
1794 assert!(
1795 candidate
1796 .recommendations
1797 .iter()
1798 .all(|recommendation| recommendation.kind != "declare_role")
1799 );
1800 }
1801 }
1802 }
1803
1804 #[test]
1805 fn adoption_report_keeps_managed_separate_from_authority() {
1806 let inventory = inventory(vec![observed_canister(
1807 "aaaaa-aa",
1808 Some("api"),
1809 CanisterControlClassV1::UserControlled,
1810 Some("api-hash"),
1811 )]);
1812 let report = report(CONFIG, Some(&inventory), Vec::new());
1813 let api = role(&report, "api");
1814
1815 assert!(
1816 api.classifications
1817 .contains(&AdoptionClassificationV1::Managed)
1818 );
1819 assert!(
1820 api.classifications
1821 .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1822 );
1823 assert_eq!(
1824 api.authority_state,
1825 AdoptionAuthorityStateV1::UserControlled
1826 );
1827 }
1828
1829 #[test]
1830 fn adoption_report_marks_role_only_package_metadata_as_conflict() {
1831 let report = report(
1832 CONFIG,
1833 None,
1834 vec![AdoptionPackageMetadataV1 {
1835 package: "store".to_string(),
1836 fleet: None,
1837 role: Some("store".to_string()),
1838 }],
1839 );
1840 let store = role(&report, "store");
1841
1842 assert_eq!(store.package_state, AdoptionPackageStateV1::MissingFleet);
1843 assert!(
1844 store
1845 .classifications
1846 .contains(&AdoptionClassificationV1::EvidenceConflict)
1847 );
1848 }
1849
1850 #[test]
1851 fn adoption_report_marks_duplicate_observed_role_as_evidence_conflict() {
1852 let inventory = inventory(vec![
1853 observed_canister(
1854 "aaaaa-aa",
1855 Some("api"),
1856 CanisterControlClassV1::DeploymentControlled,
1857 Some("api-hash-a"),
1858 ),
1859 observed_canister(
1860 "bbbbb-bb",
1861 Some("api"),
1862 CanisterControlClassV1::DeploymentControlled,
1863 Some("api-hash-b"),
1864 ),
1865 ]);
1866 let report = report(CONFIG, Some(&inventory), Vec::new());
1867 let api = role(&report, "api");
1868
1869 assert_eq!(
1870 api.observation_state,
1871 AdoptionObservationStateV1::ConflictingMatch
1872 );
1873 assert!(
1874 api.classifications
1875 .contains(&AdoptionClassificationV1::EvidenceConflict)
1876 );
1877 assert_eq!(report.summary.evidence_conflicts, 1);
1878 }
1879
1880 #[test]
1881 fn adoption_report_marks_reverse_conflicting_artifact_evidence() {
1882 let mut inventory = inventory(Vec::new());
1883 inventory
1884 .observed_artifacts
1885 .push(observed_external_api_artifact());
1886 let manifest = RoleArtifactManifestV1 {
1887 schema_version: 1,
1888 manifest_id: "local-manifest-1".to_string(),
1889 network: "local".to_string(),
1890 artifact_root: None,
1891 role_artifacts: vec![RoleArtifactV1 {
1892 source: ArtifactSourceV1::LocalBuild,
1893 build_profile: "fast".to_string(),
1894 ..external_api_role_artifact()
1895 }],
1896 unresolved_artifacts: Vec::new(),
1897 };
1898
1899 let report = adoption_report_from_config_source(AdoptionReportRequest {
1900 report_id: "artifact-conflict-2",
1901 generated_at: "2026-05-30T00:00:00Z",
1902 profile: AdoptionProfileV1::HybridExternalWasm,
1903 config_source: CONFIG,
1904 inventory: Some(&inventory),
1905 artifact_manifest: Some(&manifest),
1906 package_metadata: Vec::new(),
1907 })
1908 .expect("adoption report");
1909 let api = role(&report, "api");
1910
1911 assert!(
1912 api.classifications
1913 .contains(&AdoptionClassificationV1::EvidenceConflict)
1914 );
1915 assert!(
1916 api.warnings
1917 .iter()
1918 .any(|warning| warning == "artifact evidence contains conflicting role facts")
1919 );
1920 assert_eq!(report.summary.evidence_conflicts, 1);
1921 }
1922
1923 #[test]
1924 fn adoption_report_marks_conflicting_artifact_evidence() {
1925 let mut inventory = inventory(Vec::new());
1926 inventory.observed_artifacts.push(ObservedArtifactV1 {
1927 role: "api".to_string(),
1928 artifact_path: "local/api.wasm.gz".to_string(),
1929 file_sha256: None,
1930 file_sha256_source: Some(ArtifactDigestSourceV1::ObservedFileDigest),
1931 payload_sha256: None,
1932 payload_size_bytes: None,
1933 source: ArtifactSourceV1::LocalBuild,
1934 });
1935 let manifest = external_api_artifact_manifest();
1936 let report = adoption_report_from_config_source(AdoptionReportRequest {
1937 report_id: "artifact-conflict-1",
1938 generated_at: "2026-05-30T00:00:00Z",
1939 profile: AdoptionProfileV1::HybridExternalWasm,
1940 config_source: CONFIG,
1941 inventory: Some(&inventory),
1942 artifact_manifest: Some(&manifest),
1943 package_metadata: Vec::new(),
1944 })
1945 .expect("adoption report");
1946 let api = role(&report, "api");
1947
1948 assert!(
1949 api.classifications
1950 .contains(&AdoptionClassificationV1::EvidenceConflict)
1951 );
1952 assert!(
1953 api.warnings
1954 .iter()
1955 .any(|warning| warning == "artifact evidence contains conflicting role facts")
1956 );
1957 assert_eq!(report.summary.evidence_conflicts, 1);
1958 }
1959
1960 #[test]
1961 fn adoption_report_classifies_pool_candidates_as_resources() {
1962 let mut inventory = inventory(Vec::new());
1963 inventory.observed_pool.push(ObservedPoolCanisterV1 {
1964 pool: "users".to_string(),
1965 canister_id: "ccccc-cc".to_string(),
1966 role: Some("user_shard".to_string()),
1967 control_class: CanisterControlClassV1::UnknownUnsafe,
1968 });
1969
1970 let report = report(CONFIG, Some(&inventory), Vec::new());
1971 let pool = report
1972 .observed_canisters
1973 .iter()
1974 .find(|finding| finding.canister_id == "ccccc-cc")
1975 .expect("pool candidate finding");
1976
1977 assert!(
1978 pool.classifications
1979 .contains(&AdoptionClassificationV1::ImportedPoolCandidate)
1980 );
1981 assert_eq!(pool.matched_role.as_deref(), Some("user_shard"));
1982 }
1983
1984 #[test]
1985 fn adoption_report_round_trips_through_json() {
1986 let manifest = RoleArtifactManifestV1 {
1987 schema_version: 1,
1988 manifest_id: "manifest-1".to_string(),
1989 network: "local".to_string(),
1990 artifact_root: None,
1991 role_artifacts: vec![RoleArtifactV1 {
1992 role: "api".to_string(),
1993 source: ArtifactSourceV1::LocalBuild,
1994 build_profile: "fast".to_string(),
1995 wasm_path: None,
1996 wasm_gz_path: None,
1997 wasm_gz_size_bytes: None,
1998 wasm_sha256: None,
1999 wasm_gz_sha256: None,
2000 wasm_gz_sha256_source: None,
2001 observed_wasm_gz_file_sha256: None,
2002 observed_wasm_gz_file_sha256_source: None,
2003 installed_module_hash: None,
2004 candid_path: None,
2005 candid_sha256: None,
2006 raw_config_sha256: None,
2007 canonical_embedded_config_sha256: None,
2008 embedded_topology_sha256: None,
2009 builder_version: None,
2010 rust_toolchain: None,
2011 package_version: None,
2012 }],
2013 unresolved_artifacts: Vec::new(),
2014 };
2015 let report = adoption_report_from_config_source(AdoptionReportRequest {
2016 report_id: "adoption-1",
2017 generated_at: "2026-05-30T00:00:00Z",
2018 profile: AdoptionProfileV1::Brownfield,
2019 config_source: CONFIG,
2020 inventory: None,
2021 artifact_manifest: Some(&manifest),
2022 package_metadata: Vec::new(),
2023 })
2024 .expect("adoption report");
2025
2026 let encoded = serde_json::to_string(&report).expect("encode report");
2027 let decoded = serde_json::from_str::<AdoptionReportV1>(&encoded).expect("decode report");
2028
2029 assert_eq!(decoded, report);
2030 assert_eq!(
2031 role(&decoded, "api").artifact_state,
2032 AdoptionArtifactStateV1::CanicBuilt
2033 );
2034 }
2035
2036 #[test]
2037 fn hybrid_external_wasm_fixture_reports_hashes_without_import() {
2038 let mut inventory = inventory(vec![observed_canister(
2039 "bbbbb-bb",
2040 Some("api"),
2041 CanisterControlClassV1::DeploymentControlled,
2042 Some("api-module-hash"),
2043 )]);
2044 inventory
2045 .observed_artifacts
2046 .push(observed_external_api_artifact());
2047 let manifest = external_api_artifact_manifest();
2048
2049 let report = adoption_report_from_config_source(AdoptionReportRequest {
2050 report_id: "hybrid-1",
2051 generated_at: "2026-05-30T00:00:00Z",
2052 profile: AdoptionProfileV1::HybridExternalWasm,
2053 config_source: CONFIG,
2054 inventory: Some(&inventory),
2055 artifact_manifest: Some(&manifest),
2056 package_metadata: Vec::new(),
2057 })
2058 .expect("adoption report");
2059 let api = role(&report, "api");
2060 let observed_api = report
2061 .observed_canisters
2062 .iter()
2063 .find(|finding| finding.matched_role.as_deref() == Some("api"))
2064 .expect("api observation");
2065
2066 assert_eq!(api.artifact_state, AdoptionArtifactStateV1::ExternalWasm);
2067 assert!(
2068 api.evidence
2069 .iter()
2070 .any(|evidence| evidence == "observed canister module_hash=api-module-hash")
2071 );
2072 assert!(
2073 api.evidence
2074 .iter()
2075 .any(|evidence| evidence == "artifact manifest source=external")
2076 );
2077 assert!(
2078 api.evidence.iter().any(|evidence| evidence
2079 == "artifact manifest installed_module_hash=api-installed-module")
2080 );
2081 assert!(
2082 api.evidence
2083 .iter()
2084 .any(|evidence| evidence == "observed artifact file_sha256=api-file-sha")
2085 );
2086 assert!(
2087 api.warnings
2088 .iter()
2089 .any(|warning| warning.contains("artifact registry import is outside"))
2090 );
2091 assert_eq!(
2092 observed_api.wasm_evidence.as_deref(),
2093 Some("module_hash=api-module-hash")
2094 );
2095 assert!(
2096 report
2097 .blocked_actions
2098 .contains(&"artifact registry import".to_string())
2099 );
2100 assert!(
2101 report
2102 .recommendations
2103 .iter()
2104 .all(|recommendation| !recommendation.kind.contains("artifact"))
2105 );
2106 }
2107
2108 fn report(
2109 config_source: &str,
2110 inventory: Option<&DeploymentInventoryV1>,
2111 package_metadata: Vec<AdoptionPackageMetadataV1>,
2112 ) -> AdoptionReportV1 {
2113 report_with_profile(
2114 AdoptionProfileV1::Brownfield,
2115 config_source,
2116 inventory,
2117 package_metadata,
2118 )
2119 }
2120
2121 fn report_with_profile(
2122 profile: AdoptionProfileV1,
2123 config_source: &str,
2124 inventory: Option<&DeploymentInventoryV1>,
2125 package_metadata: Vec<AdoptionPackageMetadataV1>,
2126 ) -> AdoptionReportV1 {
2127 adoption_report_from_config_source(AdoptionReportRequest {
2128 report_id: "adoption-1",
2129 generated_at: "2026-05-30T00:00:00Z",
2130 profile,
2131 config_source,
2132 inventory,
2133 artifact_manifest: None,
2134 package_metadata,
2135 })
2136 .expect("adoption report")
2137 }
2138
2139 fn matching_metadata() -> Vec<AdoptionPackageMetadataV1> {
2140 ["root", "api", "store"]
2141 .into_iter()
2142 .map(|package| AdoptionPackageMetadataV1 {
2143 package: package.to_string(),
2144 fleet: Some("demo".to_string()),
2145 role: Some(package.to_string()),
2146 })
2147 .collect()
2148 }
2149
2150 fn external_api_artifact_manifest() -> RoleArtifactManifestV1 {
2151 RoleArtifactManifestV1 {
2152 schema_version: 1,
2153 manifest_id: "external-manifest-1".to_string(),
2154 network: "local".to_string(),
2155 artifact_root: None,
2156 role_artifacts: vec![external_api_role_artifact()],
2157 unresolved_artifacts: Vec::new(),
2158 }
2159 }
2160
2161 fn external_api_role_artifact() -> RoleArtifactV1 {
2162 RoleArtifactV1 {
2163 role: "api".to_string(),
2164 source: ArtifactSourceV1::External,
2165 build_profile: "external".to_string(),
2166 wasm_path: Some("external/api.wasm".to_string()),
2167 wasm_gz_path: Some("external/api.wasm.gz".to_string()),
2168 wasm_gz_size_bytes: Some(42),
2169 wasm_sha256: Some("api-wasm-sha".to_string()),
2170 wasm_gz_sha256: Some("api-wasm-gz-sha".to_string()),
2171 wasm_gz_sha256_source: Some(ArtifactDigestSourceV1::ObservedFileDigest),
2172 observed_wasm_gz_file_sha256: Some("api-file-sha".to_string()),
2173 observed_wasm_gz_file_sha256_source: Some(ArtifactDigestSourceV1::ObservedFileDigest),
2174 installed_module_hash: Some("api-installed-module".to_string()),
2175 candid_path: None,
2176 candid_sha256: None,
2177 raw_config_sha256: None,
2178 canonical_embedded_config_sha256: None,
2179 embedded_topology_sha256: None,
2180 builder_version: None,
2181 rust_toolchain: None,
2182 package_version: None,
2183 }
2184 }
2185
2186 fn observed_external_api_artifact() -> ObservedArtifactV1 {
2187 ObservedArtifactV1 {
2188 role: "api".to_string(),
2189 artifact_path: "external/api.wasm.gz".to_string(),
2190 file_sha256: Some("api-file-sha".to_string()),
2191 file_sha256_source: Some(ArtifactDigestSourceV1::ObservedFileDigest),
2192 payload_sha256: Some("api-payload-sha".to_string()),
2193 payload_size_bytes: Some(42),
2194 source: ArtifactSourceV1::External,
2195 }
2196 }
2197
2198 fn role<'a>(report: &'a AdoptionReportV1, role: &str) -> &'a AdoptionRoleFindingV1 {
2199 report
2200 .role_findings
2201 .iter()
2202 .find(|finding| finding.role == role)
2203 .expect("role finding")
2204 }
2205
2206 fn inventory(observed_canisters: Vec<ObservedCanisterV1>) -> DeploymentInventoryV1 {
2207 DeploymentInventoryV1 {
2208 schema_version: 1,
2209 inventory_id: "inventory-1".to_string(),
2210 observed_at: "2026-05-30T00:00:00Z".to_string(),
2211 observed_identity: None,
2212 observed_root: Some(DeploymentRootObservationV1 {
2213 deployment_name: "demo-dev".to_string(),
2214 network: "local".to_string(),
2215 fleet_template: "demo".to_string(),
2216 root_principal: "aaaaa-aa".to_string(),
2217 observed_canister_id: "aaaaa-aa".to_string(),
2218 observation_source: DeploymentRootObservationSourceV1::LocalDeploymentState,
2219 control_class: CanisterControlClassV1::DeploymentControlled,
2220 controllers: vec!["aaaaa-aa".to_string()],
2221 module_hash: None,
2222 status: Some("running".to_string()),
2223 role_assignment_source: Some("local-state".to_string()),
2224 }),
2225 local_config: LocalDeploymentConfigV1 {
2226 config_path: Some("fleets/demo/canic.toml".to_string()),
2227 raw_config_sha256: None,
2228 canonical_embedded_config_sha256: None,
2229 },
2230 observed_canisters,
2231 observed_pool: Vec::new(),
2232 observed_artifacts: vec![ObservedArtifactV1 {
2233 role: "external_app".to_string(),
2234 artifact_path: "observed:external_app".to_string(),
2235 file_sha256: None,
2236 file_sha256_source: Some(ArtifactDigestSourceV1::InstalledModuleHash),
2237 payload_sha256: None,
2238 payload_size_bytes: None,
2239 source: ArtifactSourceV1::External,
2240 }],
2241 observed_verifier_readiness: VerifierReadinessObservationV1 {
2242 status: ObservationStatusV1::NotObserved,
2243 role_epochs: Vec::new(),
2244 },
2245 unresolved_observations: Vec::new(),
2246 }
2247 }
2248
2249 fn observed_canister(
2250 canister_id: &str,
2251 role: Option<&str>,
2252 control_class: CanisterControlClassV1,
2253 module_hash: Option<&str>,
2254 ) -> ObservedCanisterV1 {
2255 ObservedCanisterV1 {
2256 canister_id: canister_id.to_string(),
2257 role: role.map(str::to_string),
2258 control_class,
2259 controllers: vec!["controller".to_string()],
2260 module_hash: module_hash.map(str::to_string),
2261 status: Some("running".to_string()),
2262 root_trust_anchor: Some("root".to_string()),
2263 canonical_embedded_config_digest: None,
2264 role_assignment_source: role.map(|_| "explicit-test-evidence".to_string()),
2265 }
2266 }
2267}