Skip to main content

orodruin_cli/
cli.rs

1use std::{ffi::OsString, path::PathBuf};
2
3use clap::{ArgAction, Args, Command, CommandFactory, FromArgMatches, Parser, Subcommand};
4use clap_complete::Shell;
5
6use crate::backend::ContainerRuntime;
7
8#[derive(Debug, Parser, PartialEq, Eq)]
9#[command(
10    name = "orodruin",
11    version = crate::build_info::VERSION,
12    long_version = crate::build_info::LONG_VERSION,
13    about
14)]
15pub struct Cli {
16    #[arg(long, global = true, action = ArgAction::SetTrue, help = "Enable debug logging")]
17    pub debug: bool,
18    #[arg(long, short = 'y', global = true, action = ArgAction::SetTrue, help = "Automatically answer yes to interactive prompts")]
19    pub yes: bool,
20    #[arg(long, global = true, help = "Path to the orodruin config file")]
21    pub config: Option<PathBuf>,
22    #[command(subcommand)]
23    pub command: Commands,
24}
25
26struct UnsupportedCommand {
27    path: &'static [&'static str],
28    hidden_name: &'static str,
29}
30
31const PODMAN_UNSUPPORTED_COMMANDS: &[UnsupportedCommand] = &[
32    UnsupportedCommand {
33        path: &["registry", "list"],
34        hidden_name: "__hidden__registry__list",
35    },
36    UnsupportedCommand {
37        path: &["builder", "start"],
38        hidden_name: "__hidden__builder__start",
39    },
40    UnsupportedCommand {
41        path: &["builder", "stop"],
42        hidden_name: "__hidden__builder__stop",
43    },
44    UnsupportedCommand {
45        path: &["system", "dns"],
46        hidden_name: "__hidden__system__dns",
47    },
48    UnsupportedCommand {
49        path: &["system", "kernel"],
50        hidden_name: "__hidden__system__kernel",
51    },
52    UnsupportedCommand {
53        path: &["system", "property"],
54        hidden_name: "__hidden__system__property",
55    },
56    UnsupportedCommand {
57        path: &["system", "start"],
58        hidden_name: "__hidden__system__start",
59    },
60    UnsupportedCommand {
61        path: &["system", "stop"],
62        hidden_name: "__hidden__system__stop",
63    },
64    UnsupportedCommand {
65        path: &["machine", "set-default"],
66        hidden_name: "__hidden__machine__set_default",
67    },
68];
69
70impl Cli {
71    pub fn parse_for_runtime<I, T>(args: I, runtime: ContainerRuntime) -> Result<Self, clap::Error>
72    where
73        I: IntoIterator<Item = T>,
74        T: Into<OsString> + Clone,
75    {
76        Self::parse_for_runtime_named(args, runtime, "orodruin")
77    }
78
79    pub fn parse_for_runtime_named<I, T>(
80        args: I,
81        runtime: ContainerRuntime,
82        program_name: &str,
83    ) -> Result<Self, clap::Error>
84    where
85        I: IntoIterator<Item = T>,
86        T: Into<OsString> + Clone,
87    {
88        let matches = Self::parsing_command_for_runtime_named(runtime, program_name)
89            .try_get_matches_from(args)?;
90        Self::from_arg_matches(&matches)
91    }
92
93    pub fn command_for_runtime(runtime: ContainerRuntime) -> Command {
94        Self::command_for_runtime_named(runtime, "orodruin")
95    }
96
97    pub fn command_for_runtime_named(runtime: ContainerRuntime, program_name: &str) -> Command {
98        prune_command_for_runtime(Self::base_command_named(program_name), runtime, &[])
99    }
100
101    fn parsing_command_for_runtime_named(runtime: ContainerRuntime, program_name: &str) -> Command {
102        hide_unsupported_commands_for_runtime(Self::base_command_named(program_name), runtime, &[])
103    }
104
105    fn base_command_named(program_name: &str) -> Command {
106        Self::command().bin_name(program_name)
107    }
108}
109
110fn prune_command_for_runtime(
111    command: Command,
112    runtime: ContainerRuntime,
113    parent_path: &[String],
114) -> Command {
115    let mut path = parent_path.to_vec();
116    path.push(command.get_name().to_string());
117
118    if let Some(unsupported) = unsupported_command(runtime, &path) {
119        return command
120            .name(unsupported.hidden_name)
121            .aliases(Vec::<&str>::new())
122            .visible_aliases(Vec::<&str>::new())
123            .hide(true);
124    }
125
126    command.mut_subcommands(|child| prune_command_for_runtime(child, runtime, &path))
127}
128
129fn hide_unsupported_commands_for_runtime(
130    command: Command,
131    runtime: ContainerRuntime,
132    parent_path: &[String],
133) -> Command {
134    let mut path = parent_path.to_vec();
135    path.push(command.get_name().to_string());
136
137    if unsupported_command(runtime, &path).is_some() {
138        return command.hide(true);
139    }
140
141    command.mut_subcommands(|child| hide_unsupported_commands_for_runtime(child, runtime, &path))
142}
143
144fn unsupported_command(
145    runtime: ContainerRuntime,
146    path: &[String],
147) -> Option<&'static UnsupportedCommand> {
148    if runtime == ContainerRuntime::AppleContainer {
149        return None;
150    }
151
152    let relative_path = path.iter().skip(1).map(String::as_str).collect::<Vec<_>>();
153    PODMAN_UNSUPPORTED_COMMANDS
154        .iter()
155        .find(|command| command.path == relative_path.as_slice())
156}
157
158#[derive(Debug, Subcommand, PartialEq, Eq)]
159pub enum Commands {
160    #[command(about = "Create a starter orodruin.toml in the current directory")]
161    Init,
162    #[command(about = "Create the container for an environment and start it if needed")]
163    Create(EnvironmentName),
164    #[command(about = "Open an interactive shell inside an environment container")]
165    Enter(EnterCommand),
166    #[command(about = "Run a command in an environment container")]
167    Run(RunCommand),
168    #[command(about = "List configured environments and their container state")]
169    List(ListCommand),
170    #[command(about = "Remove an environment container")]
171    Rm(EnvironmentName),
172    #[command(about = "Show the resolved configuration and container details for an environment")]
173    Inspect(InspectCommand),
174    #[command(about = "Pull an image with the configured container runtime")]
175    Pull(RequiredPassthroughArgs),
176    #[command(about = "List local images with the configured container runtime")]
177    Images(OptionalPassthroughArgs),
178    #[command(about = "Remove an image with the configured container runtime")]
179    Rmi(RequiredPassthroughArgs),
180    #[command(about = "List containers with the configured container runtime")]
181    Ps(OptionalPassthroughArgs),
182    #[command(about = "Show container logs with the configured container runtime")]
183    Logs(OptionalPassthroughArgs),
184    #[command(about = "Build an image with the configured container runtime")]
185    Build(RequiredPassthroughArgs),
186    #[command(
187        about = "Copy files with the configured container runtime",
188        visible_alias = "cp"
189    )]
190    Copy(RequiredPassthroughArgs),
191    #[command(about = "Log in to a registry with the configured container runtime")]
192    Login(OptionalPassthroughArgs),
193    #[command(about = "Log out from a registry with the configured container runtime")]
194    Logout(OptionalPassthroughArgs),
195    #[command(
196        subcommand,
197        about = "Run image commands with the configured container runtime"
198    )]
199    Image(ImageCommands),
200    #[command(
201        subcommand,
202        about = "Run container commands with the configured container runtime"
203    )]
204    Container(ContainerCommands),
205    #[command(
206        subcommand,
207        about = "Run registry commands with the configured container runtime"
208    )]
209    Registry(RegistryCommands),
210    #[command(
211        subcommand,
212        about = "Run volume commands with the configured container runtime"
213    )]
214    Volume(ResourceCommands),
215    #[command(
216        subcommand,
217        about = "Run network commands with the configured container runtime"
218    )]
219    Network(ResourceCommands),
220    #[command(
221        subcommand,
222        about = "Run builder commands with the configured container runtime"
223    )]
224    Builder(BuilderCommands),
225    #[command(
226        subcommand,
227        about = "Run system commands with the configured container runtime"
228    )]
229    System(SystemCommands),
230    #[command(
231        subcommand,
232        about = "Run machine commands with the configured container runtime"
233    )]
234    Machine(MachineCommands),
235    #[command(about = "Generate shell completion scripts")]
236    Completions(CompletionsCommand),
237    #[command(about = "Show version, commit, date, and build information")]
238    Version,
239}
240
241#[derive(Debug, Args, Clone, PartialEq, Eq)]
242pub struct CompletionsCommand {
243    #[arg(value_enum, help = "Shell to generate completions for")]
244    pub shell: Shell,
245}
246
247#[derive(Debug, Args, PartialEq, Eq)]
248pub struct EnvironmentName {
249    #[arg(help = "Environment name from orodruin.toml")]
250    pub env: String,
251}
252
253#[derive(Debug, Args, Clone, PartialEq, Eq)]
254pub struct ListCommand {
255    #[arg(long, action = ArgAction::SetTrue, help = "Print structured JSON output")]
256    pub json: bool,
257}
258
259#[derive(Debug, Args, Clone, PartialEq, Eq)]
260pub struct InspectCommand {
261    #[arg(help = "Environment name from orodruin.toml")]
262    pub env: String,
263    #[arg(long, action = ArgAction::SetTrue, help = "Print structured JSON output")]
264    pub json: bool,
265}
266
267#[derive(Debug, Args, Clone, PartialEq, Eq)]
268pub struct EnterCommand {
269    #[arg(
270        help = "Environment name from orodruin.toml; defaults from project.default_env or the sole environment"
271    )]
272    pub env: Option<String>,
273}
274
275#[derive(Debug, Args, PartialEq, Eq)]
276pub struct RunCommand {
277    #[arg(
278        help = "Environment name from orodruin.toml; defaults from project.default_env or the sole environment"
279    )]
280    pub env: Option<String>,
281    #[arg(
282        last = true,
283        help = "Command to execute after `--`; uses the environment default if omitted"
284    )]
285    pub command: Vec<String>,
286}
287
288#[derive(Debug, Args, Clone, PartialEq, Eq)]
289pub struct OptionalPassthroughArgs {
290    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
291    pub args: Vec<String>,
292}
293
294#[derive(Debug, Args, Clone, PartialEq, Eq)]
295pub struct RequiredPassthroughArgs {
296    #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
297    pub args: Vec<String>,
298}
299
300#[derive(Debug, Subcommand, PartialEq, Eq)]
301pub enum ImageCommands {
302    #[command(about = "Pull an image")]
303    Pull(RequiredPassthroughArgs),
304    #[command(name = "list", about = "List local images", visible_alias = "ls")]
305    List(OptionalPassthroughArgs),
306    #[command(about = "Inspect an image", visible_alias = "i")]
307    Inspect(RequiredPassthroughArgs),
308    #[command(about = "Load an image")]
309    Load(OptionalPassthroughArgs),
310    #[command(name = "remove", about = "Remove an image", visible_alias = "rm")]
311    Remove(RequiredPassthroughArgs),
312    #[command(about = "Push an image")]
313    Push(RequiredPassthroughArgs),
314    #[command(about = "Remove unused images")]
315    Prune(OptionalPassthroughArgs),
316    #[command(about = "Save an image")]
317    Save(OptionalPassthroughArgs),
318    #[command(about = "Tag an image")]
319    Tag(RequiredPassthroughArgs),
320}
321
322#[derive(Debug, Subcommand, PartialEq, Eq)]
323pub enum ContainerCommands {
324    #[command(about = "Create a container")]
325    Create(OptionalPassthroughArgs),
326    #[command(about = "Execute a command in a container")]
327    Exec(OptionalPassthroughArgs),
328    #[command(about = "Export a container")]
329    Export(OptionalPassthroughArgs),
330    #[command(about = "Kill a container")]
331    Kill(OptionalPassthroughArgs),
332    #[command(name = "list", about = "List containers", visible_alias = "ls")]
333    List(OptionalPassthroughArgs),
334    #[command(about = "Inspect a container", visible_alias = "i")]
335    Inspect(RequiredPassthroughArgs),
336    #[command(about = "Show container logs")]
337    Logs(OptionalPassthroughArgs),
338    #[command(about = "Remove stopped containers")]
339    Prune(OptionalPassthroughArgs),
340    #[command(name = "remove", about = "Remove a container", visible_alias = "rm")]
341    Remove(RequiredPassthroughArgs),
342    #[command(about = "Run a container")]
343    Run(OptionalPassthroughArgs),
344    #[command(about = "Start a container")]
345    Start(RequiredPassthroughArgs),
346    #[command(about = "Show container stats")]
347    Stats(OptionalPassthroughArgs),
348    #[command(about = "Stop a container")]
349    Stop(RequiredPassthroughArgs),
350}
351
352#[derive(Debug, Subcommand, PartialEq, Eq)]
353pub enum RegistryCommands {
354    #[command(
355        name = "list",
356        about = "List configured registries",
357        visible_alias = "ls"
358    )]
359    List(OptionalPassthroughArgs),
360    #[command(about = "Log in to a registry")]
361    Login(OptionalPassthroughArgs),
362    #[command(about = "Log out from a registry")]
363    Logout(OptionalPassthroughArgs),
364}
365
366#[derive(Debug, Subcommand, PartialEq, Eq)]
367pub enum ResourceCommands {
368    #[command(name = "list", about = "List resources", visible_alias = "ls")]
369    List(OptionalPassthroughArgs),
370    #[command(about = "Create a resource", visible_alias = "c")]
371    Create(RequiredPassthroughArgs),
372    #[command(about = "Inspect a resource", visible_alias = "i")]
373    Inspect(RequiredPassthroughArgs),
374    #[command(about = "Remove unused resources")]
375    Prune(OptionalPassthroughArgs),
376    #[command(name = "remove", about = "Remove a resource", visible_alias = "rm")]
377    Remove(RequiredPassthroughArgs),
378}
379
380#[derive(Debug, Subcommand, PartialEq, Eq)]
381pub enum BuilderCommands {
382    #[command(name = "remove", about = "Remove the builder", visible_alias = "rm")]
383    Remove(OptionalPassthroughArgs),
384    #[command(about = "Start the builder")]
385    Start(OptionalPassthroughArgs),
386    #[command(about = "Show builder status")]
387    Status(OptionalPassthroughArgs),
388    #[command(about = "Stop the builder")]
389    Stop(OptionalPassthroughArgs),
390}
391
392#[derive(Debug, Subcommand, PartialEq, Eq)]
393pub enum SystemCommands {
394    #[command(about = "Show system disk usage")]
395    Df(OptionalPassthroughArgs),
396    #[command(about = "Manage system DNS")]
397    Dns(OptionalPassthroughArgs),
398    #[command(about = "Manage kernel configuration")]
399    Kernel(OptionalPassthroughArgs),
400    #[command(about = "Show system logs")]
401    Logs(OptionalPassthroughArgs),
402    #[command(about = "Manage system properties")]
403    Property(OptionalPassthroughArgs),
404    #[command(about = "Start container services")]
405    Start(OptionalPassthroughArgs),
406    #[command(about = "Show service status")]
407    Status(OptionalPassthroughArgs),
408    #[command(about = "Stop container services")]
409    Stop(OptionalPassthroughArgs),
410    #[command(about = "Show system version")]
411    Version(OptionalPassthroughArgs),
412}
413
414#[derive(Debug, Subcommand, PartialEq, Eq)]
415pub enum MachineCommands {
416    #[command(about = "Create a container machine")]
417    Create(OptionalPassthroughArgs),
418    #[command(about = "Inspect a container machine")]
419    Inspect(OptionalPassthroughArgs),
420    #[command(name = "list", about = "List container machines", visible_alias = "ls")]
421    List(OptionalPassthroughArgs),
422    #[command(about = "Show machine logs")]
423    Logs(OptionalPassthroughArgs),
424    #[command(
425        name = "remove",
426        about = "Remove a container machine",
427        visible_alias = "rm"
428    )]
429    Remove(OptionalPassthroughArgs),
430    #[command(about = "Run a command in a container machine")]
431    Run(OptionalPassthroughArgs),
432    #[command(about = "Set machine configuration")]
433    Set(OptionalPassthroughArgs),
434    #[command(name = "set-default", about = "Set the default container machine")]
435    SetDefault(OptionalPassthroughArgs),
436    #[command(about = "Stop a container machine")]
437    Stop(OptionalPassthroughArgs),
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn parses_image_aliases() {
446        let cli = Cli::parse_from(["orodruin", "images"]);
447        assert_eq!(
448            cli.command,
449            Commands::Images(OptionalPassthroughArgs { args: vec![] })
450        );
451
452        let cli = Cli::parse_from(["orodruin", "image", "ls"]);
453        assert_eq!(
454            cli.command,
455            Commands::Image(ImageCommands::List(OptionalPassthroughArgs {
456                args: vec![]
457            }))
458        );
459
460        let cli = Cli::parse_from(["orodruin", "rmi", "alpine:latest"]);
461        assert_eq!(
462            cli.command,
463            Commands::Rmi(RequiredPassthroughArgs {
464                args: vec!["alpine:latest".into()],
465            })
466        );
467
468        let cli = Cli::parse_from(["orodruin", "image", "i", "alpine:latest"]);
469        assert_eq!(
470            cli.command,
471            Commands::Image(ImageCommands::Inspect(RequiredPassthroughArgs {
472                args: vec!["alpine:latest".into()],
473            }))
474        );
475
476        let cli = Cli::parse_from(["orodruin", "image", "load", "-i", "image.tar"]);
477        assert_eq!(
478            cli.command,
479            Commands::Image(ImageCommands::Load(OptionalPassthroughArgs {
480                args: vec!["-i".into(), "image.tar".into()],
481            }))
482        );
483    }
484
485    #[test]
486    fn parses_passthrough_aliases() {
487        let cli = Cli::parse_from(["orodruin", "cp", "src", "dst"]);
488        assert_eq!(
489            cli.command,
490            Commands::Copy(RequiredPassthroughArgs {
491                args: vec!["src".into(), "dst".into()],
492            })
493        );
494
495        let cli = Cli::parse_from(["orodruin", "registry", "ls"]);
496        assert_eq!(
497            cli.command,
498            Commands::Registry(RegistryCommands::List(OptionalPassthroughArgs {
499                args: vec![]
500            }))
501        );
502
503        let cli = Cli::parse_from(["orodruin", "volume", "c", "cache"]);
504        assert_eq!(
505            cli.command,
506            Commands::Volume(ResourceCommands::Create(RequiredPassthroughArgs {
507                args: vec!["cache".into()],
508            }))
509        );
510
511        let cli = Cli::parse_from(["orodruin", "network", "i", "bridge"]);
512        assert_eq!(
513            cli.command,
514            Commands::Network(ResourceCommands::Inspect(RequiredPassthroughArgs {
515                args: vec!["bridge".into()],
516            }))
517        );
518
519        let cli = Cli::parse_from(["orodruin", "container", "i", "demo"]);
520        assert_eq!(
521            cli.command,
522            Commands::Container(ContainerCommands::Inspect(RequiredPassthroughArgs {
523                args: vec!["demo".into()],
524            }))
525        );
526
527        let cli = Cli::parse_from(["orodruin", "container", "prune", "--all"]);
528        assert_eq!(
529            cli.command,
530            Commands::Container(ContainerCommands::Prune(OptionalPassthroughArgs {
531                args: vec!["--all".into()],
532            }))
533        );
534
535        let cli = Cli::parse_from(["orodruin", "machine", "ls"]);
536        assert_eq!(
537            cli.command,
538            Commands::Machine(MachineCommands::List(OptionalPassthroughArgs {
539                args: vec![]
540            }))
541        );
542
543        let cli = Cli::parse_from(["orodruin", "builder", "rm"]);
544        assert_eq!(
545            cli.command,
546            Commands::Builder(BuilderCommands::Remove(OptionalPassthroughArgs {
547                args: vec![]
548            }))
549        );
550
551        let cli = Cli::parse_from(["orodruin", "system", "version"]);
552        assert_eq!(
553            cli.command,
554            Commands::System(SystemCommands::Version(OptionalPassthroughArgs {
555                args: vec![]
556            }))
557        );
558    }
559
560    #[test]
561    fn parses_completions_subcommand() {
562        let cli = Cli::parse_from(["orodruin", "completions", "zsh"]);
563        assert_eq!(
564            cli.command,
565            Commands::Completions(CompletionsCommand { shell: Shell::Zsh })
566        );
567    }
568
569    #[test]
570    fn parses_global_yes_and_list_json() {
571        let cli = Cli::parse_from(["orodruin", "--yes", "list", "--json"]);
572        assert!(cli.yes);
573        assert_eq!(cli.command, Commands::List(ListCommand { json: true }));
574    }
575
576    #[test]
577    fn parses_inspect_json() {
578        let cli = Cli::parse_from(["orodruin", "inspect", "dev", "--json"]);
579        assert_eq!(
580            cli.command,
581            Commands::Inspect(InspectCommand {
582                env: "dev".into(),
583                json: true,
584            })
585        );
586    }
587
588    #[test]
589    fn parses_run_without_environment() {
590        let cli = Cli::parse_from(["orodruin", "run"]);
591        assert_eq!(
592            cli.command,
593            Commands::Run(RunCommand {
594                env: None,
595                command: vec![],
596            })
597        );
598
599        let cli = Cli::parse_from(["orodruin", "run", "dev"]);
600        assert_eq!(
601            cli.command,
602            Commands::Run(RunCommand {
603                env: Some("dev".into()),
604                command: vec![],
605            })
606        );
607
608        let cli = Cli::parse_from(["orodruin", "run", "dev", "--", "date"]);
609        assert_eq!(
610            cli.command,
611            Commands::Run(RunCommand {
612                env: Some("dev".into()),
613                command: vec!["date".into()],
614            })
615        );
616    }
617
618    #[test]
619    fn parses_enter_without_environment() {
620        let cli = Cli::parse_from(["orodruin", "enter"]);
621        assert_eq!(cli.command, Commands::Enter(EnterCommand { env: None }));
622
623        let cli = Cli::parse_from(["orodruin", "enter", "dev"]);
624        assert_eq!(
625            cli.command,
626            Commands::Enter(EnterCommand {
627                env: Some("dev".into()),
628            })
629        );
630    }
631
632    #[test]
633    fn podman_runtime_prunes_unsupported_subcommands() {
634        let command = Cli::command_for_runtime(ContainerRuntime::Podman);
635        let registry = command.find_subcommand("registry").unwrap();
636        let builder = command.find_subcommand("builder").unwrap();
637        let system = command.find_subcommand("system").unwrap();
638        let machine = command.find_subcommand("machine").unwrap();
639
640        assert!(registry.find_subcommand("list").is_none());
641        assert!(registry.find_subcommand("login").is_some());
642        assert!(builder.find_subcommand("start").is_none());
643        assert!(builder.find_subcommand("status").is_some());
644        assert!(system.find_subcommand("dns").is_none());
645        assert!(system.find_subcommand("version").is_some());
646        assert!(machine.find_subcommand("set-default").is_none());
647        assert!(machine.find_subcommand("set").is_some());
648    }
649
650    #[test]
651    fn command_for_runtime_named_uses_the_requested_binary_name() {
652        let command = Cli::command_for_runtime_named(ContainerRuntime::Podman, "rui");
653
654        assert_eq!(command.get_bin_name(), Some("rui"));
655    }
656
657    #[test]
658    fn podman_runtime_parses_hidden_unsupported_subcommands() {
659        let cli = Cli::parse_for_runtime(["orodruin", "system", "dns"], ContainerRuntime::Podman)
660            .unwrap();
661        assert_eq!(
662            cli.command,
663            Commands::System(SystemCommands::Dns(OptionalPassthroughArgs {
664                args: vec![]
665            }))
666        );
667
668        let cli =
669            Cli::parse_for_runtime(["orodruin", "registry", "list"], ContainerRuntime::Podman)
670                .unwrap();
671        assert_eq!(
672            cli.command,
673            Commands::Registry(RegistryCommands::List(OptionalPassthroughArgs {
674                args: vec![]
675            }))
676        );
677    }
678}