seaplane_cli/
cli.rs

1pub mod cmds;
2pub mod errors;
3pub mod specs;
4pub mod validator;
5
6use std::env;
7#[cfg(not(any(feature = "api_tests", feature = "semantic_ui_tests", feature = "ui_tests")))]
8use std::io::{self, BufRead};
9
10use clap::{crate_authors, value_parser, ArgAction, ArgMatches, Command};
11use const_format::concatcp;
12
13pub use crate::cli::cmds::*;
14use crate::{
15    context::Ctx,
16    error::Result,
17    printer::{ColorChoice, Printer},
18};
19
20const VERSION: &str = env!("SEAPLANE_VER_WITH_HASH");
21static AUTHORS: &str = crate_authors!();
22static LONG_VERBOSE: &str = "Display more verbose output
23
24More uses displays more verbose output
25    -v:  Display debug info
26    -vv: Display trace info";
27
28static LONG_QUIET: &str = "Suppress output at a specific level and below
29
30More uses suppresses higher levels of output
31    -q:   Only display WARN messages and above
32    -qq:  Only display ERROR messages
33    -qqq: Suppress all output";
34static LONG_API_KEY: &str =
35    "The API key associated with a Seaplane account used to access Seaplane API endpoints
36
37The value provided here will override any provided in any configuration files.
38A CLI provided value also overrides any environment variables.
39One can use a special value of '-' to signal the value should be read from STDIN.";
40
41pub trait CliCommand {
42    /// Care should be taken to keep CliCommand::update_ctx pure with no external effects such as
43    /// I/O. This allows the CLI to be fully tested without any assumptions of the testing
44    /// environment
45    fn update_ctx(&self, _matches: &ArgMatches, _ctx: &mut Ctx) -> Result<()> { Ok(()) }
46    fn run(&self, _ctx: &mut Ctx) -> Result<()> { Ok(()) }
47    fn next_subcmd<'a>(
48        &self,
49        _matches: &'a ArgMatches,
50    ) -> Option<(Box<dyn CliCommand>, &'a ArgMatches)> {
51        None
52    }
53}
54
55impl dyn CliCommand + 'static {
56    /// Performs three steps:
57    ///
58    /// - calls `self.update_ctx()`
59    /// - calls `self.run()`
60    /// - Gets the next subcommand (if any) by calling `self.next_subcmd()` and calls
61    /// `traverse_exec` on that subcommand.
62    ///
63    /// This walks down the entire *used* subcommand hierarchy ensuring the `update_ctx` was called
64    /// prior to `run` and that any deeper subcommands were executed.
65    pub fn traverse_exec(&self, matches: &ArgMatches, ctx: &mut Ctx) -> Result<()> {
66        self.update_ctx(matches, ctx)?;
67        self.run(ctx)?;
68        if let Some((c, m)) = self.next_subcmd(matches) {
69            return c.traverse_exec(m, ctx);
70        }
71        Ok(())
72    }
73
74    // Used testing the CLI to cause the CliCommand::update_ctx to be called, but not
75    // CliCommand::run
76    pub fn traverse_update_ctx(&self, matches: &ArgMatches, ctx: &mut Ctx) -> Result<()> {
77        self.update_ctx(matches, ctx)?;
78        if let Some((c, m)) = self.next_subcmd(matches) {
79            return c.traverse_update_ctx(m, ctx);
80        }
81        Ok(())
82    }
83}
84
85#[derive(Copy, Clone, Debug)]
86pub struct Seaplane;
87
88impl Seaplane {
89    pub fn command() -> Command {
90        #[cfg_attr(not(any(feature = "unstable", feature = "ui_tests")), allow(unused_mut))]
91        let mut app = Command::new("seaplane")
92            .about("Seaplane CLI for managing resources on the Seaplane Cloud")
93            .author(AUTHORS)
94            .version(VERSION)
95            .long_version(concatcp!(VERSION, "\n", env!("SEAPLANE_BUILD_FEATURES")))
96            .propagate_version(true)
97            .subcommand_required(true)
98            .arg_required_else_help(true)
99            .arg(arg!(--verbose -('v') global)
100                .help("Display more verbose output")
101                .action(ArgAction::Count)
102                .long_help(LONG_VERBOSE))
103            .arg(arg!(--quiet -('q') global)
104                .help("Suppress output at a specific level and below")
105                .action(ArgAction::Count)
106                .long_help(LONG_QUIET))
107            .arg(arg!(--color global ignore_case =["COLOR"=>"auto"])
108                .value_parser(value_parser!(ColorChoice))
109                .overrides_with_all(["color", "no-color"])
110                .help("Should the output include color?"))
111            .arg(arg!(--("no-color") global)
112                .overrides_with_all(["color", "no-color"])
113                .help("Do not color output (alias for --color=never)"))
114            .arg(arg!(--("api-key") -('A') global =["STRING"] hide_env_values)
115                .env("SEAPLANE_API_KEY")
116                .help("The API key associated with a Seaplane account used to access Seaplane API endpoints")
117                .long_help(LONG_API_KEY))
118            .arg(arg!(--("stateless") -('S') global)
119                .help("Ignore local state files, do not read from or write to them"))
120            .subcommand(SeaplaneAccount::command())
121            .subcommand(SeaplaneFlight::command())
122            .subcommand(SeaplaneFormation::command())
123            .subcommand(SeaplaneInit::command())
124            .subcommand(SeaplaneLicense::command())
125            .subcommand(SeaplaneMetadata::command())
126            .subcommand(SeaplaneLocks::command())
127            .subcommand(SeaplaneRestrict::command())
128            .subcommand(SeaplaneShellCompletion::command());
129
130        #[cfg(feature = "unstable")]
131        {
132            app = app
133                .subcommand(SeaplaneConfig::command())
134                .subcommand(SeaplaneImage::command());
135        }
136
137        #[cfg(feature = "ui_tests")]
138        {
139            app = app.term_width(0);
140        }
141        app
142    }
143}
144
145impl CliCommand for Seaplane {
146    fn run(&self, ctx: &mut Ctx) -> Result<()> {
147        // Initialize the printer now that we have all the color choices
148        Printer::init(ctx.args.color);
149        Ok(())
150    }
151
152    fn update_ctx(&self, matches: &ArgMatches, ctx: &mut Ctx) -> Result<()> {
153        // There is a "bug" where due to how clap handles nested-subcommands with global flags and
154        // overrides (yeah...niche) if two mutually exclusive flags that override each-other are
155        // used at different nesting levels, the overrides do not happen.
156        //
157        // For us this means doing `seaplane --no-color SUBCOMMAND --color=auto` effectively there
158        // will be no color output, because clap will evaluate `--no-color` to `true` (i.e. used)
159        // even though they override each-other.
160        //
161        // So we err on the side of not providing color since that is the safer option
162        ctx.args.color = match (matches.get_one("color").copied(), matches.get_flag("no-color")) {
163            (_, true) => ColorChoice::Never,
164            (Some(choice), _) => {
165                if choice != ColorChoice::Auto {
166                    choice
167                } else {
168                    ctx.args.color
169                }
170            }
171            _ => unreachable!("neither --color nor --no-color were used somehow"),
172        };
173
174        ctx.args.stateless = matches.get_flag("stateless");
175
176        // API tests sometimes write their own DB to test, so we don't want to overwrite that
177        #[cfg(not(any(
178            feature = "api_tests",
179            feature = "semantic_ui_tests",
180            feature = "ui_tests"
181        )))]
182        {
183            ctx.db = crate::context::Db::load_if(
184                ctx.flights_file(),
185                ctx.formations_file(),
186                !ctx.args.stateless,
187            )?;
188        }
189
190        if let Some(key) = &matches.get_one::<String>("api-key") {
191            if key == &"-" {
192                // We don't want to read from STDIN during tests
193                #[cfg(not(any(
194                    feature = "api_tests",
195                    feature = "semantic_ui_tests",
196                    feature = "ui_tests"
197                )))]
198                {
199                    let stdin = io::stdin();
200                    let mut lines = stdin.lock().lines();
201                    if let Some(line) = lines.next() {
202                        ctx.args.api_key = Some(line?);
203                    }
204                }
205            } else {
206                ctx.args.api_key = Some(key.to_string());
207            }
208        }
209
210        Ok(())
211    }
212
213    fn next_subcmd<'a>(
214        &self,
215        matches: &'a ArgMatches,
216    ) -> Option<(Box<dyn CliCommand>, &'a ArgMatches)> {
217        match matches.subcommand() {
218            Some(("account", m)) => Some((Box::new(SeaplaneAccount), m)),
219            Some(("flight", m)) => Some((Box::new(SeaplaneFlight), m)),
220            Some(("formation", m)) => Some((Box::new(SeaplaneFormation), m)),
221            Some(("init", m)) => Some((Box::new(SeaplaneInit), m)),
222            Some(("metadata", m)) => Some((Box::new(SeaplaneMetadata), m)),
223            Some(("locks", m)) => Some((Box::new(SeaplaneLocks), m)),
224            Some(("restrict", m)) => Some((Box::new(SeaplaneRestrict), m)),
225            Some(("shell-completion", m)) => Some((Box::new(SeaplaneShellCompletion), m)),
226            Some(("license", m)) => Some((Box::new(SeaplaneLicense), m)),
227            #[cfg(feature = "unstable")]
228            Some(("image", m)) => Some((Box::new(SeaplaneImage), m)),
229            #[cfg(feature = "unstable")]
230            Some(("config", m)) => Some((Box::new(SeaplaneConfig), m)),
231            _ => None, // TODO: handle external plugins
232        }
233    }
234}