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.13" => ("python", "3.13"),
641        "python3.12" => ("python", "3.12"),
642        "python3.11" => ("python", "3.11"),
643        "nodejs22.x" => ("nodejs", "22"),
644        "nodejs20.x" => ("nodejs", "20"),
645        "nodejs18.x" => ("nodejs", "18"),
646        "ruby3.4" => ("ruby", "3.4"),
647        "ruby3.3" => ("ruby", "3.3"),
648        "java21" => ("java", "21"),
649        "java17" => ("java", "17"),
650        "dotnet8" => ("dotnet", "8"),
651        "provided.al2023" => ("provided", "al2023"),
652        "provided.al2" => ("provided", "al2"),
653        _ => return None,
654    };
655    Some(format!("public.ecr.aws/lambda/{}:{}", base, tag))
656}
657
658/// Extract a ZIP archive to a destination directory.
659pub fn extract_zip(zip_bytes: &[u8], dest: &Path) -> Result<(), RuntimeError> {
660    let cursor = std::io::Cursor::new(zip_bytes);
661    let mut archive = zip::ZipArchive::new(cursor)
662        .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
663
664    for i in 0..archive.len() {
665        let mut file = archive
666            .by_index(i)
667            .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
668
669        let out_path = dest.join(file.enclosed_name().ok_or_else(|| {
670            RuntimeError::ZipExtractionFailed("invalid file name in ZIP".to_string())
671        })?);
672
673        if file.is_dir() {
674            std::fs::create_dir_all(&out_path)
675                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
676        } else {
677            if let Some(parent) = out_path.parent() {
678                std::fs::create_dir_all(parent)
679                    .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
680            }
681            let mut out_file = std::fs::File::create(&out_path)
682                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
683            std::io::copy(&mut file, &mut out_file)
684                .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
685
686            // Preserve executable permissions
687            #[cfg(unix)]
688            {
689                use std::os::unix::fs::PermissionsExt;
690                if let Some(mode) = file.unix_mode() {
691                    std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))
692                        .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
693                }
694            }
695        }
696    }
697    Ok(())
698}
699
700/// Detect the Docker bridge gateway IP on Linux.
701/// Returns None if detection fails.
702fn detect_bridge_gateway(cli: &str) -> Option<String> {
703    let output = std::process::Command::new(cli)
704        .args([
705            "network",
706            "inspect",
707            "bridge",
708            "--format",
709            "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
710        ])
711        .output()
712        .ok()?;
713
714    if output.status.success() {
715        let gateway = String::from_utf8_lossy(&output.stdout).trim().to_string();
716        if !gateway.is_empty() && gateway.contains('.') {
717            tracing::info!(
718                gateway = %gateway,
719                "Detected Docker bridge gateway for Lambda containers"
720            );
721            return Some(gateway);
722        }
723    }
724    None
725}
726
727fn is_cli_available(name: &str) -> bool {
728    std::process::Command::new(name)
729        .arg("info")
730        .stdout(std::process::Stdio::null())
731        .stderr(std::process::Stdio::null())
732        .status()
733        .map(|s| s.success())
734        .unwrap_or(false)
735}
736
737fn build_local_registry_docker_config(server_port: u16) -> Option<TempDir> {
738    let dir = TempDir::new().ok()?;
739    let auth = base64::engine::general_purpose::STANDARD.encode("AWS:fakecloud-lambda-runtime");
740    let config = serde_json::json!({
741        "auths": {
742            format!("127.0.0.1:{server_port}"): { "auth": auth },
743        }
744    });
745    std::fs::write(dir.path().join("config.json"), config.to_string()).ok()?;
746    Some(dir)
747}
748
749#[cfg(test)]
750mod tests {
751    use std::io::{Read, Write};
752
753    use super::*;
754
755    #[test]
756    fn test_runtime_to_image() {
757        assert_eq!(
758            runtime_to_image("python3.12"),
759            Some("public.ecr.aws/lambda/python:3.12".to_string())
760        );
761        assert_eq!(
762            runtime_to_image("nodejs20.x"),
763            Some("public.ecr.aws/lambda/nodejs:20".to_string())
764        );
765        assert_eq!(
766            runtime_to_image("provided.al2023"),
767            Some("public.ecr.aws/lambda/provided:al2023".to_string())
768        );
769        assert_eq!(
770            runtime_to_image("ruby3.4"),
771            Some("public.ecr.aws/lambda/ruby:3.4".to_string())
772        );
773        assert_eq!(
774            runtime_to_image("java21"),
775            Some("public.ecr.aws/lambda/java:21".to_string())
776        );
777        assert_eq!(
778            runtime_to_image("dotnet8"),
779            Some("public.ecr.aws/lambda/dotnet:8".to_string())
780        );
781        assert_eq!(runtime_to_image("unknown"), None);
782    }
783
784    #[test]
785    fn test_extract_zip() {
786        // Create a minimal ZIP in memory
787        let buf = Vec::new();
788        let cursor = std::io::Cursor::new(buf);
789        let mut writer = zip::ZipWriter::new(cursor);
790        let options = zip::write::SimpleFileOptions::default();
791        writer.start_file("handler.py", options).unwrap();
792        writer
793            .write_all(b"def handler(event, context):\n    return {'statusCode': 200}\n")
794            .unwrap();
795        let cursor = writer.finish().unwrap();
796        let zip_bytes = cursor.into_inner();
797
798        let dir = TempDir::new().unwrap();
799        extract_zip(&zip_bytes, dir.path()).unwrap();
800
801        let handler_path = dir.path().join("handler.py");
802        assert!(handler_path.exists());
803
804        let mut content = String::new();
805        std::fs::File::open(&handler_path)
806            .unwrap()
807            .read_to_string(&mut content)
808            .unwrap();
809        assert!(content.contains("def handler"));
810    }
811}