Skip to main content

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, add_host_arg) = resolve_host_alias(cli);
169        let sibling_host =
170            resolve_sibling_host(&host_alias, std::env::var("FAKECLOUD_IN_CONTAINER").ok());
171        Self {
172            host_alias,
173            add_host_arg,
174            sibling_host,
175        }
176    }
177
178    /// Convenience: append the `--add-host <alias>:<value>` flag pair to a
179    /// growing argv vector when this runtime needs an explicit mapping.
180    /// No-op for podman.
181    pub fn push_add_host_args(&self, argv: &mut Vec<String>) {
182        if let Some(arg) = &self.add_host_arg {
183            argv.push("--add-host".to_string());
184            argv.push(arg.clone());
185        }
186    }
187}
188
189/// Compute the `(host_alias, add_host_arg)` pair for a CLI. Pure except
190/// for the bridge-gateway daemon probe on Linux docker, so the macOS /
191/// podman branches are unit-testable without a daemon.
192pub fn resolve_host_alias(cli: &str) -> (String, Option<String>) {
193    if is_podman_binary(cli) {
194        // Podman provides `host.containers.internal` natively on every
195        // supported platform; injecting `host-gateway` on macOS fails
196        // because rootless podman's gvproxy doesn't expose the magic alias.
197        ("host.containers.internal".to_string(), None)
198    } else if cfg!(target_os = "linux") {
199        // Bare docker on Linux: resolve the bridge gateway IP and add an
200        // explicit alias. `host.docker.internal:host-gateway` only works
201        // on Docker Desktop; native Linux docker has no such magic.
202        let ip = detect_bridge_gateway(cli).unwrap_or_else(|| "172.17.0.1".to_string());
203        (
204            "host.docker.internal".to_string(),
205            Some(format!("host.docker.internal:{ip}")),
206        )
207    } else {
208        // Docker Desktop on Mac/Windows: `host-gateway` is the magic alias
209        // that resolves to the host's IP.
210        (
211            "host.docker.internal".to_string(),
212            Some("host.docker.internal:host-gateway".to_string()),
213        )
214    }
215}
216
217/// Decide what address fakecloud uses to reach the sibling containers it
218/// just spawned. Pure helper so the env-var parsing can be tested without
219/// touching the process's real environment.
220///
221/// - `Some("1")` / `Some("true")` (case-insensitive) -> fakecloud is in a
222///   container; the siblings publish their ports on the host's daemon and
223///   are reachable at the same host alias the spawned containers use to
224///   reach fakecloud — `host.docker.internal` under docker,
225///   `host.containers.internal` under podman. Hardcoding
226///   `host.docker.internal` here broke podman, whose gvproxy network only
227///   resolves `host.containers.internal` (issue #1539 follow-up).
228/// - anything else, including `None` -> fakecloud runs on the host,
229///   siblings live on `127.0.0.1:<port>`.
230pub fn resolve_sibling_host(host_alias: &str, env_value: Option<String>) -> String {
231    let in_container = env_value
232        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
233        .unwrap_or(false);
234    if in_container {
235        host_alias.to_string()
236    } else {
237        "127.0.0.1".to_string()
238    }
239}
240
241/// Hostnames fakecloud's bundled ECR/OCI registry can be addressed by from a
242/// sibling container, each at `server_port`.
243///
244/// A container-spawning service rewrites the image pull URI to the runtime's
245/// sibling host -- `host.docker.internal` under Docker, `host.containers.internal`
246/// under podman -- or leaves it `127.0.0.1` when fakecloud runs on the host. The
247/// registry enforces auth, and the Docker/Podman CLI only attaches the
248/// `Authorization` header for hosts present in `config.json`, so the isolated
249/// pull config must list *every* alias or the pull gets a 401. The map
250/// previously omitted the podman alias, so image-based Lambda/ECS pulls failed
251/// under podman-in-a-container (bug-audit 2026-06-20, 0.B2). Authorize all of
252/// them with the same credential; centralized here so the two builders can't
253/// drift again.
254pub fn registry_auth_hosts(server_port: u16) -> Vec<String> {
255    [
256        "127.0.0.1",
257        "host.docker.internal",
258        "host.containers.internal",
259    ]
260    .iter()
261    .map(|host| format!("{host}:{server_port}"))
262    .collect()
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn cli_available_false_for_missing_binary() {
271        // A binary that doesn't exist fails to spawn -> unavailable, fast.
272        assert!(!cli_available("definitely-not-a-real-cli-binary-xyz-123"));
273    }
274
275    #[cfg(unix)]
276    #[test]
277    fn cli_available_bounds_a_hanging_probe() {
278        // A CLI whose `info` invocation blocks forever (like `docker info`
279        // against an unreachable daemon) must not hang the caller: the probe
280        // is killed at CLI_PROBE_TIMEOUT and reported unavailable. Regression
281        // test for the local-conformance-probe hang.
282        use std::io::Write;
283        use std::os::unix::fs::PermissionsExt;
284
285        let dir = std::env::temp_dir().join(format!("fc-clitest-{}", std::process::id()));
286        std::fs::create_dir_all(&dir).unwrap();
287        let script = dir.join("hangcli");
288        std::fs::write(&script, "#!/bin/sh\nsleep 600\n").unwrap();
289        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
290        std::io::stdout().flush().ok();
291
292        let start = std::time::Instant::now();
293        let available = cli_available(script.to_str().unwrap());
294        let elapsed = start.elapsed();
295
296        std::fs::remove_dir_all(&dir).ok();
297        assert!(!available, "a hanging probe must report unavailable");
298        assert!(
299            elapsed < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
300            "probe took {elapsed:?}, expected it bounded near {CLI_PROBE_TIMEOUT:?}"
301        );
302    }
303
304    #[test]
305    fn is_podman_binary_matches_bare_name() {
306        assert!(is_podman_binary("podman"));
307        assert!(is_podman_binary("podman-remote"));
308    }
309
310    #[test]
311    fn registry_auth_hosts_includes_podman_alias() {
312        // The podman sibling alias (host.containers.internal) must be authorized
313        // or image-based Lambda/ECS pulls 401 under podman-in-a-container (0.B2).
314        let hosts = registry_auth_hosts(4566);
315        assert!(hosts.contains(&"127.0.0.1:4566".to_string()));
316        assert!(hosts.contains(&"host.docker.internal:4566".to_string()));
317        assert!(
318            hosts.contains(&"host.containers.internal:4566".to_string()),
319            "podman sibling alias must be authorized: {hosts:?}"
320        );
321    }
322
323    #[test]
324    fn is_podman_binary_matches_absolute_path() {
325        assert!(is_podman_binary("/opt/homebrew/bin/podman"));
326        assert!(is_podman_binary("/usr/local/bin/podman-remote"));
327    }
328
329    #[test]
330    fn is_podman_binary_rejects_docker() {
331        assert!(!is_podman_binary("docker"));
332        assert!(!is_podman_binary("/usr/local/bin/docker"));
333        assert!(!is_podman_binary("docker-credential-helper"));
334    }
335
336    #[test]
337    fn resolve_host_alias_podman_has_no_add_host() {
338        let (alias, add_host) = resolve_host_alias("podman");
339        assert_eq!(alias, "host.containers.internal");
340        assert_eq!(add_host, None);
341        let (alias, add_host) = resolve_host_alias("/opt/homebrew/bin/podman");
342        assert_eq!(alias, "host.containers.internal");
343        assert_eq!(add_host, None);
344    }
345
346    #[test]
347    fn resolve_host_alias_docker_emits_add_host() {
348        let (alias, add_host) = resolve_host_alias("docker");
349        assert_eq!(alias, "host.docker.internal");
350        // On macOS this is host-gateway; on Linux it's a bridge IP. Either
351        // way docker must get an explicit --add-host.
352        assert!(add_host.is_some());
353        assert!(add_host.unwrap().starts_with("host.docker.internal:"));
354    }
355
356    #[test]
357    fn resolve_sibling_host_defaults_to_loopback() {
358        assert_eq!(
359            resolve_sibling_host("host.docker.internal", None),
360            "127.0.0.1"
361        );
362        assert_eq!(
363            resolve_sibling_host("host.docker.internal", Some(String::new())),
364            "127.0.0.1"
365        );
366        assert_eq!(
367            resolve_sibling_host("host.docker.internal", Some("0".to_string())),
368            "127.0.0.1"
369        );
370        assert_eq!(
371            resolve_sibling_host("host.containers.internal", Some("false".to_string())),
372            "127.0.0.1"
373        );
374    }
375
376    #[test]
377    fn resolve_sibling_host_uses_host_alias_when_in_container() {
378        // Docker: siblings reachable at host.docker.internal.
379        assert_eq!(
380            resolve_sibling_host("host.docker.internal", Some("1".to_string())),
381            "host.docker.internal"
382        );
383        assert_eq!(
384            resolve_sibling_host("host.docker.internal", Some("true".to_string())),
385            "host.docker.internal"
386        );
387        assert_eq!(
388            resolve_sibling_host("host.docker.internal", Some("TRUE".to_string())),
389            "host.docker.internal"
390        );
391        // Podman: must use host.containers.internal, NOT host.docker.internal
392        // (issue #1539 follow-up — gvproxy only resolves the containers alias).
393        assert_eq!(
394            resolve_sibling_host("host.containers.internal", Some("1".to_string())),
395            "host.containers.internal"
396        );
397    }
398
399    #[test]
400    fn detect_wires_sibling_host_to_podman_alias_in_container() {
401        // Full path: a podman binary in a container must advertise siblings
402        // at host.containers.internal. resolve_host_alias drives host_alias,
403        // which resolve_sibling_host then reuses.
404        let (alias, add_host) = resolve_host_alias("podman");
405        assert_eq!(alias, "host.containers.internal");
406        assert_eq!(add_host, None);
407        assert_eq!(
408            resolve_sibling_host(&alias, Some("1".to_string())),
409            "host.containers.internal"
410        );
411    }
412
413    #[test]
414    fn push_add_host_args_noop_for_podman() {
415        let net = HostNetworking {
416            host_alias: "host.containers.internal".to_string(),
417            add_host_arg: None,
418            sibling_host: "127.0.0.1".to_string(),
419        };
420        let mut argv = vec!["create".to_string()];
421        net.push_add_host_args(&mut argv);
422        assert_eq!(argv, vec!["create".to_string()]);
423    }
424
425    #[test]
426    fn push_add_host_args_emits_for_docker() {
427        let net = HostNetworking {
428            host_alias: "host.docker.internal".to_string(),
429            add_host_arg: Some("host.docker.internal:host-gateway".to_string()),
430            sibling_host: "127.0.0.1".to_string(),
431        };
432        let mut argv = vec!["create".to_string()];
433        net.push_add_host_args(&mut argv);
434        assert_eq!(
435            argv,
436            vec![
437                "create".to_string(),
438                "--add-host".to_string(),
439                "host.docker.internal:host-gateway".to_string(),
440            ]
441        );
442    }
443}