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/// Why [`output_within`] produced no output.
273#[derive(Debug, thiserror::Error)]
274pub enum CliRunError {
275 /// The command could not be started.
276 #[error("could not run: {0}")]
277 Spawn(std::io::Error),
278 /// Waiting for the command or reading its output failed.
279 #[error("failed while running: {0}")]
280 Wait(std::io::Error),
281 /// The command outran its budget and was killed with its process group.
282 #[error("did not finish within {}s and was killed", .0.as_secs_f64())]
283 TimedOut(std::time::Duration),
284}
285
286/// The async counterpart of [`bounded_output`] for container-CLI calls that
287/// need a budget of their own: run `cmd` to completion and return its full
288/// output, or kill it once it outruns `timeout`.
289///
290/// Like the blocking helpers, the command reads stdin from `/dev/null` and
291/// runs in a process group of its own, and that whole group is SIGKILLed when
292/// the call expires -- and equally when the returned future is dropped before
293/// it finishes (an outer timeout, a `select!` that went the other way, an
294/// aborted task, a cancelled request). A wrapper CLI's grandchild (podman's
295/// build container, a `sudo` shim) dies with it instead of holding the output
296/// pipes, or a stalled registry download, for the life of the process. stdout
297/// and stderr are captured.
298pub async fn output_within(
299 cmd: &mut tokio::process::Command,
300 timeout: std::time::Duration,
301) -> Result<std::process::Output, CliRunError> {
302 cmd.stdin(std::process::Stdio::null())
303 .stdout(std::process::Stdio::piped())
304 .stderr(std::process::Stdio::piped())
305 .kill_on_drop(true);
306 #[cfg(unix)]
307 cmd.process_group(0);
308 let mut child = cmd.spawn().map_err(CliRunError::Spawn)?;
309 // Declared after `child`, so on a drop mid-call it runs first: the group
310 // is signalled while its leader is still unreaped, so the group id (the
311 // child's pid) can't have been recycled. `kill_on_drop` then takes care
312 // of the child itself.
313 let mut group = GroupKill(child.id());
314 let stdout = child.stdout.take();
315 let stderr = child.stderr.take();
316 // Drain both pipes while waiting, as `wait_with_output` does: a child whose
317 // output outgrows the pipe buffer would otherwise block on write and look
318 // wedged. The child stays owned here so the timeout path can still signal
319 // its group before it is reaped.
320 let run = async {
321 let (status, out, err) = tokio::join!(child.wait(), drain(stdout), drain(stderr));
322 Ok(std::process::Output {
323 status: status.map_err(CliRunError::Wait)?,
324 stdout: out.map_err(CliRunError::Wait)?,
325 stderr: err.map_err(CliRunError::Wait)?,
326 })
327 };
328 let finished = tokio::time::timeout(timeout, run).await;
329 match finished {
330 Ok(out) => {
331 // Ran to completion: whatever is left in its group is not ours to
332 // kill (a CLI may legitimately leave a detached helper behind).
333 group.disarm();
334 out
335 }
336 Err(_) => {
337 group.fire();
338 let _ = child.start_kill();
339 let _ = child.wait().await;
340 Err(CliRunError::TimedOut(timeout))
341 }
342 }
343}
344
345/// SIGKILLs the process group led by the held pid when dropped (or fired),
346/// unless disarmed: the group id is the child's pid because
347/// [`output_within`] makes it a group leader, so this reaches the
348/// grandchildren a direct kill of the child can't.
349struct GroupKill(Option<u32>);
350
351impl GroupKill {
352 fn disarm(&mut self) {
353 self.0 = None;
354 }
355
356 fn fire(&mut self) {
357 #[cfg(unix)]
358 if let Some(pid) = self.0.take() {
359 // SAFETY: `kill` with a negative pid targets that process group;
360 // any value is safe to pass.
361 let _ = unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) };
362 }
363 self.0 = None;
364 }
365}
366
367impl Drop for GroupKill {
368 fn drop(&mut self) {
369 self.fire();
370 }
371}
372
373async fn drain<R: tokio::io::AsyncRead + Unpin>(pipe: Option<R>) -> std::io::Result<Vec<u8>> {
374 let mut buf = Vec::new();
375 if let Some(mut pipe) = pipe {
376 tokio::io::AsyncReadExt::read_to_end(&mut pipe, &mut buf).await?;
377 }
378 Ok(buf)
379}
380
381/// True if the given PID is a live process on this host.
382///
383/// On Unix this is `kill(pid, 0)`: it returns 0 if the process exists
384/// (including zombies), or sets `errno` to `ESRCH` if not. On non-Unix
385/// platforms it conservatively returns `true`, so a caller never removes a
386/// resource it can't prove is orphaned.
387#[cfg(unix)]
388pub fn pid_alive(pid: u32) -> bool {
389 // SAFETY: `kill` with signal 0 is a liveness probe; it does not
390 // actually deliver a signal. Any PID value is safe to pass.
391 let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
392 if rc == 0 {
393 return true;
394 }
395 // errno == EPERM means the process exists but we can't signal it —
396 // still alive from our perspective.
397 std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
398}
399
400#[cfg(not(unix))]
401pub fn pid_alive(_pid: u32) -> bool {
402 true
403}
404
405/// Whether a container or network labelled `fakecloud-instance=<label>` was
406/// left behind by a fakecloud process that is gone. The label is
407/// `fakecloud-<pid>`; an object is orphaned only when that PID is neither the
408/// current process nor alive. Several fakecloud processes can share one
409/// daemon (parallel test servers, side-by-side installs), so an object owned
410/// by *another live* process is never an orphan. A label that doesn't parse is
411/// not treated as an orphan either -- nothing proves its owner is gone.
412pub fn owned_by_dead_process(label: &str, is_alive: impl Fn(u32) -> bool) -> bool {
413 let Some(pid) = label
414 .strip_prefix("fakecloud-")
415 .and_then(|p| p.parse::<u32>().ok())
416 else {
417 return false;
418 };
419 pid != std::process::id() && !is_alive(pid)
420}
421
422/// Which container engine a CLI actually drives. Decides the podman-only
423/// code paths: `host.containers.internal` without `--add-host`, and
424/// `--tls-verify=false` when pulling from fakecloud's plain-HTTP registry.
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum ContainerEngine {
427 Docker,
428 Podman,
429}
430
431/// Process-global memo of [`is_podman`] results, keyed by CLI name/path. The
432/// engine behind a CLI is fixed for the life of the process, and the probe
433/// runs a subprocess, so every runtime constructor and image pull after the
434/// first reads the answer from here.
435static PODMAN_CACHE: std::sync::OnceLock<
436 std::sync::Mutex<std::collections::HashMap<String, bool>>,
437> = std::sync::OnceLock::new();
438
439fn podman_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, bool>> {
440 PODMAN_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
441}
442
443/// True when `cli` drives podman -- including podman installed *as* `docker`.
444///
445/// The file name alone can't answer that: the `podman-docker` package
446/// (Fedora, RHEL, CentOS Stream, ...) ships a `docker` shim that execs podman,
447/// and a user may point `FAKECLOUD_CONTAINER_CLI` at any wrapper. So:
448///
449/// 1. fast path: a name containing `podman` (`podman`, `podman-remote`,
450/// `/opt/homebrew/bin/podman`) is podman without running anything;
451/// 2. otherwise ask the CLI: `<cli> --version` prints `podman version X.Y.Z`
452/// through the shim and `Docker version X.Y.Z, build ...` for Docker;
453/// 3. when that answers with something neither (a custom wrapper), look for
454/// podman-only fields in `<cli> info`.
455///
456/// Every call is bounded by [`CLI_PROBE_TIMEOUT`] (a wedged CLI costs one
457/// bound, then reads as Docker, the historical default) and the answer is
458/// memoized per CLI. Blocking: from async code use [`is_podman_async`].
459pub fn is_podman(cli: &str) -> bool {
460 if name_indicates_podman(cli) {
461 return true;
462 }
463 if let Some(&cached) = podman_cache().lock().unwrap().get(cli) {
464 return cached;
465 }
466 let podman = probe_engine(cli) == Some(ContainerEngine::Podman);
467 podman_cache()
468 .lock()
469 .unwrap()
470 .insert(cli.to_string(), podman);
471 podman
472}
473
474/// [`is_podman`] for async callers: a cached (or name-matched) answer returns
475/// at once, and a first-time probe runs on the blocking pool so it can't stall
476/// a runtime worker for up to [`CLI_PROBE_TIMEOUT`].
477pub async fn is_podman_async(cli: &str) -> bool {
478 if name_indicates_podman(cli) {
479 return true;
480 }
481 if let Some(&cached) = podman_cache().lock().unwrap().get(cli) {
482 return cached;
483 }
484 let owned = cli.to_string();
485 tokio::task::spawn_blocking(move || is_podman(&owned))
486 .await
487 .unwrap_or(false)
488}
489
490/// The name-only fast path: the file name component contains `podman`, so
491/// absolute paths and `podman-remote` register. Docker's CLI and the
492/// `podman-docker` shim are both named `docker` and fall through to the probe.
493fn name_indicates_podman(cli: &str) -> bool {
494 std::path::Path::new(cli)
495 .file_name()
496 .and_then(|n| n.to_str())
497 .map(|n| n.contains("podman"))
498 .unwrap_or(false)
499}
500
501/// Ask the CLI what it is (uncached). `None` when it can't tell -- the CLI
502/// failed, timed out, or answered with neither engine's markers.
503fn probe_engine(cli: &str) -> Option<ContainerEngine> {
504 // A failed or timed-out `--version` means the CLI isn't answering at
505 // all; don't spend a second bound on `info` against it.
506 let version = bounded_output(cli, &["--version"])?;
507 classify_version_output(&version).or_else(|| {
508 bounded_output(cli, &["info", "--format", "{{json .}}"])
509 .and_then(|info| classify_info_output(&info))
510 })
511}
512
513/// Classify `<cli> --version` output. Podman prints `podman version 5.2.0`
514/// (also through the `podman-docker` shim), Docker `Docker version 27.3.1,
515/// build ce12230`. Podman is checked first: the shim is podman whatever else
516/// the output mentions.
517pub fn classify_version_output(stdout: &str) -> Option<ContainerEngine> {
518 let lower = stdout.to_ascii_lowercase();
519 if lower.contains("podman") {
520 Some(ContainerEngine::Podman)
521 } else if lower.contains("docker") {
522 Some(ContainerEngine::Docker)
523 } else {
524 None
525 }
526}
527
528/// Classify `<cli> info --format '{{json .}}'` output by engine-specific
529/// fields: podman's host section carries `buildahVersion` / `ociRuntime`
530/// (camelCase), Docker's top level `ServerVersion`.
531pub fn classify_info_output(stdout: &str) -> Option<ContainerEngine> {
532 if stdout.contains("\"buildahVersion\"") || stdout.contains("\"ociRuntime\"") {
533 Some(ContainerEngine::Podman)
534 } else if stdout.contains("\"ServerVersion\"") {
535 Some(ContainerEngine::Docker)
536 } else {
537 None
538 }
539}
540
541/// Detect the Docker bridge gateway IP on Linux. Returns `None` if
542/// detection fails (caller falls back to the conventional `172.17.0.1`).
543///
544/// Goes through [`bounded_output`] like every other container-CLI call here:
545/// `network inspect` talks to the same daemon as the liveness probe, so a
546/// wedged one blocks it on connect forever. This runs inside runtime
547/// constructors on Linux, where an unbounded call hangs server startup outright
548/// -- the exact failure [`CLI_PROBE_TIMEOUT`] exists to prevent. On timeout the
549/// caller just takes the conventional fallback.
550pub fn detect_bridge_gateway(cli: &str) -> Option<String> {
551 let stdout = bounded_output(
552 cli,
553 &[
554 "network",
555 "inspect",
556 "bridge",
557 "--format",
558 "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
559 ],
560 )?;
561 let gateway = stdout.trim().to_string();
562 if gateway.is_empty() || !gateway.contains('.') {
563 return None;
564 }
565 Some(gateway)
566}
567
568/// Resolved container-to-host networking for a given CLI. Built once at
569/// runtime construction and reused for every container spawn.
570#[derive(Debug, Clone)]
571pub struct HostNetworking {
572 /// DNS name a spawned container uses to reach fakecloud on the host.
573 /// `host.containers.internal` for podman, `host.docker.internal` for
574 /// docker.
575 pub host_alias: String,
576 /// `<alias>:<value>` argument for `--add-host`, injected into every
577 /// container `create`/`run`. `None` when the runtime provides the
578 /// alias natively (podman).
579 pub add_host_arg: Option<String>,
580 /// Address fakecloud uses to reach the *sibling* containers it just
581 /// spawned (readiness probes + advertised endpoints). `127.0.0.1`
582 /// when fakecloud runs on the host; `host.docker.internal` when
583 /// fakecloud is itself containerized (`FAKECLOUD_IN_CONTAINER=1`).
584 pub sibling_host: String,
585}
586
587impl HostNetworking {
588 /// Resolve networking for `cli`, reading `FAKECLOUD_IN_CONTAINER` from
589 /// the process environment.
590 pub fn detect(cli: &str) -> Self {
591 let (host_alias, mut add_host_arg) = resolve_host_alias(cli);
592 // A resolving `host.docker.internal` is only trustworthy evidence that
593 // the runtime provides the alias natively (and will inject it into
594 // sibling containers too) when fakecloud is itself containerized:
595 // Docker-Desktop-class runtimes inject the alias into CONTAINERS, never
596 // onto the host. On a bare native-Linux host a resolving alias is
597 // spurious (a hijacking NXDOMAIN resolver, a stray /etc/hosts entry, or
598 // a wildcard search domain), so suppressing the bridge --add-host there
599 // would break the host route sibling containers need. Gate the
600 // suppression on the in-container signal to avoid that regression.
601 let in_container = in_container_mode(std::env::var("FAKECLOUD_IN_CONTAINER").ok());
602 add_host_arg = preserve_native_host_alias(
603 add_host_arg,
604 in_container && host_alias_resolves(&host_alias),
605 );
606 let sibling_host =
607 resolve_sibling_host(&host_alias, std::env::var("FAKECLOUD_IN_CONTAINER").ok());
608 Self {
609 host_alias,
610 add_host_arg,
611 sibling_host,
612 }
613 }
614
615 /// Convenience: append the `--add-host <alias>:<value>` flag pair to a
616 /// growing argv vector when this runtime needs an explicit mapping.
617 /// No-op for podman.
618 pub fn push_add_host_args(&self, argv: &mut Vec<String>) {
619 if let Some(arg) = &self.add_host_arg {
620 argv.push("--add-host".to_string());
621 argv.push(arg.clone());
622 }
623 }
624}
625
626/// How long to wait for the blocking `getaddrinfo` in [`host_alias_resolves`]
627/// before giving up and returning `false`. `getaddrinfo` has no timeout of its
628/// own, and a slow or unreachable DNS server would otherwise block a runtime
629/// thread at startup (this runs inside runtime constructors under
630/// `#[tokio::main]`). Bounding it — same tradeoff as [`CLI_PROBE_TIMEOUT`] —
631/// turns "DNS wedged" into "alias doesn't resolve", the safe default that keeps
632/// the `--add-host` bridge mapping.
633pub const HOST_ALIAS_RESOLVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
634
635/// True when `host_alias` resolves via the process resolver. The `getaddrinfo`
636/// call is blocking with no timeout of its own, so it runs on a spawned thread
637/// bounded by [`HOST_ALIAS_RESOLVE_TIMEOUT`]; on timeout we return `false` (the
638/// safe default that keeps `--add-host`). A leaked resolver thread on timeout
639/// is acceptable — same tradeoff as [`probe_cli`].
640fn host_alias_resolves(host_alias: &str) -> bool {
641 let (tx, rx) = std::sync::mpsc::channel();
642 let alias = host_alias.to_string();
643 std::thread::spawn(move || {
644 let resolves = std::net::ToSocketAddrs::to_socket_addrs(&(alias.as_str(), 0)).is_ok();
645 let _ = tx.send(resolves);
646 });
647 rx.recv_timeout(HOST_ALIAS_RESOLVE_TIMEOUT).unwrap_or(false)
648}
649
650fn preserve_native_host_alias(
651 add_host_arg: Option<String>,
652 should_suppress: bool,
653) -> Option<String> {
654 if add_host_arg.is_some() && should_suppress {
655 // Suppress the injected `--add-host host.docker.internal:<vm-bridge-ip>`
656 // only when fakecloud is containerized AND the alias already resolves
657 // (see the gate in `detect`). In that case a Docker-Desktop-class
658 // runtime provides `host.docker.internal` natively inside every sibling
659 // container, pointing at the real host; injecting the VM bridge-gateway
660 // IP would shadow it and break the host route. On a bare host — where a
661 // hijacking resolver can make the alias resolve spuriously — the caller
662 // passes `false` here so native Linux docker keeps the bridge mapping
663 // it genuinely needs.
664 None
665 } else {
666 add_host_arg
667 }
668}
669
670/// Compute the `(host_alias, add_host_arg)` pair for a CLI. Pure except
671/// for the bridge-gateway daemon probe on Linux docker, so the macOS /
672/// podman branches are unit-testable without a daemon.
673pub fn resolve_host_alias(cli: &str) -> (String, Option<String>) {
674 if is_podman(cli) {
675 // Podman provides `host.containers.internal` natively on every
676 // supported platform; injecting `host-gateway` on macOS fails
677 // because rootless podman's gvproxy doesn't expose the magic alias.
678 ("host.containers.internal".to_string(), None)
679 } else if cfg!(target_os = "linux") {
680 // Bare docker on Linux: resolve the bridge gateway IP and add an
681 // explicit alias. `host.docker.internal:host-gateway` only works
682 // on Docker Desktop; native Linux docker has no such magic.
683 let ip = detect_bridge_gateway(cli).unwrap_or_else(|| "172.17.0.1".to_string());
684 (
685 "host.docker.internal".to_string(),
686 Some(format!("host.docker.internal:{ip}")),
687 )
688 } else {
689 // Docker Desktop on Mac/Windows: `host-gateway` is the magic alias
690 // that resolves to the host's IP.
691 (
692 "host.docker.internal".to_string(),
693 Some("host.docker.internal:host-gateway".to_string()),
694 )
695 }
696}
697
698/// Decide what address fakecloud uses to reach the sibling containers it
699/// just spawned. Pure helper so the env-var parsing can be tested without
700/// touching the process's real environment.
701///
702/// - `Some("1")` / `Some("true")` (case-insensitive) -> fakecloud is in a
703/// container; the siblings publish their ports on the host's daemon and
704/// are reachable at the same host alias the spawned containers use to
705/// reach fakecloud — `host.docker.internal` under docker,
706/// `host.containers.internal` under podman. Hardcoding
707/// `host.docker.internal` here broke podman, whose gvproxy network only
708/// resolves `host.containers.internal` (issue #1539 follow-up).
709/// - anything else, including `None` -> fakecloud runs on the host,
710/// siblings live on `127.0.0.1:<port>`.
711pub fn resolve_sibling_host(host_alias: &str, env_value: Option<String>) -> String {
712 if in_container_mode(env_value) {
713 host_alias.to_string()
714 } else {
715 "127.0.0.1".to_string()
716 }
717}
718
719/// Parse the `FAKECLOUD_IN_CONTAINER` signal: `Some("1")` or a case-insensitive
720/// `Some("true")` mean fakecloud is running inside a container; anything else,
721/// including `None`, means it runs on the host. Single source of truth for the
722/// parse so `detect`'s native-alias gate and `resolve_sibling_host` can't drift.
723fn in_container_mode(env_value: Option<String>) -> bool {
724 env_value
725 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
726 .unwrap_or(false)
727}
728
729/// Hostnames fakecloud's bundled ECR/OCI registry can be addressed from a
730/// sibling container or the host, each at `server_port`.
731///
732/// A container-spawning service rewrites the image pull URI to the runtime's
733/// sibling host -- `host.docker.internal` under Docker, `host.containers.internal`
734/// under podman -- or leaves it `localhost` / `127.0.0.1` when fakecloud runs on
735/// the host (`localhost:<port>` is the documented local ECR endpoint, e.g.
736/// `localhost:4566`). The registry enforces auth, and the Docker/Podman CLI only
737/// attaches the `Authorization` header for hosts present in `config.json`, so the
738/// isolated pull config must list *every* alias or the pull gets a 401. The map
739/// previously omitted the podman alias, so image-based Lambda/ECS pulls failed
740/// under podman-in-a-container (bug-audit 2026-06-20, 0.B2). Authorize all of
741/// them with the same credential; centralized here so the two builders can't
742/// drift again.
743///
744/// A `FAKECLOUD_ECR_REGISTRY_HOST` override is the host the pull URI is
745/// rewritten to (see [`ecr_registry_host`]), so it is authorized as well.
746pub fn registry_auth_hosts(server_port: u16) -> Vec<String> {
747 registry_auth_hosts_with(
748 server_port,
749 std::env::var("FAKECLOUD_ECR_REGISTRY_HOST").ok(),
750 )
751}
752
753/// Pure half of [`registry_auth_hosts`]: the built-in aliases plus a
754/// non-blank registry-host override.
755pub fn registry_auth_hosts_with(server_port: u16, override_host: Option<String>) -> Vec<String> {
756 let mut hosts: Vec<String> = [
757 "localhost",
758 "127.0.0.1",
759 "host.docker.internal",
760 "host.containers.internal",
761 ]
762 .iter()
763 .map(|h| h.to_string())
764 .collect();
765 if let Some(h) = override_host
766 .map(|v| v.trim().to_string())
767 .filter(|v| !v.is_empty())
768 {
769 if !hosts.contains(&h) {
770 hosts.push(h);
771 }
772 }
773 hosts
774 .iter()
775 .map(|host| format!("{host}:{server_port}"))
776 .collect()
777}
778
779/// Host the container engine pulls fakecloud-ECR images from.
780///
781/// The pull is performed by the engine on the *host*, not by a sibling
782/// container, so the sibling host alias is the wrong address for it: Docker
783/// Desktop and OrbStack only accept a plain-HTTP registry over loopback, and
784/// `host.docker.internal` does not resolve on a Linux host at all. Defaults to
785/// `127.0.0.1`. Podman on macOS / Windows pulls inside its machine VM, where
786/// loopback is the VM and only `host.containers.internal` reaches the host.
787/// `FAKECLOUD_ECR_REGISTRY_HOST` overrides both (e.g. when a containerized
788/// fakecloud's port is published under another address).
789pub fn ecr_registry_host(cli: &str) -> String {
790 resolve_ecr_registry_host(
791 std::env::var("FAKECLOUD_ECR_REGISTRY_HOST").ok(),
792 is_podman(cli),
793 cfg!(target_os = "linux"),
794 )
795}
796
797/// Pure half of [`ecr_registry_host`].
798pub fn resolve_ecr_registry_host(env_value: Option<String>, podman: bool, linux: bool) -> String {
799 if let Some(v) = env_value
800 .map(|v| v.trim().to_string())
801 .filter(|v| !v.is_empty())
802 {
803 return v;
804 }
805 if podman && !linux {
806 "host.containers.internal".to_string()
807 } else {
808 "127.0.0.1".to_string()
809 }
810}
811
812/// Host port from `<cli> port <container> <port>` output. Docker prints one
813/// `<ip>:<port>` line per bound address family (`0.0.0.0:49153`,
814/// `[::]:49153`); podman prints the same shape. The first parseable port wins.
815pub fn parse_published_port(output: &str) -> Option<u16> {
816 output
817 .lines()
818 .filter_map(|l| l.trim().rsplit(':').next())
819 .find_map(|p| p.parse::<u16>().ok())
820}
821
822const LOOPBACK_NAMES: [&str; 2] = ["127.0.0.1", "localhost"];
823
824/// Rewrite loopback references in an environment value a sibling container
825/// will read, so they reach the host instead of the container itself.
826///
827/// Inside a container `127.0.0.1` / `localhost` is the container, so every
828/// endpoint fakecloud hands out on loopback -- the server URL, an RDS or
829/// ElastiCache endpoint address, an MSK bootstrap string -- has to name
830/// `target_host` (the host alias) instead. Rewritten:
831///
832/// - the whole value being a loopback host (`DB_HOST=127.0.0.1`);
833/// - a loopback host followed by `:<port>`, at a token boundary -- in URLs
834/// with or without userinfo (`postgres://u:p@localhost:5432/db`), bare
835/// `host:port` values and comma-separated lists
836/// (`127.0.0.1:9092,127.0.0.1:9094`);
837/// - a URL host without a port (`http://localhost/path`).
838///
839/// A port a service registered with
840/// [`crate::dataplane::register_container_port`] is swapped for its
841/// container-view port. Prose such as `EHLO localhost`, and names that merely
842/// contain a loopback name (`127.0.0.10`, `localhost.localdomain`), are left
843/// alone. When `target_host` is itself loopback (fakecloud and the workload
844/// share a network namespace) the value is returned unchanged.
845pub fn rewrite_loopback_value(value: &str, target_host: &str) -> String {
846 if LOOPBACK_NAMES.contains(&target_host) {
847 return value.to_string();
848 }
849 if LOOPBACK_NAMES.contains(&value.trim()) {
850 return value.replacen(value.trim(), target_host, 1);
851 }
852 let bytes = value.as_bytes();
853 let mut out = String::with_capacity(value.len());
854 let mut i = 0;
855 while i < value.len() {
856 let hit = LOOPBACK_NAMES
857 .iter()
858 .find(|name| value[i..].starts_with(**name) && loopback_at(value, i, name.len()));
859 let Some(name) = hit else {
860 let ch = value[i..].chars().next().unwrap_or_default();
861 out.push(ch);
862 i += ch.len_utf8().max(1);
863 continue;
864 };
865 out.push_str(target_host);
866 i += name.len();
867 // Swap a registered host port for its container-view port.
868 if bytes.get(i) == Some(&b':') {
869 let digits: String = value[i + 1..]
870 .chars()
871 .take_while(|c| c.is_ascii_digit())
872 .collect();
873 if let Some(mapped) = digits
874 .parse::<u16>()
875 .ok()
876 .and_then(crate::dataplane::container_port_for)
877 {
878 out.push(':');
879 out.push_str(&mapped.to_string());
880 i += 1 + digits.len();
881 }
882 }
883 }
884 out
885}
886
887/// Whether the loopback name at `value[i..i + len]` is a host reference: at a
888/// token boundary and followed by `:<digit>`, or a URL host (after `//` or
889/// `@`) followed by the end of the authority.
890fn loopback_at(value: &str, i: usize, len: usize) -> bool {
891 let before = value[..i].chars().next_back();
892 let boundary_before = match before {
893 None => true,
894 Some(c) => matches!(c, '/' | '@' | ',' | '=' | ';' | '(' | '"' | '\'') || c.is_whitespace(),
895 };
896 if !boundary_before {
897 return false;
898 }
899 let rest = &value[i + len..];
900 let mut after = rest.chars();
901 match after.next() {
902 Some(':') => after.next().is_some_and(|c| c.is_ascii_digit()),
903 next => {
904 let url_host = matches!(before, Some('@')) || value[..i].ends_with("//");
905 url_host && matches!(next, None | Some('/') | Some('?') | Some('#'))
906 }
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913
914 #[test]
915 fn cli_available_false_for_missing_binary() {
916 // A binary that doesn't exist fails to spawn -> unavailable, fast.
917 assert!(!cli_available("definitely-not-a-real-cli-binary-xyz-123"));
918 }
919
920 #[cfg(unix)]
921 #[test]
922 fn cli_available_bounds_a_hanging_probe() {
923 // A CLI whose `info` invocation blocks forever (like `docker info`
924 // against an unreachable daemon) must not hang the caller: the probe
925 // is killed at CLI_PROBE_TIMEOUT and reported unavailable. Regression
926 // test for the local-conformance-probe hang.
927 let (_dir, script) = fake_cli("hangcli", "[ \"$1\" = warmup ] && exit 0\nsleep 600\n");
928
929 let start = std::time::Instant::now();
930 let available = cli_available(&script);
931 let elapsed = start.elapsed();
932
933 assert!(!available, "a hanging probe must report unavailable");
934 assert!(
935 elapsed < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
936 "probe took {elapsed:?}, expected it bounded near {CLI_PROBE_TIMEOUT:?}"
937 );
938 }
939
940 #[test]
941 fn registry_auth_hosts_includes_podman_alias() {
942 // The podman sibling alias (host.containers.internal) must be authorized
943 // or image-based Lambda/ECS pulls 401 under podman-in-a-container (0.B2).
944 let hosts = registry_auth_hosts(4566);
945 assert!(hosts.contains(&"localhost:4566".to_string()));
946 assert!(hosts.contains(&"127.0.0.1:4566".to_string()));
947 assert!(hosts.contains(&"host.docker.internal:4566".to_string()));
948 assert!(
949 hosts.contains(&"host.containers.internal:4566".to_string()),
950 "podman sibling alias must be authorized: {hosts:?}"
951 );
952 }
953
954 #[test]
955 fn name_fast_path_matches_podman_names() {
956 assert!(name_indicates_podman("podman"));
957 assert!(name_indicates_podman("podman-remote"));
958 assert!(name_indicates_podman("/opt/homebrew/bin/podman"));
959 assert!(name_indicates_podman("/usr/local/bin/podman-remote"));
960 assert!(!name_indicates_podman("docker"));
961 assert!(!name_indicates_podman("/usr/local/bin/docker"));
962 assert!(!name_indicates_podman("docker-credential-helper"));
963 }
964
965 #[test]
966 fn name_fast_path_needs_no_probe() {
967 // No binary by this name exists: a podman-named CLI is podman without
968 // running anything.
969 assert!(is_podman("/nonexistent-dir-fc-2599/podman"));
970 assert!(is_podman("podman-remote-definitely-missing-xyz"));
971 }
972
973 #[test]
974 fn version_output_classifies_the_engine() {
975 assert_eq!(
976 classify_version_output("podman version 5.2.0\n"),
977 Some(ContainerEngine::Podman)
978 );
979 assert_eq!(
980 classify_version_output("Docker version 27.3.1, build ce12230\n"),
981 Some(ContainerEngine::Docker)
982 );
983 // The podman-docker shim's notice mentions Docker; it is still podman.
984 assert_eq!(
985 classify_version_output(
986 "Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.\npodman version 4.9.4\n"
987 ),
988 Some(ContainerEngine::Podman)
989 );
990 assert_eq!(classify_version_output("my-wrapper 1.0\n"), None);
991 assert_eq!(classify_version_output(""), None);
992 }
993
994 #[test]
995 fn info_output_classifies_the_engine() {
996 assert_eq!(
997 classify_info_output(
998 r#"{"host":{"buildahVersion":"1.37.0","ociRuntime":{"name":"crun"}}}"#
999 ),
1000 Some(ContainerEngine::Podman)
1001 );
1002 assert_eq!(
1003 classify_info_output(r#"{"ID":"abc","ServerVersion":"27.3.1","Driver":"overlay2"}"#),
1004 Some(ContainerEngine::Docker)
1005 );
1006 assert_eq!(classify_info_output("{}"), None);
1007 }
1008
1009 #[cfg(unix)]
1010 use crate::test_support::fake_cli;
1011
1012 #[cfg(unix)]
1013 #[test]
1014 fn podman_docker_shim_is_detected_as_podman() {
1015 // What `podman-docker` installs: a `docker` that is podman.
1016 let (_dir, cli) = fake_cli(
1017 "docker",
1018 "[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
1019 );
1020 assert!(is_podman(&cli));
1021 assert_eq!(probe_engine(&cli), Some(ContainerEngine::Podman));
1022 }
1023
1024 #[cfg(unix)]
1025 #[test]
1026 fn real_docker_cli_is_not_podman() {
1027 let (_dir, cli) = fake_cli(
1028 "docker",
1029 "[ \"$1\" = --version ] && { echo 'Docker version 27.3.1, build ce12230'; exit 0; }\nexit 1\n",
1030 );
1031 assert!(!is_podman(&cli));
1032 }
1033
1034 #[cfg(unix)]
1035 #[test]
1036 fn wrapper_with_opaque_version_falls_back_to_info() {
1037 let (_dir, cli) = fake_cli(
1038 "container-cli",
1039 "case \"$1\" in\n --version) echo 'wrapper 1.0' ;;\n info) echo '{\"host\":{\"buildahVersion\":\"1.37.0\"}}' ;;\n *) exit 1 ;;\nesac\n",
1040 );
1041 assert!(is_podman(&cli));
1042 }
1043
1044 #[cfg(unix)]
1045 #[test]
1046 fn engine_probe_is_cached_per_cli() {
1047 // Each run appends to a log; a second lookup must not run the CLI again.
1048 let (dir, cli) = fake_cli(
1049 "docker",
1050 "echo \"$*\" >> \"$(dirname \"$0\")/calls.log\"\n[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
1051 );
1052 let log = dir.path().join("calls.log");
1053 let _ = std::fs::remove_file(&log);
1054 assert!(is_podman(&cli));
1055 assert!(is_podman(&cli));
1056 let calls = std::fs::read_to_string(&log).unwrap_or_default();
1057 assert_eq!(calls.lines().collect::<Vec<_>>(), ["--version"]);
1058 }
1059
1060 #[cfg(unix)]
1061 #[tokio::test]
1062 async fn async_probe_detects_the_shim() {
1063 let (_dir, cli) = fake_cli(
1064 "docker",
1065 "[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
1066 );
1067 assert!(is_podman_async(&cli).await);
1068 }
1069
1070 #[cfg(unix)]
1071 #[test]
1072 fn engine_probe_bounds_a_hanging_cli() {
1073 // A CLI that never answers must not hang detection: one bounded call,
1074 // no `info` fallback against it, and it reads as Docker.
1075 // Only the probe's commands hang: `fake_cli`'s warmup run (no such
1076 // argument) must return at once, or it eats the test's time budget.
1077 let (_dir, cli) = fake_cli(
1078 "docker",
1079 "case \"$1\" in --version|info) sleep 600 ;; esac\nexit 1\n",
1080 );
1081 let start = std::time::Instant::now();
1082 assert!(!is_podman(&cli));
1083 let elapsed = start.elapsed();
1084 assert!(
1085 elapsed < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
1086 "probe took {elapsed:?}, expected one bound near {CLI_PROBE_TIMEOUT:?}"
1087 );
1088 }
1089
1090 #[test]
1091 fn missing_cli_is_not_podman() {
1092 // `FAKECLOUD_CONTAINER_CLI=false`-style sentinels and missing binaries
1093 // read as not-podman without error.
1094 assert!(!is_podman("definitely-not-a-real-cli-binary-fc-2599"));
1095 assert!(!is_podman("false"));
1096 }
1097
1098 #[cfg(unix)]
1099 #[test]
1100 fn resolve_host_alias_treats_the_shim_as_podman() {
1101 let (_dir, cli) = fake_cli(
1102 "docker",
1103 "[ \"$1\" = --version ] && { echo 'podman version 5.2.0'; exit 0; }\nexit 1\n",
1104 );
1105 let (alias, add_host) = resolve_host_alias(&cli);
1106 assert_eq!(alias, "host.containers.internal");
1107 assert_eq!(add_host, None);
1108 }
1109
1110 #[test]
1111 fn resolve_host_alias_podman_has_no_add_host() {
1112 let (alias, add_host) = resolve_host_alias("podman");
1113 assert_eq!(alias, "host.containers.internal");
1114 assert_eq!(add_host, None);
1115 let (alias, add_host) = resolve_host_alias("/opt/homebrew/bin/podman");
1116 assert_eq!(alias, "host.containers.internal");
1117 assert_eq!(add_host, None);
1118 }
1119
1120 #[test]
1121 #[cfg(unix)]
1122 fn resolve_host_alias_docker_emits_add_host() {
1123 // A fake Docker CLI, so a host whose `docker` is the podman-docker shim
1124 // can't flip this test.
1125 let (_dir, cli) = fake_cli(
1126 "docker",
1127 "[ \"$1\" = --version ] && { echo 'Docker version 27.3.1, build ce12230'; exit 0; }\nexit 1\n",
1128 );
1129 let (alias, add_host) = resolve_host_alias(&cli);
1130 assert_eq!(alias, "host.docker.internal");
1131 // On macOS this is host-gateway; on Linux it's a bridge IP. Either
1132 // way docker must get an explicit --add-host.
1133 assert!(add_host.is_some());
1134 assert!(add_host.unwrap().starts_with("host.docker.internal:"));
1135 }
1136
1137 #[test]
1138 fn native_host_alias_prevents_docker_add_host_override() {
1139 let add_host =
1140 preserve_native_host_alias(Some("host.docker.internal:host-gateway".to_string()), true);
1141
1142 assert_eq!(add_host, None);
1143 }
1144
1145 #[test]
1146 fn unresolved_host_alias_keeps_docker_add_host() {
1147 let add_host = preserve_native_host_alias(
1148 Some("host.docker.internal:host-gateway".to_string()),
1149 false,
1150 );
1151
1152 assert_eq!(
1153 add_host.as_deref(),
1154 Some("host.docker.internal:host-gateway")
1155 );
1156 }
1157
1158 #[test]
1159 fn absent_docker_add_host_remains_absent() {
1160 assert_eq!(preserve_native_host_alias(None, true), None);
1161 assert_eq!(preserve_native_host_alias(None, false), None);
1162 }
1163
1164 #[test]
1165 fn in_container_mode_parses_truthy_values() {
1166 assert!(in_container_mode(Some("1".to_string())));
1167 assert!(in_container_mode(Some("true".to_string())));
1168 assert!(in_container_mode(Some("True".to_string())));
1169 assert!(in_container_mode(Some("TRUE".to_string())));
1170 }
1171
1172 #[test]
1173 fn in_container_mode_rejects_falsey_and_absent() {
1174 assert!(!in_container_mode(None));
1175 assert!(!in_container_mode(Some(String::new())));
1176 assert!(!in_container_mode(Some("0".to_string())));
1177 assert!(!in_container_mode(Some("false".to_string())));
1178 assert!(!in_container_mode(Some("yes".to_string())));
1179 }
1180
1181 #[test]
1182 fn native_alias_gate_suppresses_only_in_container() {
1183 // The gate `detect` computes: `in_container && host_alias_resolves`.
1184 let add_host = || Some("host.docker.internal:172.17.0.1".to_string());
1185
1186 // In-container + resolves -> Desktop-class runtime provides the alias
1187 // natively in siblings; drop the shadowing bridge mapping.
1188 let in_container = true;
1189 let resolves = true;
1190 assert_eq!(
1191 preserve_native_host_alias(add_host(), in_container && resolves),
1192 None,
1193 );
1194
1195 // NOT in-container (bare host) + resolves -> the resolving alias is
1196 // spurious (hijacking resolver / stray hosts entry). Native Linux docker
1197 // needs the bridge mapping; must NOT drop it. Regression guard.
1198 let in_container = false;
1199 let resolves = true;
1200 assert_eq!(
1201 preserve_native_host_alias(add_host(), in_container && resolves).as_deref(),
1202 Some("host.docker.internal:172.17.0.1"),
1203 );
1204
1205 // In-container + does NOT resolve -> nothing native to preserve; keep
1206 // the injected mapping.
1207 let in_container = true;
1208 let resolves = false;
1209 assert_eq!(
1210 preserve_native_host_alias(add_host(), in_container && resolves).as_deref(),
1211 Some("host.docker.internal:172.17.0.1"),
1212 );
1213 }
1214
1215 #[test]
1216 fn resolve_sibling_host_defaults_to_loopback() {
1217 assert_eq!(
1218 resolve_sibling_host("host.docker.internal", None),
1219 "127.0.0.1"
1220 );
1221 assert_eq!(
1222 resolve_sibling_host("host.docker.internal", Some(String::new())),
1223 "127.0.0.1"
1224 );
1225 assert_eq!(
1226 resolve_sibling_host("host.docker.internal", Some("0".to_string())),
1227 "127.0.0.1"
1228 );
1229 assert_eq!(
1230 resolve_sibling_host("host.containers.internal", Some("false".to_string())),
1231 "127.0.0.1"
1232 );
1233 }
1234
1235 #[test]
1236 fn resolve_sibling_host_uses_host_alias_when_in_container() {
1237 // Docker: siblings reachable at host.docker.internal.
1238 assert_eq!(
1239 resolve_sibling_host("host.docker.internal", Some("1".to_string())),
1240 "host.docker.internal"
1241 );
1242 assert_eq!(
1243 resolve_sibling_host("host.docker.internal", Some("true".to_string())),
1244 "host.docker.internal"
1245 );
1246 assert_eq!(
1247 resolve_sibling_host("host.docker.internal", Some("TRUE".to_string())),
1248 "host.docker.internal"
1249 );
1250 // Podman: must use host.containers.internal, NOT host.docker.internal
1251 // (issue #1539 follow-up — gvproxy only resolves the containers alias).
1252 assert_eq!(
1253 resolve_sibling_host("host.containers.internal", Some("1".to_string())),
1254 "host.containers.internal"
1255 );
1256 }
1257
1258 #[test]
1259 fn detect_wires_sibling_host_to_podman_alias_in_container() {
1260 // Full path: a podman binary in a container must advertise siblings
1261 // at host.containers.internal. resolve_host_alias drives host_alias,
1262 // which resolve_sibling_host then reuses.
1263 let (alias, add_host) = resolve_host_alias("podman");
1264 assert_eq!(alias, "host.containers.internal");
1265 assert_eq!(add_host, None);
1266 assert_eq!(
1267 resolve_sibling_host(&alias, Some("1".to_string())),
1268 "host.containers.internal"
1269 );
1270 }
1271
1272 #[test]
1273 fn only_objects_of_a_dead_owner_are_orphans() {
1274 let me = std::process::id();
1275 let alive = |pid: u32| pid == 4242;
1276 // Another live fakecloud process: never an orphan.
1277 assert!(!owned_by_dead_process("fakecloud-4242", alive));
1278 // Its owner is gone: an orphan.
1279 assert!(owned_by_dead_process("fakecloud-777", alive));
1280 // The current process, even if the probe says otherwise.
1281 assert!(!owned_by_dead_process(&format!("fakecloud-{me}"), |_| {
1282 false
1283 }));
1284 // Nothing proves an unparseable owner is gone.
1285 for label in ["", "fakecloud-", "fakecloud-abc", "other-777"] {
1286 assert!(!owned_by_dead_process(label, alive), "{label:?}");
1287 }
1288 }
1289
1290 #[cfg(unix)]
1291 #[test]
1292 fn pid_alive_probes_real_processes() {
1293 assert!(pid_alive(std::process::id()));
1294 assert!(!pid_alive(u32::MAX - 1));
1295 }
1296
1297 #[test]
1298 fn push_add_host_args_noop_for_podman() {
1299 let net = HostNetworking {
1300 host_alias: "host.containers.internal".to_string(),
1301 add_host_arg: None,
1302 sibling_host: "127.0.0.1".to_string(),
1303 };
1304 let mut argv = vec!["create".to_string()];
1305 net.push_add_host_args(&mut argv);
1306 assert_eq!(argv, vec!["create".to_string()]);
1307 }
1308
1309 #[test]
1310 fn push_add_host_args_emits_for_docker() {
1311 let net = HostNetworking {
1312 host_alias: "host.docker.internal".to_string(),
1313 add_host_arg: Some("host.docker.internal:host-gateway".to_string()),
1314 sibling_host: "127.0.0.1".to_string(),
1315 };
1316 let mut argv = vec!["create".to_string()];
1317 net.push_add_host_args(&mut argv);
1318 assert_eq!(
1319 argv,
1320 vec![
1321 "create".to_string(),
1322 "--add-host".to_string(),
1323 "host.docker.internal:host-gateway".to_string(),
1324 ]
1325 );
1326 }
1327}
1328
1329#[cfg(test)]
1330mod bounded_cli_tests {
1331 use super::*;
1332 #[cfg(unix)]
1333 use crate::test_support::fake_cli;
1334
1335 /// A wedged daemon leaves the CLI blocked on connect forever. Every
1336 /// container call has to end at the bound instead of hanging its caller,
1337 /// which for the reaper means hanging server startup.
1338 #[test]
1339 fn a_hanging_cli_call_is_cut_off() {
1340 let start = std::time::Instant::now();
1341 let mut child = spawn_bounded(
1342 std::process::Command::new("sleep")
1343 .arg("600")
1344 .stdout(std::process::Stdio::null())
1345 .stderr(std::process::Stdio::null()),
1346 )
1347 .expect("sleep is available");
1348 assert!(!wait_bounded_group(&mut child));
1349 assert!(
1350 start.elapsed() < CLI_PROBE_TIMEOUT + std::time::Duration::from_secs(5),
1351 "the wait must end at the bound"
1352 );
1353 }
1354
1355 /// Output larger than a pipe buffer (64 KiB on Linux) must come back
1356 /// whole. Waiting for the child to exit before reading blocks it on write
1357 /// forever, so this used to burn the full timeout and report failure.
1358 #[test]
1359 fn output_larger_than_the_pipe_buffer_still_comes_back() {
1360 let start = std::time::Instant::now();
1361 // 200_000 bytes: comfortably past the buffer on every supported host.
1362 let out = bounded_output("sh", &["-c", "printf 'x%.0s' $(seq 1 200000)"])
1363 .expect("a large but prompt call must succeed");
1364 assert_eq!(out.len(), 200_000, "output was truncated");
1365 assert!(
1366 start.elapsed() < CLI_PROBE_TIMEOUT,
1367 "a prompt call must not reach the deadline"
1368 );
1369 }
1370
1371 #[test]
1372 fn a_prompt_cli_call_returns_its_output() {
1373 assert_eq!(
1374 bounded_output("echo", &["abc123"])
1375 .as_deref()
1376 .map(str::trim),
1377 Some("abc123")
1378 );
1379 assert!(bounded_status("true", &[]));
1380 assert!(!bounded_status("false", &[]));
1381 }
1382
1383 /// A bounded call gets an empty stdin, never fakecloud's own. The process
1384 /// group `spawn_bounded` creates is a *background* one, so a child that
1385 /// reads the controlling terminal -- a `sudo`/credential-helper wrapper
1386 /// prompting -- is stopped by SIGTTIN, which the WNOHANG `try_wait` loop
1387 /// cannot see: the call would burn the whole deadline and be killed.
1388 /// `cat` with no argument reads stdin to EOF, so it returns at once with
1389 /// nothing only when stdin is /dev/null.
1390 #[cfg(unix)]
1391 #[test]
1392 fn a_bounded_call_reads_an_empty_stdin() {
1393 let start = std::time::Instant::now();
1394 let out = bounded_output("cat", &[]).expect("a call reading stdin must not time out");
1395 assert!(out.is_empty(), "stdin must be empty, got {out:?}");
1396 assert!(
1397 start.elapsed() < CLI_PROBE_TIMEOUT,
1398 "a call reading stdin must not reach the deadline"
1399 );
1400 }
1401
1402 /// A call that finishes in budget hands back its status and both streams,
1403 /// even when its output outgrows a pipe buffer.
1404 #[cfg(unix)]
1405 #[tokio::test]
1406 async fn output_within_returns_a_prompt_calls_output() {
1407 let (_dir, cli) = fake_cli(
1408 "cli",
1409 "head -c 200000 /dev/zero | tr '\\0' x; echo oops >&2; exit 3\n",
1410 );
1411 let out = output_within(
1412 tokio::process::Command::new(&cli).arg("build"),
1413 std::time::Duration::from_secs(20),
1414 )
1415 .await
1416 .expect("a prompt call must not time out");
1417 assert_eq!(out.status.code(), Some(3));
1418 assert_eq!(out.stdout.len(), 200_000);
1419 assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "oops");
1420 }
1421
1422 /// A call that never ends is killed at its budget -- with the grandchild
1423 /// a wrapper CLI started, which would otherwise hold the pipes (and keep
1424 /// the caller waiting) after the direct child died.
1425 #[cfg(unix)]
1426 #[tokio::test]
1427 async fn output_within_kills_a_hung_call_and_its_process_group() {
1428 let dir = tempfile::tempdir().unwrap();
1429 let pidfile = dir.path().join("grandchild.pid");
1430 let (_cli_dir, cli) = fake_cli(
1431 "cli",
1432 &format!(
1433 "[ \"$1\" = warmup ] && exit 0\nsleep 1000 &\necho $! > {}\nwait\n",
1434 pidfile.display()
1435 ),
1436 );
1437 let start = std::time::Instant::now();
1438 let err = output_within(
1439 tokio::process::Command::new(&cli).arg("build"),
1440 std::time::Duration::from_millis(500),
1441 )
1442 .await
1443 .expect_err("a hung call must time out");
1444 assert!(matches!(err, CliRunError::TimedOut(_)), "{err:?}");
1445 assert!(
1446 err.to_string().contains("did not finish within 0.5s"),
1447 "{err}"
1448 );
1449 assert!(
1450 start.elapsed() < std::time::Duration::from_secs(5),
1451 "the timeout returned after {:?}",
1452 start.elapsed()
1453 );
1454 let grandchild = crate::test_support::read_pidfile(&pidfile);
1455 assert!(
1456 crate::test_support::pid_gone(grandchild),
1457 "grandchild {grandchild} survived"
1458 );
1459 }
1460
1461 /// Dropping the call before it finishes -- an outer timeout, a lost
1462 /// `select!`, an aborted task -- kills the command's whole group too, not
1463 /// just the direct child `kill_on_drop` reaches.
1464 #[cfg(unix)]
1465 #[tokio::test]
1466 async fn dropping_output_within_kills_the_process_group() {
1467 let dir = tempfile::tempdir().unwrap();
1468 let pidfile = dir.path().join("grandchild.pid");
1469 let (_cli_dir, cli) = fake_cli(
1470 "cli",
1471 &format!(
1472 "[ \"$1\" = warmup ] && exit 0\nsleep 1000 &\necho $! > {}\nwait\n",
1473 pidfile.display()
1474 ),
1475 );
1476 let mut cmd = tokio::process::Command::new(&cli);
1477 cmd.arg("build");
1478 let call = output_within(&mut cmd, std::time::Duration::from_secs(600));
1479 let start = std::time::Instant::now();
1480 // The outer budget runs out long before the call's own.
1481 assert!(
1482 tokio::time::timeout(std::time::Duration::from_millis(500), call)
1483 .await
1484 .is_err()
1485 );
1486 assert!(start.elapsed() < std::time::Duration::from_secs(5));
1487 let grandchild = crate::test_support::read_pidfile(&pidfile);
1488 assert!(
1489 crate::test_support::pid_gone(grandchild),
1490 "grandchild {grandchild} survived the dropped call"
1491 );
1492 }
1493
1494 #[tokio::test]
1495 async fn output_within_reports_a_missing_cli() {
1496 let err = output_within(
1497 &mut tokio::process::Command::new("/nonexistent-dir-fc/cli"),
1498 std::time::Duration::from_secs(5),
1499 )
1500 .await
1501 .expect_err("no such binary");
1502 assert!(matches!(err, CliRunError::Spawn(_)), "{err:?}");
1503 }
1504
1505 /// The happy path must still hand back the child's output *and* collect the
1506 /// reader, so the no-leak guarantee isn't bought by dropping output.
1507 #[test]
1508 fn a_prompt_cli_call_collects_its_reader() {
1509 let (output, reader) = run_bounded("echo", &["abc123"]);
1510 assert_eq!(output.as_deref().map(str::trim), Some("abc123"));
1511 assert!(
1512 matches!(reader, ReaderState::Finished),
1513 "reader was {reader:?}, expected it collected"
1514 );
1515 }
1516
1517 /// The bridge-gateway probe talks to the same daemon as the liveness probe,
1518 /// so it has to end at the same bound. It used to be a plain
1519 /// `Command::output()`, which against a wedged daemon hung whichever runtime
1520 /// constructor called it -- on Linux, every container-backed service at
1521 /// server startup. Timing out is not an error here: the caller falls back to
1522 /// the conventional `172.17.0.1`.
1523 #[cfg(unix)]
1524 #[test]
1525 fn a_hanging_bridge_gateway_probe_is_cut_off() {
1526 let (_dir, script) = fake_cli("hangcli", "[ \"$1\" = warmup ] && exit 0\nsleep 600\n");
1527
1528 let start = std::time::Instant::now();
1529 let gateway = detect_bridge_gateway(&script);
1530 let elapsed = start.elapsed();
1531
1532 assert_eq!(gateway, None, "a wedged daemon must report no gateway");
1533 assert!(
1534 elapsed < CLI_PROBE_TIMEOUT + READER_DRAIN_GRACE + std::time::Duration::from_secs(5),
1535 "the probe took {elapsed:?}, expected it bounded near {CLI_PROBE_TIMEOUT:?}"
1536 );
1537 }
1538
1539 /// The unavailable-CLI path keeps its shape: nothing to spawn, no gateway,
1540 /// and the caller's fallback stands.
1541 #[test]
1542 fn a_missing_cli_reports_no_bridge_gateway() {
1543 assert_eq!(
1544 detect_bridge_gateway("definitely-not-a-real-cli-binary-xyz-123"),
1545 None
1546 );
1547 }
1548
1549 /// A CLI that succeeds with no output -- an `inspect --format` over a bridge
1550 /// with no IPAM config -- still means "no gateway", not an empty
1551 /// `--add-host` value. Unchanged by the bounding; guarded so it stays that
1552 /// way.
1553 #[test]
1554 fn an_empty_gateway_is_rejected() {
1555 assert_eq!(detect_bridge_gateway("true"), None);
1556 }
1557
1558 /// `FAKECLOUD_CONTAINER_CLI` is routinely a wrapper (`sh -c 'exec docker
1559 /// "$@"'`, a `podman-remote` shim), which makes the real command a
1560 /// grandchild holding the stdout pipe. Killing only the direct child left
1561 /// the reader's `read_to_end` blocked forever -- a thread parked for the
1562 /// life of the process, once per call, on exactly the wedged-daemon path
1563 /// these bounds were added for (the server reaper calls this at startup).
1564 /// The reader reporting in is the evidence: EOF on that pipe is only
1565 /// possible once every write end is closed, so a collected buffer proves
1566 /// the grandchildren went down with the call.
1567 #[cfg(unix)]
1568 #[test]
1569 fn a_timed_out_wrapper_call_leaves_no_reader_behind() {
1570 let start = std::time::Instant::now();
1571 // A wrapper that outlives its own kill: the backgrounded sleep inherits
1572 // the stdout pipe and is not the process we spawned.
1573 let (output, reader) = run_bounded("sh", &["-c", "sleep 600 & sleep 600"]);
1574 assert_eq!(output, None, "a wedged call must report failure");
1575 assert!(
1576 matches!(reader, ReaderState::Finished),
1577 "reader was {reader:?}: the stdout reader must not outlive the call"
1578 );
1579 assert!(
1580 start.elapsed()
1581 < CLI_PROBE_TIMEOUT + READER_DRAIN_GRACE + std::time::Duration::from_secs(5),
1582 "the call must still end at the bound, took {:?}",
1583 start.elapsed()
1584 );
1585 }
1586}
1587
1588#[cfg(test)]
1589mod endpoint_tests {
1590 use super::*;
1591
1592 #[test]
1593 fn ecr_registry_host_defaults_to_loopback() {
1594 // The host daemon performs the pull; the sibling alias is wrong there.
1595 assert_eq!(resolve_ecr_registry_host(None, false, true), "127.0.0.1");
1596 assert_eq!(resolve_ecr_registry_host(None, false, false), "127.0.0.1");
1597 assert_eq!(
1598 resolve_ecr_registry_host(Some(" ".into()), false, true),
1599 "127.0.0.1"
1600 );
1601 // Podman on Linux pulls on the host; podman machine pulls in its VM.
1602 assert_eq!(resolve_ecr_registry_host(None, true, true), "127.0.0.1");
1603 assert_eq!(
1604 resolve_ecr_registry_host(None, true, false),
1605 "host.containers.internal"
1606 );
1607 assert_eq!(
1608 resolve_ecr_registry_host(Some("10.0.0.5".into()), true, false),
1609 "10.0.0.5"
1610 );
1611 }
1612
1613 #[test]
1614 fn registry_override_host_is_authorized() {
1615 // A pull rewritten to FAKECLOUD_ECR_REGISTRY_HOST must carry auth, or
1616 // the registry answers 401.
1617 let hosts = registry_auth_hosts_with(4566, Some("10.1.2.3".into()));
1618 assert!(hosts.contains(&"10.1.2.3:4566".to_string()), "{hosts:?}");
1619 assert!(hosts.contains(&"127.0.0.1:4566".to_string()));
1620 // No duplicates, blank override ignored.
1621 assert_eq!(
1622 registry_auth_hosts_with(4566, Some("127.0.0.1".into())).len(),
1623 4
1624 );
1625 assert_eq!(registry_auth_hosts_with(4566, Some(" ".into())).len(), 4);
1626 }
1627
1628 #[test]
1629 fn published_port_parses_docker_and_podman_output() {
1630 assert_eq!(
1631 parse_published_port("0.0.0.0:49153\n[::]:49153\n"),
1632 Some(49153)
1633 );
1634 assert_eq!(parse_published_port("[::]:5000"), Some(5000));
1635 assert_eq!(parse_published_port(""), None);
1636 assert_eq!(parse_published_port("garbage"), None);
1637 }
1638
1639 #[test]
1640 fn rewrite_loopback_urls_and_bare_endpoints() {
1641 let h = "host.docker.internal";
1642 assert_eq!(
1643 rewrite_loopback_value("http://localhost:4566", h),
1644 "http://host.docker.internal:4566"
1645 );
1646 assert_eq!(
1647 rewrite_loopback_value("https://127.0.0.1:4566/path", h),
1648 "https://host.docker.internal:4566/path"
1649 );
1650 // Bare host (an RDS / ElastiCache endpoint address).
1651 assert_eq!(rewrite_loopback_value("127.0.0.1", h), h);
1652 assert_eq!(rewrite_loopback_value("localhost", h), h);
1653 // Bare host:port and comma-separated broker lists.
1654 assert_eq!(
1655 rewrite_loopback_value("127.0.0.1:6379", h),
1656 "host.docker.internal:6379"
1657 );
1658 assert_eq!(
1659 rewrite_loopback_value("127.0.0.1:9092,localhost:9094", h),
1660 "host.docker.internal:9092,host.docker.internal:9094"
1661 );
1662 // Scheme-qualified DSNs with userinfo.
1663 assert_eq!(
1664 rewrite_loopback_value("postgres://u:p@localhost:5432/db", h),
1665 "postgres://u:p@host.docker.internal:5432/db"
1666 );
1667 assert_eq!(
1668 rewrite_loopback_value("redis://127.0.0.1:6379/0", h),
1669 "redis://host.docker.internal:6379/0"
1670 );
1671 // URL host without a port.
1672 assert_eq!(
1673 rewrite_loopback_value("http://localhost/health", h),
1674 "http://host.docker.internal/health"
1675 );
1676 // key=value lists.
1677 assert_eq!(
1678 rewrite_loopback_value("host=127.0.0.1:5432 user=x", h),
1679 "host=host.docker.internal:5432 user=x"
1680 );
1681 }
1682
1683 #[test]
1684 fn rewrite_loopback_leaves_prose_and_lookalikes_alone() {
1685 let h = "host.docker.internal";
1686 for v in [
1687 "EHLO localhost",
1688 "connect to localhost soon",
1689 "127.0.0.10:80",
1690 "localhost.localdomain:25",
1691 "mylocalhost:80",
1692 "",
1693 ] {
1694 assert_eq!(rewrite_loopback_value(v, h), v, "{v:?}");
1695 }
1696 // Workload sharing fakecloud's namespace: nothing to rewrite.
1697 assert_eq!(
1698 rewrite_loopback_value("http://localhost:4566", "127.0.0.1"),
1699 "http://localhost:4566"
1700 );
1701 }
1702
1703 #[test]
1704 fn rewrite_loopback_swaps_registered_container_ports() {
1705 // A broker whose host listener advertises loopback registers the
1706 // listener containers must use instead.
1707 crate::dataplane::register_container_port(41001, 41002);
1708 assert_eq!(
1709 rewrite_loopback_value("127.0.0.1:41001", "host.docker.internal"),
1710 "host.docker.internal:41002"
1711 );
1712 crate::dataplane::unregister_container_port(41001);
1713 assert_eq!(
1714 rewrite_loopback_value("127.0.0.1:41001", "host.docker.internal"),
1715 "host.docker.internal:41001"
1716 );
1717 }
1718}