diff --git a/Cargo.lock b/Cargo.lock index ea60845e..aaea0cda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3046,6 +3046,8 @@ dependencies = [ "parking_lot", "serde", "serde_json", + "sha2 0.10.9", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-postgres", diff --git a/README.md b/README.md index 9b8d1b3a..e92ef741 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Other install options (Cargo, Docker, Docker Compose, source) are documented at | SES (v2 + v1 inbound) | 110 | Sending, templates, DKIM, **real receipt rule execution** | | Cognito User Pools | 122 | Pools, clients, MFA, identity providers, full auth flows; verification email -> SES, SMS -> SNS, all 12 Lambda triggers | | Kinesis | 39 | Streams, records, shard iterators, retention | -| RDS | 163 | Real Postgres, MySQL, MariaDB, Oracle, SQL Server, Db2 via Docker; lifecycle ops emit `aws.rds` EventBridge events | +| RDS | 163 | Real Postgres, MySQL, MariaDB, Oracle, SQL Server, Db2 via Docker; lifecycle ops emit `aws.rds` EventBridge events; PostgreSQL `aws_lambda` extension invokes fakecloud Lambda functions from SQL | | ElastiCache | 75 | Real Redis, Valkey, Memcached via Docker | | Step Functions | 37 | Full ASL interpreter, Lambda/SQS/SNS/EventBridge/DynamoDB tasks | | API Gateway v1 | 124 | REST APIs, resources, methods, integrations (`MOCK`/`HTTP`/`HTTP_PROXY`/`AWS_PROXY` Lambda), deployments, stages, API keys, usage plans, authorizers, models, request validators, VPC links, domain names, base path mappings, client certs, gateway responses, docs, tags | @@ -119,7 +119,7 @@ Full guides: [fakecloud.dev/docs/guides](https://fakecloud.dev/docs/guides). | Cognito User Pools | 122 operations | [Paid only](https://docs.localstack.cloud/references/licensing/) | | SES v2 | Full send + templates + DKIM + suppression | [Paid only](https://docs.localstack.cloud/references/licensing/) | | SES inbound email | Real receipt rule action execution | [Stored but never executed](https://docs.localstack.cloud/user-guide/aws/ses/) | -| RDS | 163 operations, PostgreSQL/MySQL/MariaDB/Oracle/SQL Server/Db2 via Docker | [Paid only](https://docs.localstack.cloud/references/licensing/) | +| RDS | 163 operations, PostgreSQL/MySQL/MariaDB/Oracle/SQL Server/Db2 via Docker, PostgreSQL `aws_lambda` extension | [Paid only](https://docs.localstack.cloud/references/licensing/) | | ElastiCache | 75 operations, Redis, Valkey, and Memcached via Docker | [Paid only](https://docs.localstack.cloud/references/licensing/) | | API Gateway v1 | 124 operations — REST APIs incl. real Lambda proxy data plane | [Paid only](https://docs.localstack.cloud/references/licensing/) | | API Gateway v2 | 103 operations — HTTP APIs + developer portals | [Paid only](https://docs.localstack.cloud/references/licensing/) | diff --git a/crates/fakecloud-e2e/tests/rds_aws_lambda.rs b/crates/fakecloud-e2e/tests/rds_aws_lambda.rs new file mode 100644 index 00000000..c735b37f --- /dev/null +++ b/crates/fakecloud-e2e/tests/rds_aws_lambda.rs @@ -0,0 +1,174 @@ +//! End-to-end tests for the RDS PostgreSQL `aws_lambda` extension. +//! +//! Drives a full happy path: create a Lambda, create a Postgres DB +//! instance (which triggers the lazy build of `fakecloud-postgres`), +//! connect via tokio_postgres, run `CREATE EXTENSION aws_lambda CASCADE`, +//! and call `aws_lambda.invoke()` with both a name and an +//! `aws_commons.create_lambda_function_arn` composite. Async (`Event`) +//! invocation path is exercised too. + +mod helpers; + +use std::io::Write; + +use aws_sdk_lambda::primitives::Blob; +use helpers::TestServer; +use tokio_postgres::NoTls; + +fn make_echo_zip() -> Vec { + // Returns the raw event back to the caller so we can verify the + // payload round-trips through plpython3u + the bridge endpoint. + let buf = Vec::new(); + let cursor = std::io::Cursor::new(buf); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default(); + writer.start_file("index.py", options).unwrap(); + writer + .write_all(b"def handler(event, context):\n return event\n") + .unwrap(); + let cursor = writer.finish().unwrap(); + cursor.into_inner() +} + +async fn connect_with_retry( + host: &str, + port: i32, + user: &str, + password: &str, + dbname: &str, +) -> tokio_postgres::Client { + let connection_string = + format!("host={host} port={port} user={user} password={password} dbname={dbname}"); + let mut last_error = None; + for _ in 0..30 { + match tokio_postgres::connect(&connection_string, NoTls).await { + Ok((client, connection)) => { + tokio::spawn(async move { + let _ = connection.await; + }); + return client; + } + Err(error) => { + last_error = Some(error); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } + } + panic!( + "could not connect to postgres at {}:{}: {:?}", + host, port, last_error + ); +} + +#[tokio::test] +async fn aws_lambda_extension_invoke_round_trip() { + let server = TestServer::start().await; + let lambda = server.lambda_client().await; + let rds = server.rds_client().await; + + // 1. Create the echo Lambda. + lambda + .create_function() + .function_name("echo") + .runtime(aws_sdk_lambda::types::Runtime::Python312) + .role("arn:aws:iam::000000000000:role/test-role") + .handler("index.handler") + .code( + aws_sdk_lambda::types::FunctionCode::builder() + .zip_file(Blob::new(make_echo_zip())) + .build(), + ) + .send() + .await + .expect("create echo lambda"); + + // 2. Create the Postgres DB instance — triggers lazy fakecloud-postgres + // image build on first run, so this can take a while. + let create = rds + .create_db_instance() + .db_instance_identifier("aws-lambda-ext-db") + .allocated_storage(20) + .db_instance_class("db.t3.micro") + .engine("postgres") + .engine_version("16.3") + .master_username("admin") + .master_user_password("secret123") + .db_name("appdb") + .send() + .await + .expect("create postgres instance"); + + let endpoint = create + .db_instance() + .and_then(|i| i.endpoint()) + .expect("endpoint"); + let host = endpoint.address().expect("address").to_string(); + let port = endpoint.port().expect("port"); + + // 3. Connect and load the extension. + let client = connect_with_retry(&host, port, "admin", "secret123", "appdb").await; + client + .simple_query("CREATE EXTENSION IF NOT EXISTS aws_lambda CASCADE") + .await + .expect("load aws_lambda extension"); + + // tokio-postgres in this workspace doesn't ship the with-serde_json-1 + // feature, so we can't bind a Rust `&str` to a postgres `json` + // parameter. Embed payloads as SQL literals (test fixtures, no + // injection concern) and cast result `payload` to text on the wire. + + // 4. Sync invoke by function name + json payload — payload round-trips. + let row = client + .query_one( + "SELECT status_code, payload::text \ + FROM aws_lambda.invoke('echo', '{\"hello\":\"world\"}'::json)", + &[], + ) + .await + .expect("invoke by name"); + let status_code: i32 = row.get(0); + let payload_text: String = row.get(1); + let payload: serde_json::Value = serde_json::from_str(&payload_text).unwrap(); + assert_eq!(status_code, 200); + assert_eq!(payload, serde_json::json!({"hello": "world"})); + + // 5. aws_commons.create_lambda_function_arn returns a composite type. + let arn_row = client + .query_one( + "SELECT (aws_commons.create_lambda_function_arn('echo')).function_name", + &[], + ) + .await + .expect("create_lambda_function_arn"); + let function_name: String = arn_row.get(0); + assert_eq!(function_name, "echo"); + + // 6. Sync invoke via the composite-typed overload. + let row = client + .query_one( + "SELECT status_code, payload::text FROM aws_lambda.invoke(\ + aws_commons.create_lambda_function_arn('echo'), '{\"k\":1}'::json)", + &[], + ) + .await + .expect("invoke via composite arn"); + let status_code: i32 = row.get(0); + let payload_text: String = row.get(1); + let payload: serde_json::Value = serde_json::from_str(&payload_text).unwrap(); + assert_eq!(status_code, 200); + assert_eq!(payload, serde_json::json!({"k": 1})); + + // 7. Async (Event) invocation returns 202 immediately. + let row = client + .query_one( + "SELECT status_code, payload::text FROM aws_lambda.invoke(\ + 'echo', '{\"async\":true}'::json, NULL, 'Event')", + &[], + ) + .await + .expect("invoke async"); + let status_code: i32 = row.get(0); + let payload_text: Option = row.get(1); + assert_eq!(status_code, 202); + assert!(payload_text.is_none()); +} diff --git a/crates/fakecloud-rds/Cargo.toml b/crates/fakecloud-rds/Cargo.toml index 03ba6f42..1bf66732 100644 --- a/crates/fakecloud-rds/Cargo.toml +++ b/crates/fakecloud-rds/Cargo.toml @@ -25,3 +25,5 @@ tokio-postgres = { workspace = true } mysql_async = "0.34" base64 = { workspace = true } tracing = { workspace = true } +sha2 = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/fakecloud-rds/assets/postgres/Dockerfile b/crates/fakecloud-rds/assets/postgres/Dockerfile new file mode 100644 index 00000000..7ec37949 --- /dev/null +++ b/crates/fakecloud-rds/assets/postgres/Dockerfile @@ -0,0 +1,13 @@ +ARG PG_VERSION=16 +FROM postgres:${PG_VERSION} +ARG PG_VERSION + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + postgresql-plpython3-${PG_VERSION} \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY aws_commons.control aws_commons--1.0.sql \ + aws_lambda.control aws_lambda--1.0.sql \ + /usr/share/postgresql/${PG_VERSION}/extension/ diff --git a/crates/fakecloud-rds/assets/postgres/aws_commons--1.0.sql b/crates/fakecloud-rds/assets/postgres/aws_commons--1.0.sql new file mode 100644 index 00000000..6bd11a8e --- /dev/null +++ b/crates/fakecloud-rds/assets/postgres/aws_commons--1.0.sql @@ -0,0 +1,24 @@ +-- aws_commons extension v1.0 (fakecloud) +-- Provides composite types and helpers used by aws_lambda and aws_s3 RDS extensions. + +\echo Use "CREATE EXTENSION aws_commons" to load this file. \quit + +CREATE TYPE aws_commons._lambda_function_arn_1 AS ( + function_name text, + region text +); + +CREATE FUNCTION aws_commons.create_lambda_function_arn( + function_name text, + region text DEFAULT NULL +) RETURNS aws_commons._lambda_function_arn_1 +LANGUAGE plpgsql IMMUTABLE +AS $$ +DECLARE + result aws_commons._lambda_function_arn_1; +BEGIN + result.function_name := function_name; + result.region := region; + RETURN result; +END; +$$; diff --git a/crates/fakecloud-rds/assets/postgres/aws_commons.control b/crates/fakecloud-rds/assets/postgres/aws_commons.control new file mode 100644 index 00000000..fe261e5f --- /dev/null +++ b/crates/fakecloud-rds/assets/postgres/aws_commons.control @@ -0,0 +1,4 @@ +comment = 'AWS commons types and helpers (fakecloud)' +default_version = '1.0' +relocatable = false +schema = 'aws_commons' diff --git a/crates/fakecloud-rds/assets/postgres/aws_lambda--1.0.sql b/crates/fakecloud-rds/assets/postgres/aws_lambda--1.0.sql new file mode 100644 index 00000000..ad7d771d --- /dev/null +++ b/crates/fakecloud-rds/assets/postgres/aws_lambda--1.0.sql @@ -0,0 +1,101 @@ +-- aws_lambda extension v1.0 (fakecloud) +-- Calls fakecloud Lambda invocations through a host bridge endpoint. + +\echo Use "CREATE EXTENSION aws_lambda CASCADE" to load this file. \quit + +CREATE FUNCTION aws_lambda.invoke( + function_name text, + payload json, + region text DEFAULT NULL, + invocation_type text DEFAULT 'RequestResponse' +) RETURNS TABLE( + status_code integer, + payload json, + executed_version text, + log_result text +) +LANGUAGE plpython3u +AS $$ +import json +import os +import urllib.request +import urllib.error + +endpoint = os.environ.get('FAKECLOUD_ENDPOINT') +if not endpoint: + plpy.error('aws_lambda: FAKECLOUD_ENDPOINT not set on the database container') + +account_id = os.environ.get('FAKECLOUD_ACCOUNT_ID', '000000000000') +default_region = os.environ.get('FAKECLOUD_REGION', 'us-east-1') + +body = { + 'function_name': function_name, + 'payload': json.loads(payload) if payload is not None else None, + 'invocation_type': invocation_type, + 'region': region or default_region, +} + +req = urllib.request.Request( + endpoint.rstrip('/') + '/_fakecloud/rds/lambda-invoke', + data=json.dumps(body).encode('utf-8'), + headers={ + 'Content-Type': 'application/json', + 'X-Fakecloud-Account-Id': account_id, + }, + method='POST', +) + +http_status = None +try: + with urllib.request.urlopen(req, timeout=300) as resp: + raw = resp.read() + http_status = resp.status +except urllib.error.HTTPError as e: + raw = e.read() + http_status = e.code +except Exception as e: + plpy.error('aws_lambda: bridge call failed: {}'.format(e)) + +try: + parsed = json.loads(raw) +except ValueError: + parsed = { + 'status_code': http_status, + 'payload': {'errorMessage': raw.decode('utf-8', errors='replace')}, + } + +status_code = parsed.get('status_code') +if status_code is None: + status_code = http_status if http_status is not None else 0 + +return [( + int(status_code), + json.dumps(parsed.get('payload')) if parsed.get('payload') is not None else None, + parsed.get('executed_version'), + parsed.get('log_result'), +)] +$$; + +-- LANGUAGE SQL keeps the user-facing arg name `payload` even though it +-- also names a RETURNS TABLE column. PL/pgSQL would reject that as a +-- duplicate identifier in the function namespace; SQL doesn't. +CREATE FUNCTION aws_lambda.invoke( + function_name aws_commons._lambda_function_arn_1, + payload json, + region text DEFAULT NULL, + invocation_type text DEFAULT 'RequestResponse' +) RETURNS TABLE( + status_code integer, + payload json, + executed_version text, + log_result text +) +LANGUAGE SQL +AS $$ + SELECT * FROM aws_lambda.invoke( + (function_name).function_name, + payload, + COALESCE(region, (function_name).region), + invocation_type + ); +$$; diff --git a/crates/fakecloud-rds/assets/postgres/aws_lambda.control b/crates/fakecloud-rds/assets/postgres/aws_lambda.control new file mode 100644 index 00000000..b230e735 --- /dev/null +++ b/crates/fakecloud-rds/assets/postgres/aws_lambda.control @@ -0,0 +1,5 @@ +comment = 'AWS Lambda invocation from PostgreSQL (fakecloud)' +default_version = '1.0' +relocatable = false +schema = 'aws_lambda' +requires = 'plpython3u, aws_commons' diff --git a/crates/fakecloud-rds/src/runtime.rs b/crates/fakecloud-rds/src/runtime.rs index 21c2d4dd..5cf09d7b 100644 --- a/crates/fakecloud-rds/src/runtime.rs +++ b/crates/fakecloud-rds/src/runtime.rs @@ -1,9 +1,17 @@ use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use parking_lot::RwLock; +use sha2::{Digest, Sha256}; use tokio_postgres::NoTls; +const POSTGRES_DOCKERFILE: &str = include_str!("../assets/postgres/Dockerfile"); +const AWS_COMMONS_CONTROL: &str = include_str!("../assets/postgres/aws_commons.control"); +const AWS_COMMONS_SQL: &str = include_str!("../assets/postgres/aws_commons--1.0.sql"); +const AWS_LAMBDA_CONTROL: &str = include_str!("../assets/postgres/aws_lambda.control"); +const AWS_LAMBDA_SQL: &str = include_str!("../assets/postgres/aws_lambda--1.0.sql"); + #[derive(Debug, Clone)] pub struct RunningDbContainer { pub container_id: String, @@ -14,6 +22,9 @@ pub struct RdsRuntime { cli: String, containers: RwLock>, instance_id: String, + host_ip: String, + server_port: u16, + image_cache: RwLock>>>, } #[derive(Debug, thiserror::Error)] @@ -25,7 +36,7 @@ pub enum RuntimeError { } impl RdsRuntime { - pub fn new() -> Option { + pub fn new(server_port: u16) -> Option { let cli = if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") { if cli_available(&cli) { cli @@ -40,10 +51,22 @@ impl RdsRuntime { return None; }; + // Match Lambda runtime container-to-host networking: Linux uses the + // bridge gateway IP directly, macOS/Windows use Docker Desktop's + // host-gateway alias. Containers reach fakecloud at host.docker.internal. + let host_ip = if cfg!(target_os = "linux") { + detect_bridge_gateway(&cli).unwrap_or_else(|| "172.17.0.1".to_string()) + } else { + "host-gateway".to_string() + }; + Some(Self { cli, containers: RwLock::new(HashMap::new()), instance_id: format!("fakecloud-{}", std::process::id()), + host_ip, + server_port, + image_cache: RwLock::new(HashMap::new()), }) } @@ -51,6 +74,7 @@ impl RdsRuntime { &self.cli } + #[allow(clippy::too_many_arguments)] pub async fn ensure_postgres( &self, db_instance_identifier: &str, @@ -59,20 +83,31 @@ impl RdsRuntime { username: &str, password: &str, db_name: &str, + account_id: &str, + region: &str, ) -> Result { self.stop_container(db_instance_identifier).await; - // Determine Docker image and port based on engine - let (image, port, env_vars) = match engine { + // Determine Docker image and port based on engine. The postgres + // image is built locally (lazily) so we can ship the aws_lambda / + // aws_commons extensions plus plpython3u; other engines stay on + // upstream images. + let (image, port, env_vars, postgres_major) = match engine { "postgres" => { let major_version = engine_version.split('.').next().unwrap_or("16"); - let image = format!("postgres:{}-alpine", major_version); + let image = self.ensure_postgres_image(major_version).await?; let env_vars = vec![ format!("POSTGRES_USER={username}"), format!("POSTGRES_PASSWORD={password}"), format!("POSTGRES_DB={db_name}"), + format!( + "FAKECLOUD_ENDPOINT=http://host.docker.internal:{}", + self.server_port + ), + format!("FAKECLOUD_ACCOUNT_ID={account_id}"), + format!("FAKECLOUD_REGION={region}"), ]; - (image, "5432", env_vars) + (image, "5432", env_vars, Some(major_version.to_string())) } "mysql" => { let major_version = if engine_version.starts_with("5.7") { @@ -87,7 +122,7 @@ impl RdsRuntime { format!("MYSQL_PASSWORD={password}"), format!("MYSQL_DATABASE={db_name}"), ]; - (image, "3306", env_vars) + (image, "3306", env_vars, None) } "mariadb" => { let major_version = if engine_version.starts_with("10.11") { @@ -102,7 +137,7 @@ impl RdsRuntime { format!("MARIADB_PASSWORD={password}"), format!("MARIADB_DATABASE={db_name}"), ]; - (image, "3306", env_vars) + (image, "3306", env_vars, None) } "oracle-ee" | "oracle-se2" | "oracle-ee-cdb" | "oracle-se2-cdb" => { // Oracle Database Free is the no-cost dev edition shipped by @@ -116,7 +151,7 @@ impl RdsRuntime { format!("APP_USER_PASSWORD={password}"), format!("ORACLE_DATABASE={db_name}"), ]; - (image, "1521", env_vars) + (image, "1521", env_vars, None) } "sqlserver-ee" | "sqlserver-se" | "sqlserver-ex" | "sqlserver-web" => { // SQL Server Express is free for dev/test with no license @@ -130,7 +165,7 @@ impl RdsRuntime { format!("MSSQL_SA_PASSWORD={password}"), "MSSQL_PID=Express".to_string(), ]; - (image, "1433", env_vars) + (image, "1433", env_vars, None) } "db2-se" | "db2-ae" => { // Db2 Community Edition is free under the standard IBM @@ -143,7 +178,7 @@ impl RdsRuntime { format!("DB2INST1_PASSWORD={password}"), format!("DBNAME={db_name}"), ]; - (image, "50000", env_vars) + (image, "50000", env_vars, None) } _ => { return Err(RuntimeError::ContainerStartFailed(format!( @@ -171,6 +206,14 @@ impl RdsRuntime { args.push("--privileged".to_string()); } + // Postgres runs the aws_lambda extension which calls back into + // fakecloud over HTTP. Wire the bridge alias so plpython3u code + // can resolve host.docker.internal on every platform. + if postgres_major.is_some() { + args.push("--add-host".to_string()); + args.push(format!("host.docker.internal:{}", self.host_ip)); + } + for env_var in env_vars { args.push("-e".to_string()); args.push(env_var); @@ -525,6 +568,98 @@ impl RdsRuntime { .await; } + /// Build (or reuse) the fakecloud-postgres image for a given major + /// version. The image bakes plpython3u plus the aws_commons and + /// aws_lambda extension files so users can run + /// `CREATE EXTENSION aws_lambda CASCADE` inside any database. + /// Tag includes a content hash so changes to the embedded assets + /// invalidate the local cache automatically. + pub(crate) async fn ensure_postgres_image( + &self, + major_version: &str, + ) -> Result { + let tag = format!( + "fakecloud-postgres:{}-{}", + major_version, + postgres_assets_hash() + ); + + // Per-tag mutex so concurrent first-creates don't both shell out + // to `docker build` for the same image. Inner bool tracks whether + // the build has already succeeded in this process. + let lock = { + let mut cache = self.image_cache.write(); + cache + .entry(tag.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(false))) + .clone() + }; + let mut built = lock.lock().await; + if *built { + return Ok(tag); + } + + // Even within a single process, the image may already exist on + // the daemon from a prior run. Skip the build if `image inspect` + // succeeds. + let inspect = tokio::process::Command::new(&self.cli) + .args(["image", "inspect", &tag]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?; + if inspect.success() { + *built = true; + return Ok(tag); + } + + let build_dir = + tempfile::tempdir().map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?; + let assets: [(&str, &str); 5] = [ + ("Dockerfile", POSTGRES_DOCKERFILE), + ("aws_commons.control", AWS_COMMONS_CONTROL), + ("aws_commons--1.0.sql", AWS_COMMONS_SQL), + ("aws_lambda.control", AWS_LAMBDA_CONTROL), + ("aws_lambda--1.0.sql", AWS_LAMBDA_SQL), + ]; + for (name, contents) in assets { + tokio::fs::write(build_dir.path().join(name), contents) + .await + .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?; + } + + tracing::info!( + tag = %tag, + "Building fakecloud-postgres image with aws_lambda extension (first use can take ~60s)" + ); + + let output = tokio::process::Command::new(&self.cli) + .args([ + "build", + "--build-arg", + &format!("PG_VERSION={major_version}"), + "-t", + &tag, + ".", + ]) + .current_dir(build_dir.path()) + .output() + .await + .map_err(|e| RuntimeError::ContainerStartFailed(e.to_string()))?; + + if !output.status.success() { + return Err(RuntimeError::ContainerStartFailed(format!( + "docker build for {} failed: {}", + tag, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + + *built = true; + Ok(tag) + } + pub async fn dump_database( &self, db_instance_identifier: &str, @@ -691,3 +826,49 @@ fn cli_available(cli: &str) -> bool { .map(|status| status.success()) .unwrap_or(false) } + +/// On Linux Docker bridge networks, ask the daemon for the bridge +/// gateway IP so containers can reach the host's loopback. macOS and +/// Windows use the magic `host-gateway` alias instead. +fn detect_bridge_gateway(cli: &str) -> Option { + let output = std::process::Command::new(cli) + .args([ + "network", + "inspect", + "bridge", + "--format", + "{{range .IPAM.Config}}{{.Gateway}}{{end}}", + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let gateway = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if gateway.is_empty() || !gateway.contains('.') { + return None; + } + Some(gateway) +} + +/// Stable hash of the postgres image build context used as a tag suffix +/// — changes to the Dockerfile or extension files invalidate cached +/// images automatically. +fn postgres_assets_hash() -> &'static str { + use std::sync::OnceLock; + static HASH: OnceLock = OnceLock::new(); + HASH.get_or_init(|| { + let mut hasher = Sha256::new(); + hasher.update(POSTGRES_DOCKERFILE.as_bytes()); + hasher.update(AWS_COMMONS_CONTROL.as_bytes()); + hasher.update(AWS_COMMONS_SQL.as_bytes()); + hasher.update(AWS_LAMBDA_CONTROL.as_bytes()); + hasher.update(AWS_LAMBDA_SQL.as_bytes()); + let digest = hasher.finalize(); + digest.iter().take(6).fold(String::new(), |mut acc, b| { + use std::fmt::Write; + let _ = write!(acc, "{:02x}", b); + acc + }) + }) +} diff --git a/crates/fakecloud-rds/src/service.rs b/crates/fakecloud-rds/src/service.rs index 4db4169a..65454105 100644 --- a/crates/fakecloud-rds/src/service.rs +++ b/crates/fakecloud-rds/src/service.rs @@ -504,6 +504,8 @@ impl RdsService { &master_username, &master_user_password, &logical_db_name, + &request.account_id, + &request.region, ) .await .map_err(|error| { @@ -1472,6 +1474,8 @@ impl RdsService { &snapshot.master_username, &snapshot.master_user_password, db_name, + &request.account_id, + &request.region, ) .await { @@ -1626,6 +1630,8 @@ impl RdsService { &source_instance.master_username, &source_instance.master_user_password, &db_name, + &request.account_id, + &request.region, ) .await { diff --git a/crates/fakecloud-sdk/src/types.rs b/crates/fakecloud-sdk/src/types.rs index 89492b63..5b15696d 100644 --- a/crates/fakecloud-sdk/src/types.rs +++ b/crates/fakecloud-sdk/src/types.rs @@ -298,6 +298,32 @@ pub struct FireRuleRequest { pub rule_name: String, } +// ── RDS aws_lambda extension bridge ───────────────────────────────── + +/// Request body for `POST /_fakecloud/rds/lambda-invoke`. The endpoint is +/// the bridge that the PostgreSQL `aws_lambda` extension calls into from +/// inside an RDS DB instance container — it's normally not driven by +/// user code directly. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct RdsLambdaInvokeRequest { + pub function_name: String, + pub payload: Option, + pub invocation_type: Option, + pub region: Option, +} + +/// Shape returned by the bridge — mirrors what `aws_lambda.invoke()` +/// returns to SQL callers (RDS/Aurora-compatible). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct RdsLambdaInvokeResponse { + pub status_code: i32, + pub payload: Option, + pub executed_version: Option, + pub log_result: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FireRuleTarget { diff --git a/crates/fakecloud-server/src/main.rs b/crates/fakecloud-server/src/main.rs index 8d1f9440..019acee7 100644 --- a/crates/fakecloud-server/src/main.rs +++ b/crates/fakecloud-server/src/main.rs @@ -370,7 +370,7 @@ async fn main() { )), ); - let rds_runtime = fakecloud_rds::runtime::RdsRuntime::new().map(Arc::new); + let rds_runtime = fakecloud_rds::runtime::RdsRuntime::new(bound_addr.port()).map(Arc::new); if let Some(ref rt) = rds_runtime { tracing::info!( cli = rt.cli_name(), @@ -1796,8 +1796,12 @@ async fn main() { ), ), ); - let rds_delivery_bus = Arc::new(DeliveryBus::new().with_eventbridge(eb_delivery_for_rds)); - rds_service = rds_service.with_delivery_bus(rds_delivery_bus); + let mut rds_bus = DeliveryBus::new().with_eventbridge(eb_delivery_for_rds); + if let Some(ref ld) = lambda_delivery { + rds_bus = rds_bus.with_lambda(ld.clone()); + } + let rds_delivery_bus = Arc::new(rds_bus); + rds_service = rds_service.with_delivery_bus(rds_delivery_bus.clone()); registry.register(Arc::new(rds_service)); let elasticache_snapshot_store: Option> = if persistence_config.mode == fakecloud_persistence::StorageMode::Persistent { @@ -3479,6 +3483,104 @@ async fn main() { } }), ) + .route( + "/_fakecloud/rds/lambda-invoke", + axum::routing::post({ + let bridge_lambda = lambda_delivery.clone(); + move |headers: axum::http::HeaderMap, + axum::Json(body): axum::Json| { + let bridge_lambda = bridge_lambda.clone(); + async move { + let Some(ld) = bridge_lambda else { + return ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + axum::Json(serde_json::json!({ + "status_code": 502, + "payload": { "errorMessage": "Lambda runtime not available on this fakecloud server" }, + "executed_version": null, + "log_result": null, + })), + ); + }; + let account_id = headers + .get("x-fakecloud-account-id") + .and_then(|v| v.to_str().ok()) + .unwrap_or("000000000000") + .to_string(); + let region = body + .region + .clone() + .unwrap_or_else(|| "us-east-1".to_string()); + let function_arn = if body.function_name.starts_with("arn:") { + body.function_name.clone() + } else { + format!( + "arn:aws:lambda:{}:{}:function:{}", + region, account_id, body.function_name + ) + }; + let payload_str = body + .payload + .as_ref() + .map(|v| v.to_string()) + .unwrap_or_else(|| "null".to_string()); + let invocation_type = body + .invocation_type + .as_deref() + .unwrap_or("RequestResponse") + .to_string(); + + if invocation_type == "Event" { + let arn = function_arn.clone(); + let payload = payload_str.clone(); + tokio::spawn(async move { + let _ = ld.invoke_lambda(&arn, &payload).await; + }); + return ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ + "status_code": 202, + "payload": null, + "executed_version": "$LATEST", + "log_result": null, + })), + ); + } + + match ld.invoke_lambda(&function_arn, &payload_str).await { + Ok(bytes) => { + let payload_value = serde_json::from_slice::( + &bytes, + ) + .unwrap_or_else(|_| { + serde_json::Value::String( + String::from_utf8_lossy(&bytes).to_string(), + ) + }); + ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ + "status_code": 200, + "payload": payload_value, + "executed_version": "$LATEST", + "log_result": null, + })), + ) + } + Err(msg) => ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ + "status_code": 502, + "payload": { "errorMessage": msg }, + "executed_version": null, + "log_result": null, + })), + ), + } + } + } + }), + ) .route( "/_fakecloud/elasticache/clusters", axum::routing::get({ diff --git a/sdks/typescript/tests/e2e.test.ts b/sdks/typescript/tests/e2e.test.ts index 41dd3598..587dc0b4 100644 --- a/sdks/typescript/tests/e2e.test.ts +++ b/sdks/typescript/tests/e2e.test.ts @@ -101,6 +101,8 @@ describe("health", () => { }); describe("rds", () => { + // First run on a fresh runner builds the fakecloud-postgres image + // (plpython3u + aws_lambda extension files); allow up to 3 minutes. it("getInstances() returns fakecloud-managed DB instances", async () => { const rds = new RDSClient(awsConfig()); @@ -126,7 +128,7 @@ describe("rds", () => { expect(instance!.dbName).toBe("appdb"); expect(instance!.containerId.length).toBeGreaterThan(0); expect(instance!.hostPort).toBeGreaterThan(0); - }); + }, 180_000); }); // ── ElastiCache ───────────────────────────────────────────────────── diff --git a/website/content/docs/services/rds.md b/website/content/docs/services/rds.md index 816b7adb..740bc840 100644 --- a/website/content/docs/services/rds.md +++ b/website/content/docs/services/rds.md @@ -22,6 +22,7 @@ fakecloud implements **163 of 163** RDS operations at 100% Smithy conformance. D - **Dump and restore** — MySQL and MariaDB database dumps for snapshot/restore flows - **License models** — tracking - **EventBridge events** — lifecycle ops emit `aws.rds` events on the `default` bus, deliverable to SQS, SNS, Lambda, etc. via standard EB rules +- **PostgreSQL `aws_lambda` extension** — call fakecloud Lambda functions from inside RDS PostgreSQL via `CREATE EXTENSION aws_lambda CASCADE` and `aws_lambda.invoke(...)` (subset of the AWS RDS extension surface; see below) ## EventBridge integration @@ -53,6 +54,34 @@ Query protocol. Form-encoded body, `Action` parameter, XML responses. ## Introspection - `GET /_fakecloud/rds/instances` — list fakecloud-managed DB instances with runtime metadata (container id, host port) +- `POST /_fakecloud/rds/lambda-invoke` — internal bridge used by the PostgreSQL `aws_lambda` extension to invoke fakecloud Lambda functions from inside the DB container + +## PostgreSQL `aws_lambda` extension + +Matches the AWS RDS extension of the same name. Lets SQL running inside an RDS-managed PostgreSQL instance invoke fakecloud Lambda functions: + +```sql +CREATE EXTENSION IF NOT EXISTS aws_lambda CASCADE; + +SELECT aws_commons.create_lambda_function_arn('my_function'); + +SELECT * FROM aws_lambda.invoke( + 'my_function', + '{"body":"Hello!"}'::json +); +``` + +Implemented function signatures (subset of the AWS RDS Lambda API): + +- `aws_lambda.invoke(function_name text, payload json, region text DEFAULT NULL, invocation_type text DEFAULT 'RequestResponse')` -> returns `(status_code int, payload json, executed_version text, log_result text)` +- `aws_lambda.invoke(function_name aws_commons._lambda_function_arn_1, payload json, region text DEFAULT NULL, invocation_type text DEFAULT 'RequestResponse')` (composite-typed overload) +- `aws_commons.create_lambda_function_arn(function_name text, region text DEFAULT NULL)` -> composite of `(function_name, region)` + +`invocation_type = 'Event'` returns `(202, NULL, '$LATEST', NULL)` immediately and runs the Lambda asynchronously. + +The first time you create a PostgreSQL DB instance, fakecloud lazily builds a `fakecloud-postgres:-` Docker image off `postgres:` that bakes in `plpython3u` and the extension files. The build typically takes ~60s and the image is cached locally for subsequent runs (the hash invalidates the cache when fakecloud changes the embedded extension definitions). + +Inside the container, the extension's `plpython3u` body POSTs to `http://host.docker.internal:/_fakecloud/rds/lambda-invoke`, which routes through fakecloud's standard Lambda invocation path. ## How the Docker integration works @@ -64,7 +93,7 @@ When you call `CreateDBInstance` for PostgreSQL/MySQL/MariaDB/Oracle/SQL Server/ | Engine | Image | Port | Wait probe | |--------|-------|------|------------| -| `postgres` | `postgres:-alpine` | 5432 | `tokio-postgres` ping | +| `postgres` | `fakecloud-postgres:-` (built locally on top of `postgres:`, adds `plpython3u` + the `aws_lambda` and `aws_commons` extensions) | 5432 | `tokio-postgres` ping | | `mysql` | `mysql:` | 3306 | `mysql_async` ping | | `mariadb` | `mariadb:` | 3306 | `mysql_async` ping | | `oracle-ee` / `oracle-se2` (+`-cdb`) | `gvenzl/oracle-free:23-slim` | 1521 | log marker `DATABASE IS READY TO USE!` + TCP probe | diff --git a/website/templates/index.html b/website/templates/index.html index 171346e6..1fd5a0a9 100644 --- a/website/templates/index.html +++ b/website/templates/index.html @@ -195,7 +195,7 @@

How it compares

Cognito User Pools122 operationsPaid only SES v2110 operationsPaid only SES inbound emailReal receipt rule actionsStored but never executed - RDS163 operations, PostgreSQL/MySQL/MariaDB via DockerPaid only + RDS163 operations, PostgreSQL/MySQL/MariaDB via Docker, PostgreSQL aws_lambda extensionPaid only ElastiCache75 operations, Redis and Valkey via DockerPaid only API Gateway v2103 operations, HTTP APIs + developer portals + JWT/Lambda authorizersPaid only Bedrock111 operations (control plane + runtime)Not available