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 = spawn_bounded(
86 std::process::Command::new(cli)
87 .arg("info")
88 .stdout(std::process::Stdio::null())
89 .stderr(std::process::Stdio::null()),
90 );
91 let Ok(mut child) = child else {
92 return false;
93 };
94 wait_bounded_group(&mut child) && child.wait().map(|s| s.success()).unwrap_or(false)
95}
96
97/// Spawn a container-CLI command in a process group of its own (Unix), so a
98/// timed-out call can be torn down whole. `FAKECLOUD_CONTAINER_CLI` is
99/// routinely a wrapper -- `sh -c 'exec docker "$@"'`, a `podman-remote` shim --
100/// which makes the real command a *grandchild*: it survives `Child::kill`, goes
101/// on holding whatever pipes we handed it, and keeps running against a wedged
102/// daemon forever. Its own group makes it reachable by a single signal.
103/// Detaching these from terminal job control is fine: their lifetime is managed
104/// by deadline here, not by the shell fakecloud was started from.
105///
106/// stdin is /dev/null, and has to be: a new process group is a *background*
107/// one, so a child that reads the controlling terminal -- a `sudo` or
108/// credential-helper wrapper prompting, exactly the wrapper case above -- takes
109/// SIGTTIN, which stops it rather than ending it. `try_wait` is WNOHANG without
110/// WUNTRACED, so the loop below never sees a stopped child and the call burns
111/// the whole deadline before being killed. These calls are non-interactive
112/// anyway, so an immediate EOF is the right answer for them.
113fn spawn_bounded(cmd: &mut std::process::Command) -> std::io::Result<std::process::Child> {
114 cmd.stdin(std::process::Stdio::null());
115 #[cfg(unix)]
116 {
117 std::os::unix::process::CommandExt::process_group(cmd, 0);
118 }
119 cmd.spawn()
120}
121
122/// Wait for `child` up to [`CLI_PROBE_TIMEOUT`], killing it on expiry. Returns
123/// whether it exited on its own. Every container-CLI call goes through this:
124/// a liveness probe answering does not promise the next call will, and an
125/// unbounded one blocks the caller rather than just that command.
126///
127/// Only for a child from [`spawn_bounded`], which put it in a group of its own:
128/// the expiry kill hits that whole group, so a wrapper CLI's grandchildren die
129/// with it.
130fn wait_bounded_group(child: &mut std::process::Child) -> bool {
131 let deadline = std::time::Instant::now() + CLI_PROBE_TIMEOUT;
132 loop {
133 match child.try_wait() {
134 Ok(Some(_)) => return true,
135 Ok(None) => {}
136 Err(_) => return false,
137 }
138 if std::time::Instant::now() >= deadline {
139 // Daemon is wedged: kill the blocked call and report failure.
140 kill_expired(child);
141 let _ = child.wait();
142 return false;
143 }
144 std::thread::sleep(std::time::Duration::from_millis(25));
145 }
146}
147
148/// SIGKILL a timed-out child and its process group. [`spawn_bounded`] made the
149/// child its own group leader, so the group id is the child's pid, and the
150/// child is still unreaped here -- the pid cannot have been recycled and the
151/// signal cannot stray onto an unrelated group.
152#[cfg(unix)]
153fn kill_expired(child: &mut std::process::Child) {
154 // SAFETY: `kill` with a negative pid targets the process group of that
155 // id; any pid value is safe to pass.
156 let _ = unsafe { libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL) };
157 let _ = child.kill();
158}
159
160/// Windows has no process-group signal (a job object would be needed), so the
161/// direct child is as far as the kill reaches; [`run_bounded`] still bounds the
162/// wait on its stdout reader so the caller can't be held by a surviving
163/// grandchild.
164#[cfg(not(unix))]
165fn kill_expired(child: &mut std::process::Child) {
166 let _ = child.kill();
167}
168
169/// Whether the stdout reader thread ended before the call returned.
170#[derive(Debug)]
171enum ReaderState {
172 /// The reader returned; its thread is gone.
173 Finished,
174 /// The reader is still blocked on the pipe because a write end we could not
175 /// close is held outside the child's process group. The thread outlives the
176 /// call; the caller does not wait for it.
177 Abandoned,
178}
179
180/// Floor on how long [`run_bounded`] waits for its stdout reader once the call
181/// is over (it also gets whatever is left of the call's own budget). Both exits
182/// close every write end we control -- the child exited, or its whole process
183/// group was killed -- which ends the blocked `read_to_end` at once, so this
184/// covers scheduling only. It exists so a write end held somewhere we cannot
185/// reach costs the caller a few hundred milliseconds instead of blocking it for
186/// good, which is what an unbounded join did.
187const READER_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
188
189/// Run a container-CLI command and return its stdout, or `None` when it fails
190/// or outruns [`CLI_PROBE_TIMEOUT`].
191pub fn bounded_output(cli: &str, args: &[&str]) -> Option<String> {
192 run_bounded(cli, args).0
193}
194
195/// [`bounded_output`], plus whether its stdout reader finished -- so the
196/// timeout path's "no reader left behind" guarantee is unit-testable instead of
197/// only observable as a thread that never goes away.
198fn run_bounded(cli: &str, args: &[&str]) -> (Option<String>, ReaderState) {
199 let deadline = std::time::Instant::now() + CLI_PROBE_TIMEOUT;
200 let child = spawn_bounded(
201 std::process::Command::new(cli)
202 .args(args)
203 .stdout(std::process::Stdio::piped())
204 .stderr(std::process::Stdio::null()),
205 );
206 let Ok(mut child) = child else {
207 return (None, ReaderState::Finished);
208 };
209 let Some(mut stdout) = child.stdout.take() else {
210 kill_expired(&mut child);
211 let _ = child.wait();
212 return (None, ReaderState::Finished);
213 };
214 // Drain stdout while waiting. A child whose output outgrows the pipe
215 // buffer blocks on write until someone reads it, so waiting for exit
216 // first would deadlock until the deadline and then report the sweep as
217 // failed -- `docker ps -a` across a busy host is exactly that much output.
218 //
219 // The channel doubles as the reader's "I'm done" signal: the send is the
220 // last thing the thread does before dropping the pipe's read end, so a
221 // received buffer proves no reader is parked behind us. A `JoinHandle`
222 // can't say that without blocking, which on the timeout path is exactly
223 // what we must not do.
224 let (tx, rx) = std::sync::mpsc::channel();
225 std::thread::spawn(move || {
226 let mut buf = Vec::new();
227 let _ = std::io::Read::read_to_end(&mut stdout, &mut buf);
228 let _ = tx.send(buf);
229 });
230 // On expiry `wait_bounded_group` has killed the whole process group, so a
231 // wrapper CLI's grandchild releases the write end and the reader returns
232 // instead of blocking for the life of the process -- one leaked thread per
233 // call, on precisely the wedged-daemon path these bounds exist for.
234 let exited = wait_bounded_group(&mut child);
235 let status = child.wait().ok();
236 // Whatever is left of the call's own budget, and never less than the grace:
237 // a prompt call can afford to wait out a reader thread the scheduler hasn't
238 // run yet, a timed-out one gets only the grace, and either way the caller is
239 // back within CLI_PROBE_TIMEOUT plus that grace.
240 let grace = deadline
241 .saturating_duration_since(std::time::Instant::now())
242 .max(READER_DRAIN_GRACE);
243 let drained = rx.recv_timeout(grace).ok();
244 let output = match (exited, status, &drained) {
245 (true, Some(status), Some(buf)) if status.success() => {
246 Some(String::from_utf8_lossy(buf).into_owned())
247 }
248 _ => None,
249 };
250 let reader = if drained.is_some() {
251 ReaderState::Finished
252 } else {
253 ReaderState::Abandoned
254 };
255 (output, reader)
256}
257
258/// Run a container-CLI command for its effect only, bounded the same way.
259/// Returns whether it succeeded.
260pub fn bounded_status(cli: &str, args: &[&str]) -> bool {
261 let Ok(mut child) = spawn_bounded(
262 std::process::Command::new(cli)
263 .args(args)
264 .stdout(std::process::Stdio::null())
265 .stderr(std::process::Stdio::null()),
266 ) else {
267 return false;
268 };
269 wait_bounded_group(&mut child) && child.wait().map(|s| s.success()).unwrap_or(false)
270}
271
272/// True if the given PID is a live process on this host.
273///
274/// On Unix this is `kill(pid, 0)`: it returns 0 if the process exists
275/// (including zombies), or sets `errno` to `ESRCH` if not. On non-Unix
276/// platforms it conservatively returns `true`, so a caller never removes a
277/// resource it can't prove is orphaned.
278#[cfg(unix)]
279pub fn pid_alive(pid: u32) -> bool {
280 // SAFETY: `kill` with signal 0 is a liveness probe; it does not
281 // actually deliver a signal. Any PID value is safe to pass.
282 let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
283 if rc == 0 {
284 return true;
285 }
286 // errno == EPERM means the process exists but we can't signal it —
287 // still alive from our perspective.
288 std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
289}
290
291#[cfg(not(unix))]
292pub fn pid_alive(_pid: u32) -> bool {
293 true
294}
295
296/// Whether a container or network labelled `fakecloud-instance=<label>` was
297/// left behind by a fakecloud process that is gone. The label is
298/// `fakecloud-<pid>`; an object is orphaned only when that PID is neither the
299/// current process nor alive. Several fakecloud processes can share one
300/// daemon (parallel test servers, side-by-side installs), so an object owned
301/// by *another live* process is never an orphan. A label that doesn't parse is
302/// not treated as an orphan either -- nothing proves its owner is gone.
303pub fn owned_by_dead_process(label: &str, is_alive: impl Fn(u32) -> bool) -> bool {
304 let Some(pid) = label
305 .strip_prefix("fakecloud-")
306 .and_then(|p| p.parse::<u32>().ok())
307 else {
308 return false;
309 };
310 pid != std::process::id() && !is_alive(pid)
311}
312
313/// True when `cli` is podman or a podman-compatible binary. Matches on the
314/// filename component so absolute paths (`/opt/homebrew/bin/podman`) and
315/// wrappers (`podman-remote`) both register as podman. Docker Desktop's
316/// compatibility CLI is named `docker`, so this check is safe.
317pub fn is_podman_binary(cli: &str) -> bool {
318 std::path::Path::new(cli)
319 .file_name()
320 .and_then(|n| n.to_str())
321 .map(|n| n.contains("podman"))
322 .unwrap_or(false)
323}
324
325/// Detect the Docker bridge gateway IP on Linux. Returns `None` if
326/// detection fails (caller falls back to the conventional `172.17.0.1`).
327///
328/// Goes through [`bounded_output`] like every other container-CLI call here:
329/// `network inspect` talks to the same daemon as the liveness probe, so a
330/// wedged one blocks it on connect forever. This runs inside runtime
331/// constructors on Linux, where an unbounded call hangs server startup outright
332/// -- the exact failure [`CLI_PROBE_TIMEOUT`] exists to prevent. On timeout the
333/// caller just takes the conventional fallback.
334pub fn detect_bridge_gateway(cli: &str) -> Option<String> {
335 let stdout = bounded_output(
336 cli,
337 &[
338 "network",
339 "inspect",
340 "bridge",
341 "--format",
342 "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
343 ],
344 )?;
345 let gateway = stdout.trim().to_string();
346 if gateway.is_empty() || !gateway.contains('.') {
347 return None;
348 }
349 Some(gateway)
350}
351
352/// Resolved container-to-host networking for a given CLI. Built once at
353/// runtime construction and reused for every container spawn.
354#[derive(Debug, Clone)]
355pub struct HostNetworking {
356 /// DNS name a spawned container uses to reach fakecloud on the host.
357 /// `host.containers.internal` for podman, `host.docker.internal` for
358 /// docker.
359 pub host_alias: String,
360 /// `<alias>:<value>` argument for `--add-host`, injected into every
361 /// container `create`/`run`. `None` when the runtime provides the
362 /// alias natively (podman).
363 pub add_host_arg: Option<String>,
364 /// Address fakecloud uses to reach the *sibling* containers it just
365 /// spawned (readiness probes + advertised endpoints). `127.0.0.1`
366 /// when fakecloud runs on the host; `host.docker.internal` when
367 /// fakecloud is itself containerized (`FAKECLOUD_IN_CONTAINER=1`).
368 pub sibling_host: String,
369}
370
371impl HostNetworking {
372 /// Resolve networking for `cli`, reading `FAKECLOUD_IN_CONTAINER` from
373 /// the process environment.
374 pub fn detect(cli: &str) -> Self {
375 let (host_alias, mut add_host_arg) = resolve_host_alias(cli);
376 // A resolving `host.docker.internal` is only trustworthy evidence that
377 // the runtime provides the alias natively (and will inject it into
378 // sibling containers too) when fakecloud is itself containerized:
379 // Docker-Desktop-class runtimes inject the alias into CONTAINERS, never
380 // onto the host. On a bare native-Linux host a resolving alias is
381 // spurious (a hijacking NXDOMAIN resolver, a stray /etc/hosts entry, or
382 // a wildcard search domain), so suppressing the bridge --add-host there
383 // would break the host route sibling containers need. Gate the
384 // suppression on the in-container signal to avoid that regression.
385 let in_container = in_container_mode(std::env::var("FAKECLOUD_IN_CONTAINER").ok());
386 add_host_arg = preserve_native_host_alias(
387 add_host_arg,
388 in_container && host_alias_resolves(&host_alias),
389 );
390 let sibling_host =
391 resolve_sibling_host(&host_alias, std::env::var("FAKECLOUD_IN_CONTAINER").ok());
392 Self {
393 host_alias,
394 add_host_arg,
395 sibling_host,
396 }
397 }
398
399 /// Convenience: append the `--add-host <alias>:<value>` flag pair to a
400 /// growing argv vector when this runtime needs an explicit mapping.
401 /// No-op for podman.
402 pub fn push_add_host_args(&self, argv: &mut Vec<String>) {
403 if let Some(arg) = &self.add_host_arg {
404 argv.push("--add-host".to_string());
405 argv.push(arg.clone());
406 }
407 }
408}
409
410/// How long to wait for the blocking `getaddrinfo` in [`host_alias_resolves`]
411/// before giving up and returning `false`. `getaddrinfo` has no timeout of its
412/// own, and a slow or unreachable DNS server would otherwise block a runtime
413/// thread at startup (this runs inside runtime constructors under
414/// `#[tokio::main]`). Bounding it — same tradeoff as [`CLI_PROBE_TIMEOUT`] —
415/// turns "DNS wedged" into "alias doesn't resolve", the safe default that keeps
416/// the `--add-host` bridge mapping.
417pub const HOST_ALIAS_RESOLVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
418
419/// True when `host_alias` resolves via the process resolver. The `getaddrinfo`
420/// call is blocking with no timeout of its own, so it runs on a spawned thread
421/// bounded by [`HOST_ALIAS_RESOLVE_TIMEOUT`]; on timeout we return `false` (the
422/// safe default that keeps `--add-host`). A leaked resolver thread on timeout
423/// is acceptable — same tradeoff as [`probe_cli`].
424fn host_alias_resolves(host_alias: &str) -> bool {
425 let (tx, rx) = std::sync::mpsc::channel();
426 let alias = host_alias.to_string();
427 std::thread::spawn(move || {
428 let resolves = std::net::ToSocketAddrs::to_socket_addrs(&(alias.as_str(), 0)).is_ok();
429 let _ = tx.send(resolves);
430 });
431 rx.recv_timeout(HOST_ALIAS_RESOLVE_TIMEOUT).unwrap_or(false)
432}
433
434fn preserve_native_host_alias(
435 add_host_arg: Option<String>,
436 should_suppress: bool,
437) -> Option<String> {
438 if add_host_arg.is_some() && should_suppress {
439 // Suppress the injected `--add-host host.docker.internal:<vm-bridge-ip>`
440 // only when fakecloud is containerized AND the alias already resolves
441 // (see the gate in `detect`). In that case a Docker-Desktop-class
442 // runtime provides `host.docker.internal` natively inside every sibling
443 // container, pointing at the real host; injecting the VM bridge-gateway
444 // IP would shadow it and break the host route. On a bare host — where a
445 // hijacking resolver can make the alias resolve spuriously — the caller
446 // passes `false` here so native Linux docker keeps the bridge mapping
447 // it genuinely needs.
448 None
449 } else {
450 add_host_arg
451 }
452}
453
454/// Compute the `(host_alias, add_host_arg)` pair for a CLI. Pure except
455/// for the bridge-gateway daemon probe on Linux docker, so the macOS /
456/// podman branches are unit-testable without a daemon.
457pub fn resolve_host_alias(cli: &str) -> (String, Option<String>) {
458 if is_podman_binary(cli) {
459 // Podman provides `host.containers.internal` natively on every
460 // supported platform; injecting `host-gateway` on macOS fails
461 // because rootless podman's gvproxy doesn't expose the magic alias.
462 ("host.containers.internal".to_string(), None)
463 } else if cfg!(target_os = "linux") {
464 // Bare docker on Linux: resolve the bridge gateway IP and add an
465 // explicit alias. `host.docker.internal:host-gateway` only works
466 // on Docker Desktop; native Linux docker has no such magic.
467 let ip = detect_bridge_gateway(cli).unwrap_or_else(|| "172.17.0.1".to_string());
468 (
469 "host.docker.internal".to_string(),
470 Some(format!("host.docker.internal:{ip}")),
471 )
472 } else {
473 // Docker Desktop on Mac/Windows: `host-gateway` is the magic alias
474 // that resolves to the host's IP.
475 (
476 "host.docker.internal".to_string(),
477 Some("host.docker.internal:host-gateway".to_string()),
478 )
479 }
480}
481
482/// Decide what address fakecloud uses to reach the sibling containers it
483/// just spawned. Pure helper so the env-var parsing can be tested without
484/// touching the process's real environment.
485///
486/// - `Some("1")` / `Some("true")` (case-insensitive) -> fakecloud is in a
487/// container; the siblings publish their ports on the host's daemon and
488/// are reachable at the same host alias the spawned containers use to
489/// reach fakecloud — `host.docker.internal` under docker,
490/// `host.containers.internal` under podman. Hardcoding
491/// `host.docker.internal` here broke podman, whose gvproxy network only
492/// resolves `host.containers.internal` (issue #1539 follow-up).
493/// - anything else, including `None` -> fakecloud runs on the host,
494/// siblings live on `127.0.0.1:<port>`.
495pub fn resolve_sibling_host(host_alias: &str, env_value: Option<String>) -> String {
496 if in_container_mode(env_value) {
497 host_alias.to_string()
498 } else {
499 "127.0.0.1".to_string()
500 }
501}
502
503/// Parse the `FAKECLOUD_IN_CONTAINER` signal: `Some("1")` or a case-insensitive
504/// `Some("true")` mean fakecloud is running inside a container; anything else,
505/// including `None`, means it runs on the host. Single source of truth for the
506/// parse so `detect`'s native-alias gate and `resolve_sibling_host` can't drift.
507fn in_container_mode(env_value: Option<String>) -> bool {
508 env_value
509 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
510 .unwrap_or(false)
511}
512
513/// Hostnames fakecloud's bundled ECR/OCI registry can be addressed from a
514/// sibling container or the host, each at `server_port`.
515///
516/// A container-spawning service rewrites the image pull URI to the runtime's
517/// sibling host -- `host.docker.internal` under Docker, `host.containers.internal`
518/// under podman -- or leaves it `localhost` / `127.0.0.1` when fakecloud runs on
519/// the host (`localhost:<port>` is the documented local ECR endpoint, e.g.
520/// `localhost:4566`). The registry enforces auth, and the Docker/Podman CLI only
521/// attaches the `Authorization` header for hosts present in `config.json`, so the
522/// isolated pull config must list *every* alias or the pull gets a 401. The map
523/// previously omitted the podman alias, so image-based Lambda/ECS pulls failed
524/// under podman-in-a-container (bug-audit 2026-06-20, 0.B2). Authorize all of
525/// them with the same credential; centralized here so the two builders can't
526/// drift again.
527pub fn registry_auth_hosts(server_port: u16) -> Vec<String> {
528 [
529 "localhost",
530 "127.0.0.1",
531 "host.docker.internal",
532 "host.containers.internal",
533 ]
534 .iter()
535 .map(|host| format!("{host}:{server_port}"))
536 .collect()
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn cli_available_false_for_missing_binary() {
545 // A binary that doesn't exist fails to spawn -> unavailable, fast.
546 assert!(!cli_available("definitely-not-a-real-cli-binary-xyz-123"));
547 }
548
549 #[cfg(unix)]
550 #[test]
551 fn cli_available_bounds_a_hanging_probe() {
552 // A CLI whose `info` invocation blocks forever (like `docker info`
553 // against an unreachable daemon) must not hang the caller: the probe
554 // is killed at CLI_PROBE_TIMEOUT and reported unavailable. Regression
555 // test for the local-conformance-probe hang.
556 use std::io::Write;
557 use std::os::unix::fs::PermissionsExt;
558
559 let dir = std::env::temp_dir().join(format!("fc-clitest-{}", std::process::id()));
560 std::fs::create_dir_all(&dir).unwrap();
561 let script = dir.join("hangcli");
562 std::fs::write(&script, "#!/bin/sh\nsleep 600\n").unwrap();
563 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
564 std::io::stdout().flush().ok();
565
566 let start = std::time::Instant::now();
567 let available = cli_available(script.to_str().unwrap());
568 let elapsed = start.elapsed();
569
570 std::fs::remove_dir_all(&dir).ok();
571 assert!(!available, "a hanging probe must report unavailable");
572 assert!(
573 elapsed < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
574 "probe took {elapsed:?}, expected it bounded near {CLI_PROBE_TIMEOUT:?}"
575 );
576 }
577
578 #[test]
579 fn is_podman_binary_matches_bare_name() {
580 assert!(is_podman_binary("podman"));
581 assert!(is_podman_binary("podman-remote"));
582 }
583
584 #[test]
585 fn registry_auth_hosts_includes_podman_alias() {
586 // The podman sibling alias (host.containers.internal) must be authorized
587 // or image-based Lambda/ECS pulls 401 under podman-in-a-container (0.B2).
588 let hosts = registry_auth_hosts(4566);
589 assert!(hosts.contains(&"localhost:4566".to_string()));
590 assert!(hosts.contains(&"127.0.0.1:4566".to_string()));
591 assert!(hosts.contains(&"host.docker.internal:4566".to_string()));
592 assert!(
593 hosts.contains(&"host.containers.internal:4566".to_string()),
594 "podman sibling alias must be authorized: {hosts:?}"
595 );
596 }
597
598 #[test]
599 fn is_podman_binary_matches_absolute_path() {
600 assert!(is_podman_binary("/opt/homebrew/bin/podman"));
601 assert!(is_podman_binary("/usr/local/bin/podman-remote"));
602 }
603
604 #[test]
605 fn is_podman_binary_rejects_docker() {
606 assert!(!is_podman_binary("docker"));
607 assert!(!is_podman_binary("/usr/local/bin/docker"));
608 assert!(!is_podman_binary("docker-credential-helper"));
609 }
610
611 #[test]
612 fn resolve_host_alias_podman_has_no_add_host() {
613 let (alias, add_host) = resolve_host_alias("podman");
614 assert_eq!(alias, "host.containers.internal");
615 assert_eq!(add_host, None);
616 let (alias, add_host) = resolve_host_alias("/opt/homebrew/bin/podman");
617 assert_eq!(alias, "host.containers.internal");
618 assert_eq!(add_host, None);
619 }
620
621 #[test]
622 fn resolve_host_alias_docker_emits_add_host() {
623 let (alias, add_host) = resolve_host_alias("docker");
624 assert_eq!(alias, "host.docker.internal");
625 // On macOS this is host-gateway; on Linux it's a bridge IP. Either
626 // way docker must get an explicit --add-host.
627 assert!(add_host.is_some());
628 assert!(add_host.unwrap().starts_with("host.docker.internal:"));
629 }
630
631 #[test]
632 fn native_host_alias_prevents_docker_add_host_override() {
633 let add_host =
634 preserve_native_host_alias(Some("host.docker.internal:host-gateway".to_string()), true);
635
636 assert_eq!(add_host, None);
637 }
638
639 #[test]
640 fn unresolved_host_alias_keeps_docker_add_host() {
641 let add_host = preserve_native_host_alias(
642 Some("host.docker.internal:host-gateway".to_string()),
643 false,
644 );
645
646 assert_eq!(
647 add_host.as_deref(),
648 Some("host.docker.internal:host-gateway")
649 );
650 }
651
652 #[test]
653 fn absent_docker_add_host_remains_absent() {
654 assert_eq!(preserve_native_host_alias(None, true), None);
655 assert_eq!(preserve_native_host_alias(None, false), None);
656 }
657
658 #[test]
659 fn in_container_mode_parses_truthy_values() {
660 assert!(in_container_mode(Some("1".to_string())));
661 assert!(in_container_mode(Some("true".to_string())));
662 assert!(in_container_mode(Some("True".to_string())));
663 assert!(in_container_mode(Some("TRUE".to_string())));
664 }
665
666 #[test]
667 fn in_container_mode_rejects_falsey_and_absent() {
668 assert!(!in_container_mode(None));
669 assert!(!in_container_mode(Some(String::new())));
670 assert!(!in_container_mode(Some("0".to_string())));
671 assert!(!in_container_mode(Some("false".to_string())));
672 assert!(!in_container_mode(Some("yes".to_string())));
673 }
674
675 #[test]
676 fn native_alias_gate_suppresses_only_in_container() {
677 // The gate `detect` computes: `in_container && host_alias_resolves`.
678 let add_host = || Some("host.docker.internal:172.17.0.1".to_string());
679
680 // In-container + resolves -> Desktop-class runtime provides the alias
681 // natively in siblings; drop the shadowing bridge mapping.
682 let in_container = true;
683 let resolves = true;
684 assert_eq!(
685 preserve_native_host_alias(add_host(), in_container && resolves),
686 None,
687 );
688
689 // NOT in-container (bare host) + resolves -> the resolving alias is
690 // spurious (hijacking resolver / stray hosts entry). Native Linux docker
691 // needs the bridge mapping; must NOT drop it. Regression guard.
692 let in_container = false;
693 let resolves = true;
694 assert_eq!(
695 preserve_native_host_alias(add_host(), in_container && resolves).as_deref(),
696 Some("host.docker.internal:172.17.0.1"),
697 );
698
699 // In-container + does NOT resolve -> nothing native to preserve; keep
700 // the injected mapping.
701 let in_container = true;
702 let resolves = false;
703 assert_eq!(
704 preserve_native_host_alias(add_host(), in_container && resolves).as_deref(),
705 Some("host.docker.internal:172.17.0.1"),
706 );
707 }
708
709 #[test]
710 fn resolve_sibling_host_defaults_to_loopback() {
711 assert_eq!(
712 resolve_sibling_host("host.docker.internal", None),
713 "127.0.0.1"
714 );
715 assert_eq!(
716 resolve_sibling_host("host.docker.internal", Some(String::new())),
717 "127.0.0.1"
718 );
719 assert_eq!(
720 resolve_sibling_host("host.docker.internal", Some("0".to_string())),
721 "127.0.0.1"
722 );
723 assert_eq!(
724 resolve_sibling_host("host.containers.internal", Some("false".to_string())),
725 "127.0.0.1"
726 );
727 }
728
729 #[test]
730 fn resolve_sibling_host_uses_host_alias_when_in_container() {
731 // Docker: siblings reachable at host.docker.internal.
732 assert_eq!(
733 resolve_sibling_host("host.docker.internal", Some("1".to_string())),
734 "host.docker.internal"
735 );
736 assert_eq!(
737 resolve_sibling_host("host.docker.internal", Some("true".to_string())),
738 "host.docker.internal"
739 );
740 assert_eq!(
741 resolve_sibling_host("host.docker.internal", Some("TRUE".to_string())),
742 "host.docker.internal"
743 );
744 // Podman: must use host.containers.internal, NOT host.docker.internal
745 // (issue #1539 follow-up — gvproxy only resolves the containers alias).
746 assert_eq!(
747 resolve_sibling_host("host.containers.internal", Some("1".to_string())),
748 "host.containers.internal"
749 );
750 }
751
752 #[test]
753 fn detect_wires_sibling_host_to_podman_alias_in_container() {
754 // Full path: a podman binary in a container must advertise siblings
755 // at host.containers.internal. resolve_host_alias drives host_alias,
756 // which resolve_sibling_host then reuses.
757 let (alias, add_host) = resolve_host_alias("podman");
758 assert_eq!(alias, "host.containers.internal");
759 assert_eq!(add_host, None);
760 assert_eq!(
761 resolve_sibling_host(&alias, Some("1".to_string())),
762 "host.containers.internal"
763 );
764 }
765
766 #[test]
767 fn only_objects_of_a_dead_owner_are_orphans() {
768 let me = std::process::id();
769 let alive = |pid: u32| pid == 4242;
770 // Another live fakecloud process: never an orphan.
771 assert!(!owned_by_dead_process("fakecloud-4242", alive));
772 // Its owner is gone: an orphan.
773 assert!(owned_by_dead_process("fakecloud-777", alive));
774 // The current process, even if the probe says otherwise.
775 assert!(!owned_by_dead_process(&format!("fakecloud-{me}"), |_| {
776 false
777 }));
778 // Nothing proves an unparseable owner is gone.
779 for label in ["", "fakecloud-", "fakecloud-abc", "other-777"] {
780 assert!(!owned_by_dead_process(label, alive), "{label:?}");
781 }
782 }
783
784 #[cfg(unix)]
785 #[test]
786 fn pid_alive_probes_real_processes() {
787 assert!(pid_alive(std::process::id()));
788 assert!(!pid_alive(u32::MAX - 1));
789 }
790
791 #[test]
792 fn push_add_host_args_noop_for_podman() {
793 let net = HostNetworking {
794 host_alias: "host.containers.internal".to_string(),
795 add_host_arg: None,
796 sibling_host: "127.0.0.1".to_string(),
797 };
798 let mut argv = vec!["create".to_string()];
799 net.push_add_host_args(&mut argv);
800 assert_eq!(argv, vec!["create".to_string()]);
801 }
802
803 #[test]
804 fn push_add_host_args_emits_for_docker() {
805 let net = HostNetworking {
806 host_alias: "host.docker.internal".to_string(),
807 add_host_arg: Some("host.docker.internal:host-gateway".to_string()),
808 sibling_host: "127.0.0.1".to_string(),
809 };
810 let mut argv = vec!["create".to_string()];
811 net.push_add_host_args(&mut argv);
812 assert_eq!(
813 argv,
814 vec![
815 "create".to_string(),
816 "--add-host".to_string(),
817 "host.docker.internal:host-gateway".to_string(),
818 ]
819 );
820 }
821}
822
823#[cfg(test)]
824mod bounded_cli_tests {
825 use super::*;
826
827 /// A wedged daemon leaves the CLI blocked on connect forever. Every
828 /// container call has to end at the bound instead of hanging its caller,
829 /// which for the reaper means hanging server startup.
830 #[test]
831 fn a_hanging_cli_call_is_cut_off() {
832 let start = std::time::Instant::now();
833 let mut child = spawn_bounded(
834 std::process::Command::new("sleep")
835 .arg("600")
836 .stdout(std::process::Stdio::null())
837 .stderr(std::process::Stdio::null()),
838 )
839 .expect("sleep is available");
840 assert!(!wait_bounded_group(&mut child));
841 assert!(
842 start.elapsed() < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
843 "the wait must end at the bound"
844 );
845 }
846
847 /// Output larger than a pipe buffer (64 KiB on Linux) must come back
848 /// whole. Waiting for the child to exit before reading blocks it on write
849 /// forever, so this used to burn the full timeout and report failure.
850 #[test]
851 fn output_larger_than_the_pipe_buffer_still_comes_back() {
852 let start = std::time::Instant::now();
853 // 200_000 bytes: comfortably past the buffer on every supported host.
854 let out = bounded_output("sh", &["-c", "printf 'x%.0s' $(seq 1 200000)"])
855 .expect("a large but prompt call must succeed");
856 assert_eq!(out.len(), 200_000, "output was truncated");
857 assert!(
858 start.elapsed() < CLI_PROBE_TIMEOUT,
859 "a prompt call must not reach the deadline"
860 );
861 }
862
863 #[test]
864 fn a_prompt_cli_call_returns_its_output() {
865 assert_eq!(
866 bounded_output("echo", &["abc123"])
867 .as_deref()
868 .map(str::trim),
869 Some("abc123")
870 );
871 assert!(bounded_status("true", &[]));
872 assert!(!bounded_status("false", &[]));
873 }
874
875 /// A bounded call gets an empty stdin, never fakecloud's own. The process
876 /// group `spawn_bounded` creates is a *background* one, so a child that
877 /// reads the controlling terminal -- a `sudo`/credential-helper wrapper
878 /// prompting -- is stopped by SIGTTIN, which the WNOHANG `try_wait` loop
879 /// cannot see: the call would burn the whole deadline and be killed.
880 /// `cat` with no argument reads stdin to EOF, so it returns at once with
881 /// nothing only when stdin is /dev/null.
882 #[cfg(unix)]
883 #[test]
884 fn a_bounded_call_reads_an_empty_stdin() {
885 let start = std::time::Instant::now();
886 let out = bounded_output("cat", &[]).expect("a call reading stdin must not time out");
887 assert!(out.is_empty(), "stdin must be empty, got {out:?}");
888 assert!(
889 start.elapsed() < CLI_PROBE_TIMEOUT,
890 "a call reading stdin must not reach the deadline"
891 );
892 }
893
894 /// The happy path must still hand back the child's output *and* collect the
895 /// reader, so the no-leak guarantee isn't bought by dropping output.
896 #[test]
897 fn a_prompt_cli_call_collects_its_reader() {
898 let (output, reader) = run_bounded("echo", &["abc123"]);
899 assert_eq!(output.as_deref().map(str::trim), Some("abc123"));
900 assert!(
901 matches!(reader, ReaderState::Finished),
902 "reader was {reader:?}, expected it collected"
903 );
904 }
905
906 /// The bridge-gateway probe talks to the same daemon as the liveness probe,
907 /// so it has to end at the same bound. It used to be a plain
908 /// `Command::output()`, which against a wedged daemon hung whichever runtime
909 /// constructor called it -- on Linux, every container-backed service at
910 /// server startup. Timing out is not an error here: the caller falls back to
911 /// the conventional `172.17.0.1`.
912 #[cfg(unix)]
913 #[test]
914 fn a_hanging_bridge_gateway_probe_is_cut_off() {
915 use std::os::unix::fs::PermissionsExt;
916
917 let dir = std::env::temp_dir().join(format!("fc-gwtest-{}", std::process::id()));
918 std::fs::create_dir_all(&dir).unwrap();
919 let script = dir.join("hangcli");
920 std::fs::write(&script, "#!/bin/sh\nsleep 600\n").unwrap();
921 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
922
923 let start = std::time::Instant::now();
924 let gateway = detect_bridge_gateway(script.to_str().unwrap());
925 let elapsed = start.elapsed();
926
927 std::fs::remove_dir_all(&dir).ok();
928 assert_eq!(gateway, None, "a wedged daemon must report no gateway");
929 assert!(
930 elapsed < CLI_PROBE_TIMEOUT + READER_DRAIN_GRACE + std::time::Duration::from_secs(5),
931 "the probe took {elapsed:?}, expected it bounded near {CLI_PROBE_TIMEOUT:?}"
932 );
933 }
934
935 /// The unavailable-CLI path keeps its shape: nothing to spawn, no gateway,
936 /// and the caller's fallback stands.
937 #[test]
938 fn a_missing_cli_reports_no_bridge_gateway() {
939 assert_eq!(
940 detect_bridge_gateway("definitely-not-a-real-cli-binary-xyz-123"),
941 None
942 );
943 }
944
945 /// A CLI that succeeds with no output -- an `inspect --format` over a bridge
946 /// with no IPAM config -- still means "no gateway", not an empty
947 /// `--add-host` value. Unchanged by the bounding; guarded so it stays that
948 /// way.
949 #[test]
950 fn an_empty_gateway_is_rejected() {
951 assert_eq!(detect_bridge_gateway("true"), None);
952 }
953
954 /// `FAKECLOUD_CONTAINER_CLI` is routinely a wrapper (`sh -c 'exec docker
955 /// "$@"'`, a `podman-remote` shim), which makes the real command a
956 /// grandchild holding the stdout pipe. Killing only the direct child left
957 /// the reader's `read_to_end` blocked forever -- a thread parked for the
958 /// life of the process, once per call, on exactly the wedged-daemon path
959 /// these bounds were added for (the server reaper calls this at startup).
960 /// The reader reporting in is the evidence: EOF on that pipe is only
961 /// possible once every write end is closed, so a collected buffer proves
962 /// the grandchildren went down with the call.
963 #[cfg(unix)]
964 #[test]
965 fn a_timed_out_wrapper_call_leaves_no_reader_behind() {
966 let start = std::time::Instant::now();
967 // A wrapper that outlives its own kill: the backgrounded sleep inherits
968 // the stdout pipe and is not the process we spawned.
969 let (output, reader) = run_bounded("sh", &["-c", "sleep 600 & sleep 600"]);
970 assert_eq!(output, None, "a wedged call must report failure");
971 assert!(
972 matches!(reader, ReaderState::Finished),
973 "reader was {reader:?}: the stdout reader must not outlive the call"
974 );
975 assert!(
976 start.elapsed()
977 < CLI_PROBE_TIMEOUT + READER_DRAIN_GRACE + std::time::Duration::from_secs(5),
978 "the call must still end at the bound, took {:?}",
979 start.elapsed()
980 );
981 }
982}