Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions crates/fakecloud-e2e/tests/lambda_aws_env.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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}"
);
}
}
20 changes: 19 additions & 1 deletion crates/fakecloud-lambda/src/runtime/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}"));
}
Expand Down Expand Up @@ -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}"));
}
Expand Down
90 changes: 90 additions & 0 deletions crates/fakecloud-lambda/src/runtime/env_rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<region>:<account>:function:<n>`),
/// 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::*;
Expand Down Expand Up @@ -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"
);
}
}
Loading