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