seaplane_cli/cli/cmds/formation/
launch.rs

1use clap::{ArgMatches, Command};
2use seaplane::{
3    api::{
4        compute::v1::{ActiveConfiguration, ActiveConfigurations},
5        ApiErrorKind,
6    },
7    error::SeaplaneError,
8};
9
10use crate::{
11    api::FormationsReq,
12    cli::{
13        cmds::formation::SeaplaneFormationFetch,
14        errors,
15        validator::{validate_formation_name, validate_name_id},
16        CliCommand,
17    },
18    context::Ctx,
19    error::{CliErrorKind, Context, Result},
20    printer::{Color, Pb},
21};
22
23static LONG_ABOUT: &str = "Start a local Formation Plan creating a remote Formation Instance
24
25In many cases, or at least initially a local Formation Plan may only have a single
26Formation Configuration. In these cases this command will set that one configuration to active
27creating a remote Formation Instance with a single configuration.
28
29Things become slightly more complex when there are multiple Formation Configurations. Let's
30look at each possibility in turn.
31
32\"Local Only\" Configs Exist:
33
34A \"Local Only\" Config is a configuration that exists in the local database, but has not (yet)
35been uploaded to the Seaplane Cloud.
36
37In these cases the configurations will be sent to the Seaplane Cloud, and set to active. If the
38Seaplane Cloud already has configurations for the given Formation (either active or inactive),
39these new configurations will be appended, and traffic will be balanced between any *all*
40configurations.
41
42\"Remote Active\" Configs Exist:
43
44A \"Remote Active\" Config is a configuration that the Seaplane Cloud is aware of, and actively
45sending traffic to.
46
47These configurations will remain active and traffic will be balanced between any *all*
48configurations.
49
50\"Remote Inactive\" Configs Exist:
51
52A \"Remote Inactive\" Config is a configuration that the Seaplane Cloud is aware of, and but not
53sending traffic to.
54
55These configurations will be made active. If the Seaplane Cloud already has active
56configurations for the given Formation, these newly activated configurations will be appended,
57and traffic will be balanced between any *all* configurations. ";
58
59#[derive(Copy, Clone, Debug)]
60pub struct SeaplaneFormationLaunch;
61
62impl SeaplaneFormationLaunch {
63    pub fn command() -> Command {
64        let validator = |s: &str| validate_name_id(validate_formation_name, s);
65
66        // TODO: make it possible to selectively start only *some* configs
67        Command::new("launch")
68            .visible_alias("start")
69            .about("Start a local Formation Plan creating a remote Formation Instance")
70            .long_about(LONG_ABOUT)
71            .arg(
72                arg!(formation =["NAME|ID"] required)
73                    .value_parser(validator)
74                    .help("The name or ID of the Formation Plan to launch and create an Instance of"),
75            )
76            .arg(
77                arg!(--all - ('a'))
78                    .help("Launch all matching local Formation Plans even when the name or ID is ambiguous"),
79            )
80            .arg(arg!(--fetch|sync|synchronize - ('F')).help(
81                "Fetch remote Formation Instances and synchronize local Plan definitions prior to attempting to launch",
82            ))
83            .arg(
84                arg!(--grounded).help(
85                    "Upload the configuration(s) defined in this local Formation Plan to Seaplane but *DO NOT* set them to active",
86                ),
87            )
88    }
89}
90
91impl CliCommand for SeaplaneFormationLaunch {
92    fn run(&self, ctx: &mut Ctx) -> Result<()> {
93        let name = ctx.args.name_id.as_ref().unwrap().clone();
94        if ctx.args.fetch {
95            let old_name = ctx.args.name_id.take();
96            let old_ir = ctx.internal_run;
97            ctx.internal_run = true;
98            SeaplaneFormationFetch.run(ctx)?;
99            ctx.internal_run = old_ir;
100            ctx.args.name_id = old_name;
101        }
102        // Get the indices of any formations that match the given name/ID
103        let indices = if ctx.args.all {
104            ctx.db.formations.formation_indices_of_left_matches(&name)
105        } else {
106            ctx.db.formations.formation_indices_of_matches(&name)
107        };
108
109        match indices.len() {
110            0 => errors::no_matching_item(name, false, ctx.args.all)?,
111            1 => (),
112            _ => {
113                // TODO: and --force
114                if !ctx.args.all {
115                    errors::ambiguous_item(name, true)?;
116                }
117            }
118        }
119
120        let pb = Pb::new(ctx);
121        let grounded = ctx.formation_ctx.get_or_init().grounded;
122        let mut req = FormationsReq::new_delay_token(ctx)?;
123        for idx in indices {
124            // re unwrap: the indices returned came from Formations so they have to be valid
125            let formation = ctx.db.formations.get_formation(idx).unwrap();
126            let formation_name = formation.name.as_ref().unwrap().clone();
127            req.set_name(&formation_name)?;
128
129            // Get the local configs that don't exist remote yet
130            let cfgs_ids = if grounded {
131                formation.local_only_configs()
132            } else {
133                formation.local_or_grounded_configs()
134            };
135
136            if cfgs_ids.is_empty() {
137                if ctx.args.fetch && grounded {
138                    cli_println!("Formation Plan '{formation_name}' is already uploaded and in status Grounded");
139                    return Ok(());
140                } else {
141                    return Err(CliErrorKind::OneOff(
142                        "Cannot find local Formation Plan '{formation_name}'".into(),
143                    )
144                    .into_err())
145                    .context(
146                        "(hint: you can try adding '--fetch' to synchronize remote instances)\n",
147                    );
148                }
149            }
150
151            // Keep track if we had to make a brand new formation or not
152            let mut created_new = false;
153            let mut has_public_endpoints = false;
154            let mut cfg_uuids = Vec::new();
155
156            // Add those configurations to this formation
157            'inner: for id in &cfgs_ids {
158                if let Some(cfg) = ctx.db.formations.get_configuration(id) {
159                    has_public_endpoints = cfg.model.public_endpoints().count() > 0;
160                    if let Some(flight) = cfg.model.public_endpoints().find_map(|(_, dst)| {
161                        if !ctx.db.formations.has_flight(&dst.flight_name) {
162                            Some(dst.flight_name.clone())
163                        } else {
164                            None
165                        }
166                    }) {
167                        return Err(CliErrorKind::EndpointInvalidFlight(flight).into_err()
168                            .context("perhaps the Flight Plan exists, but only in a remote Formation Instance?\n")
169                            .context("(hint: use '")
170                            .color_context(Color::Yellow, "--fetch")
171                            .context("' to synchronize local Plan definitions with remote Instances)\n")
172                            .context("(hint: alternatively, you create the Flight Plan with '")
173                            .color_context(Color::Green, "seaplane flight plan")
174                            .context("')\n"));
175                    }
176
177                    // We don't set the configuration to active because we'll be doing that to
178                    // *all* formation configs in a minute
179                    pb.set_message("Searching for existing Formations...");
180                    if let Err(e) = req.add_configuration(&cfg.model, false) {
181                        match e.kind() {
182                            CliErrorKind::Seaplane(SeaplaneError::ApiResponse(ae))
183                                if ae.kind == ApiErrorKind::NotFound =>
184                            {
185                                // If the formation didn't exist, create it
186                                pb.set_message("Creating new Formation Instance...");
187                                let cfg_uuid = req.create(&cfg.model, !grounded)?;
188                                cfg_uuids.extend(cfg_uuid);
189                                ctx.db.formations.add_in_air_by_name(&formation_name, *id);
190                                created_new = true;
191                                break 'inner;
192                            }
193                            _ => return Err(e),
194                        }
195                    } else {
196                        pb.set_message("Found existing Formation Instance...");
197                        ctx.db.formations.add_grounded_by_name(&formation_name, *id);
198                    }
199                } else {
200                    // TODO: Inform the user of possible error? Somehow there is no config by the
201                    // ID? This is an internal error that shoulnd't happen. We got the ID from our
202                    // own internal state, if that's wrong we have big issues
203                    unreachable!()
204                }
205            }
206
207            // If the user passed `--grounded` they don't want the configuration to be set to
208            // active
209            if !grounded && !created_new {
210                // Get all configurations for this Formation
211                pb.set_message("Synchronizing Formation Configurations...");
212                cfg_uuids.extend(
213                    req.list_configuration_ids()
214                        .context("Context: failed to retrieve Formation Configuration IDs\n")?,
215                );
216                let mut active_configs = ActiveConfigurations::new();
217                for uuid in &cfg_uuids {
218                    #[cfg_attr(not(feature = "unstable"), allow(unused_mut))]
219                    let mut cfg = ActiveConfiguration::builder().uuid(*uuid);
220                    #[cfg(feature = "unstable")]
221                    {
222                        cfg = cfg.traffic_weight(1.0);
223                    }
224                    active_configs.add_configuration_mut(cfg.build()?);
225                }
226                pb.set_message("Adding Formation Configurations to remote Instance...");
227                req.set_active_configurations(&active_configs, false)
228                    .context("Context: failed to start Formation\n")?;
229                for id in cfgs_ids {
230                    ctx.db.formations.add_in_air_by_name(&formation_name, id);
231                }
232            }
233            pb.set_message("Getting Formation URL...");
234            let domain = req.get_metadata()?.url;
235
236            pb.finish_and_clear();
237            cli_print!("Successfully Launched remote Formation Instance '");
238            cli_print!(@Green, "{}", &ctx.args.name_id.as_ref().unwrap());
239            if cfg_uuids.is_empty() {
240                cli_println!("'");
241            } else {
242                cli_println!("' with Configuration UUIDs:");
243                for uuid in cfg_uuids {
244                    cli_println!(@Green, "{uuid}");
245                }
246            }
247
248            cli_print!("The remote Formation Instance URL is ");
249            cli_println!(@Green, "{domain}");
250            cli_println!(
251                "(hint: it may take up to a minute for the Formation to become fully online)"
252            );
253            if !has_public_endpoints {
254                cli_println!("(hint: there are no public endpoints configured, the Formation will not be reachable from the public internet)");
255            }
256            cli_print!("(hint: check the status of this Formation Instance with '");
257            cli_print!(@Green, "seaplane formation status {formation_name}");
258            cli_println!("')");
259        }
260
261        ctx.persist_formations()?;
262
263        Ok(())
264    }
265
266    fn update_ctx(&self, matches: &ArgMatches, ctx: &mut Ctx) -> Result<()> {
267        ctx.args.name_id = matches
268            .get_one::<String>("formation")
269            .map(ToOwned::to_owned);
270        ctx.args.fetch = matches.get_flag("fetch");
271        let fctx = ctx.formation_ctx.get_mut_or_init();
272        fctx.grounded = matches.get_flag("grounded");
273        Ok(())
274    }
275}