fakecloud_core/container_net.rs
1//! Shared container-to-host networking resolution for service runtimes
2//! that spawn sibling containers (Lambda, ECS, RDS, ElastiCache).
3//!
4//! Captures the issue #1539 fix shape in one place so the four runtimes
5//! that shell out to `docker`/`podman` can't drift apart again:
6//!
7//! - **podman** ships `host.containers.internal` as a built-in container
8//! DNS entry on every platform and must NOT receive
9//! `--add-host host.docker.internal:host-gateway` — rootless podman's
10//! gvproxy leaves the magic alias empty and the `create` fails with
11//! "host containers internal IP address is empty".
12//! - **bare docker on Linux** has no `host-gateway` magic; the bridge
13//! gateway IP has to be resolved from the daemon and injected explicitly.
14//! - **Docker Desktop on Mac/Windows** resolves the `host-gateway` magic
15//! value to the host's IP.
16//! - when fakecloud itself runs in a container (`FAKECLOUD_IN_CONTAINER=1`,
17//! baked into the published image), the sibling containers it spawns
18//! publish their ports on the *host's* daemon — reachable from inside
19//! fakecloud's container as `host.docker.internal:<port>`, not
20//! `127.0.0.1:<port>`.
21
22/// Actionable remediation appended to every error raised when a container
23/// runtime (Docker/Podman) is required for an operation but none is
24/// available. Kept in one place so RDS, Lambda, ECS, and the server startup
25/// banner all surface the same fix steps and can't drift apart.
26pub const CONTAINER_RUNTIME_HINT: &str = "Install and start Docker or Podman, or set FAKECLOUD_CONTAINER_CLI to your container CLI path.";
27
28/// Auto-detect an available container CLI. Honors `FAKECLOUD_CONTAINER_CLI`
29/// as an explicit override (returns `None` if the override doesn't work),
30/// otherwise prefers `docker` then `podman`. Returns `None` when neither
31/// is usable.
32pub fn detect_container_cli() -> Option<String> {
33 if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
34 return if cli_available(&cli) { Some(cli) } else { None };
35 }
36 if cli_available("docker") {
37 Some("docker".to_string())
38 } else if cli_available("podman") {
39 Some("podman".to_string())
40 } else {
41 None
42 }
43}
44
45/// How long to wait for `<cli> info` before giving up and treating the
46/// runtime as unavailable. A healthy daemon answers in well under a second;
47/// an unreachable or wedged daemon (stale `DOCKER_HOST`, Docker Desktop mid
48/// start, a broken socket) can leave the CLI blocked on connect *forever*,
49/// which would hang fakecloud startup and the test harness. Bounding the
50/// probe turns "daemon wedged" into "no runtime detected" instead of a hang.
51pub const CLI_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
52
53/// Process-global memo of `<cli> info` results, keyed by CLI name/path.
54///
55/// Container-runtime liveness is fixed for the life of a process, but every
56/// service runtime (Lambda, ECS, RDS, ElastiCache, EC2, MQ, MSK, ...) probes
57/// it independently at startup — a dozen-plus `detect_container_cli()` calls.
58/// Without a memo each probe re-runs `docker info`; when the daemon is wedged
59/// (see [`CLI_PROBE_TIMEOUT`]) those probes are serial 10s hangs that stack
60/// into minutes, wedging server startup and the conformance `*_probe` tests.
61/// Caching the first answer collapses that to a single probe.
62static CLI_AVAILABLE_CACHE: std::sync::OnceLock<
63 std::sync::Mutex<std::collections::HashMap<String, bool>>,
64> = std::sync::OnceLock::new();
65
66/// True when the CLI responds to `<cli> info` with success within
67/// [`CLI_PROBE_TIMEOUT`] — the same liveness probe every runtime used before
68/// this module existed, but bounded so an unreachable daemon can't hang the
69/// caller indefinitely (the CLI blocks on connect with no timeout of its own),
70/// and memoized per process so a dozen runtimes probing at startup don't each
71/// pay that bound.
72pub fn cli_available(cli: &str) -> bool {
73 let cache =
74 CLI_AVAILABLE_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
75 if let Some(&cached) = cache.lock().unwrap().get(cli) {
76 return cached;
77 }
78 let result = probe_cli(cli);
79 cache.lock().unwrap().insert(cli.to_string(), result);
80 result
81}
82
83/// Run the bounded `<cli> info` liveness probe once (uncached).
84fn probe_cli(cli: &str) -> bool {
85 let child = std::process::Command::new(cli)
86 .arg("info")
87 .stdout(std::process::Stdio::null())
88 .stderr(std::process::Stdio::null())
89 .spawn();
90 let Ok(mut child) = child else {
91 return false;
92 };
93 let deadline = std::time::Instant::now() + CLI_PROBE_TIMEOUT;
94 loop {
95 match child.try_wait() {
96 Ok(Some(status)) => return status.success(),
97 Ok(None) => {}
98 Err(_) => return false,
99 }
100 if std::time::Instant::now() >= deadline {
101 // Daemon is wedged: kill the blocked probe and report unavailable.
102 let _ = child.kill();
103 let _ = child.wait();
104 return false;
105 }
106 std::thread::sleep(std::time::Duration::from_millis(25));
107 }
108}
109
110/// True when `cli` is podman or a podman-compatible binary. Matches on the
111/// filename component so absolute paths (`/opt/homebrew/bin/podman`) and
112/// wrappers (`podman-remote`) both register as podman. Docker Desktop's
113/// compatibility CLI is named `docker`, so this check is safe.
114pub fn is_podman_binary(cli: &str) -> bool {
115 std::path::Path::new(cli)
116 .file_name()
117 .and_then(|n| n.to_str())
118 .map(|n| n.contains("podman"))
119 .unwrap_or(false)
120}
121
122/// Detect the Docker bridge gateway IP on Linux. Returns `None` if
123/// detection fails (caller falls back to the conventional `172.17.0.1`).
124pub fn detect_bridge_gateway(cli: &str) -> Option<String> {
125 let output = std::process::Command::new(cli)
126 .args([
127 "network",
128 "inspect",
129 "bridge",
130 "--format",
131 "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
132 ])
133 .output()
134 .ok()?;
135 if !output.status.success() {
136 return None;
137 }
138 let gateway = String::from_utf8_lossy(&output.stdout).trim().to_string();
139 if gateway.is_empty() || !gateway.contains('.') {
140 return None;
141 }
142 Some(gateway)
143}
144
145/// Resolved container-to-host networking for a given CLI. Built once at
146/// runtime construction and reused for every container spawn.
147#[derive(Debug, Clone)]
148pub struct HostNetworking {
149 /// DNS name a spawned container uses to reach fakecloud on the host.
150 /// `host.containers.internal` for podman, `host.docker.internal` for
151 /// docker.
152 pub host_alias: String,
153 /// `<alias>:<value>` argument for `--add-host`, injected into every
154 /// container `create`/`run`. `None` when the runtime provides the
155 /// alias natively (podman).
156 pub add_host_arg: Option<String>,
157 /// Address fakecloud uses to reach the *sibling* containers it just
158 /// spawned (readiness probes + advertised endpoints). `127.0.0.1`
159 /// when fakecloud runs on the host; `host.docker.internal` when
160 /// fakecloud is itself containerized (`FAKECLOUD_IN_CONTAINER=1`).
161 pub sibling_host: String,
162}
163
164impl HostNetworking {
165 /// Resolve networking for `cli`, reading `FAKECLOUD_IN_CONTAINER` from
166 /// the process environment.
167 pub fn detect(cli: &str) -> Self {
168 let (host_alias, mut add_host_arg) = resolve_host_alias(cli);
169 // A resolving `host.docker.internal` is only trustworthy evidence that
170 // the runtime provides the alias natively (and will inject it into
171 // sibling containers too) when fakecloud is itself containerized:
172 // Docker-Desktop-class runtimes inject the alias into CONTAINERS, never
173 // onto the host. On a bare native-Linux host a resolving alias is
174 // spurious (a hijacking NXDOMAIN resolver, a stray /etc/hosts entry, or
175 // a wildcard search domain), so suppressing the bridge --add-host there
176 // would break the host route sibling containers need. Gate the
177 // suppression on the in-container signal to avoid that regression.
178 let in_container = in_container_mode(std::env::var("FAKECLOUD_IN_CONTAINER").ok());
179 add_host_arg = preserve_native_host_alias(
180 add_host_arg,
181 in_container && host_alias_resolves(&host_alias),
182 );
183 let sibling_host =
184 resolve_sibling_host(&host_alias, std::env::var("FAKECLOUD_IN_CONTAINER").ok());
185 Self {
186 host_alias,
187 add_host_arg,
188 sibling_host,
189 }
190 }
191
192 /// Convenience: append the `--add-host <alias>:<value>` flag pair to a
193 /// growing argv vector when this runtime needs an explicit mapping.
194 /// No-op for podman.
195 pub fn push_add_host_args(&self, argv: &mut Vec<String>) {
196 if let Some(arg) = &self.add_host_arg {
197 argv.push("--add-host".to_string());
198 argv.push(arg.clone());
199 }
200 }
201}
202
203/// How long to wait for the blocking `getaddrinfo` in [`host_alias_resolves`]
204/// before giving up and returning `false`. `getaddrinfo` has no timeout of its
205/// own, and a slow or unreachable DNS server would otherwise block a runtime
206/// thread at startup (this runs inside runtime constructors under
207/// `#[tokio::main]`). Bounding it — same tradeoff as [`CLI_PROBE_TIMEOUT`] —
208/// turns "DNS wedged" into "alias doesn't resolve", the safe default that keeps
209/// the `--add-host` bridge mapping.
210pub const HOST_ALIAS_RESOLVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
211
212/// True when `host_alias` resolves via the process resolver. The `getaddrinfo`
213/// call is blocking with no timeout of its own, so it runs on a spawned thread
214/// bounded by [`HOST_ALIAS_RESOLVE_TIMEOUT`]; on timeout we return `false` (the
215/// safe default that keeps `--add-host`). A leaked resolver thread on timeout
216/// is acceptable — same tradeoff as [`probe_cli`].
217fn host_alias_resolves(host_alias: &str) -> bool {
218 let (tx, rx) = std::sync::mpsc::channel();
219 let alias = host_alias.to_string();
220 std::thread::spawn(move || {
221 let resolves = std::net::ToSocketAddrs::to_socket_addrs(&(alias.as_str(), 0)).is_ok();
222 let _ = tx.send(resolves);
223 });
224 rx.recv_timeout(HOST_ALIAS_RESOLVE_TIMEOUT).unwrap_or(false)
225}
226
227fn preserve_native_host_alias(
228 add_host_arg: Option<String>,
229 should_suppress: bool,
230) -> Option<String> {
231 if add_host_arg.is_some() && should_suppress {
232 // Suppress the injected `--add-host host.docker.internal:<vm-bridge-ip>`
233 // only when fakecloud is containerized AND the alias already resolves
234 // (see the gate in `detect`). In that case a Docker-Desktop-class
235 // runtime provides `host.docker.internal` natively inside every sibling
236 // container, pointing at the real host; injecting the VM bridge-gateway
237 // IP would shadow it and break the host route. On a bare host — where a
238 // hijacking resolver can make the alias resolve spuriously — the caller
239 // passes `false` here so native Linux docker keeps the bridge mapping
240 // it genuinely needs.
241 None
242 } else {
243 add_host_arg
244 }
245}
246
247/// Compute the `(host_alias, add_host_arg)` pair for a CLI. Pure except
248/// for the bridge-gateway daemon probe on Linux docker, so the macOS /
249/// podman branches are unit-testable without a daemon.
250pub fn resolve_host_alias(cli: &str) -> (String, Option<String>) {
251 if is_podman_binary(cli) {
252 // Podman provides `host.containers.internal` natively on every
253 // supported platform; injecting `host-gateway` on macOS fails
254 // because rootless podman's gvproxy doesn't expose the magic alias.
255 ("host.containers.internal".to_string(), None)
256 } else if cfg!(target_os = "linux") {
257 // Bare docker on Linux: resolve the bridge gateway IP and add an
258 // explicit alias. `host.docker.internal:host-gateway` only works
259 // on Docker Desktop; native Linux docker has no such magic.
260 let ip = detect_bridge_gateway(cli).unwrap_or_else(|| "172.17.0.1".to_string());
261 (
262 "host.docker.internal".to_string(),
263 Some(format!("host.docker.internal:{ip}")),
264 )
265 } else {
266 // Docker Desktop on Mac/Windows: `host-gateway` is the magic alias
267 // that resolves to the host's IP.
268 (
269 "host.docker.internal".to_string(),
270 Some("host.docker.internal:host-gateway".to_string()),
271 )
272 }
273}
274
275/// Decide what address fakecloud uses to reach the sibling containers it
276/// just spawned. Pure helper so the env-var parsing can be tested without
277/// touching the process's real environment.
278///
279/// - `Some("1")` / `Some("true")` (case-insensitive) -> fakecloud is in a
280/// container; the siblings publish their ports on the host's daemon and
281/// are reachable at the same host alias the spawned containers use to
282/// reach fakecloud — `host.docker.internal` under docker,
283/// `host.containers.internal` under podman. Hardcoding
284/// `host.docker.internal` here broke podman, whose gvproxy network only
285/// resolves `host.containers.internal` (issue #1539 follow-up).
286/// - anything else, including `None` -> fakecloud runs on the host,
287/// siblings live on `127.0.0.1:<port>`.
288pub fn resolve_sibling_host(host_alias: &str, env_value: Option<String>) -> String {
289 if in_container_mode(env_value) {
290 host_alias.to_string()
291 } else {
292 "127.0.0.1".to_string()
293 }
294}
295
296/// Parse the `FAKECLOUD_IN_CONTAINER` signal: `Some("1")` or a case-insensitive
297/// `Some("true")` mean fakecloud is running inside a container; anything else,
298/// including `None`, means it runs on the host. Single source of truth for the
299/// parse so `detect`'s native-alias gate and `resolve_sibling_host` can't drift.
300fn in_container_mode(env_value: Option<String>) -> bool {
301 env_value
302 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
303 .unwrap_or(false)
304}
305
306/// Hostnames fakecloud's bundled ECR/OCI registry can be addressed from a
307/// sibling container or the host, each at `server_port`.
308///
309/// A container-spawning service rewrites the image pull URI to the runtime's
310/// sibling host -- `host.docker.internal` under Docker, `host.containers.internal`
311/// under podman -- or leaves it `localhost` / `127.0.0.1` when fakecloud runs on
312/// the host (`localhost:<port>` is the documented local ECR endpoint, e.g.
313/// `localhost:4566`). The registry enforces auth, and the Docker/Podman CLI only
314/// attaches the `Authorization` header for hosts present in `config.json`, so the
315/// isolated pull config must list *every* alias or the pull gets a 401. The map
316/// previously omitted the podman alias, so image-based Lambda/ECS pulls failed
317/// under podman-in-a-container (bug-audit 2026-06-20, 0.B2). Authorize all of
318/// them with the same credential; centralized here so the two builders can't
319/// drift again.
320pub fn registry_auth_hosts(server_port: u16) -> Vec<String> {
321 [
322 "localhost",
323 "127.0.0.1",
324 "host.docker.internal",
325 "host.containers.internal",
326 ]
327 .iter()
328 .map(|host| format!("{host}:{server_port}"))
329 .collect()
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn cli_available_false_for_missing_binary() {
338 // A binary that doesn't exist fails to spawn -> unavailable, fast.
339 assert!(!cli_available("definitely-not-a-real-cli-binary-xyz-123"));
340 }
341
342 #[cfg(unix)]
343 #[test]
344 fn cli_available_bounds_a_hanging_probe() {
345 // A CLI whose `info` invocation blocks forever (like `docker info`
346 // against an unreachable daemon) must not hang the caller: the probe
347 // is killed at CLI_PROBE_TIMEOUT and reported unavailable. Regression
348 // test for the local-conformance-probe hang.
349 use std::io::Write;
350 use std::os::unix::fs::PermissionsExt;
351
352 let dir = std::env::temp_dir().join(format!("fc-clitest-{}", std::process::id()));
353 std::fs::create_dir_all(&dir).unwrap();
354 let script = dir.join("hangcli");
355 std::fs::write(&script, "#!/bin/sh\nsleep 600\n").unwrap();
356 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
357 std::io::stdout().flush().ok();
358
359 let start = std::time::Instant::now();
360 let available = cli_available(script.to_str().unwrap());
361 let elapsed = start.elapsed();
362
363 std::fs::remove_dir_all(&dir).ok();
364 assert!(!available, "a hanging probe must report unavailable");
365 assert!(
366 elapsed < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
367 "probe took {elapsed:?}, expected it bounded near {CLI_PROBE_TIMEOUT:?}"
368 );
369 }
370
371 #[test]
372 fn is_podman_binary_matches_bare_name() {
373 assert!(is_podman_binary("podman"));
374 assert!(is_podman_binary("podman-remote"));
375 }
376
377 #[test]
378 fn registry_auth_hosts_includes_podman_alias() {
379 // The podman sibling alias (host.containers.internal) must be authorized
380 // or image-based Lambda/ECS pulls 401 under podman-in-a-container (0.B2).
381 let hosts = registry_auth_hosts(4566);
382 assert!(hosts.contains(&"localhost:4566".to_string()));
383 assert!(hosts.contains(&"127.0.0.1:4566".to_string()));
384 assert!(hosts.contains(&"host.docker.internal:4566".to_string()));
385 assert!(
386 hosts.contains(&"host.containers.internal:4566".to_string()),
387 "podman sibling alias must be authorized: {hosts:?}"
388 );
389 }
390
391 #[test]
392 fn is_podman_binary_matches_absolute_path() {
393 assert!(is_podman_binary("/opt/homebrew/bin/podman"));
394 assert!(is_podman_binary("/usr/local/bin/podman-remote"));
395 }
396
397 #[test]
398 fn is_podman_binary_rejects_docker() {
399 assert!(!is_podman_binary("docker"));
400 assert!(!is_podman_binary("/usr/local/bin/docker"));
401 assert!(!is_podman_binary("docker-credential-helper"));
402 }
403
404 #[test]
405 fn resolve_host_alias_podman_has_no_add_host() {
406 let (alias, add_host) = resolve_host_alias("podman");
407 assert_eq!(alias, "host.containers.internal");
408 assert_eq!(add_host, None);
409 let (alias, add_host) = resolve_host_alias("/opt/homebrew/bin/podman");
410 assert_eq!(alias, "host.containers.internal");
411 assert_eq!(add_host, None);
412 }
413
414 #[test]
415 fn resolve_host_alias_docker_emits_add_host() {
416 let (alias, add_host) = resolve_host_alias("docker");
417 assert_eq!(alias, "host.docker.internal");
418 // On macOS this is host-gateway; on Linux it's a bridge IP. Either
419 // way docker must get an explicit --add-host.
420 assert!(add_host.is_some());
421 assert!(add_host.unwrap().starts_with("host.docker.internal:"));
422 }
423
424 #[test]
425 fn native_host_alias_prevents_docker_add_host_override() {
426 let add_host =
427 preserve_native_host_alias(Some("host.docker.internal:host-gateway".to_string()), true);
428
429 assert_eq!(add_host, None);
430 }
431
432 #[test]
433 fn unresolved_host_alias_keeps_docker_add_host() {
434 let add_host = preserve_native_host_alias(
435 Some("host.docker.internal:host-gateway".to_string()),
436 false,
437 );
438
439 assert_eq!(
440 add_host.as_deref(),
441 Some("host.docker.internal:host-gateway")
442 );
443 }
444
445 #[test]
446 fn absent_docker_add_host_remains_absent() {
447 assert_eq!(preserve_native_host_alias(None, true), None);
448 assert_eq!(preserve_native_host_alias(None, false), None);
449 }
450
451 #[test]
452 fn in_container_mode_parses_truthy_values() {
453 assert!(in_container_mode(Some("1".to_string())));
454 assert!(in_container_mode(Some("true".to_string())));
455 assert!(in_container_mode(Some("True".to_string())));
456 assert!(in_container_mode(Some("TRUE".to_string())));
457 }
458
459 #[test]
460 fn in_container_mode_rejects_falsey_and_absent() {
461 assert!(!in_container_mode(None));
462 assert!(!in_container_mode(Some(String::new())));
463 assert!(!in_container_mode(Some("0".to_string())));
464 assert!(!in_container_mode(Some("false".to_string())));
465 assert!(!in_container_mode(Some("yes".to_string())));
466 }
467
468 #[test]
469 fn native_alias_gate_suppresses_only_in_container() {
470 // The gate `detect` computes: `in_container && host_alias_resolves`.
471 let add_host = || Some("host.docker.internal:172.17.0.1".to_string());
472
473 // In-container + resolves -> Desktop-class runtime provides the alias
474 // natively in siblings; drop the shadowing bridge mapping.
475 let in_container = true;
476 let resolves = true;
477 assert_eq!(
478 preserve_native_host_alias(add_host(), in_container && resolves),
479 None,
480 );
481
482 // NOT in-container (bare host) + resolves -> the resolving alias is
483 // spurious (hijacking resolver / stray hosts entry). Native Linux docker
484 // needs the bridge mapping; must NOT drop it. Regression guard.
485 let in_container = false;
486 let resolves = true;
487 assert_eq!(
488 preserve_native_host_alias(add_host(), in_container && resolves).as_deref(),
489 Some("host.docker.internal:172.17.0.1"),
490 );
491
492 // In-container + does NOT resolve -> nothing native to preserve; keep
493 // the injected mapping.
494 let in_container = true;
495 let resolves = false;
496 assert_eq!(
497 preserve_native_host_alias(add_host(), in_container && resolves).as_deref(),
498 Some("host.docker.internal:172.17.0.1"),
499 );
500 }
501
502 #[test]
503 fn resolve_sibling_host_defaults_to_loopback() {
504 assert_eq!(
505 resolve_sibling_host("host.docker.internal", None),
506 "127.0.0.1"
507 );
508 assert_eq!(
509 resolve_sibling_host("host.docker.internal", Some(String::new())),
510 "127.0.0.1"
511 );
512 assert_eq!(
513 resolve_sibling_host("host.docker.internal", Some("0".to_string())),
514 "127.0.0.1"
515 );
516 assert_eq!(
517 resolve_sibling_host("host.containers.internal", Some("false".to_string())),
518 "127.0.0.1"
519 );
520 }
521
522 #[test]
523 fn resolve_sibling_host_uses_host_alias_when_in_container() {
524 // Docker: siblings reachable at host.docker.internal.
525 assert_eq!(
526 resolve_sibling_host("host.docker.internal", Some("1".to_string())),
527 "host.docker.internal"
528 );
529 assert_eq!(
530 resolve_sibling_host("host.docker.internal", Some("true".to_string())),
531 "host.docker.internal"
532 );
533 assert_eq!(
534 resolve_sibling_host("host.docker.internal", Some("TRUE".to_string())),
535 "host.docker.internal"
536 );
537 // Podman: must use host.containers.internal, NOT host.docker.internal
538 // (issue #1539 follow-up — gvproxy only resolves the containers alias).
539 assert_eq!(
540 resolve_sibling_host("host.containers.internal", Some("1".to_string())),
541 "host.containers.internal"
542 );
543 }
544
545 #[test]
546 fn detect_wires_sibling_host_to_podman_alias_in_container() {
547 // Full path: a podman binary in a container must advertise siblings
548 // at host.containers.internal. resolve_host_alias drives host_alias,
549 // which resolve_sibling_host then reuses.
550 let (alias, add_host) = resolve_host_alias("podman");
551 assert_eq!(alias, "host.containers.internal");
552 assert_eq!(add_host, None);
553 assert_eq!(
554 resolve_sibling_host(&alias, Some("1".to_string())),
555 "host.containers.internal"
556 );
557 }
558
559 #[test]
560 fn push_add_host_args_noop_for_podman() {
561 let net = HostNetworking {
562 host_alias: "host.containers.internal".to_string(),
563 add_host_arg: None,
564 sibling_host: "127.0.0.1".to_string(),
565 };
566 let mut argv = vec!["create".to_string()];
567 net.push_add_host_args(&mut argv);
568 assert_eq!(argv, vec!["create".to_string()]);
569 }
570
571 #[test]
572 fn push_add_host_args_emits_for_docker() {
573 let net = HostNetworking {
574 host_alias: "host.docker.internal".to_string(),
575 add_host_arg: Some("host.docker.internal:host-gateway".to_string()),
576 sibling_host: "127.0.0.1".to_string(),
577 };
578 let mut argv = vec!["create".to_string()];
579 net.push_add_host_args(&mut argv);
580 assert_eq!(
581 argv,
582 vec![
583 "create".to_string(),
584 "--add-host".to_string(),
585 "host.docker.internal:host-gateway".to_string(),
586 ]
587 );
588 }
589}