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/// Auto-detect an available container CLI. Honors `FAKECLOUD_CONTAINER_CLI`
23/// as an explicit override (returns `None` if the override doesn't work),
24/// otherwise prefers `docker` then `podman`. Returns `None` when neither
25/// is usable.
26pub fn detect_container_cli() -> Option<String> {
27    if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
28        return if cli_available(&cli) { Some(cli) } else { None };
29    }
30    if cli_available("docker") {
31        Some("docker".to_string())
32    } else if cli_available("podman") {
33        Some("podman".to_string())
34    } else {
35        None
36    }
37}
38
39/// True when the CLI responds to `<cli> info` with success — the same
40/// liveness probe every runtime used before this module existed.
41pub fn cli_available(cli: &str) -> bool {
42    std::process::Command::new(cli)
43        .arg("info")
44        .stdout(std::process::Stdio::null())
45        .stderr(std::process::Stdio::null())
46        .status()
47        .map(|s| s.success())
48        .unwrap_or(false)
49}
50
51/// True when `cli` is podman or a podman-compatible binary. Matches on the
52/// filename component so absolute paths (`/opt/homebrew/bin/podman`) and
53/// wrappers (`podman-remote`) both register as podman. Docker Desktop's
54/// compatibility CLI is named `docker`, so this check is safe.
55pub fn is_podman_binary(cli: &str) -> bool {
56    std::path::Path::new(cli)
57        .file_name()
58        .and_then(|n| n.to_str())
59        .map(|n| n.contains("podman"))
60        .unwrap_or(false)
61}
62
63/// Detect the Docker bridge gateway IP on Linux. Returns `None` if
64/// detection fails (caller falls back to the conventional `172.17.0.1`).
65pub fn detect_bridge_gateway(cli: &str) -> Option<String> {
66    let output = std::process::Command::new(cli)
67        .args([
68            "network",
69            "inspect",
70            "bridge",
71            "--format",
72            "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
73        ])
74        .output()
75        .ok()?;
76    if !output.status.success() {
77        return None;
78    }
79    let gateway = String::from_utf8_lossy(&output.stdout).trim().to_string();
80    if gateway.is_empty() || !gateway.contains('.') {
81        return None;
82    }
83    Some(gateway)
84}
85
86/// Resolved container-to-host networking for a given CLI. Built once at
87/// runtime construction and reused for every container spawn.
88#[derive(Debug, Clone)]
89pub struct HostNetworking {
90    /// DNS name a spawned container uses to reach fakecloud on the host.
91    /// `host.containers.internal` for podman, `host.docker.internal` for
92    /// docker.
93    pub host_alias: String,
94    /// `<alias>:<value>` argument for `--add-host`, injected into every
95    /// container `create`/`run`. `None` when the runtime provides the
96    /// alias natively (podman).
97    pub add_host_arg: Option<String>,
98    /// Address fakecloud uses to reach the *sibling* containers it just
99    /// spawned (readiness probes + advertised endpoints). `127.0.0.1`
100    /// when fakecloud runs on the host; `host.docker.internal` when
101    /// fakecloud is itself containerized (`FAKECLOUD_IN_CONTAINER=1`).
102    pub sibling_host: String,
103}
104
105impl HostNetworking {
106    /// Resolve networking for `cli`, reading `FAKECLOUD_IN_CONTAINER` from
107    /// the process environment.
108    pub fn detect(cli: &str) -> Self {
109        let (host_alias, add_host_arg) = resolve_host_alias(cli);
110        let sibling_host =
111            resolve_sibling_host(&host_alias, std::env::var("FAKECLOUD_IN_CONTAINER").ok());
112        Self {
113            host_alias,
114            add_host_arg,
115            sibling_host,
116        }
117    }
118
119    /// Convenience: append the `--add-host <alias>:<value>` flag pair to a
120    /// growing argv vector when this runtime needs an explicit mapping.
121    /// No-op for podman.
122    pub fn push_add_host_args(&self, argv: &mut Vec<String>) {
123        if let Some(arg) = &self.add_host_arg {
124            argv.push("--add-host".to_string());
125            argv.push(arg.clone());
126        }
127    }
128}
129
130/// Compute the `(host_alias, add_host_arg)` pair for a CLI. Pure except
131/// for the bridge-gateway daemon probe on Linux docker, so the macOS /
132/// podman branches are unit-testable without a daemon.
133pub fn resolve_host_alias(cli: &str) -> (String, Option<String>) {
134    if is_podman_binary(cli) {
135        // Podman provides `host.containers.internal` natively on every
136        // supported platform; injecting `host-gateway` on macOS fails
137        // because rootless podman's gvproxy doesn't expose the magic alias.
138        ("host.containers.internal".to_string(), None)
139    } else if cfg!(target_os = "linux") {
140        // Bare docker on Linux: resolve the bridge gateway IP and add an
141        // explicit alias. `host.docker.internal:host-gateway` only works
142        // on Docker Desktop; native Linux docker has no such magic.
143        let ip = detect_bridge_gateway(cli).unwrap_or_else(|| "172.17.0.1".to_string());
144        (
145            "host.docker.internal".to_string(),
146            Some(format!("host.docker.internal:{ip}")),
147        )
148    } else {
149        // Docker Desktop on Mac/Windows: `host-gateway` is the magic alias
150        // that resolves to the host's IP.
151        (
152            "host.docker.internal".to_string(),
153            Some("host.docker.internal:host-gateway".to_string()),
154        )
155    }
156}
157
158/// Decide what address fakecloud uses to reach the sibling containers it
159/// just spawned. Pure helper so the env-var parsing can be tested without
160/// touching the process's real environment.
161///
162/// - `Some("1")` / `Some("true")` (case-insensitive) -> fakecloud is in a
163///   container; the siblings publish their ports on the host's daemon and
164///   are reachable at the same host alias the spawned containers use to
165///   reach fakecloud — `host.docker.internal` under docker,
166///   `host.containers.internal` under podman. Hardcoding
167///   `host.docker.internal` here broke podman, whose gvproxy network only
168///   resolves `host.containers.internal` (issue #1539 follow-up).
169/// - anything else, including `None` -> fakecloud runs on the host,
170///   siblings live on `127.0.0.1:<port>`.
171pub fn resolve_sibling_host(host_alias: &str, env_value: Option<String>) -> String {
172    let in_container = env_value
173        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
174        .unwrap_or(false);
175    if in_container {
176        host_alias.to_string()
177    } else {
178        "127.0.0.1".to_string()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn is_podman_binary_matches_bare_name() {
188        assert!(is_podman_binary("podman"));
189        assert!(is_podman_binary("podman-remote"));
190    }
191
192    #[test]
193    fn is_podman_binary_matches_absolute_path() {
194        assert!(is_podman_binary("/opt/homebrew/bin/podman"));
195        assert!(is_podman_binary("/usr/local/bin/podman-remote"));
196    }
197
198    #[test]
199    fn is_podman_binary_rejects_docker() {
200        assert!(!is_podman_binary("docker"));
201        assert!(!is_podman_binary("/usr/local/bin/docker"));
202        assert!(!is_podman_binary("docker-credential-helper"));
203    }
204
205    #[test]
206    fn resolve_host_alias_podman_has_no_add_host() {
207        let (alias, add_host) = resolve_host_alias("podman");
208        assert_eq!(alias, "host.containers.internal");
209        assert_eq!(add_host, None);
210        let (alias, add_host) = resolve_host_alias("/opt/homebrew/bin/podman");
211        assert_eq!(alias, "host.containers.internal");
212        assert_eq!(add_host, None);
213    }
214
215    #[test]
216    fn resolve_host_alias_docker_emits_add_host() {
217        let (alias, add_host) = resolve_host_alias("docker");
218        assert_eq!(alias, "host.docker.internal");
219        // On macOS this is host-gateway; on Linux it's a bridge IP. Either
220        // way docker must get an explicit --add-host.
221        assert!(add_host.is_some());
222        assert!(add_host.unwrap().starts_with("host.docker.internal:"));
223    }
224
225    #[test]
226    fn resolve_sibling_host_defaults_to_loopback() {
227        assert_eq!(
228            resolve_sibling_host("host.docker.internal", None),
229            "127.0.0.1"
230        );
231        assert_eq!(
232            resolve_sibling_host("host.docker.internal", Some(String::new())),
233            "127.0.0.1"
234        );
235        assert_eq!(
236            resolve_sibling_host("host.docker.internal", Some("0".to_string())),
237            "127.0.0.1"
238        );
239        assert_eq!(
240            resolve_sibling_host("host.containers.internal", Some("false".to_string())),
241            "127.0.0.1"
242        );
243    }
244
245    #[test]
246    fn resolve_sibling_host_uses_host_alias_when_in_container() {
247        // Docker: siblings reachable at host.docker.internal.
248        assert_eq!(
249            resolve_sibling_host("host.docker.internal", Some("1".to_string())),
250            "host.docker.internal"
251        );
252        assert_eq!(
253            resolve_sibling_host("host.docker.internal", Some("true".to_string())),
254            "host.docker.internal"
255        );
256        assert_eq!(
257            resolve_sibling_host("host.docker.internal", Some("TRUE".to_string())),
258            "host.docker.internal"
259        );
260        // Podman: must use host.containers.internal, NOT host.docker.internal
261        // (issue #1539 follow-up — gvproxy only resolves the containers alias).
262        assert_eq!(
263            resolve_sibling_host("host.containers.internal", Some("1".to_string())),
264            "host.containers.internal"
265        );
266    }
267
268    #[test]
269    fn detect_wires_sibling_host_to_podman_alias_in_container() {
270        // Full path: a podman binary in a container must advertise siblings
271        // at host.containers.internal. resolve_host_alias drives host_alias,
272        // which resolve_sibling_host then reuses.
273        let (alias, add_host) = resolve_host_alias("podman");
274        assert_eq!(alias, "host.containers.internal");
275        assert_eq!(add_host, None);
276        assert_eq!(
277            resolve_sibling_host(&alias, Some("1".to_string())),
278            "host.containers.internal"
279        );
280    }
281
282    #[test]
283    fn push_add_host_args_noop_for_podman() {
284        let net = HostNetworking {
285            host_alias: "host.containers.internal".to_string(),
286            add_host_arg: None,
287            sibling_host: "127.0.0.1".to_string(),
288        };
289        let mut argv = vec!["create".to_string()];
290        net.push_add_host_args(&mut argv);
291        assert_eq!(argv, vec!["create".to_string()]);
292    }
293
294    #[test]
295    fn push_add_host_args_emits_for_docker() {
296        let net = HostNetworking {
297            host_alias: "host.docker.internal".to_string(),
298            add_host_arg: Some("host.docker.internal:host-gateway".to_string()),
299            sibling_host: "127.0.0.1".to_string(),
300        };
301        let mut argv = vec!["create".to_string()];
302        net.push_add_host_args(&mut argv);
303        assert_eq!(
304            argv,
305            vec![
306                "create".to_string(),
307                "--add-host".to_string(),
308                "host.docker.internal:host-gateway".to_string(),
309            ]
310        );
311    }
312}