Skip to main content

orodruin_cli/
app.rs

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