From 26235561117150e88534515adec6dcc4504f1e33 Mon Sep 17 00:00:00 2001 From: James Price Date: Tue, 8 Sep 2026 16:38:42 +0100 Subject: [PATCH] feat(lambda): give function containers the AWS environment Real Lambda injects region and execution-role credentials, and function code relies on them: an SDK client built with no region or credentials fails before it reaches an endpoint. fakecloud injected none of the standard variables, only the function's own environment, so handler code that called AWS did nothing useful -- CDK's `BucketDeployment` reported success having copied no files. Inject `AWS_REGION`, `AWS_DEFAULT_REGION` and credentials, plus `AWS_ENDPOINT_URL` pointing at the backend's host alias and this server's port. The endpoint is the one deliberate deviation from AWS: there the variable is absent and the SDK's defaults are correct, whereas here the container has to be pointed back at fakecloud or the handler reaches out to the real internet. `localhost` would resolve to the container itself, so the existing host alias is reused. Defaults are emitted before the function's own environment, so a function that sets any of them wins. The E2E asserts the environment the container actually observes. It stops short of a full SDK round-trip from inside the container: that also needs container-to-host reachability on an arbitrary host port, which does not hold on every developer machine (WSL2 here), and would make the test flaky for reasons unrelated to this change. --- crates/fakecloud-e2e/tests/lambda_aws_env.rs | 117 ++++++++++++++++++ crates/fakecloud-lambda/src/runtime/docker.rs | 20 ++- .../src/runtime/env_rewrite.rs | 90 ++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 crates/fakecloud-e2e/tests/lambda_aws_env.rs 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" + ); + } }