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
12struct WarmContainer {
14 container_id: String,
15 host_port: u16,
16 last_used: RwLock<Instant>,
17 code_sha256: String,
18}
19
20pub struct ContainerRuntime {
22 cli: String,
23 containers: RwLock<HashMap<String, WarmContainer>>,
24 starting: RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
26 instance_id: String,
27 host_ip: String,
29 server_port: u16,
33 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 pub fn new(server_port: u16) -> Option<Self> {
59 let cli = if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
60 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 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 pub async fn invoke(
114 &self,
115 func: &LambdaFunction,
116 payload: &[u8],
117 ) -> Result<Vec<u8>, RuntimeError> {
118 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 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 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 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 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 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 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 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 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 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 .arg("--add-host")
389 .arg(format!("host.docker.internal:{}", self.host_ip));
390
391 for (key, value) in &func.environment {
392 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 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 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 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 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 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 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 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 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 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 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 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
637pub 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
668pub 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 #[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
710fn 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 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}