Skip to main content

boxferry_runtime/
resolution.rs

1//! Explicit caller resolutions for lifecycle facts that runtime inspection cannot prove.
2
3use std::{collections::BTreeMap, error::Error, fmt};
4
5use boxferry_model::{Identifier, ProvenanceKind, ResourceOwnership, Sourced};
6
7/// Kind of runtime-observed resource whose lifecycle is being resolved.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9#[non_exhaustive]
10pub enum RuntimeResourceKind {
11    /// A named network.
12    Network,
13    /// A named volume.
14    Volume,
15    /// A structural service group reconstructed from a runtime pod.
16    ServiceGroup,
17}
18
19impl fmt::Display for RuntimeResourceKind {
20    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
21        formatter.write_str(match self {
22            Self::Network => "network",
23            Self::Volume => "volume",
24            Self::ServiceGroup => "service group",
25        })
26    }
27}
28
29/// Invalid or ambiguous caller-supplied runtime lifecycle resolution.
30#[derive(Clone, Debug, Eq, PartialEq)]
31#[non_exhaustive]
32pub enum RuntimeResolutionError {
33    /// Only application-owned and external resources can resolve runtime lifecycle uncertainty.
34    UnsupportedOwnership {
35        /// Resource kind being resolved.
36        kind: RuntimeResourceKind,
37        /// Neutral resource name.
38        name: String,
39    },
40    /// A resolution did not carry explicit caller/user-override provenance.
41    MissingUserOverride {
42        /// Resource kind being resolved.
43        kind: RuntimeResourceKind,
44        /// Neutral resource name.
45        name: String,
46    },
47    /// The same resource already has a resolution.
48    Duplicate {
49        /// Resource kind being resolved.
50        kind: RuntimeResourceKind,
51        /// Neutral resource name.
52        name: String,
53    },
54}
55
56impl fmt::Display for RuntimeResolutionError {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            Self::UnsupportedOwnership { kind, name } => write!(
60                formatter,
61                "runtime {kind} `{name}` must be resolved as application-owned or external"
62            ),
63            Self::MissingUserOverride { kind, name } => write!(
64                formatter,
65                "runtime {kind} `{name}` resolution requires user-override provenance"
66            ),
67            Self::Duplicate { kind, name } => {
68                write!(formatter, "runtime {kind} `{name}` already has a lifecycle resolution")
69            }
70        }
71    }
72}
73
74impl Error for RuntimeResolutionError {}
75
76/// Finite caller-owned lifecycle decisions applied during runtime reconstruction.
77///
78/// Every entry is keyed by its exact neutral resource name. A decision must select either
79/// [`ResourceOwnership::Application`] or [`ResourceOwnership::External`] and must carry at least
80/// one [`ProvenanceKind::UserOverride`] origin. Resolutions are never inferred, replaced, or
81/// applied as blanket defaults.
82#[derive(Clone, Debug, Default, Eq, PartialEq)]
83pub struct RuntimeResolutions {
84    networks: BTreeMap<Identifier, Sourced<ResourceOwnership>>,
85    volumes: BTreeMap<Identifier, Sourced<ResourceOwnership>>,
86    service_groups: BTreeMap<Identifier, Sourced<ResourceOwnership>>,
87}
88
89impl RuntimeResolutions {
90    /// Creates an empty resolution set.
91    #[must_use]
92    pub const fn new() -> Self {
93        Self {
94            networks: BTreeMap::new(),
95            volumes: BTreeMap::new(),
96            service_groups: BTreeMap::new(),
97        }
98    }
99
100    /// Resolves lifecycle ownership for one exact network name.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`RuntimeResolutionError`] for an unsupported ownership value, missing explicit
105    /// user-override provenance, or a duplicate network resolution.
106    pub fn set_network_ownership(
107        &mut self,
108        name: Identifier,
109        ownership: Sourced<ResourceOwnership>,
110    ) -> Result<(), RuntimeResolutionError> {
111        insert_resolution(&mut self.networks, RuntimeResourceKind::Network, name, ownership)
112    }
113
114    /// Resolves lifecycle ownership for one exact volume name.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`RuntimeResolutionError`] for an unsupported ownership value, missing explicit
119    /// user-override provenance, or a duplicate volume resolution.
120    pub fn set_volume_ownership(
121        &mut self,
122        name: Identifier,
123        ownership: Sourced<ResourceOwnership>,
124    ) -> Result<(), RuntimeResolutionError> {
125        insert_resolution(&mut self.volumes, RuntimeResourceKind::Volume, name, ownership)
126    }
127
128    /// Resolves lifecycle ownership for one exact structural service-group name.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`RuntimeResolutionError`] for an unsupported ownership value, missing explicit
133    /// user-override provenance, or a duplicate service-group resolution.
134    pub fn set_service_group_ownership(
135        &mut self,
136        name: Identifier,
137        ownership: Sourced<ResourceOwnership>,
138    ) -> Result<(), RuntimeResolutionError> {
139        insert_resolution(
140            &mut self.service_groups,
141            RuntimeResourceKind::ServiceGroup,
142            name,
143            ownership,
144        )
145    }
146
147    /// Returns the exact network resolution, when present.
148    #[must_use]
149    pub fn network_ownership(&self, name: &Identifier) -> Option<&Sourced<ResourceOwnership>> {
150        self.networks.get(name)
151    }
152
153    /// Returns the exact volume resolution, when present.
154    #[must_use]
155    pub fn volume_ownership(&self, name: &Identifier) -> Option<&Sourced<ResourceOwnership>> {
156        self.volumes.get(name)
157    }
158
159    /// Returns the exact service-group resolution, when present.
160    #[must_use]
161    pub fn service_group_ownership(&self, name: &Identifier) -> Option<&Sourced<ResourceOwnership>> {
162        self.service_groups.get(name)
163    }
164}
165
166fn insert_resolution(
167    resolutions: &mut BTreeMap<Identifier, Sourced<ResourceOwnership>>,
168    kind: RuntimeResourceKind,
169    name: Identifier,
170    ownership: Sourced<ResourceOwnership>,
171) -> Result<(), RuntimeResolutionError> {
172    let error_name = name.as_str().to_owned();
173    if !matches!(
174        ownership.value(),
175        ResourceOwnership::Application | ResourceOwnership::External
176    ) {
177        return Err(RuntimeResolutionError::UnsupportedOwnership { kind, name: error_name });
178    }
179    if !ownership
180        .origins()
181        .iter()
182        .any(|origin| origin.kind() == ProvenanceKind::UserOverride)
183    {
184        return Err(RuntimeResolutionError::MissingUserOverride { kind, name: error_name });
185    }
186    if resolutions.contains_key(&name) {
187        return Err(RuntimeResolutionError::Duplicate { kind, name: error_name });
188    }
189    resolutions.insert(name, ownership);
190    Ok(())
191}
192
193#[cfg(test)]
194mod tests {
195    use boxferry_model::{Identifier, Provenance, ResourceOwnership, SourceId, Sourced};
196
197    use super::{RuntimeResolutionError, RuntimeResolutions, RuntimeResourceKind};
198
199    #[test]
200    fn requires_supported_ownership_and_explicit_user_provenance() -> Result<(), String> {
201        let name = Identifier::new("data").map_err(|error| error.to_string())?;
202        let source = SourceId::new("decision:runtime-lifecycle").map_err(|error| error.to_string())?;
203        let mut resolutions = RuntimeResolutions::new();
204
205        assert_eq!(
206            resolutions.set_volume_ownership(
207                name.clone(),
208                Sourced::from_source(ResourceOwnership::Uncertain, Provenance::user_override(source.clone())),
209            ),
210            Err(RuntimeResolutionError::UnsupportedOwnership {
211                kind: RuntimeResourceKind::Volume,
212                name: "data".to_owned(),
213            })
214        );
215        assert_eq!(
216            resolutions.set_volume_ownership(
217                name.clone(),
218                Sourced::from_source(ResourceOwnership::Application, Provenance::runtime_observation(source)),
219            ),
220            Err(RuntimeResolutionError::MissingUserOverride {
221                kind: RuntimeResourceKind::Volume,
222                name: "data".to_owned(),
223            })
224        );
225        Ok(())
226    }
227
228    #[test]
229    fn retains_one_resolution_per_exact_resource_name() -> Result<(), String> {
230        let name = Identifier::new("frontend").map_err(|error| error.to_string())?;
231        let source = SourceId::new("decision:runtime-lifecycle").map_err(|error| error.to_string())?;
232        let ownership = || Sourced::from_source(ResourceOwnership::External, Provenance::user_override(source.clone()));
233        let mut resolutions = RuntimeResolutions::new();
234
235        resolutions
236            .set_network_ownership(name.clone(), ownership())
237            .map_err(|error| error.to_string())?;
238        assert_eq!(
239            resolutions.set_network_ownership(name.clone(), ownership()),
240            Err(RuntimeResolutionError::Duplicate {
241                kind: RuntimeResourceKind::Network,
242                name: "frontend".to_owned(),
243            })
244        );
245        assert_eq!(
246            resolutions.network_ownership(&name).map(Sourced::value),
247            Some(&ResourceOwnership::External)
248        );
249        Ok(())
250    }
251}