Skip to main content

fakecloud_lambda/runtime/
docker.rs

1//! Docker/Podman [`LambdaBackend`] implementation.
2//!
3//! Shells out to `docker` or `podman` CLI. Auto-detects which one is
4//! available; honors `FAKECLOUD_CONTAINER_CLI` as an override.
5
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use base64::Engine;
12use tempfile::TempDir;
13
14use super::backend::{BackendHandle, LambdaBackend, RuntimeError, WarmInstance};
15use super::env_rewrite::rewrite_localhost_envs;
16use crate::state::LambdaFunction;
17
18/// Docker/Podman-based Lambda execution backend.
19pub struct DockerBackend {
20    cli: String,
21    instance_id: String,
22    /// DNS name the container uses to reach fakecloud on the host. For
23    /// docker we use the cross-platform `host.docker.internal` alias (and
24    /// inject it via `--add-host host.docker.internal:host-gateway` on
25    /// Mac/Windows; bridge gateway IP on Linux). For podman we use its
26    /// built-in `host.containers.internal` alias, which podman injects
27    /// automatically without an `--add-host` flag — passing `host-gateway`
28    /// to podman on macOS fails with "host containers internal IP address
29    /// is empty" because podman's gvproxy network doesn't populate the
30    /// magic alias. See issue #1539.
31    host_alias: String,
32    /// `--add-host <alias>:<value>` argument injected into every container
33    /// `create`, or `None` when the runtime provides the alias natively.
34    add_host_arg: Option<String>,
35    /// Port the main fakecloud server bound to. Used to translate AWS
36    /// private-ECR URIs in `PackageType=Image` functions to fakecloud's
37    /// local OCI v2 registry.
38    server_port: u16,
39    /// Hostname fakecloud uses to reach sibling Lambda containers it
40    /// just spawned. `"127.0.0.1"` when fakecloud runs on the host (the
41    /// containers expose ports on the host loopback). When fakecloud is
42    /// itself running in a container with `/var/run/docker.sock`
43    /// bind-mounted, the spawned Lambda containers are *siblings* on
44    /// the host's daemon — their published ports are reachable from
45    /// inside fakecloud's container as `host.docker.internal:<port>`,
46    /// not `127.0.0.1:<port>`. Set via the `FAKECLOUD_IN_CONTAINER=1`
47    /// env var baked into the published image (issue #1539 Bug 4).
48    sibling_host: String,
49    /// Isolated DOCKER_CONFIG dir with Basic auth for `127.0.0.1:<port>`.
50    /// Lets `docker pull` talk to fakecloud ECR without mutating the user's
51    /// `~/.docker/config.json`.
52    docker_config: Option<Arc<TempDir>>,
53}
54
55impl DockerBackend {
56    /// Auto-detect Docker or Podman. Returns `None` if neither is available.
57    /// Override with `FAKECLOUD_CONTAINER_CLI` env var.
58    /// `server_port` is the port the main fakecloud server bound to; used
59    /// to resolve `PackageType=Image` ECR URIs against fakecloud ECR.
60    pub fn auto_detect(server_port: u16) -> Option<Self> {
61        let cli = if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
62            if std::process::Command::new(&cli)
63                .arg("info")
64                .stdout(std::process::Stdio::null())
65                .stderr(std::process::Stdio::null())
66                .status()
67                .map(|s| s.success())
68                .unwrap_or(false)
69            {
70                cli
71            } else {
72                return None;
73            }
74        } else if is_cli_available("docker") {
75            "docker".to_string()
76        } else if is_cli_available("podman") {
77            "podman".to_string()
78        } else {
79            return None;
80        };
81
82        let instance_id = format!("fakecloud-{}", std::process::id());
83
84        let (host_alias, add_host_arg) = if is_podman_binary(&cli) {
85            // Podman ships `host.containers.internal` as a built-in container
86            // DNS entry on every supported platform; injecting `host-gateway`
87            // on macOS fails because rootless podman's gvproxy doesn't
88            // expose the magic alias (issue #1539).
89            ("host.containers.internal".to_string(), None)
90        } else if cfg!(target_os = "linux") {
91            // Bare docker on Linux: resolve the bridge gateway IP and add
92            // an explicit alias. `host.docker.internal:host-gateway` only
93            // works on Docker Desktop; native Linux docker has no such
94            // magic.
95            let ip = detect_bridge_gateway(&cli).unwrap_or_else(|| "172.17.0.1".to_string());
96            (
97                "host.docker.internal".to_string(),
98                Some(format!("host.docker.internal:{ip}")),
99            )
100        } else {
101            // Docker Desktop on Mac/Windows: `host-gateway` is a Docker
102            // Desktop-only alias that resolves to the host's IP.
103            (
104                "host.docker.internal".to_string(),
105                Some("host.docker.internal:host-gateway".to_string()),
106            )
107        };
108
109        let docker_config = build_local_registry_docker_config(server_port).map(Arc::new);
110        let sibling_host = resolve_sibling_host(std::env::var("FAKECLOUD_IN_CONTAINER").ok());
111        Some(Self {
112            cli,
113            instance_id,
114            host_alias,
115            add_host_arg,
116            server_port,
117            sibling_host,
118            docker_config,
119        })
120    }
121
122    /// Append `--add-host` arguments to `cmd` when the runtime needs an
123    /// explicit host alias mapping (docker on Linux/Mac/Windows). No-op
124    /// for podman, which provides `host.containers.internal` natively.
125    fn apply_host_alias(&self, cmd: &mut tokio::process::Command) {
126        if let Some(arg) = &self.add_host_arg {
127            cmd.arg("--add-host").arg(arg);
128        }
129    }
130
131    fn docker_config_path(&self) -> Option<PathBuf> {
132        self.docker_config.as_ref().map(|d| d.path().to_path_buf())
133    }
134
135    /// Start a container for a `PackageType=Image` function. The image is
136    /// expected to already embed the Runtime Interface Emulator (RIE) or
137    /// an equivalent, exposing port 8080. AWS private-ECR URIs get
138    /// translated to fakecloud's local OCI v2 registry and retagged so
139    /// the container reports its user-visible image name.
140    async fn start_image_container(
141        &self,
142        func: &LambdaFunction,
143        layers: &[Vec<u8>],
144    ) -> Result<WarmInstance, RuntimeError> {
145        let image = func.image_uri.as_deref().ok_or_else(|| {
146            RuntimeError::ContainerStartFailed("PackageType=Image function has no ImageUri".into())
147        })?;
148
149        let local_pull_uri = fakecloud_core::ecr_uri::translate_to_local(image, self.server_port);
150        let pull_uri = local_pull_uri.as_deref().unwrap_or(image);
151
152        let mut pull_cmd = tokio::process::Command::new(&self.cli);
153        if let Some(p) = self.docker_config_path() {
154            pull_cmd.env("DOCKER_CONFIG", p);
155        }
156        let pull_out = pull_cmd
157            .args(["pull", pull_uri])
158            .output()
159            .await
160            .map_err(|e| RuntimeError::ContainerStartFailed(format!("docker pull: {e}")))?;
161        if !pull_out.status.success() {
162            return Err(RuntimeError::ContainerStartFailed(format!(
163                "docker pull failed: {}",
164                String::from_utf8_lossy(&pull_out.stderr)
165            )));
166        }
167        // Retag the local pull URI to the AWS URI so `docker create`
168        // finds the image under the user-visible name. Digest-pinned
169        // refs can't be `docker tag` targets, so fall through and
170        // create under the local URI instead.
171        let run_image = if let Some(ref local_uri) = local_pull_uri {
172            if fakecloud_core::ecr_uri::is_digest_ref(image) {
173                local_uri.clone()
174            } else {
175                let _ = tokio::process::Command::new(&self.cli)
176                    .args(["tag", local_uri, image])
177                    .output()
178                    .await;
179                image.to_string()
180            }
181        } else {
182            image.to_string()
183        };
184
185        let mut cmd = tokio::process::Command::new(&self.cli);
186        cmd.arg("create")
187            .arg("-p")
188            .arg(":8080")
189            .arg("--label")
190            .arg(format!("fakecloud-lambda={}", func.function_name))
191            .arg("--label")
192            .arg(format!("fakecloud-instance={}", self.instance_id));
193        self.apply_host_alias(&mut cmd);
194
195        for (key, value) in rewrite_localhost_envs(&func.environment, &self.host_alias) {
196            cmd.arg("-e").arg(format!("{key}={value}"));
197        }
198        cmd.arg("-e")
199            .arg(format!("AWS_LAMBDA_FUNCTION_TIMEOUT={}", func.timeout));
200
201        let tmpfs_arg = ephemeral_storage_tmpfs_arg(func.ephemeral_storage_size);
202        cmd.arg("--tmpfs").arg(tmpfs_arg);
203
204        cmd.arg(&run_image);
205
206        let output = cmd
207            .output()
208            .await
209            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
210        if !output.status.success() {
211            return Err(RuntimeError::ContainerStartFailed(
212                String::from_utf8_lossy(&output.stderr).to_string(),
213            ));
214        }
215        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
216
217        if let Err(e) = self.copy_layers_into(&container_id, layers).await {
218            self.remove_container(&container_id).await;
219            return Err(e);
220        }
221
222        let start_result = tokio::process::Command::new(&self.cli)
223            .args(["start", &container_id])
224            .output()
225            .await
226            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
227        if !start_result.status.success() {
228            self.remove_container(&container_id).await;
229            return Err(RuntimeError::ContainerStartFailed(format!(
230                "docker start failed: {}",
231                String::from_utf8_lossy(&start_result.stderr)
232            )));
233        }
234
235        let port = self.query_host_port(&container_id).await?;
236        self.wait_for_ready(&container_id, port).await?;
237
238        tracing::info!(
239            function = %func.function_name,
240            container_id = %container_id,
241            port = port,
242            image = %image,
243            "Lambda image container started"
244        );
245
246        Ok(WarmInstance {
247            endpoint: format!("{}:{port}", self.sibling_host),
248            handle: BackendHandle::Container { id: container_id },
249        })
250    }
251
252    async fn start_zip_container(
253        &self,
254        func: &LambdaFunction,
255        zip_bytes: &[u8],
256        layers: &[Vec<u8>],
257    ) -> Result<WarmInstance, RuntimeError> {
258        let image = runtime_to_image(&func.runtime)
259            .ok_or_else(|| RuntimeError::UnsupportedRuntime(func.runtime.clone()))?;
260
261        // Extract ZIP to a temp directory (only needed during container setup).
262        // Run in spawn_blocking to avoid blocking the async runtime with fs I/O.
263        let code_dir =
264            TempDir::new().map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
265        let zip_bytes = zip_bytes.to_vec();
266        let code_path = code_dir.path().to_path_buf();
267        tokio::task::spawn_blocking(move || extract_zip(&zip_bytes, &code_path))
268            .await
269            .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))??;
270
271        // Step 1: docker create (no volume mounts — works in Docker-in-Docker)
272        let mut cmd = tokio::process::Command::new(&self.cli);
273        cmd.arg("create")
274            .arg("-p")
275            .arg(":8080")
276            .arg("--label")
277            .arg(format!("fakecloud-lambda={}", func.function_name))
278            .arg("--label")
279            .arg(format!("fakecloud-instance={}", self.instance_id));
280        self.apply_host_alias(&mut cmd);
281
282        for (key, value) in rewrite_localhost_envs(&func.environment, &self.host_alias) {
283            cmd.arg("-e").arg(format!("{key}={value}"));
284        }
285
286        cmd.arg("-e")
287            .arg(format!("AWS_LAMBDA_FUNCTION_TIMEOUT={}", func.timeout));
288
289        let tmpfs_arg = ephemeral_storage_tmpfs_arg(func.ephemeral_storage_size);
290        cmd.arg("--tmpfs").arg(tmpfs_arg);
291
292        cmd.arg(&image).arg(&func.handler);
293
294        let output = cmd
295            .output()
296            .await
297            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
298
299        if !output.status.success() {
300            let stderr = String::from_utf8_lossy(&output.stderr);
301            return Err(RuntimeError::ContainerStartFailed(stderr.to_string()));
302        }
303
304        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
305
306        // Step 2: docker cp — copy code into the container
307        let cp_result = tokio::process::Command::new(&self.cli)
308            .arg("cp")
309            .arg(format!("{}/.", code_dir.path().display()))
310            .arg(format!("{}:/var/task", container_id))
311            .output()
312            .await
313            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
314
315        if !cp_result.status.success() {
316            self.remove_container(&container_id).await;
317            let stderr = String::from_utf8_lossy(&cp_result.stderr);
318            return Err(RuntimeError::ContainerStartFailed(format!(
319                "docker cp failed: {stderr}"
320            )));
321        }
322
323        // For provided/custom runtimes, also copy to /var/runtime
324        if func.runtime.starts_with("provided") {
325            let cp_runtime = tokio::process::Command::new(&self.cli)
326                .arg("cp")
327                .arg(format!("{}/.", code_dir.path().display()))
328                .arg(format!("{}:/var/runtime", container_id))
329                .output()
330                .await
331                .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
332
333            if !cp_runtime.status.success() {
334                self.remove_container(&container_id).await;
335                let stderr = String::from_utf8_lossy(&cp_runtime.stderr);
336                return Err(RuntimeError::ContainerStartFailed(format!(
337                    "docker cp to /var/runtime failed: {stderr}"
338                )));
339            }
340        }
341
342        if let Err(e) = self.copy_layers_into(&container_id, layers).await {
343            self.remove_container(&container_id).await;
344            return Err(e);
345        }
346
347        // TempDir is dropped here — code now lives inside the container
348
349        let start_result = tokio::process::Command::new(&self.cli)
350            .args(["start", &container_id])
351            .output()
352            .await
353            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
354
355        if !start_result.status.success() {
356            self.remove_container(&container_id).await;
357            let stderr = String::from_utf8_lossy(&start_result.stderr);
358            return Err(RuntimeError::ContainerStartFailed(format!(
359                "docker start failed: {stderr}"
360            )));
361        }
362
363        let port = self.query_host_port(&container_id).await?;
364        self.wait_for_ready(&container_id, port).await?;
365
366        tracing::info!(
367            function = %func.function_name,
368            container_id = %container_id,
369            port = port,
370            runtime = %func.runtime,
371            "Lambda container started"
372        );
373
374        Ok(WarmInstance {
375            endpoint: format!("{}:{port}", self.sibling_host),
376            handle: BackendHandle::Container { id: container_id },
377        })
378    }
379
380    async fn query_host_port(&self, container_id: &str) -> Result<u16, RuntimeError> {
381        let port_output = tokio::process::Command::new(&self.cli)
382            .args(["port", container_id, "8080"])
383            .output()
384            .await
385            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
386        let port_str = String::from_utf8_lossy(&port_output.stdout);
387        port_str
388            .trim()
389            .rsplit(':')
390            .next()
391            .and_then(|p| p.parse().ok())
392            .ok_or_else(|| {
393                RuntimeError::ContainerStartFailed(format!(
394                    "could not determine port from: {}",
395                    port_str.trim()
396                ))
397            })
398    }
399
400    async fn wait_for_ready(&self, container_id: &str, port: u16) -> Result<(), RuntimeError> {
401        for _ in 0..20 {
402            tokio::time::sleep(Duration::from_millis(500)).await;
403            if tokio::net::TcpStream::connect(format!("{}:{port}", self.sibling_host))
404                .await
405                .is_ok()
406            {
407                return Ok(());
408            }
409        }
410        self.remove_container(container_id).await;
411        Err(RuntimeError::ContainerStartFailed(
412            "container did not become ready within 10 seconds".to_string(),
413        ))
414    }
415
416    /// Extract each layer ZIP into a shared temp directory and `docker cp`
417    /// it into `/opt/` of the target container. Layer ZIPs include
418    /// language-specific subpaths (`python/`, `nodejs/`, `java/`, `lib/`,
419    /// `bin/`) that AWS base images already wire onto the runtime's
420    /// import paths, so plain extraction at the temp root produces the
421    /// correct on-disk layout. Empty `layers` is a no-op.
422    async fn copy_layers_into(
423        &self,
424        container_id: &str,
425        layers: &[Vec<u8>],
426    ) -> Result<(), RuntimeError> {
427        if layers.is_empty() {
428            return Ok(());
429        }
430        let layers_dir =
431            TempDir::new().map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
432        let layers_path = layers_dir.path().to_path_buf();
433        let layers_owned: Vec<Vec<u8>> = layers.to_vec();
434        tokio::task::spawn_blocking(move || {
435            for bytes in &layers_owned {
436                extract_zip(bytes, &layers_path)?;
437            }
438            Ok::<_, RuntimeError>(())
439        })
440        .await
441        .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))??;
442
443        let cp_result = tokio::process::Command::new(&self.cli)
444            .arg("cp")
445            .arg(format!("{}/.", layers_dir.path().display()))
446            .arg(format!("{}:/opt", container_id))
447            .output()
448            .await
449            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
450        if !cp_result.status.success() {
451            let stderr = String::from_utf8_lossy(&cp_result.stderr);
452            return Err(RuntimeError::ContainerStartFailed(format!(
453                "docker cp layers to /opt failed: {stderr}"
454            )));
455        }
456        Ok(())
457    }
458
459    /// Remove a container (stop + rm, since we don't use --rm with docker create).
460    async fn remove_container(&self, container_id: &str) {
461        let _ = tokio::process::Command::new(&self.cli)
462            .args(["rm", "-f", container_id])
463            .output()
464            .await;
465    }
466}
467
468#[async_trait]
469impl LambdaBackend for DockerBackend {
470    fn name(&self) -> &str {
471        &self.cli
472    }
473
474    async fn launch(
475        &self,
476        func: &LambdaFunction,
477        code_zip: Option<&[u8]>,
478        layers: &[Vec<u8>],
479        _deploy_id: &str,
480    ) -> Result<WarmInstance, RuntimeError> {
481        if func.package_type == "Image" {
482            self.start_image_container(func, layers).await
483        } else {
484            let bytes =
485                code_zip.ok_or_else(|| RuntimeError::NoCodeZip(func.function_name.clone()))?;
486            self.start_zip_container(func, bytes, layers).await
487        }
488    }
489
490    async fn terminate(&self, handle: &BackendHandle) {
491        match handle {
492            BackendHandle::Container { id } => self.remove_container(id).await,
493            // Pod handles belong to the K8s backend — defensive no-op
494            // so a mis-wired multi-backend setup doesn't panic.
495            BackendHandle::Pod { .. } => {}
496        }
497    }
498
499    async fn prepull_image(&self, image: &str) -> Result<(), RuntimeError> {
500        // Translate AWS-flavored ECR URIs to fakecloud's local registry so
501        // private-ECR `Image` package functions can be warmed too. Falls
502        // back to the URI as-is for public-ECR / Docker Hub / Quay images.
503        let local_uri = fakecloud_core::ecr_uri::translate_to_local(image, self.server_port);
504        let pull_uri = local_uri.as_deref().unwrap_or(image);
505
506        let mut cmd = tokio::process::Command::new(&self.cli);
507        if let Some(p) = self.docker_config_path() {
508            cmd.env("DOCKER_CONFIG", p);
509        }
510        let out = cmd
511            .args(["pull", pull_uri])
512            .output()
513            .await
514            .map_err(|e| RuntimeError::ContainerStartFailed(format!("docker pull: {e}")))?;
515        if !out.status.success() {
516            return Err(RuntimeError::ContainerStartFailed(format!(
517                "docker pull failed for {pull_uri}: {}",
518                String::from_utf8_lossy(&out.stderr)
519            )));
520        }
521        Ok(())
522    }
523}
524
525/// Map AWS runtime identifier to a Docker image tag.
526pub fn runtime_to_image(runtime: &str) -> Option<String> {
527    let (base, tag) = match runtime {
528        "python3.14" => ("python", "3.14"),
529        "python3.13" => ("python", "3.13"),
530        "python3.12" => ("python", "3.12"),
531        "python3.11" => ("python", "3.11"),
532        "python3.10" => ("python", "3.10"),
533        "python3.9" => ("python", "3.9"),
534        "python3.8" => ("python", "3.8"),
535        "nodejs24.x" => ("nodejs", "24"),
536        "nodejs22.x" => ("nodejs", "22"),
537        "nodejs20.x" => ("nodejs", "20"),
538        "nodejs18.x" => ("nodejs", "18"),
539        "nodejs16.x" => ("nodejs", "16"),
540        "ruby3.4" => ("ruby", "3.4"),
541        "ruby3.3" => ("ruby", "3.3"),
542        "java25" => ("java", "25"),
543        "java21" => ("java", "21"),
544        "java17" => ("java", "17"),
545        "java11" => ("java", "11"),
546        "dotnet10" => ("dotnet", "10"),
547        "dotnet8" => ("dotnet", "8"),
548        "go1.x" => ("go", "1"),
549        "provided.al2023" => ("provided", "al2023"),
550        "provided.al2" => ("provided", "al2"),
551        _ => return None,
552    };
553    Some(format!("public.ecr.aws/lambda/{base}:{tag}"))
554}
555
556/// Build the `--tmpfs` argument string used by `docker create` so that
557/// `/tmp` inside the container is sized to the function's
558/// `EphemeralStorage.Size`. Pure helper extracted from the container
559/// boot path so unit tests can verify the flag without spawning Docker.
560///
561/// Defaults to AWS's 512 MiB when `size` is `None`, and clamps to a 64
562/// MiB minimum so legacy snapshots that smuggled in absurd values still
563/// produce a tmpfs Docker accepts. The `exec` mount option matches AWS
564/// Lambda's `/tmp` behavior — handlers that unpack and run binaries
565/// from `/tmp` would otherwise hit `EACCES` against Docker's default
566/// `noexec` tmpfs.
567pub(crate) fn ephemeral_storage_tmpfs_arg(size: Option<i64>) -> String {
568    let mib = size.unwrap_or(512).max(64);
569    format!("/tmp:size={mib}m,exec")
570}
571
572/// Extract a ZIP archive to a destination directory.
573pub fn extract_zip(zip_bytes: &[u8], dest: &Path) -> Result<(), RuntimeError> {
574    let cursor = std::io::Cursor::new(zip_bytes);
575    let mut archive = zip::ZipArchive::new(cursor)
576        .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
577
578    for i in 0..archive.len() {
579        let mut file = archive
580            .by_index(i)
581            .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
582
583        let out_path = dest.join(file.enclosed_name().ok_or_else(|| {
584            RuntimeError::ZipExtractionFailed("invalid file name in ZIP".to_string())
585        })?);
586
587        if file.is_dir() {
588            std::fs::create_dir_all(&out_path)
589                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
590        } else {
591            if let Some(parent) = out_path.parent() {
592                std::fs::create_dir_all(parent)
593                    .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
594            }
595            let mut out_file = std::fs::File::create(&out_path)
596                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
597            std::io::copy(&mut file, &mut out_file)
598                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
599
600            #[cfg(unix)]
601            {
602                use std::os::unix::fs::PermissionsExt;
603                if let Some(mode) = file.unix_mode() {
604                    std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))
605                        .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
606                }
607            }
608        }
609    }
610    Ok(())
611}
612
613/// Detect the Docker bridge gateway IP on Linux. Returns None if detection fails.
614fn detect_bridge_gateway(cli: &str) -> Option<String> {
615    let output = std::process::Command::new(cli)
616        .args([
617            "network",
618            "inspect",
619            "bridge",
620            "--format",
621            "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
622        ])
623        .output()
624        .ok()?;
625
626    if output.status.success() {
627        let gateway = String::from_utf8_lossy(&output.stdout).trim().to_string();
628        if !gateway.is_empty() && gateway.contains('.') {
629            tracing::info!(
630                gateway = %gateway,
631                "Detected Docker bridge gateway for Lambda containers"
632            );
633            return Some(gateway);
634        }
635    }
636    None
637}
638
639/// Decide what loopback address fakecloud uses to reach the *sibling*
640/// Lambda containers it just spawned. Pure helper so the env-var
641/// parsing can be tested without touching the process's real
642/// environment (which would race with parallel tests).
643///
644/// - `Some("1")` or `Some("true")` -> fakecloud is in a container,
645///   sibling published ports live on `host.docker.internal:<port>`.
646/// - Anything else, including `None` -> fakecloud runs on the host,
647///   sibling ports live on `127.0.0.1:<port>`.
648fn resolve_sibling_host(env_value: Option<String>) -> String {
649    let in_container = env_value
650        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
651        .unwrap_or(false);
652    if in_container {
653        "host.docker.internal".to_string()
654    } else {
655        "127.0.0.1".to_string()
656    }
657}
658
659fn is_cli_available(name: &str) -> bool {
660    std::process::Command::new(name)
661        .arg("info")
662        .stdout(std::process::Stdio::null())
663        .stderr(std::process::Stdio::null())
664        .status()
665        .map(|s| s.success())
666        .unwrap_or(false)
667}
668
669/// True when `cli` is podman or a podman-compatible binary. Matches on the
670/// filename component so absolute paths (`/opt/homebrew/bin/podman`) and
671/// wrappers (`podman-remote`) both register as podman. Docker Desktop's
672/// compatibility CLI is named `docker`, so this check is safe.
673fn is_podman_binary(cli: &str) -> bool {
674    std::path::Path::new(cli)
675        .file_name()
676        .and_then(|n| n.to_str())
677        .map(|n| n.contains("podman"))
678        .unwrap_or(false)
679}
680
681fn build_local_registry_docker_config(server_port: u16) -> Option<TempDir> {
682    let dir = TempDir::new().ok()?;
683    let auth = base64::engine::general_purpose::STANDARD.encode("AWS:fakecloud-lambda-runtime");
684    let config = serde_json::json!({
685        "auths": {
686            format!("127.0.0.1:{server_port}"): { "auth": auth },
687        }
688    });
689    std::fs::write(dir.path().join("config.json"), config.to_string()).ok()?;
690    Some(dir)
691}
692
693#[cfg(test)]
694mod tests {
695    use std::io::{Read, Write};
696
697    use super::*;
698
699    #[test]
700    fn test_runtime_to_image() {
701        assert_eq!(
702            runtime_to_image("python3.12"),
703            Some("public.ecr.aws/lambda/python:3.12".to_string())
704        );
705        assert_eq!(
706            runtime_to_image("nodejs20.x"),
707            Some("public.ecr.aws/lambda/nodejs:20".to_string())
708        );
709        assert_eq!(
710            runtime_to_image("provided.al2023"),
711            Some("public.ecr.aws/lambda/provided:al2023".to_string())
712        );
713        assert_eq!(
714            runtime_to_image("ruby3.4"),
715            Some("public.ecr.aws/lambda/ruby:3.4".to_string())
716        );
717        assert_eq!(
718            runtime_to_image("java21"),
719            Some("public.ecr.aws/lambda/java:21".to_string())
720        );
721        assert_eq!(
722            runtime_to_image("dotnet8"),
723            Some("public.ecr.aws/lambda/dotnet:8".to_string())
724        );
725        assert_eq!(
726            runtime_to_image("nodejs16.x"),
727            Some("public.ecr.aws/lambda/nodejs:16".to_string())
728        );
729        assert_eq!(
730            runtime_to_image("python3.10"),
731            Some("public.ecr.aws/lambda/python:3.10".to_string())
732        );
733        assert_eq!(
734            runtime_to_image("python3.9"),
735            Some("public.ecr.aws/lambda/python:3.9".to_string())
736        );
737        assert_eq!(
738            runtime_to_image("python3.8"),
739            Some("public.ecr.aws/lambda/python:3.8".to_string())
740        );
741        assert_eq!(
742            runtime_to_image("java11"),
743            Some("public.ecr.aws/lambda/java:11".to_string())
744        );
745        assert_eq!(
746            runtime_to_image("go1.x"),
747            Some("public.ecr.aws/lambda/go:1".to_string())
748        );
749        assert_eq!(
750            runtime_to_image("nodejs24.x"),
751            Some("public.ecr.aws/lambda/nodejs:24".to_string())
752        );
753        assert_eq!(
754            runtime_to_image("python3.14"),
755            Some("public.ecr.aws/lambda/python:3.14".to_string())
756        );
757        assert_eq!(
758            runtime_to_image("java25"),
759            Some("public.ecr.aws/lambda/java:25".to_string())
760        );
761        assert_eq!(
762            runtime_to_image("dotnet10"),
763            Some("public.ecr.aws/lambda/dotnet:10".to_string())
764        );
765        assert_eq!(runtime_to_image("unknown"), None);
766    }
767
768    #[test]
769    fn is_podman_binary_matches_bare_name() {
770        assert!(is_podman_binary("podman"));
771        assert!(is_podman_binary("podman-remote"));
772    }
773
774    #[test]
775    fn is_podman_binary_matches_absolute_path() {
776        assert!(is_podman_binary("/opt/homebrew/bin/podman"));
777        assert!(is_podman_binary("/usr/local/bin/podman-remote"));
778    }
779
780    #[test]
781    fn is_podman_binary_rejects_docker() {
782        assert!(!is_podman_binary("docker"));
783        assert!(!is_podman_binary("/usr/local/bin/docker"));
784        // Docker Desktop's compatibility CLI is `docker`, not `podman`.
785        assert!(!is_podman_binary("docker-credential-helper"));
786    }
787
788    #[test]
789    fn resolve_sibling_host_defaults_to_loopback() {
790        assert_eq!(resolve_sibling_host(None), "127.0.0.1");
791        assert_eq!(resolve_sibling_host(Some("".to_string())), "127.0.0.1");
792        assert_eq!(resolve_sibling_host(Some("0".to_string())), "127.0.0.1");
793        assert_eq!(resolve_sibling_host(Some("false".to_string())), "127.0.0.1");
794    }
795
796    #[test]
797    fn resolve_sibling_host_uses_docker_internal_when_in_container() {
798        assert_eq!(
799            resolve_sibling_host(Some("1".to_string())),
800            "host.docker.internal"
801        );
802        assert_eq!(
803            resolve_sibling_host(Some("true".to_string())),
804            "host.docker.internal"
805        );
806        assert_eq!(
807            resolve_sibling_host(Some("TRUE".to_string())),
808            "host.docker.internal"
809        );
810    }
811
812    #[test]
813    fn test_extract_zip() {
814        let buf = Vec::new();
815        let cursor = std::io::Cursor::new(buf);
816        let mut writer = zip::ZipWriter::new(cursor);
817        let options = zip::write::SimpleFileOptions::default();
818        writer.start_file("handler.py", options).unwrap();
819        writer
820            .write_all(b"def handler(event, context):\n    return {'statusCode': 200}\n")
821            .unwrap();
822        let cursor = writer.finish().unwrap();
823        let zip_bytes = cursor.into_inner();
824
825        let dir = TempDir::new().unwrap();
826        extract_zip(&zip_bytes, dir.path()).unwrap();
827
828        let handler_path = dir.path().join("handler.py");
829        assert!(handler_path.exists());
830
831        let mut content = String::new();
832        std::fs::File::open(&handler_path)
833            .unwrap()
834            .read_to_string(&mut content)
835            .unwrap();
836        assert!(content.contains("def handler"));
837    }
838
839    #[test]
840    fn ephemeral_storage_tmpfs_arg_defaults_to_512_when_none() {
841        // None -> AWS default of 512 MiB. The `exec` flag is required so
842        // handlers that unpack and run binaries from /tmp don't hit
843        // EACCES against Docker's default `noexec` tmpfs.
844        assert_eq!(ephemeral_storage_tmpfs_arg(None), "/tmp:size=512m,exec");
845    }
846
847    #[test]
848    fn ephemeral_storage_tmpfs_arg_uses_supplied_size() {
849        assert_eq!(
850            ephemeral_storage_tmpfs_arg(Some(2048)),
851            "/tmp:size=2048m,exec"
852        );
853        assert_eq!(
854            ephemeral_storage_tmpfs_arg(Some(10240)),
855            "/tmp:size=10240m,exec"
856        );
857    }
858
859    #[test]
860    fn ephemeral_storage_tmpfs_arg_clamps_to_64_floor() {
861        // API-level validation already rejects values below 512, but the
862        // runtime defends against legacy snapshots and stale state by
863        // clamping to a 64 MiB floor that Docker still accepts.
864        assert_eq!(ephemeral_storage_tmpfs_arg(Some(0)), "/tmp:size=64m,exec");
865        assert_eq!(ephemeral_storage_tmpfs_arg(Some(32)), "/tmp:size=64m,exec");
866    }
867}