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::{
9 collections::{BTreeMap, BTreeSet},
10 str::FromStr,
11};
12use thiserror::Error;
13
14pub const ADOPTION_REPORT_SCHEMA_VERSION: u32 = 1;
15
16#[derive(Clone, Debug)]
20pub struct AdoptionReportRequest<'a> {
21 pub report_id: &'a str,
22 pub generated_at: &'a str,
23 pub profile: AdoptionProfileV1,
24 pub config_source: &'a str,
25 pub inventory: Option<&'a DeploymentInventoryV1>,
26 pub artifact_manifest: Option<&'a RoleArtifactManifestV1>,
27 pub package_metadata: Vec<AdoptionPackageMetadataV1>,
28}
29
30#[derive(Debug, Eq, Error, PartialEq)]
34pub enum AdoptionReportError {
35 #[error("invalid config: {0}")]
36 InvalidConfig(String),
37
38 #[error("missing required [fleet].name in canic.toml")]
39 MissingFleetName,
40}
41
42#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
46pub enum AdoptionProfileV1 {
47 Brownfield,
48 Partial,
49 Standalone,
50 LeafOnly,
51 HybridExternalWasm,
52 Minimal,
53}
54
55impl FromStr for AdoptionProfileV1 {
56 type Err = String;
57
58 fn from_str(value: &str) -> Result<Self, Self::Err> {
59 match value {
60 "brownfield" => Ok(Self::Brownfield),
61 "partial" => Ok(Self::Partial),
62 "standalone" => Ok(Self::Standalone),
63 "leaf-only" => Ok(Self::LeafOnly),
64 "hybrid-external-wasm" => Ok(Self::HybridExternalWasm),
65 "minimal" => Ok(Self::Minimal),
66 other => Err(format!("invalid adoption profile: {other}")),
67 }
68 }
69}
70
71#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75pub struct AdoptionReportV1 {
76 pub schema_version: u32,
77 pub report_id: String,
78 pub generated_at: String,
79 pub fleet: String,
80 pub profile: AdoptionProfileV1,
81 pub inputs: AdoptionReportInputsV1,
82 pub summary: AdoptionReportSummaryV1,
83 pub role_findings: Vec<AdoptionRoleFindingV1>,
84 pub observed_canisters: Vec<AdoptionObservedCanisterFindingV1>,
85 pub recommendations: Vec<AdoptionRecommendationV1>,
86 pub blocked_actions: Vec<String>,
87 pub warnings: Vec<String>,
88}
89
90#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
94pub struct AdoptionReportInputsV1 {
95 pub config_present: bool,
96 pub inventory_id: Option<String>,
97 pub artifact_manifest_id: Option<String>,
98 pub package_metadata_count: usize,
99 pub missing_or_stale_evidence: Vec<String>,
100}
101
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
106pub struct AdoptionReportSummaryV1 {
107 pub managed_configured_roles: usize,
108 pub declared_only_roles: usize,
109 pub attached_unobserved_roles: usize,
110 pub observed_only_canisters: usize,
111 pub user_controlled_canisters: usize,
112 pub external_controller_required: usize,
113 pub evidence_conflicts: usize,
114 pub mutating_actions_performed: usize,
115}
116
117#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
121pub struct AdoptionRoleFindingV1 {
122 pub fleet: String,
123 pub role: String,
124 pub classifications: Vec<AdoptionClassificationV1>,
125 pub declaration_state: AdoptionDeclarationStateV1,
126 pub topology_state: AdoptionTopologyStateV1,
127 pub package_state: AdoptionPackageStateV1,
128 pub observation_state: AdoptionObservationStateV1,
129 pub authority_state: AdoptionAuthorityStateV1,
130 pub artifact_state: AdoptionArtifactStateV1,
131 pub evidence: Vec<String>,
132 pub recommendations: Vec<AdoptionRecommendationV1>,
133 pub warnings: Vec<String>,
134}
135
136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
140pub struct AdoptionObservedCanisterFindingV1 {
141 pub canister_id: String,
142 pub matched_fleet: Option<String>,
143 pub matched_role: Option<String>,
144 pub confidence: AdoptionMatchConfidenceV1,
145 pub classifications: Vec<AdoptionClassificationV1>,
146 pub controllers: Vec<String>,
147 pub wasm_evidence: Option<String>,
148 pub deployment_target_evidence: Option<String>,
149 pub recommendations: Vec<AdoptionRecommendationV1>,
150 pub warnings: Vec<String>,
151}
152
153#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
157pub struct AdoptionRecommendationV1 {
158 pub kind: String,
159 pub severity: AdoptionRecommendationSeverityV1,
160 pub description: String,
161 pub suggested_action: Option<String>,
162 pub suggested_action_effect: AdoptionSuggestedActionEffectV1,
163 pub suggested_action_support: AdoptionSuggestedActionSupportV1,
164 pub suggested_action_availability: AdoptionSuggestedActionAvailabilityV1,
165 pub operator_action_requirement: AdoptionOperatorActionRequirementV1,
166}
167
168#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
172pub struct AdoptionPackageMetadataV1 {
173 pub package: String,
174 pub fleet: Option<String>,
175 pub role: Option<String>,
176}
177
178#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
182pub enum AdoptionClassificationV1 {
183 Managed,
184 DeclaredOnly,
185 ObservedOnly,
186 AttachedUnobserved,
187 UserControlled,
188 ExternalControllerRequired,
189 ImportedPoolCandidate,
190 EvidenceConflict,
191}
192
193#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
197pub enum AdoptionDeclarationStateV1 {
198 Undeclared,
199 Declared,
200}
201
202#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
206pub enum AdoptionTopologyStateV1 {
207 Unattached,
208 Attached,
209}
210
211#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
215pub enum AdoptionObservationStateV1 {
216 Unobserved,
217 Observed,
218 CandidateMatch,
219 ConflictingMatch,
220}
221
222#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
226pub enum AdoptionAuthorityStateV1 {
227 CanicAuthorized,
228 UserControlled,
229 External,
230 Unknown,
231}
232
233#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
237pub enum AdoptionArtifactStateV1 {
238 CanicBuilt,
239 ExternalWasm,
240 Unknown,
241}
242
243#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
247pub enum AdoptionPackageStateV1 {
248 UndeclaredRole,
249 NotChecked,
250 Matches,
251 MissingFleet,
252 MissingRole,
253 Mismatch,
254}
255
256#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
260pub enum AdoptionMatchConfidenceV1 {
261 None,
262 Candidate,
263 ExplicitEvidence,
264 Conflict,
265}
266
267#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
271pub enum AdoptionRecommendationSeverityV1 {
272 Info,
273 Warning,
274 Blocked,
275}
276
277#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
281pub enum AdoptionSuggestedActionEffectV1 {
282 ReadOnly,
283 MutatesState,
284}
285
286#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
290pub enum AdoptionSuggestedActionSupportV1 {
291 SupportedByAdoption,
292 UnsupportedByAdoption,
293}
294
295#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
299pub enum AdoptionSuggestedActionAvailabilityV1 {
300 AllowedIn0500,
301 BlockedIn0500,
302}
303
304#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
308pub enum AdoptionOperatorActionRequirementV1 {
309 Required,
310 NotRequired,
311}
312
313struct DeclaredRoleFindingInput<'a> {
314 profile: AdoptionProfileV1,
315 fleet: &'a str,
316 role: &'a CanisterRole,
317 package: &'a str,
318 attached: bool,
319 observed: Option<&'a [&'a crate::deployment_truth::ObservedCanisterV1]>,
320 duplicate_observation: bool,
321 packages_by_path: &'a BTreeMap<String, AdoptionPackageMetadataV1>,
322 artifact_state: Option<AdoptionArtifactStateV1>,
323 artifact_conflict: bool,
324 artifact_evidence: Option<&'a [String]>,
325}
326
327struct ObservedOnlyRoleFindingInput<'a> {
328 profile: AdoptionProfileV1,
329 fleet: &'a str,
330 role: &'a str,
331 observed: &'a [&'a crate::deployment_truth::ObservedCanisterV1],
332 duplicate_observation: bool,
333 artifact_state: Option<AdoptionArtifactStateV1>,
334 artifact_conflict: bool,
335 artifact_evidence: Option<&'a [String]>,
336}
337
338pub fn adoption_report_from_config_source(
342 request: AdoptionReportRequest<'_>,
343) -> Result<AdoptionReportV1, AdoptionReportError> {
344 let config = parse_config_model(request.config_source)
345 .map_err(|err| AdoptionReportError::InvalidConfig(err.to_string()))?;
346 let fleet = config
347 .fleet_name()
348 .ok_or(AdoptionReportError::MissingFleetName)?
349 .to_string();
350 let attached_roles = config.attached_roles();
351 let observed_by_role = observed_canisters_by_role(request.inventory);
352 let observed_duplicate_roles = duplicate_observed_roles(&observed_by_role);
353 let packages_by_path = package_metadata_by_path(request.package_metadata);
354 let artifacts_by_role = artifact_states_by_role(request.artifact_manifest, request.inventory);
355 let artifact_conflict_roles =
356 artifact_conflict_roles(request.artifact_manifest, request.inventory);
357 let artifact_evidence_by_role =
358 artifact_evidence_by_role(request.artifact_manifest, request.inventory);
359 let declared_roles = config.roles.keys().cloned().collect::<BTreeSet<_>>();
360
361 let mut role_findings = Vec::new();
362 let mut seen_roles = BTreeSet::new();
363
364 for (role, declaration) in &config.roles {
365 seen_roles.insert(role.as_str().to_string());
366 role_findings.push(role_finding_for_declared_role(DeclaredRoleFindingInput {
367 profile: request.profile,
368 fleet: &fleet,
369 role,
370 package: declaration.package.as_str(),
371 attached: attached_roles.contains(role),
372 observed: observed_by_role.get(role.as_str()).map(Vec::as_slice),
373 duplicate_observation: observed_duplicate_roles.contains(role.as_str()),
374 packages_by_path: &packages_by_path,
375 artifact_state: artifacts_by_role.get(role.as_str()).copied(),
376 artifact_conflict: artifact_conflict_roles.contains(role.as_str()),
377 artifact_evidence: artifact_evidence_by_role
378 .get(role.as_str())
379 .map(Vec::as_slice),
380 }));
381 }
382
383 for (role, observed) in &observed_by_role {
384 if seen_roles.contains(role) {
385 continue;
386 }
387 role_findings.push(role_finding_for_observed_only_role(
388 ObservedOnlyRoleFindingInput {
389 profile: request.profile,
390 fleet: &fleet,
391 role,
392 observed,
393 duplicate_observation: observed_duplicate_roles.contains(role),
394 artifact_state: artifacts_by_role.get(role.as_str()).copied(),
395 artifact_conflict: artifact_conflict_roles.contains(role),
396 artifact_evidence: artifact_evidence_by_role
397 .get(role.as_str())
398 .map(Vec::as_slice),
399 },
400 ));
401 }
402
403 role_findings.sort_by(|left, right| left.role.cmp(&right.role));
404
405 let observed_canisters = observed_canister_findings(
406 request.profile,
407 &fleet,
408 request.inventory,
409 &declared_roles,
410 &attached_roles,
411 );
412 let summary = report_summary(&role_findings, &observed_canisters);
413 let mut recommendations = Vec::new();
414 for finding in &role_findings {
415 recommendations.extend(finding.recommendations.clone());
416 }
417 for finding in &observed_canisters {
418 recommendations.extend(finding.recommendations.clone());
419 }
420
421 Ok(AdoptionReportV1 {
422 schema_version: ADOPTION_REPORT_SCHEMA_VERSION,
423 report_id: request.report_id.to_string(),
424 generated_at: request.generated_at.to_string(),
425 fleet,
426 profile: request.profile,
427 inputs: AdoptionReportInputsV1 {
428 config_present: true,
429 inventory_id: request
430 .inventory
431 .map(|inventory| inventory.inventory_id.clone()),
432 artifact_manifest_id: request
433 .artifact_manifest
434 .map(|manifest| manifest.manifest_id.clone()),
435 package_metadata_count: packages_by_path.len(),
436 missing_or_stale_evidence: missing_evidence(
437 request.inventory,
438 request.artifact_manifest,
439 ),
440 },
441 summary,
442 role_findings,
443 observed_canisters,
444 recommendations,
445 blocked_actions: blocked_actions(),
446 warnings: Vec::new(),
447 })
448}
449
450fn role_finding_for_declared_role(input: DeclaredRoleFindingInput<'_>) -> AdoptionRoleFindingV1 {
451 let role_name = input.role.as_str().to_string();
452 let observed = input.observed.unwrap_or_default();
453 let observed_any = !observed.is_empty();
454 let mut classifications = BTreeSet::new();
455 let mut evidence = Vec::new();
456 let mut warnings = Vec::new();
457 let mut recommendations = Vec::new();
458
459 evidence.push("role declaration exists".to_string());
460 if input.attached {
461 evidence.push("topology attachment exists".to_string());
462 classifications.insert(AdoptionClassificationV1::Managed);
463 } else {
464 evidence.push("no topology attachment exists".to_string());
465 classifications.insert(AdoptionClassificationV1::DeclaredOnly);
466 if input.profile == AdoptionProfileV1::LeafOnly
467 && is_leaf_only_authority_sensitive_role(&role_name)
468 {
469 warnings.push(
470 "leaf-only profile leaves authority-sensitive declared roles unattached"
471 .to_string(),
472 );
473 } else {
474 recommendations.push(attach_later_recommendation(input.fleet, &role_name));
475 }
476 }
477
478 if input.attached && !observed_any {
479 classifications.insert(AdoptionClassificationV1::AttachedUnobserved);
480 warnings.push("deployment-truth evidence does not confirm this attached role".to_string());
481 }
482
483 for canister in observed {
484 evidence.push(format!("observed canister {}", canister.canister_id));
485 if let Some(hash) = &canister.module_hash {
486 evidence.push(format!("observed canister module_hash={hash}"));
487 }
488 }
489 if let Some(artifact_evidence) = input.artifact_evidence {
490 evidence.extend(artifact_evidence.iter().cloned());
491 }
492
493 let authority_state = combined_authority_state(observed);
494 if matches!(
495 authority_state,
496 AdoptionAuthorityStateV1::UserControlled | AdoptionAuthorityStateV1::External
497 ) {
498 classifications.insert(AdoptionClassificationV1::ExternalControllerRequired);
499 }
500 if matches!(authority_state, AdoptionAuthorityStateV1::UserControlled) {
501 classifications.insert(AdoptionClassificationV1::UserControlled);
502 }
503
504 let package_state = package_state(
505 input.package,
506 input.fleet,
507 &role_name,
508 input.packages_by_path,
509 );
510 if matches!(
511 package_state,
512 AdoptionPackageStateV1::MissingFleet
513 | AdoptionPackageStateV1::MissingRole
514 | AdoptionPackageStateV1::Mismatch
515 ) {
516 classifications.insert(AdoptionClassificationV1::EvidenceConflict);
517 warnings.push("package metadata does not match declared fleet role".to_string());
518 }
519
520 if input.duplicate_observation {
521 classifications.insert(AdoptionClassificationV1::EvidenceConflict);
522 warnings.push("deployment evidence contains conflicting role facts".to_string());
523 }
524
525 if input.artifact_conflict {
526 classifications.insert(AdoptionClassificationV1::EvidenceConflict);
527 warnings.push("artifact evidence contains conflicting role facts".to_string());
528 }
529
530 let artifact_state = input
531 .artifact_state
532 .unwrap_or_else(|| artifact_state_from_observed(observed));
533 if input.profile == AdoptionProfileV1::HybridExternalWasm
534 && artifact_state == AdoptionArtifactStateV1::ExternalWasm
535 {
536 warnings.push(
537 "external Wasm evidence is reported only; artifact registry import is outside adoption reporting"
538 .to_string(),
539 );
540 }
541
542 AdoptionRoleFindingV1 {
543 fleet: input.fleet.to_string(),
544 role: role_name,
545 classifications: classifications.into_iter().collect(),
546 declaration_state: AdoptionDeclarationStateV1::Declared,
547 topology_state: if input.attached {
548 AdoptionTopologyStateV1::Attached
549 } else {
550 AdoptionTopologyStateV1::Unattached
551 },
552 package_state,
553 observation_state: observation_state(observed_any, input.duplicate_observation),
554 authority_state,
555 artifact_state,
556 evidence,
557 recommendations,
558 warnings,
559 }
560}
561
562fn role_finding_for_observed_only_role(
563 input: ObservedOnlyRoleFindingInput<'_>,
564) -> AdoptionRoleFindingV1 {
565 let mut classifications = BTreeSet::new();
566 classifications.insert(AdoptionClassificationV1::ObservedOnly);
567 if input.duplicate_observation {
568 classifications.insert(AdoptionClassificationV1::EvidenceConflict);
569 }
570 if input.artifact_conflict {
571 classifications.insert(AdoptionClassificationV1::EvidenceConflict);
572 }
573
574 let authority_state = combined_authority_state(input.observed);
575 if matches!(authority_state, AdoptionAuthorityStateV1::UserControlled) {
576 classifications.insert(AdoptionClassificationV1::UserControlled);
577 }
578 if matches!(
579 authority_state,
580 AdoptionAuthorityStateV1::UserControlled | AdoptionAuthorityStateV1::External
581 ) {
582 classifications.insert(AdoptionClassificationV1::ExternalControllerRequired);
583 }
584
585 let artifact_state = input
586 .artifact_state
587 .unwrap_or_else(|| artifact_state_from_observed(input.observed));
588 let mut evidence = input
589 .observed
590 .iter()
591 .flat_map(|canister| {
592 let mut evidence = vec![format!("observed canister {}", canister.canister_id)];
593 if let Some(hash) = &canister.module_hash {
594 evidence.push(format!("observed canister module_hash={hash}"));
595 }
596 evidence
597 })
598 .collect::<Vec<_>>();
599 if let Some(artifact_evidence) = input.artifact_evidence {
600 evidence.extend(artifact_evidence.iter().cloned());
601 }
602
603 let mut warnings = observed_only_warnings(input.profile, input.role);
604 if input.artifact_conflict {
605 warnings.push("artifact evidence contains conflicting role facts".to_string());
606 }
607 if input.profile == AdoptionProfileV1::HybridExternalWasm
608 && artifact_state == AdoptionArtifactStateV1::ExternalWasm
609 {
610 warnings.push(
611 "external Wasm evidence is reported only; artifact registry import is outside adoption reporting"
612 .to_string(),
613 );
614 }
615
616 AdoptionRoleFindingV1 {
617 fleet: input.fleet.to_string(),
618 role: input.role.to_string(),
619 classifications: classifications.into_iter().collect(),
620 declaration_state: AdoptionDeclarationStateV1::Undeclared,
621 topology_state: AdoptionTopologyStateV1::Unattached,
622 package_state: AdoptionPackageStateV1::UndeclaredRole,
623 observation_state: observation_state(true, input.duplicate_observation),
624 authority_state,
625 artifact_state,
626 evidence,
627 recommendations: observed_only_recommendations(
628 input.profile,
629 input.fleet,
630 input.role,
631 authority_state,
632 ),
633 warnings,
634 }
635}
636
637fn observed_canister_findings(
638 profile: AdoptionProfileV1,
639 fleet: &str,
640 inventory: Option<&DeploymentInventoryV1>,
641 declarations: &BTreeSet<CanisterRole>,
642 attached_roles: &BTreeSet<CanisterRole>,
643) -> Vec<AdoptionObservedCanisterFindingV1> {
644 let Some(inventory) = inventory else {
645 return Vec::new();
646 };
647
648 let mut findings = Vec::new();
649 for canister in &inventory.observed_canisters {
650 let role = canister.role.as_deref();
651 let declared =
652 role.is_some_and(|role| declarations.contains(&CanisterRole::owned(role.to_string())));
653 let attached = role
654 .is_some_and(|role| attached_roles.contains(&CanisterRole::owned(role.to_string())));
655 let mut classifications = BTreeSet::new();
656 if role.is_none() || !declared {
657 classifications.insert(AdoptionClassificationV1::ObservedOnly);
658 }
659 if matches!(
660 authority_state_for_control_class(canister.control_class),
661 AdoptionAuthorityStateV1::UserControlled
662 ) {
663 classifications.insert(AdoptionClassificationV1::UserControlled);
664 }
665 if matches!(
666 authority_state_for_control_class(canister.control_class),
667 AdoptionAuthorityStateV1::UserControlled | AdoptionAuthorityStateV1::External
668 ) {
669 classifications.insert(AdoptionClassificationV1::ExternalControllerRequired);
670 }
671
672 findings.push(AdoptionObservedCanisterFindingV1 {
673 canister_id: canister.canister_id.clone(),
674 matched_fleet: role.map(|_| fleet.to_string()),
675 matched_role: role.map(str::to_string),
676 confidence: match (role, declared, attached) {
677 (Some(_), true, true) => AdoptionMatchConfidenceV1::ExplicitEvidence,
678 (Some(_), _, _) => AdoptionMatchConfidenceV1::Candidate,
679 (None, _, _) => AdoptionMatchConfidenceV1::None,
680 },
681 classifications: classifications.into_iter().collect(),
682 controllers: canister.controllers.clone(),
683 wasm_evidence: canister
684 .module_hash
685 .as_ref()
686 .map(|hash| format!("module_hash={hash}")),
687 deployment_target_evidence: Some(inventory.inventory_id.clone()),
688 recommendations: match (role, declared) {
689 (Some(role), false) => observed_only_recommendations(
690 profile,
691 fleet,
692 role,
693 authority_state_for_control_class(canister.control_class),
694 ),
695 _ => Vec::new(),
696 },
697 warnings: role
698 .map(|role| observed_only_warnings(profile, role))
699 .unwrap_or_default(),
700 });
701 }
702
703 for pool in &inventory.observed_pool {
704 findings.push(AdoptionObservedCanisterFindingV1 {
705 canister_id: pool.canister_id.clone(),
706 matched_fleet: pool.role.as_ref().map(|_| fleet.to_string()),
707 matched_role: pool.role.clone(),
708 confidence: AdoptionMatchConfidenceV1::Candidate,
709 classifications: vec![AdoptionClassificationV1::ImportedPoolCandidate],
710 controllers: Vec::new(),
711 wasm_evidence: None,
712 deployment_target_evidence: Some(format!("pool={}", pool.pool)),
713 recommendations: Vec::new(),
714 warnings: vec!["pool import is outside 0.50.0".to_string()],
715 });
716 }
717
718 findings.sort_by(|left, right| left.canister_id.cmp(&right.canister_id));
719 findings
720}
721
722fn observed_only_recommendations(
723 profile: AdoptionProfileV1,
724 fleet: &str,
725 role: &str,
726 authority_state: AdoptionAuthorityStateV1,
727) -> Vec<AdoptionRecommendationV1> {
728 if profile == AdoptionProfileV1::LeafOnly && is_leaf_only_authority_sensitive_role(role) {
729 return Vec::new();
730 }
731
732 if authority_state != AdoptionAuthorityStateV1::CanicAuthorized {
733 return vec![review_authority_before_declaration_recommendation(
734 fleet,
735 role,
736 authority_state,
737 )];
738 }
739
740 vec![declare_role_recommendation(fleet, role)]
741}
742
743fn observed_only_warnings(profile: AdoptionProfileV1, role: &str) -> Vec<String> {
744 if profile == AdoptionProfileV1::LeafOnly && is_leaf_only_authority_sensitive_role(role) {
745 return vec![
746 "leaf-only profile leaves authority-sensitive observed roles external".to_string(),
747 ];
748 }
749
750 Vec::new()
751}
752
753fn is_leaf_only_authority_sensitive_role(role: &str) -> bool {
754 matches!(role, "root" | "governance" | "governance_root")
755}
756
757fn package_state(
758 package: &str,
759 fleet: &str,
760 role: &str,
761 packages_by_path: &BTreeMap<String, AdoptionPackageMetadataV1>,
762) -> AdoptionPackageStateV1 {
763 let Some(metadata) = packages_by_path.get(package) else {
764 return AdoptionPackageStateV1::NotChecked;
765 };
766 if metadata.fleet.is_none() {
767 return AdoptionPackageStateV1::MissingFleet;
768 }
769 if metadata.role.is_none() {
770 return AdoptionPackageStateV1::MissingRole;
771 }
772 if metadata.fleet.as_deref() == Some(fleet) && metadata.role.as_deref() == Some(role) {
773 AdoptionPackageStateV1::Matches
774 } else {
775 AdoptionPackageStateV1::Mismatch
776 }
777}
778
779fn observed_canisters_by_role(
780 inventory: Option<&DeploymentInventoryV1>,
781) -> BTreeMap<String, Vec<&crate::deployment_truth::ObservedCanisterV1>> {
782 let mut observed = BTreeMap::<String, Vec<&crate::deployment_truth::ObservedCanisterV1>>::new();
783 let Some(inventory) = inventory else {
784 return observed;
785 };
786
787 for canister in &inventory.observed_canisters {
788 if let Some(role) = &canister.role {
789 observed.entry(role.clone()).or_default().push(canister);
790 }
791 }
792 observed
793}
794
795fn duplicate_observed_roles(
796 observed_by_role: &BTreeMap<String, Vec<&crate::deployment_truth::ObservedCanisterV1>>,
797) -> BTreeSet<String> {
798 observed_by_role
799 .iter()
800 .filter(|(_, canisters)| canisters.len() > 1)
801 .map(|(role, _)| role.clone())
802 .collect()
803}
804
805fn package_metadata_by_path(
806 metadata: Vec<AdoptionPackageMetadataV1>,
807) -> BTreeMap<String, AdoptionPackageMetadataV1> {
808 metadata
809 .into_iter()
810 .map(|metadata| (metadata.package.clone(), metadata))
811 .collect()
812}
813
814fn artifact_states_by_role(
815 manifest: Option<&RoleArtifactManifestV1>,
816 inventory: Option<&DeploymentInventoryV1>,
817) -> BTreeMap<String, AdoptionArtifactStateV1> {
818 let mut states = BTreeMap::new();
819
820 if let Some(manifest) = manifest {
821 for artifact in &manifest.role_artifacts {
822 states.insert(
823 artifact.role.clone(),
824 artifact_state_for_source(artifact.source),
825 );
826 }
827 }
828
829 if let Some(inventory) = inventory {
830 for artifact in &inventory.observed_artifacts {
831 states
832 .entry(artifact.role.clone())
833 .or_insert_with(|| artifact_state_for_source(artifact.source));
834 }
835 }
836
837 states
838}
839
840fn artifact_conflict_roles(
841 manifest: Option<&RoleArtifactManifestV1>,
842 inventory: Option<&DeploymentInventoryV1>,
843) -> BTreeSet<String> {
844 let mut manifest_states = BTreeMap::new();
845 let mut conflict_roles = BTreeSet::new();
846
847 if let Some(manifest) = manifest {
848 for artifact in &manifest.role_artifacts {
849 let state = artifact_state_for_source(artifact.source);
850 if manifest_states
851 .insert(artifact.role.clone(), state)
852 .is_some_and(|previous| previous != state)
853 {
854 conflict_roles.insert(artifact.role.clone());
855 }
856 }
857 }
858
859 if let Some(inventory) = inventory {
860 for artifact in &inventory.observed_artifacts {
861 let state = artifact_state_for_source(artifact.source);
862 if manifest_states
863 .get(&artifact.role)
864 .is_some_and(|previous| *previous != state)
865 {
866 conflict_roles.insert(artifact.role.clone());
867 }
868 }
869 }
870
871 conflict_roles
872}
873
874fn artifact_evidence_by_role(
875 manifest: Option<&RoleArtifactManifestV1>,
876 inventory: Option<&DeploymentInventoryV1>,
877) -> BTreeMap<String, Vec<String>> {
878 let mut evidence = BTreeMap::<String, Vec<String>>::new();
879
880 if let Some(manifest) = manifest {
881 for artifact in &manifest.role_artifacts {
882 let role_evidence = evidence.entry(artifact.role.clone()).or_default();
883 role_evidence.push(format!(
884 "artifact manifest source={}",
885 artifact_source_label(artifact.source)
886 ));
887 if let Some(hash) = &artifact.installed_module_hash {
888 role_evidence.push(format!("artifact manifest installed_module_hash={hash}"));
889 }
890 if let Some(hash) = &artifact.wasm_sha256 {
891 role_evidence.push(format!("artifact manifest wasm_sha256={hash}"));
892 }
893 if let Some(hash) = &artifact.wasm_gz_sha256 {
894 role_evidence.push(format!("artifact manifest wasm_gz_sha256={hash}"));
895 }
896 }
897 }
898
899 if let Some(inventory) = inventory {
900 for artifact in &inventory.observed_artifacts {
901 let role_evidence = evidence.entry(artifact.role.clone()).or_default();
902 role_evidence.push(format!(
903 "observed artifact source={} path={}",
904 artifact_source_label(artifact.source),
905 artifact.artifact_path
906 ));
907 if let Some(hash) = &artifact.file_sha256 {
908 role_evidence.push(format!("observed artifact file_sha256={hash}"));
909 }
910 if let Some(hash) = &artifact.payload_sha256 {
911 role_evidence.push(format!("observed artifact payload_sha256={hash}"));
912 }
913 if let Some(size) = artifact.payload_size_bytes {
914 role_evidence.push(format!("observed artifact payload_size_bytes={size}"));
915 }
916 }
917 }
918
919 evidence
920}
921
922const fn artifact_state_for_source(source: ArtifactSourceV1) -> AdoptionArtifactStateV1 {
923 match source {
924 ArtifactSourceV1::External | ArtifactSourceV1::Unknown => {
925 AdoptionArtifactStateV1::ExternalWasm
926 }
927 ArtifactSourceV1::LocalBuild
928 | ArtifactSourceV1::ReleaseSet
929 | ArtifactSourceV1::WasmStore => AdoptionArtifactStateV1::CanicBuilt,
930 }
931}
932
933const fn artifact_source_label(source: ArtifactSourceV1) -> &'static str {
934 match source {
935 ArtifactSourceV1::LocalBuild => "local-build",
936 ArtifactSourceV1::ReleaseSet => "release-set",
937 ArtifactSourceV1::WasmStore => "wasm-store",
938 ArtifactSourceV1::External => "external",
939 ArtifactSourceV1::Unknown => "unknown",
940 }
941}
942
943fn combined_authority_state(
944 observed: &[&crate::deployment_truth::ObservedCanisterV1],
945) -> AdoptionAuthorityStateV1 {
946 let mut states = observed
947 .iter()
948 .map(|canister| authority_state_for_control_class(canister.control_class))
949 .collect::<BTreeSet<_>>();
950 if states.is_empty() {
951 return AdoptionAuthorityStateV1::Unknown;
952 }
953 if states.remove(&AdoptionAuthorityStateV1::UserControlled) {
954 return AdoptionAuthorityStateV1::UserControlled;
955 }
956 if states.remove(&AdoptionAuthorityStateV1::External) {
957 return AdoptionAuthorityStateV1::External;
958 }
959 if states.remove(&AdoptionAuthorityStateV1::Unknown) {
960 return AdoptionAuthorityStateV1::Unknown;
961 }
962 AdoptionAuthorityStateV1::CanicAuthorized
963}
964
965const fn authority_state_for_control_class(
966 control_class: CanisterControlClassV1,
967) -> AdoptionAuthorityStateV1 {
968 match control_class {
969 CanisterControlClassV1::DeploymentControlled | CanisterControlClassV1::CanicManagedPool => {
970 AdoptionAuthorityStateV1::CanicAuthorized
971 }
972 CanisterControlClassV1::UserControlled => AdoptionAuthorityStateV1::UserControlled,
973 CanisterControlClassV1::ExternallyImported | CanisterControlClassV1::JointlyControlled => {
974 AdoptionAuthorityStateV1::External
975 }
976 CanisterControlClassV1::UnknownUnsafe => AdoptionAuthorityStateV1::Unknown,
977 }
978}
979
980const fn observation_state(observed: bool, conflict: bool) -> AdoptionObservationStateV1 {
981 match (observed, conflict) {
982 (_, true) => AdoptionObservationStateV1::ConflictingMatch,
983 (true, false) => AdoptionObservationStateV1::Observed,
984 (false, false) => AdoptionObservationStateV1::Unobserved,
985 }
986}
987
988fn artifact_state_from_observed(
989 observed: &[&crate::deployment_truth::ObservedCanisterV1],
990) -> AdoptionArtifactStateV1 {
991 if observed
992 .iter()
993 .any(|canister| canister.module_hash.is_some())
994 {
995 AdoptionArtifactStateV1::ExternalWasm
996 } else {
997 AdoptionArtifactStateV1::Unknown
998 }
999}
1000
1001fn missing_evidence(
1002 inventory: Option<&DeploymentInventoryV1>,
1003 artifact_manifest: Option<&RoleArtifactManifestV1>,
1004) -> Vec<String> {
1005 let mut evidence = Vec::new();
1006
1007 if let Some(inventory) = inventory {
1008 evidence.extend(inventory.unresolved_observations.iter().map(|gap| {
1009 format!(
1010 "unresolved inventory observation {}: {}",
1011 gap.key, gap.description
1012 )
1013 }));
1014 } else {
1015 evidence.push("deployment inventory was not supplied".to_string());
1016 }
1017
1018 if let Some(manifest) = artifact_manifest {
1019 evidence.extend(manifest.unresolved_artifacts.iter().map(|gap| {
1020 format!(
1021 "unresolved artifact evidence {}: {}",
1022 gap.key, gap.description
1023 )
1024 }));
1025 }
1026
1027 evidence
1028}
1029
1030fn report_summary(
1031 role_findings: &[AdoptionRoleFindingV1],
1032 observed_findings: &[AdoptionObservedCanisterFindingV1],
1033) -> AdoptionReportSummaryV1 {
1034 AdoptionReportSummaryV1 {
1035 managed_configured_roles: role_findings
1036 .iter()
1037 .filter(|finding| {
1038 finding
1039 .classifications
1040 .contains(&AdoptionClassificationV1::Managed)
1041 })
1042 .count(),
1043 declared_only_roles: role_findings
1044 .iter()
1045 .filter(|finding| {
1046 finding
1047 .classifications
1048 .contains(&AdoptionClassificationV1::DeclaredOnly)
1049 })
1050 .count(),
1051 attached_unobserved_roles: role_findings
1052 .iter()
1053 .filter(|finding| {
1054 finding
1055 .classifications
1056 .contains(&AdoptionClassificationV1::AttachedUnobserved)
1057 })
1058 .count(),
1059 observed_only_canisters: observed_findings
1060 .iter()
1061 .filter(|finding| {
1062 finding
1063 .classifications
1064 .contains(&AdoptionClassificationV1::ObservedOnly)
1065 })
1066 .count(),
1067 user_controlled_canisters: observed_findings
1068 .iter()
1069 .filter(|finding| {
1070 finding
1071 .classifications
1072 .contains(&AdoptionClassificationV1::UserControlled)
1073 })
1074 .count(),
1075 external_controller_required: role_findings
1076 .iter()
1077 .filter(|finding| {
1078 finding
1079 .classifications
1080 .contains(&AdoptionClassificationV1::ExternalControllerRequired)
1081 })
1082 .count(),
1083 evidence_conflicts: role_findings
1084 .iter()
1085 .filter(|finding| {
1086 finding
1087 .classifications
1088 .contains(&AdoptionClassificationV1::EvidenceConflict)
1089 })
1090 .count(),
1091 mutating_actions_performed: 0,
1092 }
1093}
1094
1095fn declare_role_recommendation(fleet: &str, role: &str) -> AdoptionRecommendationV1 {
1096 AdoptionRecommendationV1 {
1097 kind: "declare_role".to_string(),
1098 severity: AdoptionRecommendationSeverityV1::Info,
1099 description: format!("declare observed role candidate {fleet}.{role} before attachment"),
1100 suggested_action: Some(format!(
1101 "canic fleet role declare {fleet} {role} --package <path>"
1102 )),
1103 suggested_action_effect: AdoptionSuggestedActionEffectV1::MutatesState,
1104 suggested_action_support: AdoptionSuggestedActionSupportV1::UnsupportedByAdoption,
1105 suggested_action_availability: AdoptionSuggestedActionAvailabilityV1::BlockedIn0500,
1106 operator_action_requirement: AdoptionOperatorActionRequirementV1::Required,
1107 }
1108}
1109
1110fn review_authority_before_declaration_recommendation(
1111 fleet: &str,
1112 role: &str,
1113 authority_state: AdoptionAuthorityStateV1,
1114) -> AdoptionRecommendationV1 {
1115 AdoptionRecommendationV1 {
1116 kind: "review_authority_before_declaration".to_string(),
1117 severity: AdoptionRecommendationSeverityV1::Warning,
1118 description: format!(
1119 "review {fleet}.{role} authority before declaring observed role candidate ({})",
1120 adoption_authority_state_label(authority_state)
1121 ),
1122 suggested_action: None,
1123 suggested_action_effect: AdoptionSuggestedActionEffectV1::ReadOnly,
1124 suggested_action_support: AdoptionSuggestedActionSupportV1::SupportedByAdoption,
1125 suggested_action_availability: AdoptionSuggestedActionAvailabilityV1::AllowedIn0500,
1126 operator_action_requirement: AdoptionOperatorActionRequirementV1::Required,
1127 }
1128}
1129
1130const fn adoption_authority_state_label(authority_state: AdoptionAuthorityStateV1) -> &'static str {
1131 match authority_state {
1132 AdoptionAuthorityStateV1::CanicAuthorized => "canic-authorized",
1133 AdoptionAuthorityStateV1::UserControlled => "user-controlled",
1134 AdoptionAuthorityStateV1::External => "external",
1135 AdoptionAuthorityStateV1::Unknown => "unknown",
1136 }
1137}
1138
1139fn attach_later_recommendation(fleet: &str, role: &str) -> AdoptionRecommendationV1 {
1140 AdoptionRecommendationV1 {
1141 kind: "attach_role_later".to_string(),
1142 severity: AdoptionRecommendationSeverityV1::Info,
1143 description: format!("attach {fleet}.{role} explicitly only when topology is ready"),
1144 suggested_action: Some(format!(
1145 "canic fleet role attach {fleet} {role} --subnet <subnet>"
1146 )),
1147 suggested_action_effect: AdoptionSuggestedActionEffectV1::MutatesState,
1148 suggested_action_support: AdoptionSuggestedActionSupportV1::UnsupportedByAdoption,
1149 suggested_action_availability: AdoptionSuggestedActionAvailabilityV1::BlockedIn0500,
1150 operator_action_requirement: AdoptionOperatorActionRequirementV1::Required,
1151 }
1152}
1153
1154fn blocked_actions() -> Vec<String> {
1155 [
1156 "controller changes",
1157 "topology attachment",
1158 "pool import",
1159 "install",
1160 "upgrade",
1161 "reinstall",
1162 "stop",
1163 "start",
1164 "delete",
1165 "deploy",
1166 "promote",
1167 "rollback",
1168 "artifact registry import",
1169 ]
1170 .into_iter()
1171 .map(str::to_string)
1172 .collect()
1173}
1174
1175#[cfg(test)]
1176mod tests;