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}