Skip to main content

fakecloud_lambda/
runtime.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use base64::Engine;
7use parking_lot::RwLock;
8use tempfile::TempDir;
9
10use crate::state::LambdaFunction;
11
12/// A running container kept warm for reuse.
13struct WarmContainer {
14    container_id: String,
15    host_port: u16,
16    last_used: RwLock<Instant>,
17    code_sha256: String,
18}
19
20/// Docker/Podman-based Lambda execution engine.
21pub struct ContainerRuntime {
22    cli: String,
23    containers: RwLock<HashMap<String, WarmContainer>>,
24    /// Serializes container startup per function to prevent duplicate containers.
25    starting: RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
26    instance_id: String,
27    /// IP address that containers should use to reach the host
28    host_ip: String,
29    /// Port the main fakecloud server bound to. Used to translate AWS
30    /// private-ECR URIs in `PackageType=Image` functions to fakecloud's
31    /// local OCI v2 registry.
32    server_port: u16,
33    /// Isolated DOCKER_CONFIG dir with Basic auth for `127.0.0.1:<port>`.
34    /// Lets `docker pull` talk to fakecloud ECR without mutating the user's
35    /// `~/.docker/config.json`.
36    docker_config: Option<Arc<TempDir>>,
37}
38
39#[derive(Debug, thiserror::Error)]
40pub enum RuntimeError {
41    #[error("no code ZIP provided for function {0}")]
42    NoCodeZip(String),
43    #[error("unsupported runtime: {0}")]
44    UnsupportedRuntime(String),
45    #[error("container failed to start: {0}")]
46    ContainerStartFailed(String),
47    #[error("invocation failed: {0}")]
48    InvocationFailed(String),
49    #[error("ZIP extraction failed: {0}")]
50    ZipExtractionFailed(String),
51}
52
53impl ContainerRuntime {
54    /// Auto-detect Docker or Podman. Returns `None` if neither is available.
55    /// Override with `FAKECLOUD_CONTAINER_CLI` env var.
56    /// `server_port` is the port the main fakecloud server bound to; used
57    /// to resolve `PackageType=Image` ECR URIs against fakecloud ECR.
58    pub fn new(server_port: u16) -> Option<Self> {
59        let cli = if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
60            // Verify the configured CLI works
61            if std::process::Command::new(&cli)
62                .arg("info")
63                .stdout(std::process::Stdio::null())
64                .stderr(std::process::Stdio::null())
65                .status()
66                .map(|s| s.success())
67                .unwrap_or(false)
68            {
69                cli
70            } else {
71                return None;
72            }
73        } else if is_cli_available("docker") {
74            "docker".to_string()
75        } else if is_cli_available("podman") {
76            "podman".to_string()
77        } else {
78            return None;
79        };
80
81        let instance_id = format!("fakecloud-{}", std::process::id());
82
83        // Detect the appropriate host address for containers
84        // On Linux, use the bridge gateway IP directly (more reliable)
85        // On Mac/Windows, use host-gateway which Docker Desktop handles
86        let host_ip = if cfg!(target_os = "linux") {
87            detect_bridge_gateway(&cli).unwrap_or_else(|| "172.17.0.1".to_string())
88        } else {
89            "host-gateway".to_string()
90        };
91
92        let docker_config = build_local_registry_docker_config(server_port).map(Arc::new);
93        Some(Self {
94            cli,
95            containers: RwLock::new(HashMap::new()),
96            starting: RwLock::new(HashMap::new()),
97            instance_id,
98            host_ip,
99            server_port,
100            docker_config,
101        })
102    }
103
104    fn docker_config_path(&self) -> Option<PathBuf> {
105        self.docker_config.as_ref().map(|d| d.path().to_path_buf())
106    }
107
108    pub fn cli_name(&self) -> &str {
109        &self.cli
110    }
111
112    /// Invoke a Lambda function, starting a container if needed.
113    pub async fn invoke(
114        &self,
115        func: &LambdaFunction,
116        payload: &[u8],
117    ) -> Result<Vec<u8>, RuntimeError> {
118        // Zip-based functions need code bytes; image-based functions have
119        // everything baked into the image. Defer the zip check until we
120        // know we need to start a fresh container.
121        let is_image = func.package_type == "Image";
122        if !is_image && func.code_zip.is_none() {
123            return Err(RuntimeError::NoCodeZip(func.function_name.clone()));
124        }
125
126        // Check for warm container with matching code
127        let port = {
128            let containers = self.containers.read();
129            if let Some(container) = containers.get(&func.function_name) {
130                if container.code_sha256 == func.code_sha256 {
131                    *container.last_used.write() = Instant::now();
132                    Some(container.host_port)
133                } else {
134                    None
135                }
136            } else {
137                None
138            }
139        };
140
141        let port = match port {
142            Some(p) => p,
143            None => {
144                // Serialize container startup per function to prevent duplicates
145                let startup_lock = {
146                    let mut starting = self.starting.write();
147                    starting
148                        .entry(func.function_name.clone())
149                        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
150                        .clone()
151                };
152                let _guard = startup_lock.lock().await;
153
154                // Re-check after acquiring lock — another task may have started it
155                let existing_port = {
156                    let containers = self.containers.read();
157                    containers
158                        .get(&func.function_name)
159                        .filter(|c| c.code_sha256 == func.code_sha256)
160                        .map(|c| {
161                            *c.last_used.write() = Instant::now();
162                            c.host_port
163                        })
164                };
165                if let Some(p) = existing_port {
166                    p
167                } else {
168                    self.stop_container(&func.function_name).await;
169                    let container = if is_image {
170                        self.start_image_container(func).await?
171                    } else {
172                        let zip_bytes = func
173                            .code_zip
174                            .as_ref()
175                            .ok_or_else(|| RuntimeError::NoCodeZip(func.function_name.clone()))?;
176                        self.start_container(func, zip_bytes).await?
177                    };
178                    let p = container.host_port;
179                    self.containers
180                        .write()
181                        .insert(func.function_name.clone(), container);
182                    p
183                }
184            }
185        };
186
187        // POST to the RIE endpoint
188        let url = format!(
189            "http://localhost:{}/2015-03-31/functions/function/invocations",
190            port
191        );
192        let client = reqwest::Client::new();
193        let resp = client
194            .post(&url)
195            .body(payload.to_vec())
196            .timeout(Duration::from_secs(func.timeout as u64 + 5))
197            .send()
198            .await
199            .map_err(|e| RuntimeError::InvocationFailed(e.to_string()))?;
200
201        let body = resp
202            .bytes()
203            .await
204            .map_err(|e| RuntimeError::InvocationFailed(e.to_string()))?;
205
206        Ok(body.to_vec())
207    }
208
209    /// Start a container for a `PackageType=Image` function. The image is
210    /// expected to already embed the Runtime Interface Emulator (RIE) or
211    /// an equivalent, exposing port 8080 — that's the AWS convention for
212    /// container-based Lambda. AWS private-ECR URIs get translated to
213    /// fakecloud's local OCI v2 registry and retagged so the container
214    /// reports its user-visible image name.
215    async fn start_image_container(
216        &self,
217        func: &LambdaFunction,
218    ) -> Result<WarmContainer, RuntimeError> {
219        let image = func.image_uri.as_deref().ok_or_else(|| {
220            RuntimeError::ContainerStartFailed("PackageType=Image function has no ImageUri".into())
221        })?;
222
223        // Translate AWS private-ECR URIs to fakecloud ECR's local endpoint.
224        let local_pull_uri = fakecloud_core::ecr_uri::translate_to_local(image, self.server_port);
225        let pull_uri = local_pull_uri.as_deref().unwrap_or(image);
226
227        let mut pull_cmd = tokio::process::Command::new(&self.cli);
228        if let Some(p) = self.docker_config_path() {
229            pull_cmd.env("DOCKER_CONFIG", p);
230        }
231        let pull_out = pull_cmd
232            .args(["pull", pull_uri])
233            .output()
234            .await
235            .map_err(|e| RuntimeError::ContainerStartFailed(format!("docker pull: {e}")))?;
236        if !pull_out.status.success() {
237            return Err(RuntimeError::ContainerStartFailed(format!(
238                "docker pull failed: {}",
239                String::from_utf8_lossy(&pull_out.stderr)
240            )));
241        }
242        // Retag the local pull URI to the AWS URI so `docker create`
243        // finds the image under the user-visible name. Digest-pinned
244        // refs can't be `docker tag` targets, so fall through and
245        // create under the local URI instead.
246        let run_image = if let Some(ref local_uri) = local_pull_uri {
247            if fakecloud_core::ecr_uri::is_digest_ref(image) {
248                local_uri.clone()
249            } else {
250                let _ = tokio::process::Command::new(&self.cli)
251                    .args(["tag", local_uri, image])
252                    .output()
253                    .await;
254                image.to_string()
255            }
256        } else {
257            image.to_string()
258        };
259
260        let mut cmd = tokio::process::Command::new(&self.cli);
261        cmd.arg("create")
262            .arg("-p")
263            .arg(":8080")
264            .arg("--label")
265            .arg(format!("fakecloud-lambda={}", func.function_name))
266            .arg("--label")
267            .arg(format!("fakecloud-instance={}", self.instance_id))
268            .arg("--add-host")
269            .arg(format!("host.docker.internal:{}", self.host_ip));
270
271        for (key, value) in &func.environment {
272            let transformed_value = value
273                .replace("http://127.0.0.1:", "http://host.docker.internal:")
274                .replace("https://127.0.0.1:", "https://host.docker.internal:")
275                .replace("http://localhost:", "http://host.docker.internal:")
276                .replace("https://localhost:", "https://host.docker.internal:");
277            cmd.arg("-e").arg(format!("{}={}", key, transformed_value));
278        }
279        cmd.arg("-e")
280            .arg(format!("AWS_LAMBDA_FUNCTION_TIMEOUT={}", func.timeout));
281
282        cmd.arg(&run_image);
283
284        let output = cmd
285            .output()
286            .await
287            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
288        if !output.status.success() {
289            return Err(RuntimeError::ContainerStartFailed(
290                String::from_utf8_lossy(&output.stderr).to_string(),
291            ));
292        }
293        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
294
295        let start_result = tokio::process::Command::new(&self.cli)
296            .args(["start", &container_id])
297            .output()
298            .await
299            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
300        if !start_result.status.success() {
301            let _ = self.remove_container(&container_id).await;
302            return Err(RuntimeError::ContainerStartFailed(format!(
303                "docker start failed: {}",
304                String::from_utf8_lossy(&start_result.stderr)
305            )));
306        }
307
308        let port_output = tokio::process::Command::new(&self.cli)
309            .args(["port", &container_id, "8080"])
310            .output()
311            .await
312            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
313        let port_str = String::from_utf8_lossy(&port_output.stdout);
314        let port: u16 = port_str
315            .trim()
316            .rsplit(':')
317            .next()
318            .and_then(|p| p.parse().ok())
319            .ok_or_else(|| {
320                RuntimeError::ContainerStartFailed(format!(
321                    "could not determine port from: {}",
322                    port_str.trim()
323                ))
324            })?;
325
326        let mut ready = false;
327        for _ in 0..20 {
328            tokio::time::sleep(Duration::from_millis(500)).await;
329            if tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
330                .await
331                .is_ok()
332            {
333                ready = true;
334                break;
335            }
336        }
337        if !ready {
338            let _ = self.remove_container(&container_id).await;
339            return Err(RuntimeError::ContainerStartFailed(
340                "container did not become ready within 10 seconds".to_string(),
341            ));
342        }
343
344        tracing::info!(
345            function = %func.function_name,
346            container_id = %container_id,
347            port = port,
348            image = %image,
349            "Lambda image container started"
350        );
351
352        Ok(WarmContainer {
353            container_id,
354            host_port: port,
355            last_used: RwLock::new(Instant::now()),
356            code_sha256: func.code_sha256.clone(),
357        })
358    }
359
360    async fn start_container(
361        &self,
362        func: &LambdaFunction,
363        zip_bytes: &[u8],
364    ) -> Result<WarmContainer, RuntimeError> {
365        let image = runtime_to_image(&func.runtime)
366            .ok_or_else(|| RuntimeError::UnsupportedRuntime(func.runtime.clone()))?;
367
368        // Extract ZIP to a temp directory (only needed during container setup).
369        // Run in spawn_blocking to avoid blocking the async runtime with fs I/O.
370        let code_dir =
371            TempDir::new().map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
372        let zip_bytes = zip_bytes.to_vec();
373        let code_path = code_dir.path().to_path_buf();
374        tokio::task::spawn_blocking(move || extract_zip(&zip_bytes, &code_path))
375            .await
376            .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))??;
377
378        // Step 1: docker create (no volume mounts — works in Docker-in-Docker)
379        let mut cmd = tokio::process::Command::new(&self.cli);
380        cmd.arg("create")
381            .arg("-p")
382            .arg(":8080")
383            .arg("--label")
384            .arg(format!("fakecloud-lambda={}", func.function_name))
385            .arg("--label")
386            .arg(format!("fakecloud-instance={}", self.instance_id))
387            // Map host.docker.internal to the detected host IP (bridge gateway on Linux, or explicit IP)
388            .arg("--add-host")
389            .arg(format!("host.docker.internal:{}", self.host_ip));
390
391        for (key, value) in &func.environment {
392            // Transform localhost URLs to use host.docker.internal, which we've set up via --add-host
393            let transformed_value = value
394                .replace("http://127.0.0.1:", "http://host.docker.internal:")
395                .replace("https://127.0.0.1:", "https://host.docker.internal:")
396                .replace("http://localhost:", "http://host.docker.internal:")
397                .replace("https://localhost:", "https://host.docker.internal:");
398            cmd.arg("-e").arg(format!("{}={}", key, transformed_value));
399        }
400
401        cmd.arg("-e")
402            .arg(format!("AWS_LAMBDA_FUNCTION_TIMEOUT={}", func.timeout));
403
404        cmd.arg(&image).arg(&func.handler);
405
406        let output = cmd
407            .output()
408            .await
409            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
410
411        if !output.status.success() {
412            let stderr = String::from_utf8_lossy(&output.stderr);
413            return Err(RuntimeError::ContainerStartFailed(stderr.to_string()));
414        }
415
416        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
417
418        // Step 2: docker cp — copy code into the container
419        let cp_result = tokio::process::Command::new(&self.cli)
420            .arg("cp")
421            .arg(format!("{}/.", code_dir.path().display()))
422            .arg(format!("{}:/var/task", container_id))
423            .output()
424            .await
425            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
426
427        if !cp_result.status.success() {
428            let _ = self.remove_container(&container_id).await;
429            let stderr = String::from_utf8_lossy(&cp_result.stderr);
430            return Err(RuntimeError::ContainerStartFailed(format!(
431                "docker cp failed: {}",
432                stderr
433            )));
434        }
435
436        // For provided/custom runtimes, also copy to /var/runtime
437        if func.runtime.starts_with("provided") {
438            let cp_runtime = tokio::process::Command::new(&self.cli)
439                .arg("cp")
440                .arg(format!("{}/.", code_dir.path().display()))
441                .arg(format!("{}:/var/runtime", container_id))
442                .output()
443                .await
444                .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
445
446            if !cp_runtime.status.success() {
447                let _ = self.remove_container(&container_id).await;
448                let stderr = String::from_utf8_lossy(&cp_runtime.stderr);
449                return Err(RuntimeError::ContainerStartFailed(format!(
450                    "docker cp to /var/runtime failed: {}",
451                    stderr
452                )));
453            }
454        }
455
456        // TempDir is dropped here — code now lives inside the container
457
458        // Step 3: docker start
459        let start_result = tokio::process::Command::new(&self.cli)
460            .args(["start", &container_id])
461            .output()
462            .await
463            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
464
465        if !start_result.status.success() {
466            let _ = self.remove_container(&container_id).await;
467            let stderr = String::from_utf8_lossy(&start_result.stderr);
468            return Err(RuntimeError::ContainerStartFailed(format!(
469                "docker start failed: {}",
470                stderr
471            )));
472        }
473
474        // Query the actual assigned port
475        let port_output = tokio::process::Command::new(&self.cli)
476            .args(["port", &container_id, "8080"])
477            .output()
478            .await
479            .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
480
481        let port_str = String::from_utf8_lossy(&port_output.stdout);
482        let port: u16 = port_str
483            .trim()
484            .rsplit(':')
485            .next()
486            .and_then(|p| p.parse().ok())
487            .ok_or_else(|| {
488                RuntimeError::ContainerStartFailed(format!(
489                    "could not determine port from: {}",
490                    port_str.trim()
491                ))
492            })?;
493
494        // Wait for RIE to start accepting connections
495        let mut ready = false;
496        for _ in 0..20 {
497            tokio::time::sleep(Duration::from_millis(500)).await;
498            if tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
499                .await
500                .is_ok()
501            {
502                ready = true;
503                break;
504            }
505        }
506
507        if !ready {
508            let _ = self.remove_container(&container_id).await;
509            return Err(RuntimeError::ContainerStartFailed(
510                "container did not become ready within 10 seconds".to_string(),
511            ));
512        }
513
514        tracing::info!(
515            function = %func.function_name,
516            container_id = %container_id,
517            port = port,
518            runtime = %func.runtime,
519            "Lambda container started"
520        );
521
522        Ok(WarmContainer {
523            container_id,
524            host_port: port,
525            last_used: RwLock::new(Instant::now()),
526            code_sha256: func.code_sha256.clone(),
527        })
528    }
529
530    /// Remove a container (stop + rm, since we don't use --rm with docker create).
531    async fn remove_container(&self, container_id: &str) {
532        let _ = tokio::process::Command::new(&self.cli)
533            .args(["rm", "-f", container_id])
534            .output()
535            .await;
536    }
537
538    /// Stop and remove a container for a specific function.
539    pub async fn stop_container(&self, function_name: &str) {
540        let container = self.containers.write().remove(function_name);
541        if let Some(container) = container {
542            tracing::info!(
543                function = %function_name,
544                container_id = %container.container_id,
545                "stopping Lambda container"
546            );
547            self.remove_container(&container.container_id).await;
548        }
549    }
550
551    /// Stop and remove all containers (used on server shutdown or reset).
552    pub async fn stop_all(&self) {
553        let containers: Vec<(String, String)> = {
554            let mut map = self.containers.write();
555            map.drain()
556                .map(|(name, c)| (name, c.container_id))
557                .collect()
558        };
559        for (name, container_id) in containers {
560            tracing::info!(
561                function = %name,
562                container_id = %container_id,
563                "stopping Lambda container (cleanup)"
564            );
565            self.remove_container(&container_id).await;
566        }
567    }
568
569    /// List all warm containers and their metadata for introspection.
570    pub fn list_warm_containers(
571        &self,
572        lambda_state: &crate::state::SharedLambdaState,
573    ) -> Vec<serde_json::Value> {
574        let containers = self.containers.read();
575        let accounts = lambda_state.read();
576        containers
577            .iter()
578            .map(|(name, container)| {
579                let runtime = accounts
580                    .iter()
581                    .find_map(|(_, state)| state.functions.get(name).map(|f| f.runtime.clone()))
582                    .unwrap_or_default();
583                let last_used = container.last_used.read();
584                let idle_secs = last_used.elapsed().as_secs();
585                serde_json::json!({
586                    "functionName": name,
587                    "runtime": runtime,
588                    "containerId": container.container_id,
589                    "lastUsedSecsAgo": idle_secs,
590                })
591            })
592            .collect()
593    }
594
595    /// Evict (stop and remove) the warm container for a specific function.
596    /// Returns true if a container was found and evicted.
597    pub async fn evict_container(&self, function_name: &str) -> bool {
598        let container = self.containers.write().remove(function_name);
599        if let Some(container) = container {
600            tracing::info!(
601                function = %function_name,
602                container_id = %container.container_id,
603                "evicting Lambda container via simulation API"
604            );
605            self.remove_container(&container.container_id).await;
606            true
607        } else {
608            false
609        }
610    }
611
612    /// Background loop that stops containers idle longer than `ttl`.
613    pub async fn run_cleanup_loop(self: Arc<Self>, ttl: Duration) {
614        let mut interval = tokio::time::interval(Duration::from_secs(30));
615        loop {
616            interval.tick().await;
617            self.cleanup_idle(ttl).await;
618        }
619    }
620
621    async fn cleanup_idle(&self, ttl: Duration) {
622        let expired: Vec<String> = {
623            let containers = self.containers.read();
624            containers
625                .iter()
626                .filter(|(_, c)| c.last_used.read().elapsed() > ttl)
627                .map(|(name, _)| name.clone())
628                .collect()
629        };
630        for name in expired {
631            tracing::info!(function = %name, "stopping idle Lambda container");
632            self.stop_container(&name).await;
633        }
634    }
635}
636
637/// Map AWS runtime identifier to a Docker image tag.
638pub fn runtime_to_image(runtime: &str) -> Option<String> {
639    let (base, tag) = match runtime {
640        "python3.14" => ("python", "3.14"),
641        "python3.13" => ("python", "3.13"),
642        "python3.12" => ("python", "3.12"),
643        "python3.11" => ("python", "3.11"),
644        "python3.10" => ("python", "3.10"),
645        "python3.9" => ("python", "3.9"),
646        "python3.8" => ("python", "3.8"),
647        "nodejs24.x" => ("nodejs", "24"),
648        "nodejs22.x" => ("nodejs", "22"),
649        "nodejs20.x" => ("nodejs", "20"),
650        "nodejs18.x" => ("nodejs", "18"),
651        "nodejs16.x" => ("nodejs", "16"),
652        "ruby3.4" => ("ruby", "3.4"),
653        "ruby3.3" => ("ruby", "3.3"),
654        "java25" => ("java", "25"),
655        "java21" => ("java", "21"),
656        "java17" => ("java", "17"),
657        "java11" => ("java", "11"),
658        "dotnet10" => ("dotnet", "10"),
659        "dotnet8" => ("dotnet", "8"),
660        "go1.x" => ("go", "1"),
661        "provided.al2023" => ("provided", "al2023"),
662        "provided.al2" => ("provided", "al2"),
663        _ => return None,
664    };
665    Some(format!("public.ecr.aws/lambda/{}:{}", base, tag))
666}
667
668/// Extract a ZIP archive to a destination directory.
669pub fn extract_zip(zip_bytes: &[u8], dest: &Path) -> Result<(), RuntimeError> {
670    let cursor = std::io::Cursor::new(zip_bytes);
671    let mut archive = zip::ZipArchive::new(cursor)
672        .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
673
674    for i in 0..archive.len() {
675        let mut file = archive
676            .by_index(i)
677            .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
678
679        let out_path = dest.join(file.enclosed_name().ok_or_else(|| {
680            RuntimeError::ZipExtractionFailed("invalid file name in ZIP".to_string())
681        })?);
682
683        if file.is_dir() {
684            std::fs::create_dir_all(&out_path)
685                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
686        } else {
687            if let Some(parent) = out_path.parent() {
688                std::fs::create_dir_all(parent)
689                    .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
690            }
691            let mut out_file = std::fs::File::create(&out_path)
692                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
693            std::io::copy(&mut file, &mut out_file)
694                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
695
696            // Preserve executable permissions
697            #[cfg(unix)]
698            {
699                use std::os::unix::fs::PermissionsExt;
700                if let Some(mode) = file.unix_mode() {
701                    std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))
702                        .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
703                }
704            }
705        }
706    }
707    Ok(())
708}
709
710/// Detect the Docker bridge gateway IP on Linux.
711/// Returns None if detection fails.
712fn detect_bridge_gateway(cli: &str) -> Option<String> {
713    let output = std::process::Command::new(cli)
714        .args([
715            "network",
716            "inspect",
717            "bridge",
718            "--format",
719            "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
720        ])
721        .output()
722        .ok()?;
723
724    if output.status.success() {
725        let gateway = String::from_utf8_lossy(&output.stdout).trim().to_string();
726        if !gateway.is_empty() && gateway.contains('.') {
727            tracing::info!(
728                gateway = %gateway,
729                "Detected Docker bridge gateway for Lambda containers"
730            );
731            return Some(gateway);
732        }
733    }
734    None
735}
736
737fn is_cli_available(name: &str) -> bool {
738    std::process::Command::new(name)
739        .arg("info")
740        .stdout(std::process::Stdio::null())
741        .stderr(std::process::Stdio::null())
742        .status()
743        .map(|s| s.success())
744        .unwrap_or(false)
745}
746
747fn build_local_registry_docker_config(server_port: u16) -> Option<TempDir> {
748    let dir = TempDir::new().ok()?;
749    let auth = base64::engine::general_purpose::STANDARD.encode("AWS:fakecloud-lambda-runtime");
750    let config = serde_json::json!({
751        "auths": {
752            format!("127.0.0.1:{server_port}"): { "auth": auth },
753        }
754    });
755    std::fs::write(dir.path().join("config.json"), config.to_string()).ok()?;
756    Some(dir)
757}
758
759#[cfg(test)]
760mod tests {
761    use std::io::{Read, Write};
762
763    use super::*;
764
765    #[test]
766    fn test_runtime_to_image() {
767        assert_eq!(
768            runtime_to_image("python3.12"),
769            Some("public.ecr.aws/lambda/python:3.12".to_string())
770        );
771        assert_eq!(
772            runtime_to_image("nodejs20.x"),
773            Some("public.ecr.aws/lambda/nodejs:20".to_string())
774        );
775        assert_eq!(
776            runtime_to_image("provided.al2023"),
777            Some("public.ecr.aws/lambda/provided:al2023".to_string())
778        );
779        assert_eq!(
780            runtime_to_image("ruby3.4"),
781            Some("public.ecr.aws/lambda/ruby:3.4".to_string())
782        );
783        assert_eq!(
784            runtime_to_image("java21"),
785            Some("public.ecr.aws/lambda/java:21".to_string())
786        );
787        assert_eq!(
788            runtime_to_image("dotnet8"),
789            Some("public.ecr.aws/lambda/dotnet:8".to_string())
790        );
791        assert_eq!(
792            runtime_to_image("nodejs16.x"),
793            Some("public.ecr.aws/lambda/nodejs:16".to_string())
794        );
795        assert_eq!(
796            runtime_to_image("python3.10"),
797            Some("public.ecr.aws/lambda/python:3.10".to_string())
798        );
799        assert_eq!(
800            runtime_to_image("python3.9"),
801            Some("public.ecr.aws/lambda/python:3.9".to_string())
802        );
803        assert_eq!(
804            runtime_to_image("python3.8"),
805            Some("public.ecr.aws/lambda/python:3.8".to_string())
806        );
807        assert_eq!(
808            runtime_to_image("java11"),
809            Some("public.ecr.aws/lambda/java:11".to_string())
810        );
811        assert_eq!(
812            runtime_to_image("go1.x"),
813            Some("public.ecr.aws/lambda/go:1".to_string())
814        );
815        assert_eq!(
816            runtime_to_image("nodejs24.x"),
817            Some("public.ecr.aws/lambda/nodejs:24".to_string())
818        );
819        assert_eq!(
820            runtime_to_image("python3.14"),
821            Some("public.ecr.aws/lambda/python:3.14".to_string())
822        );
823        assert_eq!(
824            runtime_to_image("java25"),
825            Some("public.ecr.aws/lambda/java:25".to_string())
826        );
827        assert_eq!(
828            runtime_to_image("dotnet10"),
829            Some("public.ecr.aws/lambda/dotnet:10".to_string())
830        );
831        assert_eq!(runtime_to_image("unknown"), None);
832    }
833
834    #[test]
835    fn test_extract_zip() {
836        // Create a minimal ZIP in memory
837        let buf = Vec::new();
838        let cursor = std::io::Cursor::new(buf);
839        let mut writer = zip::ZipWriter::new(cursor);
840        let options = zip::write::SimpleFileOptions::default();
841        writer.start_file("handler.py", options).unwrap();
842        writer
843            .write_all(b"def handler(event, context):\n    return {'statusCode': 200}\n")
844            .unwrap();
845        let cursor = writer.finish().unwrap();
846        let zip_bytes = cursor.into_inner();
847
848        let dir = TempDir::new().unwrap();
849        extract_zip(&zip_bytes, dir.path()).unwrap();
850
851        let handler_path = dir.path().join("handler.py");
852        assert!(handler_path.exists());
853
854        let mut content = String::new();
855        std::fs::File::open(&handler_path)
856            .unwrap()
857            .read_to_string(&mut content)
858            .unwrap();
859        assert!(content.contains("def handler"));
860    }
861}