1use std::{collections::BTreeMap, error::Error, fmt};
4
5use boxferry_model::{Identifier, ProvenanceKind, ResourceOwnership, Sourced};
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9#[non_exhaustive]
10pub enum RuntimeResourceKind {
11 Network,
13 Volume,
15 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#[derive(Clone, Debug, Eq, PartialEq)]
31#[non_exhaustive]
32pub enum RuntimeResolutionError {
33 UnsupportedOwnership {
35 kind: RuntimeResourceKind,
37 name: String,
39 },
40 MissingUserOverride {
42 kind: RuntimeResourceKind,
44 name: String,
46 },
47 Duplicate {
49 kind: RuntimeResourceKind,
51 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#[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 #[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 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 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 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 #[must_use]
149 pub fn network_ownership(&self, name: &Identifier) -> Option<&Sourced<ResourceOwnership>> {
150 self.networks.get(name)
151 }
152
153 #[must_use]
155 pub fn volume_ownership(&self, name: &Identifier) -> Option<&Sourced<ResourceOwnership>> {
156 self.volumes.get(name)
157 }
158
159 #[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}