seaplane_cli/cli/cmds/formation/
common.rs

1//! The common args effectively represent a "Formation Configuration" which many commands can
2//! include.
3//!
4//! The only additional information is the formation name, which is not part of the configuration,
5//! but many commands need as well.
6
7use clap::{builder::PossibleValue, value_parser, Arg};
8use seaplane::api::shared::v1::{Provider as ProviderModel, Region as RegionModel};
9
10use crate::cli::validator::{
11    validate_endpoint, validate_formation_name, validate_name_id, validate_name_id_path_inline,
12    validate_public_endpoint,
13};
14
15static LONG_NAME: &str =
16    "A human readable name for the Formation (must be unique within the tenant)
17
18Rules for a valid name are as follows:
19
20  - may only include 0-9, a-z, A-Z, and '-' (hyphen)
21  - hyphens ('-') may not be repeated (i.e. '--')
22  - no more than three (3) total hyphens
23  - the total length must be <= 27
24
25Some of these restrictions may be lifted in the future.";
26
27static LONG_AFFINITY: &str = "A Formation Instance that this Formation has an affinity for.
28
29This is a hint to the scheduler to place containers running in each of these
30formations \"close\" to eachother (for some version of close including but
31not limited to latency).
32
33Multiple items can be passed as a comma separated list, or by using the argument
34multiple times.";
35
36static LONG_CONNECTION: &str = "A Formation Instance that this Formation is connected to.
37
38Two formations can communicate over their formation endpoints (the endpoints configured via
39--formation-endpoints) if and only if both formations opt in to that connection (list
40each other in their connections map)
41
42Multiple items can be passed as a comma separated list, or by using the argument
43multiple times.";
44
45static LONG_PUBLIC_ENDPOINT: &str = r#"An endpoint that will publicly exposed on Instances of this Formation Plan
46
47Public Endpoints take the form '{ROUTE}={FLIGHT}:{PORT}'. Where
48
49ROUTE  := An HTTP URL route
50FLIGHT := NAME or ID
51PORT   := Network Port (0-65535)
52
53This describes which Flight and port should serve the HTTP traffic arriving at this Formation's
54domain URL using the specified route.
55
56For example, consider:
57
58$ seaplane formation edit Foo --public-endpoint /foo/bar=baz:1234
59
60Would mean, all HTTP traffic from the public internet hitting the route '/foo/bar' on the 'Foo'
61Formation's domain should be directed to this Formation's Flight named 'baz' on port '1234'
62
63In the future, support for other protocols such as 'tcp:port' or 'udp:port' may be added alongside
64'http' routes.
65
66Multiple items can be passed as a comma separated list, or by using the argument
67multiple times."#;
68
69static LONG_FORMATION_ENDPOINT: &str = r#"An endpoint that will only exposed privately by Instances of this Formation Plan (only exposed to other Formations)
70
71Formation Endpoints take the form '{PROTO}:{TARGET}={FLIGHT}:{PORT}'. Where
72
73PROTO  := [http | https] | tcp | udp
74TARGET := ROUTE | PORT
75ROUTE  := with PROTO http, and HTTP URL route, can be elided
76PORT   := with PROTO tcp | PROTO udp a Network Port (0-65535)
77FLIGHT := NAME or ID
78PORT   := Network Port (0-65535)
79
80This describes where traffic arriving at instances of this Formation's domains URL from the private network
81should be sent.
82
83For example, consider:
84
85$ seaplane formation edit Foo --formation-endpoint tcp:22=baz:2222
86
87Would mean, route all traffic arriving to the 'Foo' Formation's domain URL on TCP/22 from the
88private network to the the Formation's Flight named 'baz' on port '2222'. The PROTO of the incoming
89traffic will be used for the PROTO of the outgoing traffic to FLIGHT
90
91Note 'https' can be used interchangeably with 'http' for convenience sake. It does NOT however
92require the traffic actually be HTTPS. Here 'http' (or convenience 'https') simply means "Traffic
93using the HTTP" protocol.
94
95Multiple items can be passed as a comma separated list, or by using the argument
96multiple times."#;
97
98static LONG_FLIGHT_ENDPOINT: &str = r#"An endpoint that will only be exposed privately on Instances of this Formation Plan (only exposed to Flights within this same Formation Instance)
99
100Flight Endpoints take the form '{PROTO}:{TARGET}={FLIGHT}:{PORT}'. Where
101
102PROTO  := [http | https] | tcp | udp
103TARGET := ROUTE | PORT
104ROUTE  := with PROTO http, and HTTP URL route, can be elided
105PORT   := with PROTO tcp | PROTO udp a Network Port (0-65535)
106FLIGHT := NAME or ID
107PORT   := Network Port (0-65535)
108
109This describes where traffic arriving at this Formation's domain URL from within this Formation's
110private network should be sent.
111
112For example, consider:
113
114$ seaplane formation edit Foo --flight-endpoint udp:1234=baz:4321
115
116Would mean, route all traffic arriving to the 'Foo' Formation's domain URL on UDP/1234 from the
117Formation's private network to the the Formation's Flight named 'baz' on port '4321'. The PROTO of
118the incoming traffic will be used for the PROTO of the outgoing traffic to FLIGHT
119
120Note 'https' can be used interchangeably with 'http' for convenience sake. It does NOT however
121require the traffic actually be HTTPS. Here 'http' (or convenience 'https') simply means "Traffic
122using the HTTP" protocol.
123
124Multiple items can be passed as a comma separated list, or by using the argument
125multiple times."#;
126
127static LONG_REGION: &str =
128    "A region in which this Formation's Flights are allowed to run in (See REGION SPEC below)
129
130Multiple items can be passed as a comma separated list, or by using the argument
131multiple times.";
132
133static LONG_PROVIDER: &str = "A provider that this Formation's Flights are permitted to run on
134
135Multiple items can be passed as a comma separated list, or by using the argument
136multiple times.";
137
138static LONG_EXCLUDE_PROVIDER: &str =
139    "A provider that this Formation's Flights are *NOT* permitted to run on
140
141This will override any values given to --provider
142
143Multiple items can be passed as a comma separated list, or by using the argument
144multiple times.";
145
146static LONG_EXCLUDE_REGION: &str =
147    "A region in which this Formation's Flights are *NOT* allowed to run in (See REGION SPEC below)
148
149This will override any values given to --region
150
151Multiple items can be passed as a comma separated list, or by using the argument
152multiple times.";
153
154static LONG_FLIGHT: &str =
155    "A Flight Plan to include in this Formation in the form of ID|NAME|@path|@-|INLINE-SPEC (See FLIGHT SPEC below)
156
157Multiple items can be passed as a SEMICOLON (';') separated list or by using the argument multiple
158times. Note that when using the INLINE-SPEC it's usually easiest to only place one Flight Plan per
159--include-flight-plan argument
160
161$ seaplane formation plan \\
162    --include-flight-plan name=flight1,image=nginx:latest \\
163    --include-flight-plan name=flight2,image=hello:latest
164
165Which would create, and include, two Flight Plans (flight1, and flight2).";
166// NOTE: we can't use `derive(clap::ValueEnum)` because it of how it derives the to_possible_value
167// which appears to unconditionally use shish-ka-bob case which we don't want.
168/// We provide a shim between the Seaplane Provider so we can do some additional UX work like 'all'
169#[derive(Debug, Copy, Clone, PartialEq, Eq, strum::EnumString)]
170#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
171pub enum Provider {
172    Aws,
173    Azure,
174    DigitalOcean,
175    Equinix,
176    Gcp,
177    All,
178}
179
180impl clap::ValueEnum for Provider {
181    fn value_variants<'a>() -> &'a [Self] {
182        use Provider::*;
183        &[Aws, Azure, DigitalOcean, Equinix, Gcp, All]
184    }
185
186    fn to_possible_value<'a>(&self) -> Option<PossibleValue> {
187        use Provider::*;
188        match self {
189            Aws => Some(PossibleValue::new("aws")),
190            Azure => Some(PossibleValue::new("azure")),
191            DigitalOcean => Some(PossibleValue::new("digitalocean")),
192            Equinix => Some(PossibleValue::new("equinix")),
193            Gcp => Some(PossibleValue::new("gcp")),
194            All => Some(PossibleValue::new("all")),
195        }
196    }
197
198    fn from_str(input: &str, _ignore_case: bool) -> Result<Self, String> {
199        // Because we use strum(ascii_ignore_case) for our FromStr impl we unconditionally ignore
200        // case and can ignore clap's hint
201        input.parse().map_err(|e| format!("{e}"))
202    }
203}
204
205impl Provider {
206    pub fn into_model(&self) -> Option<ProviderModel> {
207        if self == &Provider::All {
208            None
209        } else {
210            Some(self.into())
211        }
212    }
213}
214
215#[allow(clippy::from_over_into)]
216impl<'a> Into<ProviderModel> for &'a Provider {
217    fn into(self) -> ProviderModel {
218        use Provider::*;
219        match self {
220            Aws => ProviderModel::AWS,
221            Azure => ProviderModel::Azure,
222            DigitalOcean => ProviderModel::DigitalOcean,
223            Equinix => ProviderModel::Equinix,
224            Gcp => ProviderModel::GCP,
225            All => {
226                panic!("Provider::All cannot be converted into seaplane::api::shared::v1::Provider")
227            }
228        }
229    }
230}
231
232// TODO: @needs-decision @pre-1.0 we need to come back and address how many aliases there are and
233// their sort order for the CLI's "possible values" message. They're currently "grouped" in region
234// aliases, but for one it's too many values, also it makes the sort look wild in the help message.
235// The Compute API only uses the XA, XC, XE values, but those are the least user friendly.
236/// We provide a shim between the Seaplane Region so we can do some additional UX work
237#[allow(clippy::upper_case_acronyms)]
238#[derive(Debug, Copy, Clone, PartialEq, Eq, strum::EnumString)]
239#[strum(ascii_case_insensitive, serialize_all = "lowercase")]
240pub enum Region {
241    XA,
242    Asia,
243    XC,
244    PRC,
245    PeoplesRepublicOfChina,
246    XE,
247    Europe,
248    EU,
249    XF,
250    Africa,
251    XN,
252    NorthAmerica,
253    NAmerica,
254    XO,
255    Oceania,
256    XQ,
257    Antarctica,
258    XS,
259    SAmerica,
260    SouthAmerica,
261    XU,
262    UK,
263    UnitedKingdom,
264    All,
265}
266
267impl clap::ValueEnum for Region {
268    fn value_variants<'a>() -> &'a [Self] {
269        use Region::*;
270        &[
271            XA, // Asia,
272            XC, // PRC, PeoplesRepublicOfChina,
273            XE, // Europe, EU,
274            XF, // Africa,
275            XN, // NorthAmerica, NAmerica,
276            XO, // Oceania,
277            XQ, // Antarctica,
278            XS, // SAmerica, SouthAmerica,
279            XU, // UK, UnitedKingdom,
280            All,
281        ]
282    }
283
284    fn to_possible_value<'a>(&self) -> Option<PossibleValue> {
285        use Region::*;
286        match self {
287            XA => Some(PossibleValue::new("xa").alias("asia")),
288            Asia => None,
289            XC => Some(PossibleValue::new("xc").aliases([
290                "prc",
291                "china",
292                "peoples-republic-of-china",
293                "peoples_republic_of_china",
294                "peoplesrepublicofchina",
295            ])),
296            PRC => None,
297            PeoplesRepublicOfChina => None,
298            XE => Some(PossibleValue::new("xe").aliases(["europe", "eu"])),
299            Europe => None,
300            EU => None,
301            XF => Some(PossibleValue::new("xf").alias("africa")),
302            Africa => None,
303            XN => Some(PossibleValue::new("xn").aliases([
304                "namerica",
305                "northamerica",
306                "n-america",
307                "north-america",
308                "n_america",
309                "north_america",
310            ])),
311            NorthAmerica => None,
312            NAmerica => None,
313            XO => Some(PossibleValue::new("xo").alias("oceania")),
314            Oceania => None,
315            XQ => Some(PossibleValue::new("xq").alias("antarctica")),
316            Antarctica => None,
317            XS => Some(PossibleValue::new("xs").aliases([
318                "samerica",
319                "southamerica",
320                "s-america",
321                "south-america",
322                "s_america",
323                "south_america",
324            ])),
325            SAmerica => None,
326            SouthAmerica => None,
327            XU => Some(PossibleValue::new("xu").aliases([
328                "uk",
329                "unitedkingdom",
330                "united-kingdom",
331                "united_kingdom",
332            ])),
333            UK => None,
334            UnitedKingdom => None,
335            All => Some(PossibleValue::new("all")),
336        }
337    }
338
339    fn from_str(input: &str, _ignore_case: bool) -> Result<Self, String> {
340        // Because we use strum(ascii_ignore_case) for our FromStr impl we unconditionally ignore
341        // case and can ignore clap's hint
342        input.parse().map_err(|e| format!("{e}"))
343    }
344}
345
346impl Region {
347    pub fn into_model(&self) -> Option<RegionModel> {
348        if self == &Region::All {
349            None
350        } else {
351            Some(self.into())
352        }
353    }
354}
355
356#[allow(clippy::from_over_into)]
357impl<'a> Into<RegionModel> for &'a Region {
358    fn into(self) -> RegionModel {
359        use Region::*;
360        match self {
361            XA | Asia => RegionModel::XA,
362            XC | PRC | PeoplesRepublicOfChina => RegionModel::XC,
363            XE | Europe | EU => RegionModel::XE,
364            XF | Africa => RegionModel::XF,
365            XN | NorthAmerica | NAmerica => RegionModel::XN,
366            XO | Oceania => RegionModel::XO,
367            XQ | Antarctica => RegionModel::XQ,
368            XS | SAmerica | SouthAmerica => RegionModel::XS,
369            XU | UK | UnitedKingdom => RegionModel::XU,
370            All => panic!("Region::All cannot be converted into seaplane::api::shared::v1::Region"),
371        }
372    }
373}
374
375pub fn args() -> Vec<Arg> {
376    let validator = |s: &str| validate_name_id(validate_formation_name, s);
377    #[cfg_attr(not(feature = "unstable"), allow(unused_mut))]
378    let mut hide = true;
379    let _ = hide;
380    #[cfg(feature = "unstable")]
381    {
382        hide = false;
383    }
384
385    // TODO: add --from with support for @file and @- (stdin)
386    vec![
387        arg!(name_id --name -('n') =["STRING"])
388            .help("A human readable name for the Formation (must be unique within the tenant) if omitted a pseudo random name will be assigned")
389            .long_help(LONG_NAME)
390            .value_parser(validate_formation_name),
391        arg!(--launch|active)
392            .overrides_with("launch") // Override with self so someone can do `--launch --active` which isn't needed, but people will do it
393            .help("This Formation Plan should be deployed and set as active right away (requires a formation configuration)"),
394        arg!(--grounded|("no-active"))
395            .help("This Formation Plan should be deployed but NOT set as active (requires a formation configuration)"),
396        arg!(--("include-flight-plan")|("include-flight-plans") -('I') =["SPEC"]...)
397            .help("Use local Flight Plan in this Formation in the form of ID|NAME|@path|@-|INLINE-SPEC (supports SEMICOLON (';') separated list, or multiple uses) (See FLIGHT SPEC below)")
398            .value_delimiter(';')
399            .required(true)
400            .long_help(LONG_FLIGHT)
401            .value_parser(validate_name_id_path_inline),
402        arg!(--provider|providers =["PROVIDER"=>"all"]... ignore_case)
403            .help("A provider that this Formation's Flights are permitted to run on (supports comma separated list, or multiple uses)")
404            .long_help(LONG_PROVIDER)
405            .value_parser(value_parser!(Provider)),
406        arg!(--("exclude-provider")|("exclude-providers") =["PROVIDER"]... ignore_case)
407            .help("A provider that this Formation's Flights are *NOT* permitted to run on (supports comma separated list, or multiple uses)")
408            .long_help(LONG_EXCLUDE_PROVIDER)
409            .value_parser(value_parser!(Provider)),
410        arg!(--region|regions =["REGION"=>"all"]... ignore_case)
411            .help("A region in which this Formation's Flights are allowed to run in (supports comma separated list, or multiple uses) (See REGION SPEC below)")
412            .long_help(LONG_REGION)
413            .value_parser(value_parser!(Region)),
414        arg!(--("exclude-region")|("exclude-regions") =["REGION"]... ignore_case)
415            .help("A region in which this Formation's Flights are *NOT* allowed to run in (supports comma separated list, or multiple uses) (See REGION SPEC below)")
416            .long_help(LONG_EXCLUDE_REGION)
417            .value_parser(value_parser!(Region)),
418        // TODO: maybe allow omitting http:
419        arg!(--("public-endpoint")|("public-endpoints") =["SPEC"]...)
420            .help("An endpoint that will be publicly exposed by instances of this Formation Plan in the form of 'ROUTE=FLIGHT:PORT' (supports comma separated list, or multiple uses)")
421            .long_help(LONG_PUBLIC_ENDPOINT)
422            .value_parser(validate_public_endpoint),
423        // TODO: maybe allow omitting the Flight's port if it's the same
424        arg!(--("flight-endpoint")|("flight-endpoints") =["SPEC"]...)
425            .value_parser(validate_endpoint)
426            .help("An endpoint that will only be privately exposed on Instances of this Formation Plan to Flights within the same Formation Instance. In the form of 'PROTO:TARGET=FLIGHT:PORT' (supports comma separated list, or multiple uses)")
427            .long_help(LONG_FLIGHT_ENDPOINT),
428        arg!(--affinity|affinities =["NAME|ID"]...)
429            .help("A Formation that this Formation has an affinity for (supports comma separated list, or multiple uses)")
430            .long_help(LONG_AFFINITY)
431            .value_parser(validator)
432            .hide(hide), // Hidden on feature = unstable
433        arg!(--connection|connections =["NAME|ID"]...)
434            .help("A Formations that this Formation is connected to (supports comma separated list, or multiple uses)")
435            .long_help(LONG_CONNECTION)
436            .value_parser(validator)
437            .hide(hide), // Hidden on feature = unstable
438        // TODO: maybe allow omitting the Flight's port if it's the same
439        arg!(--("formation-endpoint")|("formation-endpoints") =["SPEC"]...)
440            .value_parser(validate_endpoint)
441            .help("An endpoints that will only be exposed privately on Instances of this Formation Plan to other Formations within the same tenant and who have declared mutual connections. In the form of 'PROTO:TARGET=FLIGHT:PORT' (supports comma separated list, or multiple uses)")
442            .long_help(LONG_FORMATION_ENDPOINT)
443            .hide(hide), // Hidden on feature = unstable
444    ]
445}