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