Skip to main content

orodruin_cli/
app.rs

1use std::{
2    ffi::OsString,
3    fs,
4    io::{self, BufRead, Write},
5    net::IpAddr,
6    path::Path,
7    process::{Command as ProcessCommand, Stdio},
8};
9
10use clap_complete::generate;
11use serde_json::{Value, to_string_pretty};
12
13use crate::{
14    backend::{
15        BackendError, CommandSpec, ContainerBackend, ContainerCliBackend, ContainerRuntime,
16        ExecRequest,
17    },
18    cli::{
19        BuilderCommands, Cli, Commands, CompletionsCommand, ContainerCommands, EnvironmentName,
20        ImageCommands, MachineCommands, OptionalPassthroughArgs, RegistryCommands,
21        RequiredPassthroughArgs, ResourceCommands, RunCommand, SystemCommands,
22    },
23    config::{CONFIG_FILE_NAME, LoadedConfig, ProjectConfig, default_init_config},
24    env_model::{ResolvedEnvironment, ResolvedUser},
25    error::OrodruinError,
26    state::ContainerSummary,
27};
28
29struct PassthroughInvocation {
30    step: String,
31    command: Vec<String>,
32}
33
34pub fn run<I, T>(args: I) -> Result<(), OrodruinError>
35where
36    I: IntoIterator<Item = T>,
37    T: Into<OsString> + Clone,
38{
39    let args = args.into_iter().map(Into::into).collect::<Vec<OsString>>();
40    let program_name = args
41        .first()
42        .and_then(|value| Path::new(value).file_name())
43        .and_then(|value| value.to_str())
44        .unwrap_or("orodruin")
45        .to_string();
46    let runtime = runtime_for_os(std::env::consts::OS)?;
47    let cli = Cli::parse_for_runtime_named(args, runtime, &program_name)
48        .unwrap_or_else(|error| error.exit());
49    let backend = ContainerCliBackend::new(cli.debug, runtime);
50    run_with_backend_for_runtime(cli, &backend, runtime)
51}
52
53#[cfg(test)]
54fn run_with_backend(cli: Cli, backend: &dyn ContainerBackend) -> Result<(), OrodruinError> {
55    run_with_backend_for_runtime(cli, backend, ContainerRuntime::AppleContainer)
56}
57
58fn run_with_backend_for_runtime(
59    cli: Cli,
60    backend: &dyn ContainerBackend,
61    runtime: ContainerRuntime,
62) -> Result<(), OrodruinError> {
63    match cli.command {
64        Commands::Init => init_command()?,
65        Commands::Create(environment) => {
66            let loaded = load_config(cli.config.as_deref())?;
67            let resolved = resolve_environment(&loaded, &environment)?;
68            materialize_environment(backend, &resolved)?;
69            println!("ready {}", resolved.container_name);
70        }
71        Commands::Enter(environment) => {
72            let loaded = load_config(cli.config.as_deref())?;
73            let resolved =
74                resolve_optional_environment(&loaded, environment.env.as_deref(), "enter")?;
75            ensure_container_system_running(backend, runtime)?;
76            materialize_environment(backend, &resolved)?;
77            ensure_container_user(backend, &resolved)?;
78            match backend.exec(
79                &resolved.container_name,
80                &ExecRequest {
81                    workdir: Some(resolved.workdir.clone()),
82                    env: exec_environment(&resolved),
83                    command: resolved.shell.clone(),
84                    interactive: true,
85                    user: Some(resolved.user.clone()),
86                },
87            ) {
88                Ok(()) => {}
89                Err(BackendError::CommandFailed { .. }) => {}
90                Err(error) => return Err(error.into()),
91            }
92        }
93        Commands::Ssh(command) => {
94            let loaded = load_config(cli.config.as_deref())?;
95            let resolved = resolve_optional_environment(&loaded, command.env.as_deref(), "ssh")?;
96            materialize_environment(backend, &resolved)?;
97            ensure_container_user(backend, &resolved)?;
98            let target = resolve_ssh_target(backend, &resolved)?;
99            let spec = build_ssh_spec(&resolved, &target);
100            if command.print {
101                println!("{}", spec.render());
102            } else {
103                run_host_command(&spec)?;
104            }
105        }
106        Commands::Run(run) => {
107            let loaded = load_config(cli.config.as_deref())?;
108            let environment = EnvironmentName {
109                env: run.env.clone(),
110            };
111            let resolved = resolve_environment(&loaded, &environment)?;
112            materialize_environment(backend, &resolved)?;
113            let command = resolve_run_command(&resolved, run)?;
114            ensure_container_user(backend, &resolved)?;
115            backend.exec(
116                &resolved.container_name,
117                &ExecRequest {
118                    workdir: Some(resolved.workdir.clone()),
119                    env: exec_environment(&resolved),
120                    command,
121                    interactive: false,
122                    user: Some(resolved.user.clone()),
123                },
124            )?;
125        }
126        Commands::List => {
127            let loaded = load_config(cli.config.as_deref())?;
128            let containers = backend.list_all()?;
129            for (name, config) in &loaded.config.envs {
130                let resolved =
131                    ResolvedEnvironment::resolve(&loaded.root, &loaded.config, name, config);
132                let summary = containers
133                    .iter()
134                    .find(|summary| summary.matches(&resolved.container_name));
135                print_summary(name, &resolved.container_name, summary);
136            }
137        }
138        Commands::Rm(environment) => {
139            let loaded = load_config(cli.config.as_deref())?;
140            let resolved = resolve_environment(&loaded, &environment)?;
141            if !container_exists(backend, &resolved.container_name)? {
142                return Err(OrodruinError::Message(format!(
143                    "environment `{}` is not created",
144                    resolved.environment_name
145                )));
146            }
147            backend.delete(&resolved.container_name)?;
148            println!("removed {}", resolved.container_name);
149        }
150        Commands::Inspect(environment) => {
151            let loaded = load_config(cli.config.as_deref())?;
152            let resolved = resolve_environment(&loaded, &environment)?;
153            println!("env: {}", resolved.environment_name);
154            println!("container: {}", resolved.container_name);
155            println!("image: {}", resolved.image);
156            println!("workdir: {}", resolved.workdir);
157            if !container_exists(backend, &resolved.container_name)? {
158                println!("container not created");
159                return Ok(());
160            }
161            let inspect = backend.inspect_raw(&resolved.container_name)?;
162            match inspect {
163                Some(value) => println!(
164                    "{}",
165                    to_string_pretty(&value).unwrap_or_else(|_| value.to_string())
166                ),
167                None => println!("container not created"),
168            }
169        }
170        Commands::Pull(args) => run_passthrough(backend, runtime, pull_passthrough(runtime, args))?,
171        Commands::Images(args) => {
172            run_passthrough(backend, runtime, images_passthrough(runtime, args))?
173        }
174        Commands::Rmi(args) => run_passthrough(backend, runtime, rmi_passthrough(runtime, args))?,
175        Commands::Ps(args) => run_passthrough(backend, runtime, ps_passthrough(runtime, args))?,
176        Commands::Logs(args) => run_passthrough(backend, runtime, logs_passthrough(runtime, args))?,
177        Commands::Build(args) => {
178            run_passthrough(backend, runtime, build_passthrough(runtime, args))?
179        }
180        Commands::Copy(args) => run_passthrough(backend, runtime, copy_passthrough(runtime, args))?,
181        Commands::Login(args) => {
182            run_passthrough(backend, runtime, login_passthrough(runtime, args))?
183        }
184        Commands::Logout(args) => {
185            run_passthrough(backend, runtime, logout_passthrough(runtime, args))?
186        }
187        Commands::Image(command) => {
188            run_passthrough(backend, runtime, image_passthrough(runtime, command)?)?
189        }
190        Commands::Container(command) => {
191            run_passthrough(backend, runtime, container_passthrough(runtime, command))?
192        }
193        Commands::Registry(command) => {
194            run_passthrough(backend, runtime, registry_passthrough(runtime, command)?)?
195        }
196        Commands::Volume(command) => run_passthrough(
197            backend,
198            runtime,
199            resource_passthrough(runtime, "volume", command),
200        )?,
201        Commands::Network(command) => run_passthrough(
202            backend,
203            runtime,
204            resource_passthrough(runtime, "network", command),
205        )?,
206        Commands::Builder(command) => {
207            run_passthrough(backend, runtime, builder_passthrough(runtime, command)?)?
208        }
209        Commands::System(command) => {
210            run_passthrough(backend, runtime, system_passthrough(runtime, command)?)?
211        }
212        Commands::Machine(command) => {
213            run_passthrough(backend, runtime, machine_passthrough(runtime, command)?)?
214        }
215        Commands::Completions(command) => print!("{}", render_completions(runtime, command)?),
216        Commands::Version => println!("{}", crate::build_info::render()),
217    }
218
219    Ok(())
220}
221
222fn run_passthrough(
223    backend: &dyn ContainerBackend,
224    runtime: ContainerRuntime,
225    invocation: PassthroughInvocation,
226) -> Result<(), OrodruinError> {
227    let spec = ContainerCliBackend::build_passthrough_spec(runtime, invocation.command);
228    backend.run_command(&invocation.step, &spec)?;
229    Ok(())
230}
231
232fn passthrough(step: impl Into<String>, command: Vec<String>) -> PassthroughInvocation {
233    PassthroughInvocation {
234        step: step.into(),
235        command,
236    }
237}
238
239fn passthrough_with_args(
240    step: impl Into<String>,
241    prefix: Vec<&str>,
242    args: impl Into<Vec<String>>,
243) -> PassthroughInvocation {
244    let mut command = prefix.into_iter().map(str::to_string).collect::<Vec<_>>();
245    command.extend(args.into());
246    passthrough(step, command)
247}
248
249fn runtime_for_os(os: &str) -> Result<ContainerRuntime, OrodruinError> {
250    ContainerRuntime::from_os(os).ok_or_else(|| {
251        OrodruinError::Message(format!(
252            "unsupported operating system `{os}`; supported platforms are macOS and Linux"
253        ))
254    })
255}
256
257fn render_completions(
258    runtime: ContainerRuntime,
259    command: CompletionsCommand,
260) -> Result<String, OrodruinError> {
261    let mut cli = Cli::command_for_runtime(runtime);
262    let mut output = Vec::new();
263    let name = cli.get_name().to_string();
264    generate(command.shell, &mut cli, name, &mut output);
265    String::from_utf8(output).map_err(|error| OrodruinError::Message(error.to_string()))
266}
267
268fn resource_passthrough(
269    runtime: ContainerRuntime,
270    resource: &str,
271    command: ResourceCommands,
272) -> PassthroughInvocation {
273    let list = match (runtime, resource) {
274        (ContainerRuntime::AppleContainer, _) => "list",
275        (ContainerRuntime::Podman, "volume") => "ls",
276        (ContainerRuntime::Podman, "network") => "ls",
277        (ContainerRuntime::Podman, _) => "list",
278    };
279    let remove = match runtime {
280        ContainerRuntime::AppleContainer => "delete",
281        ContainerRuntime::Podman => "rm",
282    };
283
284    match command {
285        ResourceCommands::List(args) => {
286            passthrough_owned(format!("list {resource}s"), vec![resource, list], args)
287        }
288        ResourceCommands::Create(args) => {
289            passthrough_owned(format!("create {resource}"), vec![resource, "create"], args)
290        }
291        ResourceCommands::Inspect(args) => passthrough_owned(
292            format!("inspect {resource}"),
293            vec![resource, "inspect"],
294            args,
295        ),
296        ResourceCommands::Prune(args) => {
297            passthrough_owned(format!("prune {resource}s"), vec![resource, "prune"], args)
298        }
299        ResourceCommands::Remove(args) => {
300            passthrough_owned(format!("remove {resource}"), vec![resource, remove], args)
301        }
302    }
303}
304
305fn passthrough_owned(
306    step: String,
307    prefix: Vec<&str>,
308    args: impl Into<Vec<String>>,
309) -> PassthroughInvocation {
310    passthrough_with_args(step, prefix, args)
311}
312
313fn pull_passthrough(
314    runtime: ContainerRuntime,
315    args: RequiredPassthroughArgs,
316) -> PassthroughInvocation {
317    match runtime {
318        ContainerRuntime::AppleContainer => {
319            passthrough_with_args("pull image", vec!["image", "pull"], args)
320        }
321        ContainerRuntime::Podman => passthrough_with_args("pull image", vec!["pull"], args),
322    }
323}
324
325fn images_passthrough(
326    runtime: ContainerRuntime,
327    args: OptionalPassthroughArgs,
328) -> PassthroughInvocation {
329    match runtime {
330        ContainerRuntime::AppleContainer => {
331            passthrough_with_args("list images", vec!["image", "list"], args)
332        }
333        ContainerRuntime::Podman => passthrough_with_args("list images", vec!["images"], args),
334    }
335}
336
337fn rmi_passthrough(
338    runtime: ContainerRuntime,
339    args: RequiredPassthroughArgs,
340) -> PassthroughInvocation {
341    match runtime {
342        ContainerRuntime::AppleContainer => {
343            passthrough_with_args("remove image", vec!["image", "delete"], args)
344        }
345        ContainerRuntime::Podman => passthrough_with_args("remove image", vec!["rmi"], args),
346    }
347}
348
349fn ps_passthrough(
350    runtime: ContainerRuntime,
351    args: OptionalPassthroughArgs,
352) -> PassthroughInvocation {
353    match runtime {
354        ContainerRuntime::AppleContainer => {
355            passthrough_with_args("list containers", vec!["list"], args)
356        }
357        ContainerRuntime::Podman => passthrough_with_args("list containers", vec!["ps"], args),
358    }
359}
360
361fn logs_passthrough(
362    runtime: ContainerRuntime,
363    args: OptionalPassthroughArgs,
364) -> PassthroughInvocation {
365    match runtime {
366        ContainerRuntime::AppleContainer | ContainerRuntime::Podman => {
367            passthrough_with_args("show container logs", vec!["logs"], args)
368        }
369    }
370}
371
372fn build_passthrough(
373    runtime: ContainerRuntime,
374    args: RequiredPassthroughArgs,
375) -> PassthroughInvocation {
376    match runtime {
377        ContainerRuntime::AppleContainer | ContainerRuntime::Podman => {
378            passthrough_with_args("build image", vec!["build"], args)
379        }
380    }
381}
382
383fn copy_passthrough(
384    runtime: ContainerRuntime,
385    args: RequiredPassthroughArgs,
386) -> PassthroughInvocation {
387    match runtime {
388        ContainerRuntime::AppleContainer => passthrough_with_args("copy files", vec!["copy"], args),
389        ContainerRuntime::Podman => passthrough_with_args("copy files", vec!["cp"], args),
390    }
391}
392
393fn login_passthrough(
394    runtime: ContainerRuntime,
395    args: OptionalPassthroughArgs,
396) -> PassthroughInvocation {
397    match runtime {
398        ContainerRuntime::AppleContainer => {
399            passthrough_with_args("login registry", vec!["registry", "login"], args)
400        }
401        ContainerRuntime::Podman => passthrough_with_args("login registry", vec!["login"], args),
402    }
403}
404
405fn logout_passthrough(
406    runtime: ContainerRuntime,
407    args: OptionalPassthroughArgs,
408) -> PassthroughInvocation {
409    match runtime {
410        ContainerRuntime::AppleContainer => {
411            passthrough_with_args("logout registry", vec!["registry", "logout"], args)
412        }
413        ContainerRuntime::Podman => passthrough_with_args("logout registry", vec!["logout"], args),
414    }
415}
416
417fn image_passthrough(
418    runtime: ContainerRuntime,
419    command: ImageCommands,
420) -> Result<PassthroughInvocation, OrodruinError> {
421    Ok(match (runtime, command) {
422        (ContainerRuntime::AppleContainer, ImageCommands::Pull(args)) => {
423            passthrough_with_args("pull image", vec!["image", "pull"], args)
424        }
425        (ContainerRuntime::Podman, ImageCommands::Pull(args)) => {
426            passthrough_with_args("pull image", vec!["image", "pull"], args)
427        }
428        (ContainerRuntime::AppleContainer, ImageCommands::List(args)) => {
429            passthrough_with_args("list images", vec!["image", "list"], args)
430        }
431        (ContainerRuntime::Podman, ImageCommands::List(args)) => {
432            passthrough_with_args("list images", vec!["image", "list"], args)
433        }
434        (_, ImageCommands::Inspect(args)) => {
435            passthrough_with_args("inspect image", vec!["image", "inspect"], args)
436        }
437        (_, ImageCommands::Load(args)) => {
438            passthrough_with_args("load image", vec!["image", "load"], args)
439        }
440        (ContainerRuntime::AppleContainer, ImageCommands::Remove(args)) => {
441            passthrough_with_args("remove image", vec!["image", "delete"], args)
442        }
443        (ContainerRuntime::Podman, ImageCommands::Remove(args)) => {
444            passthrough_with_args("remove image", vec!["image", "rm"], args)
445        }
446        (_, ImageCommands::Push(args)) => {
447            passthrough_with_args("push image", vec!["image", "push"], args)
448        }
449        (_, ImageCommands::Prune(args)) => {
450            passthrough_with_args("prune images", vec!["image", "prune"], args)
451        }
452        (_, ImageCommands::Save(args)) => {
453            passthrough_with_args("save image", vec!["image", "save"], args)
454        }
455        (_, ImageCommands::Tag(args)) => {
456            passthrough_with_args("tag image", vec!["image", "tag"], args)
457        }
458    })
459}
460
461fn container_passthrough(
462    runtime: ContainerRuntime,
463    command: ContainerCommands,
464) -> PassthroughInvocation {
465    match (runtime, command) {
466        (ContainerRuntime::AppleContainer, ContainerCommands::Create(args)) => {
467            passthrough_with_args("create container", vec!["create"], args)
468        }
469        (ContainerRuntime::Podman, ContainerCommands::Create(args)) => {
470            passthrough_with_args("create container", vec!["container", "create"], args)
471        }
472        (ContainerRuntime::AppleContainer, ContainerCommands::Exec(args)) => {
473            passthrough_with_args("exec container command", vec!["exec"], args)
474        }
475        (ContainerRuntime::Podman, ContainerCommands::Exec(args)) => {
476            passthrough_with_args("exec container command", vec!["container", "exec"], args)
477        }
478        (ContainerRuntime::AppleContainer, ContainerCommands::Export(args)) => {
479            passthrough_with_args("export container", vec!["export"], args)
480        }
481        (ContainerRuntime::Podman, ContainerCommands::Export(args)) => {
482            passthrough_with_args("export container", vec!["container", "export"], args)
483        }
484        (ContainerRuntime::AppleContainer, ContainerCommands::Kill(args)) => {
485            passthrough_with_args("kill container", vec!["kill"], args)
486        }
487        (ContainerRuntime::Podman, ContainerCommands::Kill(args)) => {
488            passthrough_with_args("kill container", vec!["container", "kill"], args)
489        }
490        (ContainerRuntime::AppleContainer, ContainerCommands::List(args)) => {
491            passthrough_with_args("list containers", vec!["list"], args)
492        }
493        (ContainerRuntime::Podman, ContainerCommands::List(args)) => {
494            passthrough_with_args("list containers", vec!["container", "ls"], args)
495        }
496        (ContainerRuntime::AppleContainer, ContainerCommands::Inspect(args)) => {
497            passthrough_with_args("inspect container", vec!["inspect"], args)
498        }
499        (ContainerRuntime::Podman, ContainerCommands::Inspect(args)) => {
500            passthrough_with_args("inspect container", vec!["container", "inspect"], args)
501        }
502        (ContainerRuntime::AppleContainer, ContainerCommands::Logs(args)) => {
503            passthrough_with_args("show container logs", vec!["logs"], args)
504        }
505        (ContainerRuntime::Podman, ContainerCommands::Logs(args)) => {
506            passthrough_with_args("show container logs", vec!["container", "logs"], args)
507        }
508        (ContainerRuntime::AppleContainer, ContainerCommands::Prune(args)) => {
509            passthrough_with_args("prune containers", vec!["prune"], args)
510        }
511        (ContainerRuntime::Podman, ContainerCommands::Prune(args)) => {
512            passthrough_with_args("prune containers", vec!["container", "prune"], args)
513        }
514        (ContainerRuntime::AppleContainer, ContainerCommands::Remove(args)) => {
515            passthrough_with_args("remove container", vec!["delete"], args)
516        }
517        (ContainerRuntime::Podman, ContainerCommands::Remove(args)) => {
518            passthrough_with_args("remove container", vec!["container", "rm"], args)
519        }
520        (ContainerRuntime::AppleContainer, ContainerCommands::Run(args)) => {
521            passthrough_with_args("run container", vec!["run"], args)
522        }
523        (ContainerRuntime::Podman, ContainerCommands::Run(args)) => {
524            passthrough_with_args("run container", vec!["container", "run"], args)
525        }
526        (ContainerRuntime::AppleContainer, ContainerCommands::Start(args)) => {
527            passthrough_with_args("start container", vec!["start"], args)
528        }
529        (ContainerRuntime::Podman, ContainerCommands::Start(args)) => {
530            passthrough_with_args("start container", vec!["container", "start"], args)
531        }
532        (ContainerRuntime::AppleContainer, ContainerCommands::Stats(args)) => {
533            passthrough_with_args("container stats", vec!["stats"], args)
534        }
535        (ContainerRuntime::Podman, ContainerCommands::Stats(args)) => {
536            passthrough_with_args("container stats", vec!["container", "stats"], args)
537        }
538        (ContainerRuntime::AppleContainer, ContainerCommands::Stop(args)) => {
539            passthrough_with_args("stop container", vec!["stop"], args)
540        }
541        (ContainerRuntime::Podman, ContainerCommands::Stop(args)) => {
542            passthrough_with_args("stop container", vec!["container", "stop"], args)
543        }
544    }
545}
546
547fn registry_passthrough(
548    runtime: ContainerRuntime,
549    command: RegistryCommands,
550) -> Result<PassthroughInvocation, OrodruinError> {
551    match (runtime, command) {
552        (ContainerRuntime::AppleContainer, RegistryCommands::List(args)) => Ok(
553            passthrough_with_args("list registries", vec!["registry", "list"], args),
554        ),
555        (ContainerRuntime::Podman, RegistryCommands::List(_)) => Err(unsupported_with_podman(
556            "registry list",
557            "Podman does not provide an equivalent registry listing command",
558        )),
559        (ContainerRuntime::AppleContainer, RegistryCommands::Login(args)) => Ok(
560            passthrough_with_args("login registry", vec!["registry", "login"], args),
561        ),
562        (ContainerRuntime::Podman, RegistryCommands::Login(args)) => {
563            Ok(passthrough_with_args("login registry", vec!["login"], args))
564        }
565        (ContainerRuntime::AppleContainer, RegistryCommands::Logout(args)) => Ok(
566            passthrough_with_args("logout registry", vec!["registry", "logout"], args),
567        ),
568        (ContainerRuntime::Podman, RegistryCommands::Logout(args)) => Ok(passthrough_with_args(
569            "logout registry",
570            vec!["logout"],
571            args,
572        )),
573    }
574}
575
576fn builder_passthrough(
577    runtime: ContainerRuntime,
578    command: BuilderCommands,
579) -> Result<PassthroughInvocation, OrodruinError> {
580    match runtime {
581        ContainerRuntime::AppleContainer => Ok(match command {
582            BuilderCommands::Remove(args) => {
583                passthrough_with_args("remove builder", vec!["builder", "delete"], args)
584            }
585            BuilderCommands::Start(args) => {
586                passthrough_with_args("start builder", vec!["builder", "start"], args)
587            }
588            BuilderCommands::Status(args) => {
589                passthrough_with_args("builder status", vec!["builder", "status"], args)
590            }
591            BuilderCommands::Stop(args) => {
592                passthrough_with_args("stop builder", vec!["builder", "stop"], args)
593            }
594        }),
595        ContainerRuntime::Podman => match command {
596            BuilderCommands::Remove(args) => Ok(passthrough_with_args(
597                "prune builder cache",
598                vec!["builder", "prune"],
599                args,
600            )),
601            BuilderCommands::Status(args) => Ok(passthrough_with_args(
602                "inspect builder capabilities",
603                vec!["builder", "inspect"],
604                args,
605            )),
606            BuilderCommands::Start(_) => Err(unsupported_with_podman(
607                "builder start",
608                "Podman buildx does not provide a start subcommand",
609            )),
610            BuilderCommands::Stop(_) => Err(unsupported_with_podman(
611                "builder stop",
612                "Podman buildx does not provide a stop subcommand",
613            )),
614        },
615    }
616}
617
618fn system_passthrough(
619    runtime: ContainerRuntime,
620    command: SystemCommands,
621) -> Result<PassthroughInvocation, OrodruinError> {
622    match runtime {
623        ContainerRuntime::AppleContainer => Ok(match command {
624            SystemCommands::Df(args) => {
625                passthrough_with_args("system df", vec!["system", "df"], args)
626            }
627            SystemCommands::Dns(args) => {
628                passthrough_with_args("system dns", vec!["system", "dns"], args)
629            }
630            SystemCommands::Kernel(args) => {
631                passthrough_with_args("system kernel", vec!["system", "kernel"], args)
632            }
633            SystemCommands::Logs(args) => {
634                passthrough_with_args("system logs", vec!["system", "logs"], args)
635            }
636            SystemCommands::Property(args) => {
637                passthrough_with_args("system property", vec!["system", "property"], args)
638            }
639            SystemCommands::Start(args) => {
640                passthrough_with_args("start system", vec!["system", "start"], args)
641            }
642            SystemCommands::Status(args) => {
643                passthrough_with_args("system status", vec!["system", "status"], args)
644            }
645            SystemCommands::Stop(args) => {
646                passthrough_with_args("stop system", vec!["system", "stop"], args)
647            }
648            SystemCommands::Version(args) => {
649                passthrough_with_args("system version", vec!["system", "version"], args)
650            }
651        }),
652        ContainerRuntime::Podman => match command {
653            SystemCommands::Df(args) => Ok(passthrough_with_args(
654                "system df",
655                vec!["system", "df"],
656                args,
657            )),
658            SystemCommands::Logs(args) => Ok(passthrough_with_args(
659                "system events",
660                vec!["system", "events"],
661                args,
662            )),
663            SystemCommands::Status(args) => Ok(passthrough_with_args(
664                "system info",
665                vec!["system", "info"],
666                args,
667            )),
668            SystemCommands::Version(args) => Ok(passthrough_with_args(
669                "podman version",
670                vec!["version"],
671                args,
672            )),
673            SystemCommands::Dns(_) => Err(unsupported_with_podman(
674                "system dns",
675                "Podman does not provide a system dns subcommand",
676            )),
677            SystemCommands::Kernel(_) => Err(unsupported_with_podman(
678                "system kernel",
679                "Podman does not provide a system kernel subcommand",
680            )),
681            SystemCommands::Property(_) => Err(unsupported_with_podman(
682                "system property",
683                "Podman does not provide a system property subcommand",
684            )),
685            SystemCommands::Start(_) => Err(unsupported_with_podman(
686                "system start",
687                "Podman does not provide a system start subcommand on Linux",
688            )),
689            SystemCommands::Stop(_) => Err(unsupported_with_podman(
690                "system stop",
691                "Podman does not provide a system stop subcommand on Linux",
692            )),
693        },
694    }
695}
696
697fn machine_passthrough(
698    runtime: ContainerRuntime,
699    command: MachineCommands,
700) -> Result<PassthroughInvocation, OrodruinError> {
701    match runtime {
702        ContainerRuntime::AppleContainer => Ok(match command {
703            MachineCommands::Create(args) => {
704                passthrough_with_args("create machine", vec!["machine", "create"], args)
705            }
706            MachineCommands::Inspect(args) => {
707                passthrough_with_args("inspect machine", vec!["machine", "inspect"], args)
708            }
709            MachineCommands::List(args) => {
710                passthrough_with_args("list machines", vec!["machine", "list"], args)
711            }
712            MachineCommands::Logs(args) => {
713                passthrough_with_args("machine logs", vec!["machine", "logs"], args)
714            }
715            MachineCommands::Remove(args) => {
716                passthrough_with_args("remove machine", vec!["machine", "delete"], args)
717            }
718            MachineCommands::Run(args) => {
719                passthrough_with_args("run machine command", vec!["machine", "run"], args)
720            }
721            MachineCommands::Set(args) => {
722                passthrough_with_args("set machine", vec!["machine", "set"], args)
723            }
724            MachineCommands::SetDefault(args) => {
725                passthrough_with_args("set default machine", vec!["machine", "set-default"], args)
726            }
727            MachineCommands::Stop(args) => {
728                passthrough_with_args("stop machine", vec!["machine", "stop"], args)
729            }
730        }),
731        ContainerRuntime::Podman => match command {
732            MachineCommands::Create(args) => Ok(passthrough_with_args(
733                "create machine",
734                vec!["machine", "init"],
735                args,
736            )),
737            MachineCommands::Inspect(args) => Ok(passthrough_with_args(
738                "inspect machine",
739                vec!["machine", "inspect"],
740                args,
741            )),
742            MachineCommands::List(args) => Ok(passthrough_with_args(
743                "list machines",
744                vec!["machine", "list"],
745                args,
746            )),
747            MachineCommands::Logs(args) => Ok(passthrough_with_args(
748                "show machine info",
749                vec!["machine", "info"],
750                args,
751            )),
752            MachineCommands::Remove(args) => Ok(passthrough_with_args(
753                "remove machine",
754                vec!["machine", "rm"],
755                args,
756            )),
757            MachineCommands::Run(args) => Ok(passthrough_with_args(
758                "ssh into machine",
759                vec!["machine", "ssh"],
760                args,
761            )),
762            MachineCommands::Set(args) => Ok(passthrough_with_args(
763                "set machine",
764                vec!["machine", "set"],
765                args,
766            )),
767            MachineCommands::SetDefault(_) => Err(unsupported_with_podman(
768                "machine set-default",
769                "Podman machine does not provide a set-default subcommand",
770            )),
771            MachineCommands::Stop(args) => Ok(passthrough_with_args(
772                "stop machine",
773                vec!["machine", "stop"],
774                args,
775            )),
776        },
777    }
778}
779
780fn unsupported_with_podman(command: &str, reason: &str) -> OrodruinError {
781    OrodruinError::Message(format!(
782        "`{command}` is not supported with the Linux Podman backend: {reason}"
783    ))
784}
785
786impl From<OptionalPassthroughArgs> for Vec<String> {
787    fn from(value: OptionalPassthroughArgs) -> Self {
788        value.args
789    }
790}
791
792impl From<RequiredPassthroughArgs> for Vec<String> {
793    fn from(value: RequiredPassthroughArgs) -> Self {
794        value.args
795    }
796}
797
798fn init_command() -> Result<(), OrodruinError> {
799    let cwd = std::env::current_dir()?;
800    let path = cwd.join(CONFIG_FILE_NAME);
801    if path.exists() {
802        return Err(OrodruinError::Message(format!(
803            "{CONFIG_FILE_NAME} already exists at {}",
804            path.display()
805        )));
806    }
807
808    let project_name = cwd
809        .file_name()
810        .and_then(|value| value.to_str())
811        .unwrap_or("project");
812    fs::write(&path, default_init_config(project_name)?)?;
813    println!("created {}", path.display());
814    Ok(())
815}
816
817fn load_config(explicit_path: Option<&Path>) -> Result<LoadedConfig, OrodruinError> {
818    let cwd = std::env::current_dir()?;
819    Ok(ProjectConfig::load_from(&cwd, explicit_path)?)
820}
821
822fn resolve_environment(
823    loaded: &LoadedConfig,
824    environment: &EnvironmentName,
825) -> Result<ResolvedEnvironment, OrodruinError> {
826    resolve_environment_by_name(loaded, &environment.env)
827}
828
829fn resolve_optional_environment<'a>(
830    loaded: &'a LoadedConfig,
831    env_name: Option<&'a str>,
832    command_name: &str,
833) -> Result<ResolvedEnvironment, OrodruinError> {
834    let env_name = match env_name {
835        Some(env_name) => env_name,
836        None => resolve_default_environment_name(loaded, command_name)?,
837    };
838
839    resolve_environment_by_name(loaded, env_name)
840}
841
842fn resolve_default_environment_name<'a>(
843    loaded: &'a LoadedConfig,
844    command_name: &str,
845) -> Result<&'a str, OrodruinError> {
846    if let Some(default_env) = loaded.config.project.default_env.as_deref() {
847        return Ok(default_env);
848    }
849
850    if loaded.config.envs.len() == 1 {
851        return Ok(loaded
852            .config
853            .envs
854            .keys()
855            .next()
856            .expect("single environment checked above")
857            .as_str());
858    }
859
860    Err(OrodruinError::Message(format!(
861        "`orodruin {command_name}` needs an environment name; set project.default_env in {} or define only one environment",
862        loaded.path.display(),
863    )))
864}
865
866fn resolve_environment_by_name(
867    loaded: &LoadedConfig,
868    env_name: &str,
869) -> Result<ResolvedEnvironment, OrodruinError> {
870    let config = loaded.config.envs.get(env_name).ok_or_else(|| {
871        OrodruinError::Message(format!(
872            "environment `{}` is not defined in {}",
873            env_name,
874            loaded.path.display()
875        ))
876    })?;
877
878    Ok(ResolvedEnvironment::resolve(
879        &loaded.root,
880        &loaded.config,
881        env_name,
882        config,
883    ))
884}
885
886fn resolve_run_command(
887    resolved: &ResolvedEnvironment,
888    run: RunCommand,
889) -> Result<Vec<String>, OrodruinError> {
890    if !run.command.is_empty() {
891        return Ok(run.command);
892    }
893    resolved.default_command.clone().ok_or_else(|| {
894        OrodruinError::Message(format!(
895            "environment `{}` has no default command; provide one after `--`",
896            resolved.environment_name
897        ))
898    })
899}
900
901fn resolve_ssh_target(
902    backend: &dyn ContainerBackend,
903    resolved: &ResolvedEnvironment,
904) -> Result<String, OrodruinError> {
905    let inspect = backend
906        .inspect_raw(&resolved.container_name)?
907        .ok_or_else(|| {
908            OrodruinError::Message(format!(
909                "container `{}` is running but inspect returned no payload",
910                resolved.container_name
911            ))
912        })?;
913
914    ssh_host_from_inspect(&inspect).ok_or_else(|| {
915        OrodruinError::Message(format!(
916            "could not determine an ssh host for `{}` from container inspect output",
917            resolved.container_name
918        ))
919    })
920}
921
922fn ssh_host_from_inspect(value: &Value) -> Option<String> {
923    if let Some(host) = value
924        .get("status")
925        .and_then(|status| status.get("networks"))
926        .and_then(Value::as_array)
927        .and_then(|networks| {
928            networks.iter().find_map(|network| {
929                find_ip_field(network, &["ipv4Address", "ipAddress", "address"])
930            })
931        })
932    {
933        return Some(host);
934    }
935
936    find_ip_field(
937        value,
938        &["ipv4Address", "ipAddress", "ip_address", "IPAddress", "ip"],
939    )
940}
941
942fn find_ip_field(value: &Value, field_names: &[&str]) -> Option<String> {
943    match value {
944        Value::Object(map) => {
945            for field_name in field_names {
946                if let Some(candidate) = map.get(*field_name).and_then(Value::as_str)
947                    && let Some(host) = normalize_ip(candidate)
948                {
949                    return Some(host);
950                }
951            }
952
953            map.values()
954                .find_map(|nested| find_ip_field(nested, field_names))
955        }
956        Value::Array(values) => values
957            .iter()
958            .find_map(|nested| find_ip_field(nested, field_names)),
959        Value::String(candidate) => normalize_ip(candidate),
960        _ => None,
961    }
962}
963
964fn normalize_ip(candidate: &str) -> Option<String> {
965    let host = candidate.split('/').next()?.trim();
966    host.parse::<IpAddr>().ok().map(|_| host.to_string())
967}
968
969fn build_ssh_spec(resolved: &ResolvedEnvironment, host: &str) -> CommandSpec {
970    CommandSpec {
971        program: "ssh".into(),
972        args: vec![format!("{}@{host}", resolved.user.username)],
973    }
974}
975
976fn run_host_command(spec: &CommandSpec) -> Result<(), OrodruinError> {
977    let status = ProcessCommand::new(&spec.program)
978        .args(&spec.args)
979        .stdin(Stdio::inherit())
980        .stdout(Stdio::inherit())
981        .stderr(Stdio::inherit())
982        .status()?;
983    if status.success() {
984        return Ok(());
985    }
986
987    Err(OrodruinError::CommandFailed {
988        command: spec.render(),
989        status: status.code(),
990    })
991}
992
993fn materialize_environment(
994    backend: &dyn ContainerBackend,
995    resolved: &ResolvedEnvironment,
996) -> Result<(), OrodruinError> {
997    let current = backend
998        .list_all()?
999        .into_iter()
1000        .find(|summary| summary.matches(&resolved.container_name));
1001
1002    match current {
1003        Some(summary) if summary.running => Ok(()),
1004        Some(_) => {
1005            backend.start(&resolved.container_name)?;
1006            Ok(())
1007        }
1008        None => {
1009            if let Some(build) = &resolved.build {
1010                backend.build_image(build)?;
1011            }
1012            backend.create(resolved)?;
1013            Ok(())
1014        }
1015    }
1016}
1017
1018fn ensure_container_system_running(
1019    backend: &dyn ContainerBackend,
1020    runtime: ContainerRuntime,
1021) -> Result<(), OrodruinError> {
1022    ensure_container_system_running_with_prompt(backend, runtime, prompt_to_start_container_system)
1023}
1024
1025fn ensure_container_system_running_with_prompt(
1026    backend: &dyn ContainerBackend,
1027    runtime: ContainerRuntime,
1028    prompt: impl FnOnce() -> Result<bool, OrodruinError>,
1029) -> Result<(), OrodruinError> {
1030    if !runtime.manages_system_lifecycle() {
1031        return Ok(());
1032    }
1033
1034    if backend.system_running()? {
1035        return Ok(());
1036    }
1037
1038    if prompt()? {
1039        backend.start_system()?;
1040        return Ok(());
1041    }
1042
1043    Err(OrodruinError::Message(
1044        "container system not running; start it first with `container system start`".into(),
1045    ))
1046}
1047
1048fn prompt_to_start_container_system() -> Result<bool, OrodruinError> {
1049    let stdin = io::stdin();
1050    let mut stdin = stdin.lock();
1051    let stderr = io::stderr();
1052    let mut stderr = stderr.lock();
1053    prompt_to_start_container_system_with_io(&mut stdin, &mut stderr)
1054}
1055
1056fn prompt_to_start_container_system_with_io<R, W>(
1057    stdin: &mut R,
1058    stderr: &mut W,
1059) -> Result<bool, OrodruinError>
1060where
1061    R: BufRead,
1062    W: Write,
1063{
1064    loop {
1065        write!(
1066            stderr,
1067            "container system is not running. Start it now? [y/N] "
1068        )?;
1069        stderr.flush()?;
1070
1071        let mut line = String::new();
1072        if stdin.read_line(&mut line)? == 0 {
1073            return Ok(false);
1074        }
1075
1076        match line.trim().to_ascii_lowercase().as_str() {
1077            "y" | "yes" => return Ok(true),
1078            "n" | "no" | "" => return Ok(false),
1079            _ => {
1080                writeln!(stderr, "please answer y or n")?;
1081            }
1082        }
1083    }
1084}
1085
1086fn ensure_container_user(
1087    backend: &dyn ContainerBackend,
1088    resolved: &ResolvedEnvironment,
1089) -> Result<(), OrodruinError> {
1090    backend.exec(
1091        &resolved.container_name,
1092        &ExecRequest {
1093            workdir: Some("/".into()),
1094            env: vec![
1095                ("ORODRUIN_HOST_USER".into(), resolved.user.username.clone()),
1096                ("ORODRUIN_HOST_UID".into(), resolved.user.uid.to_string()),
1097                ("ORODRUIN_HOST_GID".into(), resolved.user.gid.to_string()),
1098                ("ORODRUIN_HOST_HOME".into(), resolved.user.home.clone()),
1099            ],
1100            command: vec![
1101                "/bin/sh".into(),
1102                "-lc".into(),
1103                bootstrap_user_script().into(),
1104            ],
1105            interactive: false,
1106            user: Some(ResolvedUser {
1107                username: "root".into(),
1108                uid: 0,
1109                gid: 0,
1110                home: "/root".into(),
1111            }),
1112        },
1113    )?;
1114    Ok(())
1115}
1116
1117fn exec_environment(resolved: &ResolvedEnvironment) -> Vec<(String, String)> {
1118    let mut env = resolved
1119        .env
1120        .iter()
1121        .map(|(key, value)| (key.clone(), value.clone()))
1122        .collect::<Vec<_>>();
1123    env.push(("HOME".into(), resolved.user.home.clone()));
1124    env.push(("USER".into(), resolved.user.username.clone()));
1125    env.push(("LOGNAME".into(), resolved.user.username.clone()));
1126    env
1127}
1128
1129fn bootstrap_user_script() -> &'static str {
1130    r#"set -eu
1131
1132username="${ORODRUIN_HOST_USER}"
1133uid="${ORODRUIN_HOST_UID}"
1134gid="${ORODRUIN_HOST_GID}"
1135home="${ORODRUIN_HOST_HOME}"
1136
1137lookup_group_by_gid() {
1138    awk -F: -v gid="$1" '$3 == gid { print $1; exit }' /etc/group
1139}
1140
1141lookup_user_by_name() {
1142    awk -F: -v name="$1" '$1 == name { print $3 ":" $4; exit }' /etc/passwd
1143}
1144
1145lookup_user_by_uid() {
1146    awk -F: -v uid="$1" '$3 == uid { print $1; exit }' /etc/passwd
1147}
1148
1149group_name="$(lookup_group_by_gid "$gid")"
1150if [ -z "$group_name" ]; then
1151    if command -v groupadd >/dev/null 2>&1; then
1152        groupadd -g "$gid" "$username"
1153        group_name="$username"
1154    elif command -v addgroup >/dev/null 2>&1; then
1155        addgroup -g "$gid" "$username"
1156        group_name="$username"
1157    else
1158        echo "missing groupadd/addgroup for gid ${gid}" >&2
1159        exit 1
1160    fi
1161fi
1162
1163existing_user="$(lookup_user_by_name "$username")"
1164if [ -n "$existing_user" ]; then
1165    existing_uid="${existing_user%%:*}"
1166    existing_gid="${existing_user##*:}"
1167    if [ "$existing_uid" != "$uid" ] || [ "$existing_gid" != "$gid" ]; then
1168        echo "user ${username} exists with uid:gid ${existing_uid}:${existing_gid}, expected ${uid}:${gid}" >&2
1169        exit 1
1170    fi
1171else
1172    uid_owner="$(lookup_user_by_uid "$uid")"
1173    if [ -n "$uid_owner" ] && [ "$uid_owner" != "$username" ]; then
1174        username="$uid_owner"
1175        existing_user="$(lookup_user_by_name "$username")"
1176        existing_gid="${existing_user##*:}"
1177        if [ "$existing_gid" != "$gid" ]; then
1178            echo "uid ${uid} already owned by ${uid_owner} with gid ${existing_gid}, expected ${gid}" >&2
1179            exit 1
1180        fi
1181    else
1182        if command -v useradd >/dev/null 2>&1; then
1183            if useradd -K UID_MIN=0 -K UID_MAX=60000 -m -d "$home" -u "$uid" -g "$gid" -s /bin/sh "$username"; then
1184                :
1185            else
1186                useradd -m -d "$home" -u "$uid" -g "$gid" -s /bin/sh "$username"
1187            fi
1188        elif command -v adduser >/dev/null 2>&1; then
1189            adduser -D -h "$home" -u "$uid" -G "$group_name" "$username"
1190        else
1191            echo "missing useradd/adduser for ${username}" >&2
1192            exit 1
1193        fi
1194    fi
1195fi
1196
1197mkdir -p "$home"
1198chown "$uid:$gid" "$home"
1199"#
1200}
1201
1202fn container_exists(
1203    backend: &dyn ContainerBackend,
1204    container_name: &str,
1205) -> Result<bool, OrodruinError> {
1206    Ok(backend
1207        .list_all()?
1208        .iter()
1209        .any(|summary| summary.matches(container_name)))
1210}
1211
1212fn print_summary(name: &str, container_name: &str, summary: Option<&ContainerSummary>) {
1213    let state = match summary {
1214        Some(summary) if summary.running => "running",
1215        Some(summary) => summary.status.as_deref().unwrap_or("created"),
1216        None => "not-created",
1217    };
1218    println!("{name}\t{container_name}\t{state}");
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use std::{
1224        cell::RefCell,
1225        collections::VecDeque,
1226        path::{Path, PathBuf},
1227        sync::{Mutex, MutexGuard},
1228    };
1229
1230    use serde_json::json;
1231
1232    use super::*;
1233    use crate::{
1234        backend::{BackendError, CommandSpec},
1235        cli::{
1236            BuilderCommands, Commands, ContainerCommands, EnterCommand, ImageCommands,
1237            MachineCommands, OptionalPassthroughArgs, RegistryCommands, ResourceCommands,
1238            SshCommand, SystemCommands,
1239        },
1240        config::{EnvironmentConfig, ProjectMetadata},
1241        env_model::ResolvedBuild,
1242    };
1243
1244    #[derive(Default)]
1245    struct MockBackend {
1246        list_results: RefCell<VecDeque<Vec<ContainerSummary>>>,
1247        inspect_value: RefCell<Option<serde_json::Value>>,
1248        inspect_calls: RefCell<Vec<String>>,
1249        system_running_results: RefCell<VecDeque<Result<bool, BackendError>>>,
1250        created: RefCell<Vec<String>>,
1251        started: RefCell<Vec<String>>,
1252        system_starts: RefCell<usize>,
1253        deleted: RefCell<Vec<String>>,
1254        execs: RefCell<Vec<ExecRequest>>,
1255        exec_results: RefCell<VecDeque<Result<(), BackendError>>>,
1256        builds: RefCell<Vec<String>>,
1257        commands: RefCell<Vec<(String, CommandSpec)>>,
1258    }
1259
1260    impl MockBackend {
1261        fn with_lists(lists: Vec<Vec<ContainerSummary>>) -> Self {
1262            Self {
1263                list_results: RefCell::new(lists.into()),
1264                ..Self::default()
1265            }
1266        }
1267    }
1268
1269    static CURRENT_DIR_LOCK: Mutex<()> = Mutex::new(());
1270
1271    struct CurrentDirGuard<'a> {
1272        _lock: MutexGuard<'a, ()>,
1273        previous: PathBuf,
1274    }
1275
1276    impl<'a> CurrentDirGuard<'a> {
1277        fn enter(path: &Path) -> Self {
1278            let lock = CURRENT_DIR_LOCK
1279                .lock()
1280                .unwrap_or_else(|error| error.into_inner());
1281            let previous = std::env::current_dir().unwrap();
1282            std::env::set_current_dir(path).unwrap();
1283            Self {
1284                _lock: lock,
1285                previous,
1286            }
1287        }
1288    }
1289
1290    impl Drop for CurrentDirGuard<'_> {
1291        fn drop(&mut self) {
1292            std::env::set_current_dir(&self.previous).unwrap();
1293        }
1294    }
1295
1296    impl ContainerBackend for MockBackend {
1297        fn list_all(&self) -> Result<Vec<ContainerSummary>, BackendError> {
1298            Ok(self
1299                .list_results
1300                .borrow_mut()
1301                .pop_front()
1302                .unwrap_or_default())
1303        }
1304
1305        fn inspect_raw(
1306            &self,
1307            container_name: &str,
1308        ) -> Result<Option<serde_json::Value>, BackendError> {
1309            self.inspect_calls
1310                .borrow_mut()
1311                .push(container_name.to_string());
1312            Ok(self.inspect_value.borrow().clone())
1313        }
1314
1315        fn system_running(&self) -> Result<bool, BackendError> {
1316            self.system_running_results
1317                .borrow_mut()
1318                .pop_front()
1319                .unwrap_or(Ok(true))
1320        }
1321
1322        fn start_system(&self) -> Result<(), BackendError> {
1323            *self.system_starts.borrow_mut() += 1;
1324            Ok(())
1325        }
1326
1327        fn build_image(&self, build: &ResolvedBuild) -> Result<(), BackendError> {
1328            self.builds.borrow_mut().push(build.tag.clone());
1329            Ok(())
1330        }
1331
1332        fn create(&self, environment: &ResolvedEnvironment) -> Result<(), BackendError> {
1333            self.created
1334                .borrow_mut()
1335                .push(environment.container_name.clone());
1336            Ok(())
1337        }
1338
1339        fn start(&self, container_name: &str) -> Result<(), BackendError> {
1340            self.started.borrow_mut().push(container_name.to_string());
1341            Ok(())
1342        }
1343
1344        fn exec(&self, _container_name: &str, request: &ExecRequest) -> Result<(), BackendError> {
1345            self.execs.borrow_mut().push(request.clone());
1346            self.exec_results.borrow_mut().pop_front().unwrap_or(Ok(()))
1347        }
1348
1349        fn delete(&self, container_name: &str) -> Result<(), BackendError> {
1350            self.deleted.borrow_mut().push(container_name.to_string());
1351            Ok(())
1352        }
1353
1354        fn run_command(&self, step: &str, spec: &CommandSpec) -> Result<(), BackendError> {
1355            self.commands
1356                .borrow_mut()
1357                .push((step.to_string(), spec.clone()));
1358            Ok(())
1359        }
1360    }
1361
1362    fn write_config(root: &Path) {
1363        fs::write(
1364            root.join(CONFIG_FILE_NAME),
1365            r#"
1366                [project]
1367                name = "demo"
1368
1369                [envs.dev]
1370                image = "ubuntu:latest"
1371                shell = ["/bin/bash"]
1372                startup_command = ["sleep", "infinity"]
1373
1374                [envs.ci]
1375                build = { context = ".", file = "Containerfile", tag = "demo-ci:dev" }
1376                default_command = ["cargo", "test"]
1377            "#,
1378        )
1379        .unwrap();
1380    }
1381
1382    fn resolved_name(root: &Path, env_name: &str) -> String {
1383        let loaded = ProjectConfig::load_from(root, Some(&root.join(CONFIG_FILE_NAME))).unwrap();
1384        let config = loaded.config.envs.get(env_name).unwrap();
1385        ResolvedEnvironment::resolve(&loaded.root, &loaded.config, env_name, config).container_name
1386    }
1387
1388    #[test]
1389    fn create_is_idempotent_for_existing_running_container() {
1390        let tempdir = tempfile::tempdir().unwrap();
1391        write_config(tempdir.path());
1392        let _guard = CurrentDirGuard::enter(tempdir.path());
1393        let container_name = resolved_name(tempdir.path(), "dev");
1394
1395        let backend = MockBackend::with_lists(vec![vec![ContainerSummary {
1396            id: container_name.clone(),
1397            name: Some(container_name),
1398            status: Some("running".into()),
1399            running: true,
1400        }]]);
1401
1402        let cli = Cli {
1403            debug: false,
1404            config: None,
1405            command: Commands::Create(EnvironmentName { env: "dev".into() }),
1406        };
1407
1408        run_with_backend(cli, &backend).unwrap();
1409        assert!(backend.created.borrow().is_empty());
1410        assert!(backend.started.borrow().is_empty());
1411    }
1412
1413    #[test]
1414    fn enter_starts_before_exec_when_needed() {
1415        let tempdir = tempfile::tempdir().unwrap();
1416        write_config(tempdir.path());
1417        let _guard = CurrentDirGuard::enter(tempdir.path());
1418        let container_name = resolved_name(tempdir.path(), "dev");
1419
1420        let backend = MockBackend::with_lists(vec![vec![ContainerSummary {
1421            id: container_name.clone(),
1422            name: Some(container_name),
1423            status: Some("stopped".into()),
1424            running: false,
1425        }]]);
1426
1427        let cli = Cli {
1428            debug: false,
1429            config: None,
1430            command: Commands::Enter(EnterCommand {
1431                env: Some("dev".into()),
1432            }),
1433        };
1434
1435        run_with_backend(cli, &backend).unwrap();
1436        assert_eq!(backend.started.borrow().len(), 1);
1437        assert_eq!(backend.execs.borrow().len(), 2);
1438        assert_eq!(
1439            backend.execs.borrow()[1].command,
1440            vec![String::from("/bin/bash")]
1441        );
1442        assert_eq!(
1443            backend.execs.borrow()[1].user.as_ref().map(|user| user.uid),
1444            Some(unsafe { libc::getuid() })
1445        );
1446    }
1447
1448    #[test]
1449    fn enter_prompts_before_starting_container_system() {
1450        let tempdir = tempfile::tempdir().unwrap();
1451        write_config(tempdir.path());
1452        let _guard = CurrentDirGuard::enter(tempdir.path());
1453
1454        let backend = MockBackend {
1455            system_running_results: RefCell::new(VecDeque::from([Ok(false)])),
1456            ..MockBackend::default()
1457        };
1458
1459        ensure_container_system_running_with_prompt(
1460            &backend,
1461            ContainerRuntime::AppleContainer,
1462            || Ok(true),
1463        )
1464        .unwrap();
1465        assert_eq!(*backend.system_starts.borrow(), 1);
1466    }
1467
1468    #[test]
1469    fn enter_aborts_when_container_system_prompt_declined() {
1470        let tempdir = tempfile::tempdir().unwrap();
1471        write_config(tempdir.path());
1472        let _guard = CurrentDirGuard::enter(tempdir.path());
1473
1474        let backend = MockBackend {
1475            system_running_results: RefCell::new(VecDeque::from([Ok(false)])),
1476            ..MockBackend::default()
1477        };
1478
1479        let error = ensure_container_system_running_with_prompt(
1480            &backend,
1481            ContainerRuntime::AppleContainer,
1482            || Ok(false),
1483        )
1484        .unwrap_err();
1485        assert!(error.to_string().contains("container system not running"));
1486        assert_eq!(*backend.system_starts.borrow(), 0);
1487    }
1488
1489    #[test]
1490    fn podman_runtime_skips_container_system_prompt() {
1491        let backend = MockBackend {
1492            system_running_results: RefCell::new(VecDeque::from([Ok(false)])),
1493            ..MockBackend::default()
1494        };
1495
1496        ensure_container_system_running_with_prompt(&backend, ContainerRuntime::Podman, || {
1497            panic!("podman should not prompt for a container system startup")
1498        })
1499        .unwrap();
1500
1501        assert_eq!(*backend.system_starts.borrow(), 0);
1502    }
1503
1504    #[test]
1505    fn prompt_accepts_yes_and_no() {
1506        let mut stdin = std::io::Cursor::new(b"maybe\ny\n");
1507        let mut stderr = Vec::new();
1508        let accepted = prompt_to_start_container_system_with_io(&mut stdin, &mut stderr).unwrap();
1509        assert!(accepted);
1510        assert!(
1511            String::from_utf8(stderr)
1512                .unwrap()
1513                .contains("please answer y or n")
1514        );
1515
1516        let mut stdin = std::io::Cursor::new(b"n\n");
1517        let mut stderr = Vec::new();
1518        let declined = prompt_to_start_container_system_with_io(&mut stdin, &mut stderr).unwrap();
1519        assert!(!declined);
1520    }
1521
1522    #[test]
1523    fn enter_ignores_interactive_shell_exit_status() {
1524        let tempdir = tempfile::tempdir().unwrap();
1525        write_config(tempdir.path());
1526        let _guard = CurrentDirGuard::enter(tempdir.path());
1527
1528        let backend = MockBackend::with_lists(vec![vec![]]);
1529        *backend.exec_results.borrow_mut() = VecDeque::from([
1530            Ok(()),
1531            Err(BackendError::CommandFailed {
1532                step: "exec command".into(),
1533                command: "container exec demo /bin/bash".into(),
1534                status: Some(127),
1535                stderr: String::new(),
1536            }),
1537        ]);
1538
1539        let cli = Cli {
1540            debug: false,
1541            config: None,
1542            command: Commands::Enter(EnterCommand {
1543                env: Some("dev".into()),
1544            }),
1545        };
1546
1547        run_with_backend(cli, &backend).unwrap();
1548        assert_eq!(
1549            backend.execs.borrow()[1].command,
1550            vec![String::from("/bin/bash")]
1551        );
1552    }
1553
1554    #[test]
1555    fn rm_targets_resolved_container() {
1556        let tempdir = tempfile::tempdir().unwrap();
1557        write_config(tempdir.path());
1558        let _guard = CurrentDirGuard::enter(tempdir.path());
1559        let container_name = resolved_name(tempdir.path(), "dev");
1560
1561        let backend = MockBackend::with_lists(vec![vec![ContainerSummary {
1562            id: container_name.clone(),
1563            name: Some(container_name),
1564            status: Some("running".into()),
1565            running: true,
1566        }]]);
1567
1568        let cli = Cli {
1569            debug: false,
1570            config: None,
1571            command: Commands::Rm(EnvironmentName { env: "dev".into() }),
1572        };
1573
1574        run_with_backend(cli, &backend).unwrap();
1575        assert_eq!(backend.deleted.borrow().len(), 1);
1576    }
1577
1578    #[test]
1579    fn run_uses_default_command_when_not_provided() {
1580        let tempdir = tempfile::tempdir().unwrap();
1581        write_config(tempdir.path());
1582        let _guard = CurrentDirGuard::enter(tempdir.path());
1583
1584        let backend = MockBackend::with_lists(vec![vec![]]);
1585
1586        let cli = Cli {
1587            debug: false,
1588            config: None,
1589            command: Commands::Run(RunCommand {
1590                env: "ci".into(),
1591                command: vec![],
1592            }),
1593        };
1594
1595        run_with_backend(cli, &backend).unwrap();
1596        assert_eq!(
1597            backend.execs.borrow()[1].command,
1598            vec![String::from("cargo"), String::from("test")]
1599        );
1600        assert_eq!(backend.builds.borrow()[0], "demo-ci:dev");
1601    }
1602
1603    #[test]
1604    fn inspect_reports_backend_payload() {
1605        let tempdir = tempfile::tempdir().unwrap();
1606        write_config(tempdir.path());
1607        let _guard = CurrentDirGuard::enter(tempdir.path());
1608
1609        let container_name = resolved_name(tempdir.path(), "dev");
1610        let backend = MockBackend::with_lists(vec![vec![ContainerSummary {
1611            id: container_name.clone(),
1612            name: Some(container_name.clone()),
1613            status: Some("running".into()),
1614            running: true,
1615        }]]);
1616        *backend.inspect_value.borrow_mut() = Some(json!({ "name": "demo" }));
1617
1618        let loaded = load_config(None).unwrap();
1619        let resolved =
1620            resolve_environment(&loaded, &EnvironmentName { env: "dev".into() }).unwrap();
1621        let payload = backend
1622            .inspect_raw(&resolved.container_name)
1623            .unwrap()
1624            .unwrap();
1625        assert_eq!(backend.inspect_calls.borrow().as_slice(), &[container_name]);
1626        assert_eq!(payload["name"], "demo");
1627    }
1628
1629    #[test]
1630    fn inspect_skips_backend_lookup_for_missing_container() {
1631        let tempdir = tempfile::tempdir().unwrap();
1632        write_config(tempdir.path());
1633        let _guard = CurrentDirGuard::enter(tempdir.path());
1634
1635        let backend = MockBackend::with_lists(vec![vec![]]);
1636        let cli = Cli {
1637            debug: false,
1638            config: None,
1639            command: Commands::Inspect(EnvironmentName { env: "dev".into() }),
1640        };
1641
1642        run_with_backend(cli, &backend).unwrap();
1643        assert!(backend.inspect_calls.borrow().is_empty());
1644    }
1645
1646    #[test]
1647    fn existing_built_container_does_not_rebuild_image() {
1648        let tempdir = tempfile::tempdir().unwrap();
1649        write_config(tempdir.path());
1650        let _guard = CurrentDirGuard::enter(tempdir.path());
1651        let container_name = resolved_name(tempdir.path(), "ci");
1652
1653        let backend = MockBackend::with_lists(vec![vec![ContainerSummary {
1654            id: container_name.clone(),
1655            name: Some(container_name),
1656            status: Some("running".into()),
1657            running: true,
1658        }]]);
1659
1660        let cli = Cli {
1661            debug: false,
1662            config: None,
1663            command: Commands::Create(EnvironmentName { env: "ci".into() }),
1664        };
1665
1666        run_with_backend(cli, &backend).unwrap();
1667        assert!(backend.builds.borrow().is_empty());
1668    }
1669
1670    #[test]
1671    fn project_resolution_uses_config_name() {
1672        let project = ProjectConfig {
1673            project: ProjectMetadata {
1674                name: Some("Demo".into()),
1675                default_env: None,
1676            },
1677            envs: Default::default(),
1678        };
1679        let env = EnvironmentConfig {
1680            image: Some("ubuntu:latest".into()),
1681            build: None,
1682            container_name: None,
1683            project_mount: None,
1684            workdir: None,
1685            env: Default::default(),
1686            preserve_env: vec![],
1687            mounts: vec![],
1688            shell: None,
1689            startup_command: None,
1690            default_command: None,
1691        };
1692
1693        let resolved = ResolvedEnvironment::resolve(Path::new("/tmp/demo"), &project, "dev", &env);
1694        assert!(resolved.container_name.starts_with("orodruin-demo-"));
1695    }
1696
1697    #[test]
1698    fn enter_bootstraps_user_before_shell_exec() {
1699        let tempdir = tempfile::tempdir().unwrap();
1700        write_config(tempdir.path());
1701        let _guard = CurrentDirGuard::enter(tempdir.path());
1702
1703        let backend = MockBackend::with_lists(vec![vec![]]);
1704        let cli = Cli {
1705            debug: false,
1706            config: None,
1707            command: Commands::Enter(EnterCommand {
1708                env: Some("dev".into()),
1709            }),
1710        };
1711
1712        run_with_backend(cli, &backend).unwrap();
1713
1714        let execs = backend.execs.borrow();
1715        assert_eq!(execs[0].command[0], "/bin/sh");
1716        assert!(execs[0].command[2].contains("useradd -K UID_MIN=0 -K UID_MAX=60000"));
1717        assert!(
1718            execs[0].command[2].contains(
1719                "useradd -m -d \"$home\" -u \"$uid\" -g \"$gid\" -s /bin/sh \"$username\""
1720            )
1721        );
1722        assert_eq!(execs[0].user.as_ref().map(|user| user.uid), Some(0));
1723        assert_eq!(execs[0].user.as_ref().map(|user| user.gid), Some(0));
1724        assert_eq!(
1725            execs[1].env.iter().find(|(key, _)| key == "HOME"),
1726            Some(&(
1727                String::from("HOME"),
1728                format!("/home/{}", execs[1].user.as_ref().unwrap().username)
1729            ))
1730        );
1731    }
1732
1733    #[test]
1734    fn bootstrap_user_script_reuses_existing_uid_owner() {
1735        let script = bootstrap_user_script();
1736        assert!(script.contains("username=\"$uid_owner\""));
1737        assert!(script.contains(
1738            "uid ${uid} already owned by ${uid_owner} with gid ${existing_gid}, expected ${gid}"
1739        ));
1740    }
1741
1742    #[test]
1743    fn pull_dispatches_to_image_pull() {
1744        let backend = MockBackend::default();
1745        let cli = Cli {
1746            debug: false,
1747            config: None,
1748            command: Commands::Pull(RequiredPassthroughArgs {
1749                args: vec!["alpine:latest".into()],
1750            }),
1751        };
1752
1753        run_with_backend(cli, &backend).unwrap();
1754
1755        let commands = backend.commands.borrow();
1756        assert_eq!(commands[0].0, "pull image");
1757        assert_eq!(commands[0].1.args, ["image", "pull", "alpine:latest"]);
1758    }
1759
1760    #[test]
1761    fn ps_dispatches_to_container_list() {
1762        let backend = MockBackend::default();
1763        let cli = Cli {
1764            debug: false,
1765            config: None,
1766            command: Commands::Ps(OptionalPassthroughArgs {
1767                args: vec!["--all".into()],
1768            }),
1769        };
1770
1771        run_with_backend(cli, &backend).unwrap();
1772
1773        let commands = backend.commands.borrow();
1774        assert_eq!(commands[0].0, "list containers");
1775        assert_eq!(commands[0].1.args, ["list", "--all"]);
1776    }
1777
1778    #[test]
1779    fn nested_registry_and_resource_commands_map_to_container_cli() {
1780        let backend = MockBackend::default();
1781
1782        run_with_backend(
1783            Cli {
1784                debug: false,
1785                config: None,
1786                command: Commands::Registry(RegistryCommands::List(OptionalPassthroughArgs {
1787                    args: vec![],
1788                })),
1789            },
1790            &backend,
1791        )
1792        .unwrap();
1793
1794        run_with_backend(
1795            Cli {
1796                debug: false,
1797                config: None,
1798                command: Commands::Image(ImageCommands::Prune(OptionalPassthroughArgs {
1799                    args: vec![],
1800                })),
1801            },
1802            &backend,
1803        )
1804        .unwrap();
1805
1806        run_with_backend(
1807            Cli {
1808                debug: false,
1809                config: None,
1810                command: Commands::Volume(ResourceCommands::Remove(RequiredPassthroughArgs {
1811                    args: vec!["cache".into()],
1812                })),
1813            },
1814            &backend,
1815        )
1816        .unwrap();
1817
1818        let commands = backend.commands.borrow();
1819        assert_eq!(commands[0].1.args, ["registry", "list"]);
1820        assert_eq!(commands[1].1.args, ["image", "prune"]);
1821        assert_eq!(commands[2].1.args, ["volume", "delete", "cache"]);
1822    }
1823
1824    #[test]
1825    fn nested_image_commands_map_to_container_cli() {
1826        let backend = MockBackend::default();
1827
1828        run_with_backend(
1829            Cli {
1830                debug: false,
1831                config: None,
1832                command: Commands::Image(ImageCommands::Inspect(RequiredPassthroughArgs {
1833                    args: vec!["alpine:latest".into()],
1834                })),
1835            },
1836            &backend,
1837        )
1838        .unwrap();
1839
1840        run_with_backend(
1841            Cli {
1842                debug: false,
1843                config: None,
1844                command: Commands::Image(ImageCommands::Push(RequiredPassthroughArgs {
1845                    args: vec!["demo:latest".into()],
1846                })),
1847            },
1848            &backend,
1849        )
1850        .unwrap();
1851
1852        run_with_backend(
1853            Cli {
1854                debug: false,
1855                config: None,
1856                command: Commands::Image(ImageCommands::Tag(RequiredPassthroughArgs {
1857                    args: vec!["demo:latest".into(), "demo:v1".into()],
1858                })),
1859            },
1860            &backend,
1861        )
1862        .unwrap();
1863
1864        let commands = backend.commands.borrow();
1865        assert_eq!(commands[0].1.args, ["image", "inspect", "alpine:latest"]);
1866        assert_eq!(commands[1].1.args, ["image", "push", "demo:latest"]);
1867        assert_eq!(
1868            commands[2].1.args,
1869            ["image", "tag", "demo:latest", "demo:v1"]
1870        );
1871    }
1872
1873    #[test]
1874    fn image_load_and_save_map_to_container_cli() {
1875        let backend = MockBackend::default();
1876
1877        run_with_backend(
1878            Cli {
1879                debug: false,
1880                config: None,
1881                command: Commands::Image(ImageCommands::Load(OptionalPassthroughArgs {
1882                    args: vec!["-i".into(), "image.tar".into()],
1883                })),
1884            },
1885            &backend,
1886        )
1887        .unwrap();
1888
1889        run_with_backend(
1890            Cli {
1891                debug: false,
1892                config: None,
1893                command: Commands::Image(ImageCommands::Save(OptionalPassthroughArgs {
1894                    args: vec!["-o".into(), "image.tar".into(), "demo:latest".into()],
1895                })),
1896            },
1897            &backend,
1898        )
1899        .unwrap();
1900
1901        let commands = backend.commands.borrow();
1902        assert_eq!(commands[0].1.args, ["image", "load", "-i", "image.tar"]);
1903        assert_eq!(
1904            commands[1].1.args,
1905            ["image", "save", "-o", "image.tar", "demo:latest"]
1906        );
1907    }
1908
1909    #[test]
1910    fn nested_container_commands_map_to_container_cli() {
1911        let backend = MockBackend::default();
1912
1913        run_with_backend(
1914            Cli {
1915                debug: false,
1916                config: None,
1917                command: Commands::Container(ContainerCommands::Inspect(RequiredPassthroughArgs {
1918                    args: vec!["demo".into()],
1919                })),
1920            },
1921            &backend,
1922        )
1923        .unwrap();
1924
1925        run_with_backend(
1926            Cli {
1927                debug: false,
1928                config: None,
1929                command: Commands::Container(ContainerCommands::Start(RequiredPassthroughArgs {
1930                    args: vec!["demo".into()],
1931                })),
1932            },
1933            &backend,
1934        )
1935        .unwrap();
1936
1937        run_with_backend(
1938            Cli {
1939                debug: false,
1940                config: None,
1941                command: Commands::Container(ContainerCommands::Stop(RequiredPassthroughArgs {
1942                    args: vec!["demo".into()],
1943                })),
1944            },
1945            &backend,
1946        )
1947        .unwrap();
1948
1949        run_with_backend(
1950            Cli {
1951                debug: false,
1952                config: None,
1953                command: Commands::Container(ContainerCommands::Prune(OptionalPassthroughArgs {
1954                    args: vec![],
1955                })),
1956            },
1957            &backend,
1958        )
1959        .unwrap();
1960
1961        let commands = backend.commands.borrow();
1962        assert_eq!(commands[0].1.args, ["inspect", "demo"]);
1963        assert_eq!(commands[1].1.args, ["start", "demo"]);
1964        assert_eq!(commands[2].1.args, ["stop", "demo"]);
1965        assert_eq!(commands[3].1.args, ["prune"]);
1966    }
1967
1968    #[test]
1969    fn extended_container_commands_map_to_container_cli() {
1970        let backend = MockBackend::default();
1971
1972        for command in [
1973            Commands::Container(ContainerCommands::Create(OptionalPassthroughArgs {
1974                args: vec!["--name".into(), "demo".into(), "alpine".into()],
1975            })),
1976            Commands::Container(ContainerCommands::Run(OptionalPassthroughArgs {
1977                args: vec!["--rm".into(), "alpine".into(), "echo".into(), "hi".into()],
1978            })),
1979            Commands::Container(ContainerCommands::Exec(OptionalPassthroughArgs {
1980                args: vec!["demo".into(), "sh".into()],
1981            })),
1982            Commands::Container(ContainerCommands::Kill(OptionalPassthroughArgs {
1983                args: vec!["demo".into()],
1984            })),
1985            Commands::Container(ContainerCommands::Stats(OptionalPassthroughArgs {
1986                args: vec!["demo".into()],
1987            })),
1988            Commands::Container(ContainerCommands::Export(OptionalPassthroughArgs {
1989                args: vec!["demo".into()],
1990            })),
1991        ] {
1992            run_with_backend(
1993                Cli {
1994                    debug: false,
1995                    config: None,
1996                    command,
1997                },
1998                &backend,
1999            )
2000            .unwrap();
2001        }
2002
2003        let commands = backend.commands.borrow();
2004        assert_eq!(commands[0].1.args, ["create", "--name", "demo", "alpine"]);
2005        assert_eq!(commands[1].1.args, ["run", "--rm", "alpine", "echo", "hi"]);
2006        assert_eq!(commands[2].1.args, ["exec", "demo", "sh"]);
2007        assert_eq!(commands[3].1.args, ["kill", "demo"]);
2008        assert_eq!(commands[4].1.args, ["stats", "demo"]);
2009        assert_eq!(commands[5].1.args, ["export", "demo"]);
2010    }
2011
2012    #[test]
2013    fn builder_system_and_machine_commands_map_to_container_cli() {
2014        let backend = MockBackend::default();
2015
2016        for command in [
2017            Commands::Builder(BuilderCommands::Status(OptionalPassthroughArgs {
2018                args: vec![],
2019            })),
2020            Commands::System(SystemCommands::Version(OptionalPassthroughArgs {
2021                args: vec![],
2022            })),
2023            Commands::Machine(MachineCommands::List(OptionalPassthroughArgs {
2024                args: vec![],
2025            })),
2026            Commands::Machine(MachineCommands::SetDefault(OptionalPassthroughArgs {
2027                args: vec!["desktop".into()],
2028            })),
2029        ] {
2030            run_with_backend(
2031                Cli {
2032                    debug: false,
2033                    config: None,
2034                    command,
2035                },
2036                &backend,
2037            )
2038            .unwrap();
2039        }
2040
2041        let commands = backend.commands.borrow();
2042        assert_eq!(commands[0].1.args, ["builder", "status"]);
2043        assert_eq!(commands[1].1.args, ["system", "version"]);
2044        assert_eq!(commands[2].1.args, ["machine", "list"]);
2045        assert_eq!(commands[3].1.args, ["machine", "set-default", "desktop"]);
2046    }
2047
2048    #[test]
2049    fn podman_passthrough_commands_use_linux_cli_shape() {
2050        let backend = MockBackend::default();
2051
2052        for command in [
2053            Commands::Pull(RequiredPassthroughArgs {
2054                args: vec!["alpine:latest".into()],
2055            }),
2056            Commands::Ps(OptionalPassthroughArgs {
2057                args: vec!["--all".into()],
2058            }),
2059            Commands::Copy(RequiredPassthroughArgs {
2060                args: vec!["host.txt".into(), "demo:/tmp/host.txt".into()],
2061            }),
2062            Commands::Volume(ResourceCommands::List(OptionalPassthroughArgs {
2063                args: vec![],
2064            })),
2065            Commands::Network(ResourceCommands::Remove(RequiredPassthroughArgs {
2066                args: vec!["demo-net".into()],
2067            })),
2068            Commands::Container(ContainerCommands::Remove(RequiredPassthroughArgs {
2069                args: vec!["demo".into()],
2070            })),
2071            Commands::System(SystemCommands::Version(OptionalPassthroughArgs {
2072                args: vec![],
2073            })),
2074            Commands::System(SystemCommands::Status(OptionalPassthroughArgs {
2075                args: vec![],
2076            })),
2077            Commands::Machine(MachineCommands::Create(OptionalPassthroughArgs {
2078                args: vec!["devvm".into()],
2079            })),
2080            Commands::Machine(MachineCommands::Run(OptionalPassthroughArgs {
2081                args: vec!["devvm".into()],
2082            })),
2083            Commands::Builder(BuilderCommands::Status(OptionalPassthroughArgs {
2084                args: vec![],
2085            })),
2086            Commands::Builder(BuilderCommands::Remove(OptionalPassthroughArgs {
2087                args: vec!["--all".into()],
2088            })),
2089        ] {
2090            run_with_backend_for_runtime(
2091                Cli {
2092                    debug: false,
2093                    config: None,
2094                    command,
2095                },
2096                &backend,
2097                ContainerRuntime::Podman,
2098            )
2099            .unwrap();
2100        }
2101
2102        let commands = backend.commands.borrow();
2103        assert_eq!(commands[0].1.program, "podman");
2104        assert_eq!(commands[0].1.args, ["pull", "alpine:latest"]);
2105        assert_eq!(commands[1].1.args, ["ps", "--all"]);
2106        assert_eq!(commands[2].1.args, ["cp", "host.txt", "demo:/tmp/host.txt"]);
2107        assert_eq!(commands[3].1.args, ["volume", "ls"]);
2108        assert_eq!(commands[4].1.args, ["network", "rm", "demo-net"]);
2109        assert_eq!(commands[5].1.args, ["container", "rm", "demo"]);
2110        assert_eq!(commands[6].1.args, ["version"]);
2111        assert_eq!(commands[7].1.args, ["system", "info"]);
2112        assert_eq!(commands[8].1.args, ["machine", "init", "devvm"]);
2113        assert_eq!(commands[9].1.args, ["machine", "ssh", "devvm"]);
2114        assert_eq!(commands[10].1.args, ["builder", "inspect"]);
2115        assert_eq!(commands[11].1.args, ["builder", "prune", "--all"]);
2116    }
2117
2118    #[test]
2119    fn podman_rejects_unsupported_apple_only_passthroughs() {
2120        let backend = MockBackend::default();
2121        let system_error = run_with_backend_for_runtime(
2122            Cli {
2123                debug: false,
2124                config: None,
2125                command: Commands::System(SystemCommands::Dns(OptionalPassthroughArgs {
2126                    args: vec![],
2127                })),
2128            },
2129            &backend,
2130            ContainerRuntime::Podman,
2131        )
2132        .unwrap_err();
2133
2134        let registry_error = run_with_backend_for_runtime(
2135            Cli {
2136                debug: false,
2137                config: None,
2138                command: Commands::Registry(RegistryCommands::List(OptionalPassthroughArgs {
2139                    args: vec![],
2140                })),
2141            },
2142            &backend,
2143            ContainerRuntime::Podman,
2144        )
2145        .unwrap_err();
2146
2147        let machine_error = run_with_backend_for_runtime(
2148            Cli {
2149                debug: false,
2150                config: None,
2151                command: Commands::Machine(MachineCommands::SetDefault(OptionalPassthroughArgs {
2152                    args: vec!["devvm".into()],
2153                })),
2154            },
2155            &backend,
2156            ContainerRuntime::Podman,
2157        )
2158        .unwrap_err();
2159
2160        assert!(system_error.to_string().contains("system dns"));
2161        assert!(registry_error.to_string().contains("registry list"));
2162        assert!(machine_error.to_string().contains("machine set-default"));
2163    }
2164
2165    #[test]
2166    fn enter_uses_project_default_env_when_omitted() {
2167        let tempdir = tempfile::tempdir().unwrap();
2168        fs::write(
2169            tempdir.path().join(CONFIG_FILE_NAME),
2170            r#"
2171                [project]
2172                name = "demo"
2173                default_env = "ci"
2174
2175                [envs.dev]
2176                image = "ubuntu:latest"
2177
2178                [envs.ci]
2179                build = { context = ".", file = "Containerfile", tag = "demo-ci:dev" }
2180            "#,
2181        )
2182        .unwrap();
2183        let _guard = CurrentDirGuard::enter(tempdir.path());
2184
2185        let backend = MockBackend::with_lists(vec![vec![]]);
2186        let cli = Cli {
2187            debug: false,
2188            config: None,
2189            command: Commands::Enter(EnterCommand { env: None }),
2190        };
2191
2192        run_with_backend(cli, &backend).unwrap();
2193        assert_eq!(backend.builds.borrow().as_slice(), ["demo-ci:dev"]);
2194    }
2195
2196    #[test]
2197    fn ssh_print_uses_project_default_env_when_omitted() {
2198        let tempdir = tempfile::tempdir().unwrap();
2199        fs::write(
2200            tempdir.path().join(CONFIG_FILE_NAME),
2201            r#"
2202                [project]
2203                name = "demo"
2204                default_env = "ci"
2205
2206                [envs.dev]
2207                image = "ubuntu:latest"
2208
2209                [envs.ci]
2210                build = { context = ".", file = "Containerfile", tag = "demo-ci:dev" }
2211            "#,
2212        )
2213        .unwrap();
2214        let _guard = CurrentDirGuard::enter(tempdir.path());
2215        let container_name = resolved_name(tempdir.path(), "ci");
2216
2217        let backend = MockBackend::with_lists(vec![vec![]]);
2218        *backend.inspect_value.borrow_mut() = Some(json!({
2219            "status": {
2220                "networks": [{ "ipv4Address": "192.168.64.2/24" }]
2221            }
2222        }));
2223        let cli = Cli {
2224            debug: false,
2225            config: None,
2226            command: Commands::Ssh(SshCommand {
2227                env: None,
2228                print: true,
2229            }),
2230        };
2231
2232        run_with_backend(cli, &backend).unwrap();
2233        assert_eq!(backend.builds.borrow().as_slice(), ["demo-ci:dev"]);
2234        assert_eq!(backend.inspect_calls.borrow().as_slice(), [container_name]);
2235    }
2236
2237    #[test]
2238    fn enter_uses_single_environment_when_omitted() {
2239        let tempdir = tempfile::tempdir().unwrap();
2240        fs::write(
2241            tempdir.path().join(CONFIG_FILE_NAME),
2242            r#"
2243                [project]
2244                name = "demo"
2245
2246                [envs.dev]
2247                image = "ubuntu:latest"
2248                shell = ["/bin/bash"]
2249            "#,
2250        )
2251        .unwrap();
2252        let _guard = CurrentDirGuard::enter(tempdir.path());
2253        let container_name = resolved_name(tempdir.path(), "dev");
2254
2255        let backend = MockBackend::with_lists(vec![vec![]]);
2256        let cli = Cli {
2257            debug: false,
2258            config: None,
2259            command: Commands::Enter(EnterCommand { env: None }),
2260        };
2261
2262        run_with_backend(cli, &backend).unwrap();
2263        assert_eq!(backend.created.borrow().as_slice(), [container_name]);
2264    }
2265
2266    #[test]
2267    fn enter_without_env_errors_when_ambiguous() {
2268        let tempdir = tempfile::tempdir().unwrap();
2269        write_config(tempdir.path());
2270        let _guard = CurrentDirGuard::enter(tempdir.path());
2271
2272        let backend = MockBackend::default();
2273        let cli = Cli {
2274            debug: false,
2275            config: None,
2276            command: Commands::Enter(EnterCommand { env: None }),
2277        };
2278
2279        let error = run_with_backend(cli, &backend).unwrap_err();
2280        assert!(error.to_string().contains("set project.default_env"));
2281    }
2282
2283    #[test]
2284    fn ssh_without_env_errors_when_ambiguous() {
2285        let tempdir = tempfile::tempdir().unwrap();
2286        write_config(tempdir.path());
2287        let _guard = CurrentDirGuard::enter(tempdir.path());
2288
2289        let backend = MockBackend::default();
2290        let cli = Cli {
2291            debug: false,
2292            config: None,
2293            command: Commands::Ssh(SshCommand {
2294                env: None,
2295                print: true,
2296            }),
2297        };
2298
2299        let error = run_with_backend(cli, &backend).unwrap_err();
2300        assert!(
2301            error
2302                .to_string()
2303                .contains("`orodruin ssh` needs an environment name")
2304        );
2305    }
2306
2307    #[test]
2308    fn ssh_host_uses_network_ipv4_address_without_cidr() {
2309        let host = ssh_host_from_inspect(&json!({
2310            "status": {
2311                "networks": [{
2312                    "ipv4Address": "192.168.64.2/24",
2313                    "ipv6Address": "fdc8:9ac1:53c8:9dd2:f85b:11ff:fe51:30c9/64"
2314                }]
2315            }
2316        }));
2317
2318        assert_eq!(host.as_deref(), Some("192.168.64.2"));
2319    }
2320
2321    #[test]
2322    fn ssh_print_errors_when_inspect_has_no_ip() {
2323        let tempdir = tempfile::tempdir().unwrap();
2324        write_config(tempdir.path());
2325        let _guard = CurrentDirGuard::enter(tempdir.path());
2326
2327        let backend = MockBackend::with_lists(vec![vec![]]);
2328        *backend.inspect_value.borrow_mut() = Some(json!({ "status": { "state": "running" } }));
2329        let cli = Cli {
2330            debug: false,
2331            config: None,
2332            command: Commands::Ssh(SshCommand {
2333                env: Some("dev".into()),
2334                print: true,
2335            }),
2336        };
2337
2338        let error = run_with_backend(cli, &backend).unwrap_err();
2339        assert!(
2340            error
2341                .to_string()
2342                .contains("could not determine an ssh host")
2343        );
2344    }
2345
2346    #[test]
2347    fn completions_render_current_subcommands() {
2348        let output = render_completions(
2349            ContainerRuntime::AppleContainer,
2350            CompletionsCommand {
2351                shell: clap_complete::Shell::Bash,
2352            },
2353        )
2354        .unwrap();
2355
2356        assert!(output.contains("completions"));
2357        assert!(output.contains("orodruin"));
2358        assert!(output.contains("inspect"));
2359        assert!(output.contains("ssh"));
2360    }
2361
2362    #[test]
2363    fn podman_completions_omit_pruned_subcommands() {
2364        let output = render_completions(
2365            ContainerRuntime::Podman,
2366            CompletionsCommand {
2367                shell: clap_complete::Shell::Bash,
2368            },
2369        )
2370        .unwrap();
2371
2372        assert!(output.contains("version"));
2373        assert!(!output.contains("set-default"));
2374        assert!(!output.contains(" dns "));
2375    }
2376}