Skip to main content

canic_host/adoption/
mod.rs

1//! Passive adoption profile and onboarding reports.
2
3use 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///
17/// AdoptionReportRequest
18///
19#[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///
31/// AdoptionReportError
32///
33#[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///
43/// AdoptionProfileV1
44///
45#[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///
72/// AdoptionReportV1
73///
74#[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///
91/// AdoptionReportInputsV1
92///
93#[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///
103/// AdoptionReportSummaryV1
104///
105#[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///
118/// AdoptionRoleFindingV1
119///
120#[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///
137/// AdoptionObservedCanisterFindingV1
138///
139#[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///
154/// AdoptionRecommendationV1
155///
156#[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///
169/// AdoptionPackageMetadataV1
170///
171#[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///
179/// AdoptionClassificationV1
180///
181#[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///
194/// AdoptionDeclarationStateV1
195///
196#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
197pub enum AdoptionDeclarationStateV1 {
198    Undeclared,
199    Declared,
200}
201
202///
203/// AdoptionTopologyStateV1
204///
205#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
206pub enum AdoptionTopologyStateV1 {
207    Unattached,
208    Attached,
209}
210
211///
212/// AdoptionObservationStateV1
213///
214#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
215pub enum AdoptionObservationStateV1 {
216    Unobserved,
217    Observed,
218    CandidateMatch,
219    ConflictingMatch,
220}
221
222///
223/// AdoptionAuthorityStateV1
224///
225#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
226pub enum AdoptionAuthorityStateV1 {
227    CanicAuthorized,
228    UserControlled,
229    External,
230    Unknown,
231}
232
233///
234/// AdoptionArtifactStateV1
235///
236#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
237pub enum AdoptionArtifactStateV1 {
238    CanicBuilt,
239    ExternalWasm,
240    Unknown,
241}
242
243///
244/// AdoptionPackageStateV1
245///
246#[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///
257/// AdoptionMatchConfidenceV1
258///
259#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
260pub enum AdoptionMatchConfidenceV1 {
261    None,
262    Candidate,
263    ExplicitEvidence,
264    Conflict,
265}
266
267///
268/// AdoptionRecommendationSeverityV1
269///
270#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
271pub enum AdoptionRecommendationSeverityV1 {
272    Info,
273    Warning,
274    Blocked,
275}
276
277///
278/// AdoptionSuggestedActionEffectV1
279///
280#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
281pub enum AdoptionSuggestedActionEffectV1 {
282    ReadOnly,
283    MutatesState,
284}
285
286///
287/// AdoptionSuggestedActionSupportV1
288///
289#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
290pub enum AdoptionSuggestedActionSupportV1 {
291    SupportedByAdoption,
292    UnsupportedByAdoption,
293}
294
295///
296/// AdoptionSuggestedActionAvailabilityV1
297///
298#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
299pub enum AdoptionSuggestedActionAvailabilityV1 {
300    AllowedIn0500,
301    BlockedIn0500,
302}
303
304///
305/// AdoptionOperatorActionRequirementV1
306///
307#[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
338///
339/// adoption_report_from_config_source
340///
341pub 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;