seaplane_cli/ops/
flight.rs

1use std::{
2    fs,
3    io::{self, Read, Write},
4    path::{Path, PathBuf},
5};
6
7use seaplane::api::compute::v1::Flight as FlightModel;
8use serde::{Deserialize, Serialize};
9use tabwriter::TabWriter;
10
11use crate::{
12    context::{Ctx, FlightCtx},
13    error::{CliError, CliErrorKind, Context, Result},
14    fs::{FromDisk, ToDisk},
15    ops::Id,
16    printer::{Color, Output},
17};
18
19/// A wrapper round a Flight model
20#[derive(Deserialize, Serialize, Clone, Debug)]
21pub struct Flight {
22    pub id: Id,
23    #[serde(flatten)]
24    pub model: FlightModel,
25}
26
27impl Flight {
28    pub fn new(model: FlightModel) -> Self { Self { id: Id::new(), model } }
29
30    pub fn from_json(s: &str) -> Result<Flight> { serde_json::from_str(s).map_err(CliError::from) }
31
32    pub fn starts_with(&self, s: &str) -> bool {
33        self.id.to_string().starts_with(s) || self.model.name().starts_with(s)
34    }
35
36    /// Applies the non-default differences from `ctx`
37    pub fn update_from(&mut self, ctx: &FlightCtx, keep_src_name: bool) -> Result<()> {
38        let mut dest_builder = FlightModel::builder();
39
40        // Name
41        if keep_src_name {
42            dest_builder = dest_builder.name(self.model.name());
43        } else {
44            dest_builder = dest_builder.name(&ctx.name_id);
45        }
46
47        if let Some(image) = ctx.image.clone() {
48            dest_builder = dest_builder.image_reference(image);
49        } else {
50            dest_builder = dest_builder.image_reference(self.model.image().clone());
51        }
52
53        if ctx.minimum != 1 {
54            dest_builder = dest_builder.minimum(ctx.minimum);
55        } else {
56            dest_builder = dest_builder.minimum(self.model.minimum());
57        }
58
59        if let Some(max) = ctx.maximum {
60            dest_builder = dest_builder.maximum(max);
61        } else if ctx.reset_maximum {
62            dest_builder.clear_maximum();
63        } else if let Some(max) = self.model.maximum() {
64            dest_builder = dest_builder.maximum(max);
65        }
66
67        // Architecture
68        for arch in ctx.architecture.iter().chain(self.model.architecture()) {
69            dest_builder = dest_builder.add_architecture(*arch);
70        }
71
72        // API Permission
73        #[cfg(feature = "unstable")]
74        {
75            let orig_api_perms = self.model.api_permission();
76            let cli_api_perms = ctx.api_permission;
77            match (orig_api_perms, cli_api_perms) {
78                (true, false) => dest_builder = dest_builder.api_permission(false),
79                (false, true) => dest_builder = dest_builder.api_permission(true),
80                _ => (),
81            }
82        }
83
84        self.model = dest_builder.build().expect("Failed to build Flight");
85        Ok(())
86    }
87
88    /// Creates a Flight from either a JSON string in STDIN (@-) or a path pointed to by @path.
89    fn from_at_str(flight: &str) -> Result<Self> {
90        // First try to create for a @- (STDIN)
91        if flight == "@-" {
92            let mut buf = String::new();
93            let stdin = io::stdin();
94            let mut stdin_lock = stdin.lock();
95            stdin_lock.read_to_string(&mut buf)?;
96
97            // TODO: we need to check for and handle duplicates
98            let new_flight = Flight::from_json(&buf)?;
99            return Ok(new_flight);
100        // next try to create if using @path
101        } else if let Some(path) = flight.strip_prefix('@') {
102            let new_flight = Flight::from_json(
103                &fs::read_to_string(path)
104                    .map_err(CliError::from)
105                    .context("\n\tpath: ")
106                    .with_color_context(|| (Color::Yellow, path))?,
107            )?;
108            return Ok(new_flight);
109        }
110
111        Err(CliErrorKind::InvalidCliValue(None, flight.into()).into_err())
112    }
113}
114
115#[derive(Debug, Deserialize, Serialize, Default, Clone)]
116#[serde(transparent)]
117pub struct Flights {
118    #[serde(skip)]
119    loaded_from: Option<PathBuf>,
120    inner: Vec<Flight>,
121}
122
123impl FromDisk for Flights {
124    fn set_loaded_from<P: AsRef<Path>>(&mut self, p: P) {
125        self.loaded_from = Some(p.as_ref().into());
126    }
127
128    fn loaded_from(&self) -> Option<&Path> { self.loaded_from.as_deref() }
129}
130
131impl ToDisk for Flights {}
132
133impl Flights {
134    /// Takes strings in the form of @- or @path and creates then adds them to the DB. Only one @-
135    /// may be used or an Error is returned.
136    ///
137    /// Returns a list of any created IDs
138    pub fn add_from_at_strs<S>(&mut self, flights: Vec<S>) -> Result<Vec<String>>
139    where
140        S: AsRef<str>,
141    {
142        if flights.iter().filter(|f| f.as_ref() == "@-").count() > 1 {
143            return Err(CliErrorKind::MultipleAtStdin.into_err());
144        }
145        let mut ret = Vec::new();
146
147        for flight in flights {
148            let new_flight = Flight::from_at_str(flight.as_ref())?;
149            ret.push(new_flight.model.name().to_owned());
150            self.inner.push(new_flight);
151        }
152
153        Ok(ret)
154    }
155
156    /// Removes any Flight definitions from the matching indices and returns a Vec of all removed
157    /// Flights
158    pub fn remove_indices(&mut self, indices: &[usize]) -> Vec<Flight> {
159        // TODO: There is probably a much more performant way to remove a bunch of times from a Vec
160        // but we're talking such a small number of items this should never matter.
161
162        indices
163            .iter()
164            .enumerate()
165            .map(|(i, idx)| self.inner.remove(idx - i))
166            .collect()
167    }
168
169    /// Returns all indices of where the flight's Name or ID (as hex) begins with the `needle`
170    pub fn indices_of_left_matches(&self, needle: &str) -> Vec<usize> {
171        // TODO: Are there any names that also coincide with valid hex?
172        self.inner
173            .iter()
174            .enumerate()
175            .filter(|(_idx, flight)| flight.starts_with(needle))
176            .map(|(idx, _flight)| idx)
177            .collect()
178    }
179
180    /// Returns all indices of where the flight's Name or ID (as hex) is an exact match of `needle`
181    pub fn indices_of_matches(&self, needle: &str) -> Vec<usize> {
182        // TODO: Are there any names that also coincide with valid hex?
183        self.inner
184            .iter()
185            .enumerate()
186            .filter(|(_idx, flight)| {
187                flight.id.to_string() == needle || flight.model.name() == needle
188            })
189            .map(|(idx, _flight)| idx)
190            .collect()
191    }
192
193    pub fn iter(&self) -> impl Iterator<Item = &Flight> { self.inner.iter() }
194
195    pub fn clone_flight(&mut self, src: &str, exact: bool) -> Result<Flight> {
196        let src_flight = self.remove_flight(src, exact)?;
197        let model = src_flight.model.clone();
198
199        // Re add the source flight
200        self.inner.push(src_flight);
201
202        Ok(Flight::new(model))
203    }
204
205    /// Either updates a matching local flight, or creates a new one. Returns NEW flight names and
206    /// IDs
207    pub fn update_or_create_flight(&mut self, model: &FlightModel) -> Vec<(String, Id)> {
208        let mut found = false;
209        let mut ret = Vec::new();
210        for flight in self
211            .inner
212            .iter_mut()
213            .filter(|f| f.model.name() == model.name() && f.model.image_str() == model.image_str())
214        {
215            found = true;
216            flight.model.set_minimum(model.minimum());
217            flight.model.set_maximum(model.maximum());
218
219            for arch in model.architecture() {
220                flight.model.add_architecture(*arch);
221            }
222
223            #[cfg(feature = "unstable")]
224            {
225                flight.model.set_api_permission(model.api_permission());
226            }
227        }
228
229        if !found {
230            let f = Flight::new(model.clone());
231            ret.push((f.model.name().to_owned(), f.id));
232            self.inner.push(f);
233        }
234
235        ret
236    }
237
238    pub fn update_flight(&mut self, src: &str, exact: bool, ctx: &FlightCtx) -> Result<()> {
239        let mut src_flight = self.remove_flight(src, exact)?;
240        src_flight.update_from(ctx, ctx.generated_name)?;
241
242        // Re add the source flight
243        self.inner.push(src_flight);
244
245        Ok(())
246    }
247
248    pub fn add_flight(&mut self, flight: Flight) { self.inner.push(flight); }
249
250    pub fn remove_flight(&mut self, src: &str, exact: bool) -> Result<Flight> {
251        // Ensure only one current Flight matches what the user gave
252        // TODO: We should check if these ones we remove are referenced remote or not
253        let indices =
254            if exact { self.indices_of_matches(src) } else { self.indices_of_left_matches(src) };
255        match indices.len() {
256            0 => return Err(CliErrorKind::NoMatchingItem(src.into()).into_err()),
257            1 => (),
258            _ => return Err(CliErrorKind::AmbiguousItem(src.into()).into_err()),
259        }
260
261        Ok(self.remove_indices(&indices).pop().unwrap())
262    }
263
264    pub fn find_name(&self, name: &str) -> Option<&Flight> {
265        self.inner.iter().find(|f| f.model.name() == name)
266    }
267
268    pub fn find_name_or_partial_id(&self, needle: &str) -> Option<&Flight> {
269        self.inner
270            .iter()
271            .find(|f| f.model.name() == needle || f.id.to_string().starts_with(needle))
272    }
273}
274
275impl Output for Flights {
276    fn print_json(&self, _ctx: &Ctx) -> Result<()> {
277        cli_println!("{}", serde_json::to_string(self)?);
278
279        Ok(())
280    }
281
282    fn print_table(&self, ctx: &Ctx) -> Result<()> {
283        let buf = Vec::new();
284        let mut tw = TabWriter::new(buf);
285        // TODO: Add local/remote formation references
286        writeln!(tw, "LOCAL ID\tNAME\tIMAGE\tMIN\tMAX\tARCH\tAPI PERMS")?;
287        for flight in self.iter() {
288            let arch = flight
289                .model
290                .architecture()
291                .map(ToString::to_string)
292                .collect::<Vec<_>>()
293                .join(",");
294
295            #[cfg_attr(not(feature = "unstable"), allow(unused_mut))]
296            let mut api_perms = false;
297            // Due to our use of cfg and Rust's "unused-assignment" lint
298            let _ = api_perms;
299            #[cfg(feature = "unstable")]
300            {
301                api_perms = flight.model.api_permission();
302            }
303            writeln!(
304                tw,
305                "{}\t{}\t{}\t{}\t{}\t{}\t{}",
306                &flight.id.to_string()[..8], // TODO: make sure length is not ambiguous
307                flight.model.name(),
308                flight.model.image_str().trim_start_matches(&ctx.registry),
309                flight.model.minimum(),
310                flight
311                    .model
312                    .maximum()
313                    .map(|n| format!("{n}"))
314                    .unwrap_or_else(|| "INF".into()),
315                if arch.is_empty() { "auto" } else { &*arch },
316                api_perms,
317            )?;
318        }
319        tw.flush()?;
320
321        cli_println!(
322            "{}",
323            String::from_utf8_lossy(
324                &tw.into_inner()
325                    .map_err(|_| CliError::bail("IO flush error"))?
326            )
327        );
328
329        Ok(())
330    }
331}