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