1use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use base64::Engine;
12use tempfile::TempDir;
13
14use super::backend::{BackendHandle, LambdaBackend, RuntimeError, WarmInstance};
15use super::env_rewrite::rewrite_localhost_envs;
16use crate::state::LambdaFunction;
17
18pub struct DockerBackend {
20 cli: String,
21 instance_id: String,
22 host_alias: String,
32 add_host_arg: Option<String>,
35 server_port: u16,
39 sibling_host: String,
49 docker_config: Option<Arc<TempDir>>,
53}
54
55impl DockerBackend {
56 pub fn auto_detect(server_port: u16) -> Option<Self> {
61 let cli = if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
62 if std::process::Command::new(&cli)
63 .arg("info")
64 .stdout(std::process::Stdio::null())
65 .stderr(std::process::Stdio::null())
66 .status()
67 .map(|s| s.success())
68 .unwrap_or(false)
69 {
70 cli
71 } else {
72 return None;
73 }
74 } else if is_cli_available("docker") {
75 "docker".to_string()
76 } else if is_cli_available("podman") {
77 "podman".to_string()
78 } else {
79 return None;
80 };
81
82 let instance_id = format!("fakecloud-{}", std::process::id());
83
84 let (host_alias, add_host_arg) = if is_podman_binary(&cli) {
85 ("host.containers.internal".to_string(), None)
90 } else if cfg!(target_os = "linux") {
91 let ip = detect_bridge_gateway(&cli).unwrap_or_else(|| "172.17.0.1".to_string());
96 (
97 "host.docker.internal".to_string(),
98 Some(format!("host.docker.internal:{ip}")),
99 )
100 } else {
101 (
104 "host.docker.internal".to_string(),
105 Some("host.docker.internal:host-gateway".to_string()),
106 )
107 };
108
109 let docker_config = build_local_registry_docker_config(server_port).map(Arc::new);
110 let sibling_host = resolve_sibling_host(std::env::var("FAKECLOUD_IN_CONTAINER").ok());
111 Some(Self {
112 cli,
113 instance_id,
114 host_alias,
115 add_host_arg,
116 server_port,
117 sibling_host,
118 docker_config,
119 })
120 }
121
122 fn apply_host_alias(&self, cmd: &mut tokio::process::Command) {
126 if let Some(arg) = &self.add_host_arg {
127 cmd.arg("--add-host").arg(arg);
128 }
129 }
130
131 fn docker_config_path(&self) -> Option<PathBuf> {
132 self.docker_config.as_ref().map(|d| d.path().to_path_buf())
133 }
134
135 async fn start_image_container(
141 &self,
142 func: &LambdaFunction,
143 layers: &[Vec<u8>],
144 ) -> Result<WarmInstance, RuntimeError> {
145 let image = func.image_uri.as_deref().ok_or_else(|| {
146 RuntimeError::ContainerStartFailed("PackageType=Image function has no ImageUri".into())
147 })?;
148
149 let local_pull_uri = fakecloud_core::ecr_uri::translate_to_local(image, self.server_port);
150 let pull_uri = local_pull_uri.as_deref().unwrap_or(image);
151
152 let mut pull_cmd = tokio::process::Command::new(&self.cli);
153 if let Some(p) = self.docker_config_path() {
154 pull_cmd.env("DOCKER_CONFIG", p);
155 }
156 let pull_out = pull_cmd
157 .args(["pull", pull_uri])
158 .output()
159 .await
160 .map_err(|e| RuntimeError::ContainerStartFailed(format!("docker pull: {e}")))?;
161 if !pull_out.status.success() {
162 return Err(RuntimeError::ContainerStartFailed(format!(
163 "docker pull failed: {}",
164 String::from_utf8_lossy(&pull_out.stderr)
165 )));
166 }
167 let run_image = if let Some(ref local_uri) = local_pull_uri {
172 if fakecloud_core::ecr_uri::is_digest_ref(image) {
173 local_uri.clone()
174 } else {
175 let _ = tokio::process::Command::new(&self.cli)
176 .args(["tag", local_uri, image])
177 .output()
178 .await;
179 image.to_string()
180 }
181 } else {
182 image.to_string()
183 };
184
185 let mut cmd = tokio::process::Command::new(&self.cli);
186 cmd.arg("create")
187 .arg("-p")
188 .arg(":8080")
189 .arg("--label")
190 .arg(format!("fakecloud-lambda={}", func.function_name))
191 .arg("--label")
192 .arg(format!("fakecloud-instance={}", self.instance_id));
193 self.apply_host_alias(&mut cmd);
194
195 for (key, value) in rewrite_localhost_envs(&func.environment, &self.host_alias) {
196 cmd.arg("-e").arg(format!("{key}={value}"));
197 }
198 cmd.arg("-e")
199 .arg(format!("AWS_LAMBDA_FUNCTION_TIMEOUT={}", func.timeout));
200
201 let tmpfs_arg = ephemeral_storage_tmpfs_arg(func.ephemeral_storage_size);
202 cmd.arg("--tmpfs").arg(tmpfs_arg);
203
204 cmd.arg(&run_image);
205
206 let output = cmd
207 .output()
208 .await
209 .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
210 if !output.status.success() {
211 return Err(RuntimeError::ContainerStartFailed(
212 String::from_utf8_lossy(&output.stderr).to_string(),
213 ));
214 }
215 let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
216
217 if let Err(e) = self.copy_layers_into(&container_id, layers).await {
218 self.remove_container(&container_id).await;
219 return Err(e);
220 }
221
222 let start_result = tokio::process::Command::new(&self.cli)
223 .args(["start", &container_id])
224 .output()
225 .await
226 .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
227 if !start_result.status.success() {
228 self.remove_container(&container_id).await;
229 return Err(RuntimeError::ContainerStartFailed(format!(
230 "docker start failed: {}",
231 String::from_utf8_lossy(&start_result.stderr)
232 )));
233 }
234
235 let port = self.query_host_port(&container_id).await?;
236 self.wait_for_ready(&container_id, port).await?;
237
238 tracing::info!(
239 function = %func.function_name,
240 container_id = %container_id,
241 port = port,
242 image = %image,
243 "Lambda image container started"
244 );
245
246 Ok(WarmInstance {
247 endpoint: format!("{}:{port}", self.sibling_host),
248 handle: BackendHandle::Container { id: container_id },
249 })
250 }
251
252 async fn start_zip_container(
253 &self,
254 func: &LambdaFunction,
255 zip_bytes: &[u8],
256 layers: &[Vec<u8>],
257 ) -> Result<WarmInstance, RuntimeError> {
258 let image = runtime_to_image(&func.runtime)
259 .ok_or_else(|| RuntimeError::UnsupportedRuntime(func.runtime.clone()))?;
260
261 let code_dir =
264 TempDir::new().map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
265 let zip_bytes = zip_bytes.to_vec();
266 let code_path = code_dir.path().to_path_buf();
267 tokio::task::spawn_blocking(move || extract_zip(&zip_bytes, &code_path))
268 .await
269 .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))??;
270
271 let mut cmd = tokio::process::Command::new(&self.cli);
273 cmd.arg("create")
274 .arg("-p")
275 .arg(":8080")
276 .arg("--label")
277 .arg(format!("fakecloud-lambda={}", func.function_name))
278 .arg("--label")
279 .arg(format!("fakecloud-instance={}", self.instance_id));
280 self.apply_host_alias(&mut cmd);
281
282 for (key, value) in rewrite_localhost_envs(&func.environment, &self.host_alias) {
283 cmd.arg("-e").arg(format!("{key}={value}"));
284 }
285
286 cmd.arg("-e")
287 .arg(format!("AWS_LAMBDA_FUNCTION_TIMEOUT={}", func.timeout));
288
289 let tmpfs_arg = ephemeral_storage_tmpfs_arg(func.ephemeral_storage_size);
290 cmd.arg("--tmpfs").arg(tmpfs_arg);
291
292 cmd.arg(&image).arg(&func.handler);
293
294 let output = cmd
295 .output()
296 .await
297 .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
298
299 if !output.status.success() {
300 let stderr = String::from_utf8_lossy(&output.stderr);
301 return Err(RuntimeError::ContainerStartFailed(stderr.to_string()));
302 }
303
304 let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
305
306 let cp_result = tokio::process::Command::new(&self.cli)
308 .arg("cp")
309 .arg(format!("{}/.", code_dir.path().display()))
310 .arg(format!("{}:/var/task", container_id))
311 .output()
312 .await
313 .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
314
315 if !cp_result.status.success() {
316 self.remove_container(&container_id).await;
317 let stderr = String::from_utf8_lossy(&cp_result.stderr);
318 return Err(RuntimeError::ContainerStartFailed(format!(
319 "docker cp failed: {stderr}"
320 )));
321 }
322
323 if func.runtime.starts_with("provided") {
325 let cp_runtime = tokio::process::Command::new(&self.cli)
326 .arg("cp")
327 .arg(format!("{}/.", code_dir.path().display()))
328 .arg(format!("{}:/var/runtime", container_id))
329 .output()
330 .await
331 .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
332
333 if !cp_runtime.status.success() {
334 self.remove_container(&container_id).await;
335 let stderr = String::from_utf8_lossy(&cp_runtime.stderr);
336 return Err(RuntimeError::ContainerStartFailed(format!(
337 "docker cp to /var/runtime failed: {stderr}"
338 )));
339 }
340 }
341
342 if let Err(e) = self.copy_layers_into(&container_id, layers).await {
343 self.remove_container(&container_id).await;
344 return Err(e);
345 }
346
347 let start_result = tokio::process::Command::new(&self.cli)
350 .args(["start", &container_id])
351 .output()
352 .await
353 .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
354
355 if !start_result.status.success() {
356 self.remove_container(&container_id).await;
357 let stderr = String::from_utf8_lossy(&start_result.stderr);
358 return Err(RuntimeError::ContainerStartFailed(format!(
359 "docker start failed: {stderr}"
360 )));
361 }
362
363 let port = self.query_host_port(&container_id).await?;
364 self.wait_for_ready(&container_id, port).await?;
365
366 tracing::info!(
367 function = %func.function_name,
368 container_id = %container_id,
369 port = port,
370 runtime = %func.runtime,
371 "Lambda container started"
372 );
373
374 Ok(WarmInstance {
375 endpoint: format!("{}:{port}", self.sibling_host),
376 handle: BackendHandle::Container { id: container_id },
377 })
378 }
379
380 async fn query_host_port(&self, container_id: &str) -> Result<u16, RuntimeError> {
381 let port_output = tokio::process::Command::new(&self.cli)
382 .args(["port", container_id, "8080"])
383 .output()
384 .await
385 .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
386 let port_str = String::from_utf8_lossy(&port_output.stdout);
387 port_str
388 .trim()
389 .rsplit(':')
390 .next()
391 .and_then(|p| p.parse().ok())
392 .ok_or_else(|| {
393 RuntimeError::ContainerStartFailed(format!(
394 "could not determine port from: {}",
395 port_str.trim()
396 ))
397 })
398 }
399
400 async fn wait_for_ready(&self, container_id: &str, port: u16) -> Result<(), RuntimeError> {
401 for _ in 0..20 {
402 tokio::time::sleep(Duration::from_millis(500)).await;
403 if tokio::net::TcpStream::connect(format!("{}:{port}", self.sibling_host))
404 .await
405 .is_ok()
406 {
407 return Ok(());
408 }
409 }
410 self.remove_container(container_id).await;
411 Err(RuntimeError::ContainerStartFailed(
412 "container did not become ready within 10 seconds".to_string(),
413 ))
414 }
415
416 async fn copy_layers_into(
423 &self,
424 container_id: &str,
425 layers: &[Vec<u8>],
426 ) -> Result<(), RuntimeError> {
427 if layers.is_empty() {
428 return Ok(());
429 }
430 let layers_dir =
431 TempDir::new().map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
432 let layers_path = layers_dir.path().to_path_buf();
433 let layers_owned: Vec<Vec<u8>> = layers.to_vec();
434 tokio::task::spawn_blocking(move || {
435 for bytes in &layers_owned {
436 extract_zip(bytes, &layers_path)?;
437 }
438 Ok::<_, RuntimeError>(())
439 })
440 .await
441 .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))??;
442
443 let cp_result = tokio::process::Command::new(&self.cli)
444 .arg("cp")
445 .arg(format!("{}/.", layers_dir.path().display()))
446 .arg(format!("{}:/opt", container_id))
447 .output()
448 .await
449 .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?;
450 if !cp_result.status.success() {
451 let stderr = String::from_utf8_lossy(&cp_result.stderr);
452 return Err(RuntimeError::ContainerStartFailed(format!(
453 "docker cp layers to /opt failed: {stderr}"
454 )));
455 }
456 Ok(())
457 }
458
459 async fn remove_container(&self, container_id: &str) {
461 let _ = tokio::process::Command::new(&self.cli)
462 .args(["rm", "-f", container_id])
463 .output()
464 .await;
465 }
466}
467
468#[async_trait]
469impl LambdaBackend for DockerBackend {
470 fn name(&self) -> &str {
471 &self.cli
472 }
473
474 async fn launch(
475 &self,
476 func: &LambdaFunction,
477 code_zip: Option<&[u8]>,
478 layers: &[Vec<u8>],
479 _deploy_id: &str,
480 ) -> Result<WarmInstance, RuntimeError> {
481 if func.package_type == "Image" {
482 self.start_image_container(func, layers).await
483 } else {
484 let bytes =
485 code_zip.ok_or_else(|| RuntimeError::NoCodeZip(func.function_name.clone()))?;
486 self.start_zip_container(func, bytes, layers).await
487 }
488 }
489
490 async fn terminate(&self, handle: &BackendHandle) {
491 match handle {
492 BackendHandle::Container { id } => self.remove_container(id).await,
493 BackendHandle::Pod { .. } => {}
496 }
497 }
498
499 async fn prepull_image(&self, image: &str) -> Result<(), RuntimeError> {
500 let local_uri = fakecloud_core::ecr_uri::translate_to_local(image, self.server_port);
504 let pull_uri = local_uri.as_deref().unwrap_or(image);
505
506 let mut cmd = tokio::process::Command::new(&self.cli);
507 if let Some(p) = self.docker_config_path() {
508 cmd.env("DOCKER_CONFIG", p);
509 }
510 let out = cmd
511 .args(["pull", pull_uri])
512 .output()
513 .await
514 .map_err(|e| RuntimeError::ContainerStartFailed(format!("docker pull: {e}")))?;
515 if !out.status.success() {
516 return Err(RuntimeError::ContainerStartFailed(format!(
517 "docker pull failed for {pull_uri}: {}",
518 String::from_utf8_lossy(&out.stderr)
519 )));
520 }
521 Ok(())
522 }
523}
524
525pub fn runtime_to_image(runtime: &str) -> Option<String> {
527 let (base, tag) = match runtime {
528 "python3.14" => ("python", "3.14"),
529 "python3.13" => ("python", "3.13"),
530 "python3.12" => ("python", "3.12"),
531 "python3.11" => ("python", "3.11"),
532 "python3.10" => ("python", "3.10"),
533 "python3.9" => ("python", "3.9"),
534 "python3.8" => ("python", "3.8"),
535 "nodejs24.x" => ("nodejs", "24"),
536 "nodejs22.x" => ("nodejs", "22"),
537 "nodejs20.x" => ("nodejs", "20"),
538 "nodejs18.x" => ("nodejs", "18"),
539 "nodejs16.x" => ("nodejs", "16"),
540 "ruby3.4" => ("ruby", "3.4"),
541 "ruby3.3" => ("ruby", "3.3"),
542 "java25" => ("java", "25"),
543 "java21" => ("java", "21"),
544 "java17" => ("java", "17"),
545 "java11" => ("java", "11"),
546 "dotnet10" => ("dotnet", "10"),
547 "dotnet8" => ("dotnet", "8"),
548 "go1.x" => ("go", "1"),
549 "provided.al2023" => ("provided", "al2023"),
550 "provided.al2" => ("provided", "al2"),
551 _ => return None,
552 };
553 Some(format!("public.ecr.aws/lambda/{base}:{tag}"))
554}
555
556pub(crate) fn ephemeral_storage_tmpfs_arg(size: Option<i64>) -> String {
568 let mib = size.unwrap_or(512).max(64);
569 format!("/tmp:size={mib}m,exec")
570}
571
572pub fn extract_zip(zip_bytes: &[u8], dest: &Path) -> Result<(), RuntimeError> {
574 let cursor = std::io::Cursor::new(zip_bytes);
575 let mut archive = zip::ZipArchive::new(cursor)
576 .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
577
578 for i in 0..archive.len() {
579 let mut file = archive
580 .by_index(i)
581 .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
582
583 let out_path = dest.join(file.enclosed_name().ok_or_else(|| {
584 RuntimeError::ZipExtractionFailed("invalid file name in ZIP".to_string())
585 })?);
586
587 if file.is_dir() {
588 std::fs::create_dir_all(&out_path)
589 .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
590 } else {
591 if let Some(parent) = out_path.parent() {
592 std::fs::create_dir_all(parent)
593 .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
594 }
595 let mut out_file = std::fs::File::create(&out_path)
596 .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
597 std::io::copy(&mut file, &mut out_file)
598 .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
599
600 #[cfg(unix)]
601 {
602 use std::os::unix::fs::PermissionsExt;
603 if let Some(mode) = file.unix_mode() {
604 std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))
605 .map_err(|e| RuntimeError::ZipExtractionFailed(e.to_string()))?;
606 }
607 }
608 }
609 }
610 Ok(())
611}
612
613fn detect_bridge_gateway(cli: &str) -> Option<String> {
615 let output = std::process::Command::new(cli)
616 .args([
617 "network",
618 "inspect",
619 "bridge",
620 "--format",
621 "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
622 ])
623 .output()
624 .ok()?;
625
626 if output.status.success() {
627 let gateway = String::from_utf8_lossy(&output.stdout).trim().to_string();
628 if !gateway.is_empty() && gateway.contains('.') {
629 tracing::info!(
630 gateway = %gateway,
631 "Detected Docker bridge gateway for Lambda containers"
632 );
633 return Some(gateway);
634 }
635 }
636 None
637}
638
639fn resolve_sibling_host(env_value: Option<String>) -> String {
649 let in_container = env_value
650 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
651 .unwrap_or(false);
652 if in_container {
653 "host.docker.internal".to_string()
654 } else {
655 "127.0.0.1".to_string()
656 }
657}
658
659fn is_cli_available(name: &str) -> bool {
660 std::process::Command::new(name)
661 .arg("info")
662 .stdout(std::process::Stdio::null())
663 .stderr(std::process::Stdio::null())
664 .status()
665 .map(|s| s.success())
666 .unwrap_or(false)
667}
668
669fn is_podman_binary(cli: &str) -> bool {
674 std::path::Path::new(cli)
675 .file_name()
676 .and_then(|n| n.to_str())
677 .map(|n| n.contains("podman"))
678 .unwrap_or(false)
679}
680
681fn build_local_registry_docker_config(server_port: u16) -> Option<TempDir> {
682 let dir = TempDir::new().ok()?;
683 let auth = base64::engine::general_purpose::STANDARD.encode("AWS:fakecloud-lambda-runtime");
684 let config = serde_json::json!({
685 "auths": {
686 format!("127.0.0.1:{server_port}"): { "auth": auth },
687 }
688 });
689 std::fs::write(dir.path().join("config.json"), config.to_string()).ok()?;
690 Some(dir)
691}
692
693#[cfg(test)]
694mod tests {
695 use std::io::{Read, Write};
696
697 use super::*;
698
699 #[test]
700 fn test_runtime_to_image() {
701 assert_eq!(
702 runtime_to_image("python3.12"),
703 Some("public.ecr.aws/lambda/python:3.12".to_string())
704 );
705 assert_eq!(
706 runtime_to_image("nodejs20.x"),
707 Some("public.ecr.aws/lambda/nodejs:20".to_string())
708 );
709 assert_eq!(
710 runtime_to_image("provided.al2023"),
711 Some("public.ecr.aws/lambda/provided:al2023".to_string())
712 );
713 assert_eq!(
714 runtime_to_image("ruby3.4"),
715 Some("public.ecr.aws/lambda/ruby:3.4".to_string())
716 );
717 assert_eq!(
718 runtime_to_image("java21"),
719 Some("public.ecr.aws/lambda/java:21".to_string())
720 );
721 assert_eq!(
722 runtime_to_image("dotnet8"),
723 Some("public.ecr.aws/lambda/dotnet:8".to_string())
724 );
725 assert_eq!(
726 runtime_to_image("nodejs16.x"),
727 Some("public.ecr.aws/lambda/nodejs:16".to_string())
728 );
729 assert_eq!(
730 runtime_to_image("python3.10"),
731 Some("public.ecr.aws/lambda/python:3.10".to_string())
732 );
733 assert_eq!(
734 runtime_to_image("python3.9"),
735 Some("public.ecr.aws/lambda/python:3.9".to_string())
736 );
737 assert_eq!(
738 runtime_to_image("python3.8"),
739 Some("public.ecr.aws/lambda/python:3.8".to_string())
740 );
741 assert_eq!(
742 runtime_to_image("java11"),
743 Some("public.ecr.aws/lambda/java:11".to_string())
744 );
745 assert_eq!(
746 runtime_to_image("go1.x"),
747 Some("public.ecr.aws/lambda/go:1".to_string())
748 );
749 assert_eq!(
750 runtime_to_image("nodejs24.x"),
751 Some("public.ecr.aws/lambda/nodejs:24".to_string())
752 );
753 assert_eq!(
754 runtime_to_image("python3.14"),
755 Some("public.ecr.aws/lambda/python:3.14".to_string())
756 );
757 assert_eq!(
758 runtime_to_image("java25"),
759 Some("public.ecr.aws/lambda/java:25".to_string())
760 );
761 assert_eq!(
762 runtime_to_image("dotnet10"),
763 Some("public.ecr.aws/lambda/dotnet:10".to_string())
764 );
765 assert_eq!(runtime_to_image("unknown"), None);
766 }
767
768 #[test]
769 fn is_podman_binary_matches_bare_name() {
770 assert!(is_podman_binary("podman"));
771 assert!(is_podman_binary("podman-remote"));
772 }
773
774 #[test]
775 fn is_podman_binary_matches_absolute_path() {
776 assert!(is_podman_binary("/opt/homebrew/bin/podman"));
777 assert!(is_podman_binary("/usr/local/bin/podman-remote"));
778 }
779
780 #[test]
781 fn is_podman_binary_rejects_docker() {
782 assert!(!is_podman_binary("docker"));
783 assert!(!is_podman_binary("/usr/local/bin/docker"));
784 assert!(!is_podman_binary("docker-credential-helper"));
786 }
787
788 #[test]
789 fn resolve_sibling_host_defaults_to_loopback() {
790 assert_eq!(resolve_sibling_host(None), "127.0.0.1");
791 assert_eq!(resolve_sibling_host(Some("".to_string())), "127.0.0.1");
792 assert_eq!(resolve_sibling_host(Some("0".to_string())), "127.0.0.1");
793 assert_eq!(resolve_sibling_host(Some("false".to_string())), "127.0.0.1");
794 }
795
796 #[test]
797 fn resolve_sibling_host_uses_docker_internal_when_in_container() {
798 assert_eq!(
799 resolve_sibling_host(Some("1".to_string())),
800 "host.docker.internal"
801 );
802 assert_eq!(
803 resolve_sibling_host(Some("true".to_string())),
804 "host.docker.internal"
805 );
806 assert_eq!(
807 resolve_sibling_host(Some("TRUE".to_string())),
808 "host.docker.internal"
809 );
810 }
811
812 #[test]
813 fn test_extract_zip() {
814 let buf = Vec::new();
815 let cursor = std::io::Cursor::new(buf);
816 let mut writer = zip::ZipWriter::new(cursor);
817 let options = zip::write::SimpleFileOptions::default();
818 writer.start_file("handler.py", options).unwrap();
819 writer
820 .write_all(b"def handler(event, context):\n return {'statusCode': 200}\n")
821 .unwrap();
822 let cursor = writer.finish().unwrap();
823 let zip_bytes = cursor.into_inner();
824
825 let dir = TempDir::new().unwrap();
826 extract_zip(&zip_bytes, dir.path()).unwrap();
827
828 let handler_path = dir.path().join("handler.py");
829 assert!(handler_path.exists());
830
831 let mut content = String::new();
832 std::fs::File::open(&handler_path)
833 .unwrap()
834 .read_to_string(&mut content)
835 .unwrap();
836 assert!(content.contains("def handler"));
837 }
838
839 #[test]
840 fn ephemeral_storage_tmpfs_arg_defaults_to_512_when_none() {
841 assert_eq!(ephemeral_storage_tmpfs_arg(None), "/tmp:size=512m,exec");
845 }
846
847 #[test]
848 fn ephemeral_storage_tmpfs_arg_uses_supplied_size() {
849 assert_eq!(
850 ephemeral_storage_tmpfs_arg(Some(2048)),
851 "/tmp:size=2048m,exec"
852 );
853 assert_eq!(
854 ephemeral_storage_tmpfs_arg(Some(10240)),
855 "/tmp:size=10240m,exec"
856 );
857 }
858
859 #[test]
860 fn ephemeral_storage_tmpfs_arg_clamps_to_64_floor() {
861 assert_eq!(ephemeral_storage_tmpfs_arg(Some(0)), "/tmp:size=64m,exec");
865 assert_eq!(ephemeral_storage_tmpfs_arg(Some(32)), "/tmp:size=64m,exec");
866 }
867}