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