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
13struct WarmContainer {
15 container_id: String,
16 host_port: u16,
17 last_used: RwLock<Instant>,
18 deploy_id: String,
23}
24
25fn 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
43pub struct ContainerRuntime {
45 cli: String,
46 containers: RwLock<HashMap<String, WarmContainer>>,
47 starting: RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
49 instance_id: String,
50 host_ip: String,
52 server_port: u16,
56 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 pub fn new(server_port: u16) -> Option<Self> {
82 let cli = if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
83 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 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 pub async fn invoke(
140 &self,
141 func: &LambdaFunction,
142 payload: &[u8],
143 layers: &[Vec<u8>],
144 ) -> Result<Vec<u8>, RuntimeError> {
145 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 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 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 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 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 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 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 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 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 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 .arg("--add-host")
428 .arg(format!("host.docker.internal:{}", self.host_ip));
429
430 for (key, value) in &func.environment {
431 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 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 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 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 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 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 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 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 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 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 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 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 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
724pub 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
755pub 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 #[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
797fn 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 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}