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