seaplane_cli/context/
flight.rs

1use seaplane::{
2    api::compute::v1::{Architecture as ArchitectureModel, Flight as FlightModel},
3    rexports::container_image_ref::ImageReference,
4};
5
6use crate::{
7    cli::{
8        cmds::flight::{
9            common::Architecture, str_to_image_ref, SeaplaneFlightCommonArgMatches,
10            FLIGHT_MINIMUM_DEFAULT,
11        },
12        validator::validate_flight_name,
13    },
14    context::Ctx,
15    error::{CliErrorKind, Result},
16    ops::generate_flight_name,
17};
18
19/// Represents the "Source of Truth" i.e. it combines all the CLI options, ENV vars, and config
20/// values into a single structure that can be used later to build models for the API or local
21/// structs for serializing
22// TODO: we may not want to derive this we implement circular references
23#[derive(Debug, Clone)]
24pub struct FlightCtx {
25    pub image: Option<ImageReference>,
26    pub name_id: String,
27    pub minimum: u64,
28    pub maximum: Option<u64>,
29    pub architecture: Vec<ArchitectureModel>,
30    pub api_permission: bool,
31    pub reset_maximum: bool,
32    // True if we randomly generated the name. False if the user provided it
33    pub generated_name: bool,
34}
35
36impl Default for FlightCtx {
37    fn default() -> Self {
38        Self {
39            name_id: generate_flight_name(),
40            image: None,
41            minimum: FLIGHT_MINIMUM_DEFAULT,
42            maximum: None,
43            architecture: Vec::new(),
44            api_permission: false,
45            reset_maximum: false,
46            generated_name: true,
47        }
48    }
49}
50
51impl FlightCtx {
52    /// Builds a FlightCtx from a string value using the inline flight spec syntax:
53    ///
54    /// name=FOO,image=nginx:latest,api-permission,architecture=amd64,minimum=1,maximum,2
55    ///
56    /// Where only image=... is required
57    pub fn from_inline_flight(inline_flight: &str, registry: &str) -> Result<FlightCtx> {
58        if inline_flight.contains(' ') {
59            return Err(CliErrorKind::InlineFlightHasSpace.into_err());
60        }
61
62        let mut fctx = FlightCtx::default();
63
64        let parts = inline_flight.split(',');
65
66        macro_rules! parse_item {
67            ($item:expr, $f:expr) => {{
68                let mut item = $item.split('=');
69                item.next();
70                if let Some(value) = item.next() {
71                    if value.is_empty() {
72                        return Err(
73                            CliErrorKind::InlineFlightMissingValue($item.to_string()).into_err()
74                        );
75                    }
76                    $f(value)
77                } else {
78                    Err(CliErrorKind::InlineFlightMissingValue($item.to_string()).into_err())
79                }
80            }};
81            ($item:expr) => {{
82                parse_item!($item, |n| { Ok(n) })
83            }};
84        }
85
86        for part in parts {
87            match part.trim() {
88                // @TODO technically nameFOOBAR=.. is valid... oh well
89                name if part.starts_with("name") => {
90                    fctx.name_id = parse_item!(name, |n: &str| {
91                        if validate_flight_name(n).is_err() {
92                            Err(CliErrorKind::InlineFlightInvalidName(n.to_string()).into_err())
93                        } else {
94                            Ok(n.to_string())
95                        }
96                    })?;
97                    fctx.generated_name = false;
98                }
99                // @TODO technically imageFOOBAR=.. is valid... oh well
100                img if part.starts_with("image") => {
101                    fctx.image = Some(str_to_image_ref(registry, parse_item!(img)?)?);
102                }
103                // @TODO technically maxFOOBAR=.. is valid... oh well
104                max if part.starts_with("max") => {
105                    fctx.maximum = Some(parse_item!(max)?.parse()?);
106                }
107                // @TODO technically minFOOBAR=.. is valid... oh well
108                min if part.starts_with("min") => {
109                    fctx.minimum = parse_item!(min)?.parse()?;
110                }
111                // @TODO technically archFOOBAR=.. is valid... oh well
112                arch if part.starts_with("arch") => {
113                    fctx.architecture.push(parse_item!(arch)?.parse()?);
114                }
115                "api-permission" | "api-permissions" => {
116                    fctx.api_permission = true;
117                }
118                // @TODO technically api-permissionFOOBAR=.. is valid... oh well
119                perm if part.starts_with("api-permission") => {
120                    let _ = parse_item!(perm, |perm: &str| {
121                        fctx.api_permission = match perm {
122                            t if t.eq_ignore_ascii_case("true") => true,
123                            f if f.eq_ignore_ascii_case("false") => true,
124                            _ => {
125                                return Err(CliErrorKind::InlineFlightUnknownItem(
126                                    perm.to_string(),
127                                )
128                                .into_err());
129                            }
130                        };
131                        Ok(())
132                    });
133                }
134                _ => {
135                    return Err(CliErrorKind::InlineFlightUnknownItem(part.to_string()).into_err());
136                }
137            }
138        }
139
140        if fctx.image.is_none() {
141            return Err(CliErrorKind::InlineFlightMissingImage.into_err());
142        }
143
144        Ok(fctx)
145    }
146
147    /// Builds a FlightCtx from ArgMatches using some `prefix` if any to search for args
148    pub fn from_flight_common(
149        matches: &SeaplaneFlightCommonArgMatches,
150        ctx: &Ctx,
151    ) -> Result<FlightCtx> {
152        let matches = matches.0;
153        let mut generated_name = false;
154        // We generate a random name if one is not provided
155        let name = matches
156            .get_one::<String>("name")
157            .map(ToOwned::to_owned)
158            .unwrap_or_else(|| {
159                generated_name = true;
160                generate_flight_name()
161            });
162
163        // We have to use if let in order to use the ? operator
164        let image = if let Some(s) = matches.get_one::<String>("image") {
165            Some(str_to_image_ref(&ctx.registry, s)?)
166        } else {
167            None
168        };
169
170        Ok(FlightCtx {
171            image,
172            name_id: name,
173            minimum: *matches
174                .get_one::<u64>("minimum")
175                // clap validates valid u64 prior to this
176                .unwrap_or(&FLIGHT_MINIMUM_DEFAULT),
177            maximum: matches.get_one::<u64>("maximum").copied(),
178            // clap validates valid u64 prior to this
179            //.expect("failed to parse valid u64"),
180            architecture: matches
181                .get_many::<Architecture>("architecture")
182                .unwrap_or_default()
183                .map(|a| a.into())
184                .collect(),
185            // because of clap overrides we only have to check api_permissions
186            api_permission: matches.get_flag("api-permission"),
187            reset_maximum: matches.get_flag("no-maximum"),
188            generated_name,
189        })
190    }
191
192    /// Creates a new seaplane::api::compute::v1::Flight from the contained values
193    pub fn model(&self) -> FlightModel {
194        // Create the new Flight model from the CLI inputs
195        let mut flight_model = FlightModel::builder()
196            .name(self.name_id.clone())
197            .minimum(self.minimum);
198
199        #[cfg(feature = "unstable")]
200        {
201            flight_model = flight_model.api_permission(self.api_permission);
202        }
203
204        if let Some(image) = self.image.clone() {
205            flight_model = flight_model.image_reference(image);
206        }
207
208        // We have to conditionally set the `maximum` because the builder takes a `u64` but we have
209        // an `Option<u64>` so can't just blindly overwrite it like we do with `minimum` above.
210        if let Some(n) = self.maximum {
211            flight_model = flight_model.maximum(n);
212        }
213
214        // Add all the architectures. In the CLI they're a Vec but in the Model they're a HashSet
215        // which is the reason for the slightly awkward loop
216        for arch in &self.architecture {
217            flight_model = flight_model.add_architecture(*arch);
218        }
219
220        // Create a new Flight struct we can add to our local JSON "DB"
221        flight_model
222            .build()
223            .expect("Failed to build Flight from inputs")
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::context::DEFAULT_IMAGE_REGISTRY_URL as IR;
231
232    #[test]
233    fn from_inline_flight_valid() {
234        assert!(FlightCtx::from_inline_flight(
235            "image=demos/nginx:latest,name=foo,maximum=2,minimum=2,api-permission,architecture=amd64", IR
236        )
237        .is_ok());
238        assert!(FlightCtx::from_inline_flight(
239            "image=demos/nginx:latest,name=foo,maximum=2,minimum=2,api-permission",
240            IR
241        )
242        .is_ok());
243        assert!(FlightCtx::from_inline_flight(
244            "image=demos/nginx:latest,name=foo,maximum=2,minimum=2",
245            IR
246        )
247        .is_ok());
248        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest,name=foo", IR).is_ok());
249        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest", IR).is_ok());
250        assert!(FlightCtx::from_inline_flight(
251            "image=demos/nginx:latest,name=foo,max=2,minimum=2,api-permission,architecture=amd64",
252            IR
253        )
254        .is_ok());
255        assert!(FlightCtx::from_inline_flight(
256            "image=demos/nginx:latest,name=foo,maximum=2,min=2,api-permission",
257            IR
258        )
259        .is_ok());
260        assert!(
261            FlightCtx::from_inline_flight("image=demos/nginx:latest,api-permissions", IR).is_ok()
262        );
263        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest,arch=amd64", IR).is_ok());
264        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest,arch=arm64", IR).is_ok());
265        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest,api-permission=true", IR)
266            .is_ok(),);
267        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest,api-permission=false", IR)
268            .is_ok());
269    }
270
271    #[test]
272    fn from_inline_flight_invalid() {
273        assert_eq!(FlightCtx::from_inline_flight(
274            "image= demos/nginx:latest,name=foo,maximum=2,minimum=2,api-permission,architecture=amd64", IR
275        )
276        .unwrap_err().kind(), &CliErrorKind::InlineFlightHasSpace);
277        assert_eq!(
278            FlightCtx::from_inline_flight(
279                "image=demos/nginx:latest, name=foo,maximum=2,minimum=2,api-permission",
280                IR
281            )
282            .unwrap_err()
283            .kind(),
284            &CliErrorKind::InlineFlightHasSpace
285        );
286        assert_eq!(
287            FlightCtx::from_inline_flight("name=foo,maximum=2,minimum=2", IR)
288                .unwrap_err()
289                .kind(),
290            &CliErrorKind::InlineFlightMissingImage
291        );
292        assert_eq!(
293            FlightCtx::from_inline_flight(",image=demos/nginx:latest,name=foo", IR)
294                .unwrap_err()
295                .kind(),
296            &CliErrorKind::InlineFlightUnknownItem("".into())
297        );
298        assert_eq!(
299            FlightCtx::from_inline_flight("image=demos/nginx:latest,", IR)
300                .unwrap_err()
301                .kind(),
302            &CliErrorKind::InlineFlightUnknownItem("".into())
303        );
304        assert_eq!(
305            FlightCtx::from_inline_flight("image=demos/nginx:latest,foo", IR)
306                .unwrap_err()
307                .kind(),
308            &CliErrorKind::InlineFlightUnknownItem("foo".into())
309        );
310        assert_eq!(
311            FlightCtx::from_inline_flight("image=demos/nginx:latest,name=invalid_name", IR)
312                .unwrap_err()
313                .kind(),
314            &CliErrorKind::InlineFlightInvalidName("invalid_name".into())
315        );
316        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest,max=2.3", IR)
317            .unwrap_err()
318            .kind()
319            .is_parse_int(),);
320        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest,max=foo", IR)
321            .unwrap_err()
322            .kind()
323            .is_parse_int());
324        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest,arch=foo", IR)
325            .unwrap_err()
326            .kind()
327            .is_strum_parse(),);
328        assert_eq!(
329            FlightCtx::from_inline_flight("image=demos/nginx:latest,name", IR)
330                .unwrap_err()
331                .kind(),
332            &CliErrorKind::InlineFlightMissingValue("name".into())
333        );
334        assert_eq!(
335            FlightCtx::from_inline_flight("image=demos/nginx:latest,name=foo,arch", IR)
336                .unwrap_err()
337                .kind(),
338            &CliErrorKind::InlineFlightMissingValue("arch".into())
339        );
340        assert_eq!(
341            FlightCtx::from_inline_flight("image,name=foo", IR)
342                .unwrap_err()
343                .kind(),
344            &CliErrorKind::InlineFlightMissingValue("image".into())
345        );
346        assert_eq!(
347            FlightCtx::from_inline_flight("image=demos/nginx:latest,name=foo,min=", IR)
348                .unwrap_err()
349                .kind(),
350            &CliErrorKind::InlineFlightMissingValue("min".into())
351        );
352        assert_eq!(
353            FlightCtx::from_inline_flight("image=demos/nginx:latest,name=foo,max=", IR)
354                .unwrap_err()
355                .kind(),
356            &CliErrorKind::InlineFlightMissingValue("max".into())
357        );
358    }
359}