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