1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
#[cfg(not(any(feature = "ui_tests", feature = "semantic_ui_tests")))]
use std::{
    fs,
    io::{self, Read},
    path::Path,
};

use seaplane::{
    api::compute::v2::Flight as FlightModel, rexports::container_image_ref::ImageReference,
};
use serde::Deserialize;

#[cfg(not(any(feature = "ui_tests", feature = "semantic_ui_tests")))]
use crate::{error::Context, printer::Color};
use crate::{
    error::{CliError, CliErrorKind, Result},
    ops::{flight::str_to_image_ref, generate_name, validator::validate_name},
};

/// Represents the "Source of Truth" i.e. it combines all the CLI options, ENV vars, and config
/// values into a single structure that can be used later to build models for the API or local
/// structs for serializing
// TODO: we may not want to derive this we implement circular references
#[derive(Debug, Clone, Deserialize)]
pub struct FlightCtx {
    pub image: ImageReference,
    #[serde(rename = "name")]
    pub name_id: Option<String>,
    // True if we randomly generated the name. False if the user provided it
    #[serde(skip)]
    pub generated_name: bool,
}

impl FlightCtx {
    /// Builds a FlightCtx from a string value using the inline flight spec syntax:
    ///
    /// name=FOO,image=nginx:latest
    ///
    /// Where only image=... is required
    pub fn from_inline_flight(inline_flight: &str, registry: &str) -> Result<FlightCtx> {
        if inline_flight.contains(' ') {
            return Err(CliErrorKind::InlineFlightHasSpace.into_err());
        }

        let parts = inline_flight.split(',');

        macro_rules! parse_item {
            ($item:expr, $f:expr) => {{
                let mut item = $item.split('=');
                item.next();
                if let Some(value) = item.next() {
                    if value.is_empty() {
                        return Err(
                            CliErrorKind::InlineFlightMissingValue($item.to_string()).into_err()
                        );
                    }
                    $f(value)
                } else {
                    Err(CliErrorKind::InlineFlightMissingValue($item.to_string()).into_err())
                }
            }};
            ($item:expr) => {{
                parse_item!($item, |n| { Ok(n) })
            }};
        }

        let mut image = None;
        let mut generated_name = true;
        let mut name_id = None;

        for part in parts {
            match part.trim() {
                // @TODO technically nameFOOBAR=.. is valid... oh well
                name if part.starts_with("name") => {
                    name_id = parse_item!(name, |n: &str| {
                        if validate_name(n).is_err() {
                            Err(CliErrorKind::InlineFlightInvalidName(n.to_string()).into_err())
                        } else {
                            Ok(Some(n.to_string()))
                        }
                    })?;
                    generated_name = false;
                }
                // @TODO technically imageFOOBAR=.. is valid... oh well
                img if part.starts_with("image") => {
                    image = Some(str_to_image_ref(registry, parse_item!(img)?)?);
                }
                _ => {
                    return Err(CliErrorKind::InlineFlightUnknownItem(part.to_string()).into_err());
                }
            }
        }

        if image.is_none() {
            return Err(CliErrorKind::InlineFlightMissingImage.into_err());
        }
        if generated_name {
            name_id = Some(generate_name());
        }

        Ok(FlightCtx { image: image.unwrap(), name_id, generated_name })
    }

    /// Try to deserialize a Flight from a JSON string or convert to a CLI Error
    pub fn from_json(s: &str) -> Result<Self> { serde_json::from_str(s).map_err(CliError::from) }

    /// Create from an string which can be a PATH, `-` (STDIN), or the INLINE spec.
    pub fn from_str(flight: &str, registry: &str) -> Result<Vec<Self>> {
        cfg_if::cfg_if! {
            if #[cfg(not(any(feature = "ui_tests", feature = "semantic_ui_tests")))] {
                // First try to create for a - (STDIN)
                if flight == "-" {
                    let mut buf = String::new();
                    let stdin = io::stdin();
                    let mut stdin_lock = stdin.lock();
                    stdin_lock.read_to_string(&mut buf)?;

                    return Ok(vec![FlightCtx::from_json(&buf)?]);
                }

                if flight.contains('=') {
                    return Ok(vec![FlightCtx::from_inline_flight(flight, registry)?]);
                }

                let mut res = Vec::new();
                for path in flight.split(',') {
                    if Path::exists(path.as_ref()) {
                        // next try to create if using path
                        res.push(FlightCtx::from_json(
                            &fs::read_to_string(path)
                                .map_err(CliError::from)
                                .context("\n\tpath: ")
                                .with_color_context(|| (Color::Yellow, path))?,
                        )?);
                    }
                }

                if res.is_empty() {
                    return Err(CliErrorKind::InvalidCliValue(None, flight.into()).into_err());
                }
                Ok(res)
            } else {
                // We're in a UI tests so just try to parse an inline spec and ignore everything
                // else
                if flight.contains('=') {
                    return Ok(vec![FlightCtx::from_inline_flight(flight, registry)?]);
                }
                Ok(Vec::new())
            }
        }
    }

    /// Creates a new seaplane::api::compute::v2::Flight from the contained values
    pub fn model(&self) -> FlightModel {
        // Create the new Flight model from the CLI inputs
        let flight_model = FlightModel::builder()
            .name(
                self.name_id
                    .clone()
                    .or_else(|| Some(generate_name()))
                    .unwrap(),
            )
            .image_reference(self.image.clone());

        // Create a new Flight struct we can add to our local JSON "DB"
        flight_model
            .build()
            .expect("Failed to build Flight from inputs")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::DEFAULT_IMAGE_REGISTRY_URL as IR;

    #[test]
    fn from_inline_flight_valid() {
        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest,name=foo", IR).is_ok());
        assert!(FlightCtx::from_inline_flight("image=demos/nginx:latest", IR).is_ok());
    }

    #[test]
    fn from_inline_flight_invalid() {
        assert_eq!(
            FlightCtx::from_inline_flight("image= demos/nginx:latest,name=foo", IR)
                .unwrap_err()
                .kind(),
            &CliErrorKind::InlineFlightHasSpace
        );
        assert_eq!(
            FlightCtx::from_inline_flight("image=demos/nginx:latest, name=foo", IR)
                .unwrap_err()
                .kind(),
            &CliErrorKind::InlineFlightHasSpace
        );
        assert_eq!(
            FlightCtx::from_inline_flight("name=foo", IR)
                .unwrap_err()
                .kind(),
            &CliErrorKind::InlineFlightMissingImage
        );
        assert_eq!(
            FlightCtx::from_inline_flight(",image=demos/nginx:latest,name=foo", IR)
                .unwrap_err()
                .kind(),
            &CliErrorKind::InlineFlightUnknownItem("".into())
        );
        assert_eq!(
            FlightCtx::from_inline_flight("image=demos/nginx:latest,", IR)
                .unwrap_err()
                .kind(),
            &CliErrorKind::InlineFlightUnknownItem("".into())
        );
        assert_eq!(
            FlightCtx::from_inline_flight("image=demos/nginx:latest,foo", IR)
                .unwrap_err()
                .kind(),
            &CliErrorKind::InlineFlightUnknownItem("foo".into())
        );
        assert_eq!(
            FlightCtx::from_inline_flight("image=demos/nginx:latest,name=invalid_name", IR)
                .unwrap_err()
                .kind(),
            &CliErrorKind::InlineFlightInvalidName("invalid_name".into())
        );
        assert_eq!(
            FlightCtx::from_inline_flight("image=demos/nginx:latest,name", IR)
                .unwrap_err()
                .kind(),
            &CliErrorKind::InlineFlightMissingValue("name".into())
        );
        assert_eq!(
            FlightCtx::from_inline_flight("image,name=foo", IR)
                .unwrap_err()
                .kind(),
            &CliErrorKind::InlineFlightMissingValue("image".into())
        );
    }
}