seaplane_cli/context/
formation.rs

1use std::collections::HashSet;
2
3use seaplane::api::{
4    compute::v1::FormationConfiguration as FormationConfigurationModel,
5    shared::v1::{Provider as ProviderModel, Region as RegionModel},
6};
7
8use crate::{
9    cli::{cmds::formation::SeaplaneFormationPlanArgMatches, Provider, Region},
10    context::Ctx,
11    error::{CliError, CliErrorKind, Context, Result},
12    ops::{flight::Flights, formation::Endpoint, generate_formation_name},
13    printer::Color,
14};
15
16fn no_matching_flight(flight: &str) -> CliError {
17    CliErrorKind::NoMatchingItem(flight.to_string())
18        .into_err()
19        .context("(hint: create the Flight Plan with '")
20        .with_color_context(|| (Color::Green, format!("seaplane flight plan {flight}")))
21        .context("')\n")
22        .context("(hint: or try fetching remote definitions with '")
23        .color_context(Color::Green, "seaplane formation fetch-remote")
24        .context("')\n")
25}
26
27/// Represents the "Source of Truth" i.e. it combines all the CLI options, ENV vars, and config
28/// values into a single structure that can be used later to build models for the API or local
29/// structs for serializing
30///
31/// A somewhat counter-intuitive thing about Formations and their models is the there is no
32/// "Formation Model" only a "Formation Configuration Model" This is because a "Formation" so to
33/// speak is really just a named collection of configurations and info about their traffic
34/// weights/activation statuses.
35// TODO: we may not want to derive this we implement circular references
36#[derive(Debug, Clone)]
37pub struct FormationCtx {
38    pub name_id: String,
39    pub launch: bool,
40    pub remote: bool,
41    pub local: bool,
42    pub grounded: bool,
43    pub recursive: bool,
44    // TODO: make multiple possible
45    pub cfg_ctx: FormationCfgCtx,
46}
47
48impl Default for FormationCtx {
49    fn default() -> Self {
50        Self {
51            name_id: generate_formation_name(),
52            launch: false,
53            cfg_ctx: FormationCfgCtx::default(),
54            remote: false,
55            local: true,
56            grounded: false,
57            recursive: false,
58        }
59    }
60}
61
62impl FormationCtx {
63    /// `flight` is the name of the argument for the Flight's name/id
64    pub fn update_from_formation_plan(
65        &mut self,
66        matches: &SeaplaneFormationPlanArgMatches,
67        flights_db: &Flights,
68    ) -> Result<()> {
69        let matches = matches.0;
70        // TODO: check if "all" was used along with another value within regions/providers and err
71
72        let mut flight_names = Vec::new();
73
74        // Translate the flight NAME|ID into a NAME
75        for flight in matches
76            .get_many::<String>("include-flight-plan")
77            .unwrap_or_default()
78            // Filter out @ strings and inline definitions
79            .filter(|f| !(f.starts_with('@') || f.contains('=')))
80        {
81            // Try to lookup either a partial ID match, or exact NAME match
82            if let Some(flight) = flights_db.find_name_or_partial_id(flight) {
83                // Look for exact name matches, or partial ID matches and map to their name
84                flight_names.push(flight.model.name().to_owned());
85            } else {
86                #[cfg(not(any(feature = "ui_tests", feature = "semantic_ui_tests",)))]
87                {
88                    // No match
89                    return Err(no_matching_flight(flight));
90                }
91            }
92        }
93
94        self.name_id = matches
95            .get_one::<String>("name_id")
96            .map(ToOwned::to_owned)
97            .unwrap_or_else(generate_formation_name);
98
99        self.grounded = matches.get_flag("grounded");
100        self.launch = matches.get_flag("launch");
101        self.cfg_ctx
102            .flights
103            .extend(flight_names.iter().map(|s| s.to_string()));
104        self.cfg_ctx.affinities.extend(
105            matches
106                .get_many::<String>("affinity")
107                .unwrap_or_default()
108                .map(ToOwned::to_owned),
109        );
110        self.cfg_ctx.connections.extend(
111            matches
112                .get_many::<String>("connection")
113                .unwrap_or_default()
114                .map(ToOwned::to_owned),
115        );
116        self.cfg_ctx.providers_allowed = matches
117            .get_many::<Provider>("provider")
118            .unwrap_or_default()
119            .filter_map(Provider::into_model)
120            .collect();
121        self.cfg_ctx.providers_denied = matches
122            .get_many::<Provider>("exclude-provider")
123            .unwrap_or_default()
124            .filter_map(Provider::into_model)
125            .collect();
126        self.cfg_ctx.regions_allowed = matches
127            .get_many::<Region>("region")
128            .unwrap_or_default()
129            .filter_map(Region::into_model)
130            .collect();
131        self.cfg_ctx.regions_denied = matches
132            .get_many::<Region>("exclude-region")
133            .unwrap_or_default()
134            .filter_map(Region::into_model)
135            .collect();
136        self.cfg_ctx.public_endpoints = matches
137            .get_many::<Endpoint>("public-endpoint")
138            .unwrap_or_default()
139            .cloned()
140            .collect();
141        self.cfg_ctx.formation_endpoints = matches
142            .get_many::<Endpoint>("formation-endpoint")
143            .unwrap_or_default()
144            .cloned()
145            .collect();
146        self.cfg_ctx.flight_endpoints = matches
147            .get_many::<Endpoint>("flight-endpoint")
148            .unwrap_or_default()
149            .cloned()
150            .collect();
151        Ok(())
152    }
153
154    /// Creates a new seaplane::api::compute::v1::FormationConfiguration from the contained values
155    pub fn configuration_model(&self, ctx: &Ctx) -> Result<FormationConfigurationModel> {
156        // Create the new Formation model from the CLI inputs
157        let mut f_model = FormationConfigurationModel::builder();
158
159        for flight_name in &self.cfg_ctx.flights {
160            let flight = ctx
161                .db
162                .flights
163                .find_name(flight_name)
164                .ok_or_else(|| no_matching_flight(flight_name))?;
165            f_model = f_model.add_flight(flight.model.clone());
166        }
167
168        // TODO: clean this up...yuck
169        for &item in &self.cfg_ctx.providers_allowed {
170            f_model = f_model.add_allowed_provider(item);
171        }
172        for &item in &self.cfg_ctx.providers_denied {
173            f_model = f_model.add_denied_provider(item);
174        }
175        for &item in &self.cfg_ctx.regions_allowed {
176            f_model = f_model.add_allowed_region(item);
177        }
178        for &item in &self.cfg_ctx.regions_denied {
179            f_model = f_model.add_denied_region(item);
180        }
181        for item in &self.cfg_ctx.public_endpoints {
182            f_model = f_model.add_public_endpoint(item.key(), item.value());
183        }
184        for item in &self.cfg_ctx.flight_endpoints {
185            f_model = f_model.add_flight_endpoint(item.key(), item.value());
186        }
187        #[cfg(feature = "unstable")]
188        {
189            for item in &self.cfg_ctx.affinities {
190                f_model = f_model.add_affinity(item);
191            }
192            for item in &self.cfg_ctx.connections {
193                f_model = f_model.add_connection(item);
194            }
195            for item in &self.cfg_ctx.formation_endpoints {
196                f_model = f_model.add_formation_endpoint(item.key(), item.value());
197            }
198        }
199
200        // TODO: probably match and check errors
201        f_model.build().map_err(Into::into)
202    }
203}
204
205#[derive(Default, Debug, Clone)]
206pub struct FormationCfgCtx {
207    /// `String` is a flight name because that's the only thing shared by both local and remote
208    pub flights: Vec<String>,
209    /// `String` is a flight name because that's the only thing shared by both local and remote
210    pub affinities: Vec<String>,
211    /// `String` is a flight name because that's the only thing shared by both local and remote
212    pub connections: Vec<String>,
213    /// Use actual API model since that is ultimately what we want
214    pub providers_allowed: HashSet<ProviderModel>,
215    /// Use actual API model since that is ultimately what we want
216    pub providers_denied: HashSet<ProviderModel>,
217    /// Use actual API model since that is ultimately what we want
218    pub regions_allowed: HashSet<RegionModel>,
219    /// Use actual API model since that is ultimately what we want
220    pub regions_denied: HashSet<RegionModel>,
221    pub public_endpoints: Vec<Endpoint>,
222    pub formation_endpoints: Vec<Endpoint>,
223    pub flight_endpoints: Vec<Endpoint>,
224}