diff --git a/crates/fakecloud-e2e/tests/lambda_aws_env.rs b/crates/fakecloud-e2e/tests/lambda_aws_env.rs new file mode 100644 index 000000000..c4f6ecfb3 --- /dev/null +++ b/crates/fakecloud-e2e/tests/lambda_aws_env.rs @@ -0,0 +1,117 @@ +//! A Lambda's container must be able to reach fakecloud, not real AWS. +//! +//! Real Lambda injects region and execution-role credentials, and function code +//! relies on them. fakecloud injected none, and nothing pointed the SDK at the +//! emulator, so handler code that called AWS silently targeted the internet — +//! CDK's `BucketDeployment` reported success having copied no files. + +mod helpers; + +use aws_sdk_lambda::primitives::Blob; +use aws_sdk_lambda::types::{FunctionCode, Runtime}; +use helpers::TestServer; + +fn docker_available() -> bool { + std::process::Command::new("docker") + .arg("info") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn build_python_handler_zip(body: &str) -> Vec { + use std::io::Write; + let mut buf = Vec::new(); + { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); + let opts: zip::write::FileOptions<'_, ()> = + zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("index.py", opts).unwrap(); + zip.write_all(body.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + buf +} + +#[tokio::test] +async fn lambda_container_receives_the_aws_environment() { + if !docker_available() { + eprintln!("docker required for Lambda execution; skipping"); + return; + } + let server = TestServer::start().await; + let lambda = server.lambda_client().await; + + // The handler reports the environment it actually sees inside the + // container, which is the thing that was missing. + let zip_bytes = build_python_handler_zip( + "import os\n\ + def handler(event, context):\n\ + \x20 return {k: os.environ.get(k) for k in\n\ + \x20 ('AWS_ENDPOINT_URL','AWS_REGION','AWS_DEFAULT_REGION',\n\ + \x20 'AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY')}\n", + ); + lambda + .create_function() + .function_name("env-probe-fn") + .runtime(Runtime::Python312) + .role("arn:aws:iam::123456789012:role/env-probe-role") + .handler("index.handler") + .timeout(30) + .code(FunctionCode::builder().zip_file(zip_bytes.into()).build()) + .send() + .await + .expect("create_function"); + + let invoked = lambda + .invoke() + .function_name("env-probe-fn") + .payload(Blob::new("{}")) + .send() + .await + .expect("invoke"); + let payload = String::from_utf8( + invoked + .payload() + .map(|b| b.as_ref().to_vec()) + .unwrap_or_default(), + ) + .unwrap_or_default(); + assert!( + invoked.function_error().is_none(), + "handler errored: {payload}" + ); + + let env: serde_json::Value = serde_json::from_str(&payload).expect("handler returned JSON"); + let endpoint = env["AWS_ENDPOINT_URL"] + .as_str() + .unwrap_or_default() + .to_string(); + + // Points back at fakecloud on the host, on the port this server bound. + assert!( + endpoint.ends_with(&format!(":{}", server.port())), + "endpoint {endpoint} should target this server's port {}", + server.port() + ); + // Never `localhost`: inside the container that is the container itself. + assert!( + !endpoint.contains("localhost:") && !endpoint.contains("127.0.0.1"), + "endpoint {endpoint} must use the container's host alias" + ); + // Real Lambda supplies these; an SDK client without them fails before it + // ever reaches an endpoint. + for key in [ + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + ] { + assert!( + env[key].as_str().is_some_and(|v| !v.is_empty()), + "{key} missing from the container environment: {payload}" + ); + } +} diff --git a/crates/fakecloud-lambda/src/runtime/docker.rs b/crates/fakecloud-lambda/src/runtime/docker.rs index a5d33906a..a92dae8ce 100644 --- a/crates/fakecloud-lambda/src/runtime/docker.rs +++ b/crates/fakecloud-lambda/src/runtime/docker.rs @@ -12,7 +12,7 @@ use base64::Engine; use tempfile::TempDir; use super::backend::{BackendHandle, LambdaBackend, RuntimeError, WarmInstance}; -use super::env_rewrite::rewrite_localhost_envs; +use super::env_rewrite::{default_aws_envs, region_from_function_arn, rewrite_localhost_envs}; use crate::state::LambdaFunction; /// Docker/Podman-based Lambda execution backend. @@ -159,6 +159,15 @@ impl DockerBackend { .arg(format!("fakecloud-instance={}", self.instance_id)); self.apply_host_alias(&mut cmd); + // Defaults first: docker's last `-e` wins, so the function's own + // environment overrides anything it sets for itself. + for (key, value) in default_aws_envs( + &self.host_alias, + self.server_port, + region_from_function_arn(&func.function_arn), + ) { + cmd.arg("-e").arg(format!("{key}={value}")); + } for (key, value) in rewrite_localhost_envs(&func.environment, &self.host_alias) { cmd.arg("-e").arg(format!("{key}={value}")); } @@ -246,6 +255,15 @@ impl DockerBackend { .arg(format!("fakecloud-instance={}", self.instance_id)); self.apply_host_alias(&mut cmd); + // Defaults first: docker's last `-e` wins, so the function's own + // environment overrides anything it sets for itself. + for (key, value) in default_aws_envs( + &self.host_alias, + self.server_port, + region_from_function_arn(&func.function_arn), + ) { + cmd.arg("-e").arg(format!("{key}={value}")); + } for (key, value) in rewrite_localhost_envs(&func.environment, &self.host_alias) { cmd.arg("-e").arg(format!("{key}={value}")); } diff --git a/crates/fakecloud-lambda/src/runtime/env_rewrite.rs b/crates/fakecloud-lambda/src/runtime/env_rewrite.rs index 94222f343..ecd91c181 100644 --- a/crates/fakecloud-lambda/src/runtime/env_rewrite.rs +++ b/crates/fakecloud-lambda/src/runtime/env_rewrite.rs @@ -30,6 +30,45 @@ fn rewrite_value(value: &str, target_host: &str) -> String { .replace("https://localhost:", &format!("https://{target_host}:")) } +/// The standard AWS environment a Lambda gets, plus the endpoint override +/// that keeps SDK calls inside fakecloud. +/// +/// Real Lambda injects region and execution-role credentials, and function code +/// relies on them: an SDK client constructed with no region or credentials +/// fails outright. fakecloud injected none of them, so handler code that called +/// AWS did nothing useful — CDK's `BucketDeployment` reported success having +/// copied no files. +/// +/// `AWS_ENDPOINT_URL` is the one deliberate deviation from AWS. On real Lambda +/// it is absent and the SDK's default endpoints are correct; here the container +/// must be pointed back at fakecloud on the host, or the handler reaches out to +/// real AWS instead. `host` is the backend's host alias, since `localhost` +/// inside the container is the container itself. +/// +/// The function's own environment is applied after these, so a function that +/// sets any of them keeps its value. +pub fn default_aws_envs(host: &str, port: u16, region: &str) -> Vec<(String, String)> { + [ + ("AWS_ENDPOINT_URL", format!("http://{host}:{port}")), + ("AWS_REGION", region.to_string()), + ("AWS_DEFAULT_REGION", region.to_string()), + ("AWS_ACCESS_KEY_ID", "test".to_string()), + ("AWS_SECRET_ACCESS_KEY", "test".to_string()), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect() +} + +/// Region from a function ARN (`arn:aws:lambda:::function:`), +/// falling back to `us-east-1` as the AWS SDKs do when none is configured. +pub fn region_from_function_arn(arn: &str) -> &str { + arn.split(':') + .nth(3) + .filter(|r| !r.is_empty()) + .unwrap_or("us-east-1") +} + #[cfg(test)] mod tests { use super::*; @@ -89,4 +128,55 @@ mod tests { ); assert_eq!(out[0].1, "http://h:4566 http://h:4566"); } + + #[test] + fn default_envs_point_the_sdk_at_fakecloud_on_the_host() { + let envs = default_aws_envs("host.docker.internal", 4566, "eu-west-2"); + let get = |k: &str| { + envs.iter() + .find(|(key, _)| key == k) + .map(|(_, v)| v.clone()) + }; + // Not `localhost`: inside the container that is the container itself. + assert_eq!( + get("AWS_ENDPOINT_URL").as_deref(), + Some("http://host.docker.internal:4566") + ); + assert_eq!(get("AWS_REGION").as_deref(), Some("eu-west-2")); + assert_eq!(get("AWS_DEFAULT_REGION").as_deref(), Some("eu-west-2")); + // Real Lambda supplies execution-role credentials; an SDK client with + // none fails before it ever reaches the endpoint. + assert!(get("AWS_ACCESS_KEY_ID").is_some()); + assert!(get("AWS_SECRET_ACCESS_KEY").is_some()); + } + + #[test] + fn function_environment_overrides_the_defaults() { + // Emitted defaults-first so a later `-e` wins, matching docker's + // last-one-wins semantics. + let defaults = default_aws_envs("host.docker.internal", 4566, "us-east-1"); + let user = rewrite_localhost_envs( + &env(&[("AWS_ENDPOINT_URL", "http://localhost:9999")]), + "host.docker.internal", + ); + let merged: Vec<(String, String)> = defaults.into_iter().chain(user).collect(); + let last = merged + .iter() + .rfind(|(k, _)| k == "AWS_ENDPOINT_URL") + .expect("endpoint present"); + assert_eq!(last.1, "http://host.docker.internal:9999"); + } + + #[test] + fn region_is_read_from_the_function_arn() { + assert_eq!( + region_from_function_arn("arn:aws:lambda:eu-west-2:123456789012:function:f"), + "eu-west-2" + ); + assert_eq!(region_from_function_arn("not-an-arn"), "us-east-1"); + assert_eq!( + region_from_function_arn("arn:aws:lambda::1:function:f"), + "us-east-1" + ); + } }