From 89545d332ccd49d16a147f80c184b8d3c988b827 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Mon, 29 Dec 2025 13:05:16 +0800 Subject: [PATCH 1/5] feat(lambda): add BDD testing support for Lambda functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LambdaAsserter interface and implementation with assertions for: - Function existence and configuration (runtime, handler, timeout, memory) - Environment variables - Versions and aliases - Function URLs and auth types - Layers and event source mappings - Add Lambda helper in awshelpers for SDK client creation - Add Gherkin step definitions for all Lambda assertions - Add Terraform examples: - basic-function: Simple Lambda with configurable runtime/handler - function-with-alias: Lambda with published version and alias - function-url: Lambda with public/IAM-authenticated URL - Add feature files for testing Lambda scenarios 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- examples/aws/lambda/basic-function/main.tf | 76 +++++ examples/aws/lambda/basic-function/outputs.tf | 29 ++ .../aws/lambda/basic-function/variables.tf | 50 +++ examples/aws/lambda/function-url/main.tf | 107 ++++++ examples/aws/lambda/function-url/outputs.tf | 24 ++ examples/aws/lambda/function-url/variables.tf | 41 +++ .../aws/lambda/function-with-alias/main.tf | 83 +++++ .../aws/lambda/function-with-alias/outputs.tf | 29 ++ .../lambda/function-with-alias/variables.tf | 24 ++ features/aws/lambda/lambda_alias.feature | 22 ++ features/aws/lambda/lambda_function.feature | 31 ++ .../aws/lambda/lambda_function_url.feature | 24 ++ go.mod | 1 + go.sum | 2 + pkg/assertions/aws/lambda.go | 312 +++++++++++++++++ pkg/awshelpers/lambda.go | 25 ++ pkg/steps/aws/aws.go | 3 + pkg/steps/aws/lambda.go | 322 ++++++++++++++++++ 18 files changed, 1205 insertions(+) create mode 100644 examples/aws/lambda/basic-function/main.tf create mode 100644 examples/aws/lambda/basic-function/outputs.tf create mode 100644 examples/aws/lambda/basic-function/variables.tf create mode 100644 examples/aws/lambda/function-url/main.tf create mode 100644 examples/aws/lambda/function-url/outputs.tf create mode 100644 examples/aws/lambda/function-url/variables.tf create mode 100644 examples/aws/lambda/function-with-alias/main.tf create mode 100644 examples/aws/lambda/function-with-alias/outputs.tf create mode 100644 examples/aws/lambda/function-with-alias/variables.tf create mode 100644 features/aws/lambda/lambda_alias.feature create mode 100644 features/aws/lambda/lambda_function.feature create mode 100644 features/aws/lambda/lambda_function_url.feature create mode 100644 pkg/assertions/aws/lambda.go create mode 100644 pkg/awshelpers/lambda.go create mode 100644 pkg/steps/aws/lambda.go diff --git a/examples/aws/lambda/basic-function/main.tf b/examples/aws/lambda/basic-function/main.tf new file mode 100644 index 0000000..07b3ac9 --- /dev/null +++ b/examples/aws/lambda/basic-function/main.tf @@ -0,0 +1,76 @@ +provider "aws" { + region = var.region +} + +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.72.1" + } + } +} + +# IAM role for Lambda +resource "aws_iam_role" "lambda" { + name = "${var.function_name}-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) + + tags = var.tags +} + +# Attach basic execution policy +resource "aws_iam_role_policy_attachment" "lambda_basic" { + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# Lambda function code (inline Python) +data "archive_file" "lambda_zip" { + type = "zip" + output_path = "${path.module}/lambda.zip" + + source { + content = <<-EOF + def handler(event, context): + return { + 'statusCode': 200, + 'body': 'Hello from Lambda!' + } + EOF + filename = "index.py" + } +} + +# Lambda function +resource "aws_lambda_function" "main" { + function_name = var.function_name + role = aws_iam_role.lambda.arn + handler = var.handler + runtime = var.runtime + timeout = var.timeout + memory_size = var.memory_size + + filename = data.archive_file.lambda_zip.output_path + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + + environment { + variables = var.environment_variables + } + + tags = var.tags +} diff --git a/examples/aws/lambda/basic-function/outputs.tf b/examples/aws/lambda/basic-function/outputs.tf new file mode 100644 index 0000000..5e03647 --- /dev/null +++ b/examples/aws/lambda/basic-function/outputs.tf @@ -0,0 +1,29 @@ +output "function_name" { + description = "Name of the Lambda function" + value = aws_lambda_function.main.function_name +} + +output "function_arn" { + description = "ARN of the Lambda function" + value = aws_lambda_function.main.arn +} + +output "invoke_arn" { + description = "Invoke ARN of the Lambda function" + value = aws_lambda_function.main.invoke_arn +} + +output "role_arn" { + description = "ARN of the IAM role" + value = aws_iam_role.lambda.arn +} + +output "runtime" { + description = "Runtime of the Lambda function" + value = aws_lambda_function.main.runtime +} + +output "handler" { + description = "Handler of the Lambda function" + value = aws_lambda_function.main.handler +} diff --git a/examples/aws/lambda/basic-function/variables.tf b/examples/aws/lambda/basic-function/variables.tf new file mode 100644 index 0000000..1307752 --- /dev/null +++ b/examples/aws/lambda/basic-function/variables.tf @@ -0,0 +1,50 @@ +variable "region" { + description = "AWS region" + type = string + default = "us-east-1" +} + +variable "function_name" { + description = "Name of the Lambda function" + type = string +} + +variable "runtime" { + description = "Lambda runtime" + type = string + default = "python3.12" +} + +variable "handler" { + description = "Lambda handler" + type = string + default = "index.handler" +} + +variable "timeout" { + description = "Function timeout in seconds" + type = number + default = 30 +} + +variable "memory_size" { + description = "Function memory in MB" + type = number + default = 128 +} + +variable "environment_variables" { + description = "Environment variables for the function" + type = map(string) + default = { + ENVIRONMENT = "test" + } +} + +variable "tags" { + description = "Tags for the resources" + type = map(string) + default = { + ManagedBy = "terraform" + } +} diff --git a/examples/aws/lambda/function-url/main.tf b/examples/aws/lambda/function-url/main.tf new file mode 100644 index 0000000..d543f0e --- /dev/null +++ b/examples/aws/lambda/function-url/main.tf @@ -0,0 +1,107 @@ +provider "aws" { + region = var.region +} + +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.72.1" + } + } +} + +# IAM role for Lambda +resource "aws_iam_role" "lambda" { + name = "${var.function_name}-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) + + tags = var.tags +} + +# Attach basic execution policy +resource "aws_iam_role_policy_attachment" "lambda_basic" { + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# Lambda function code (inline Python) +data "archive_file" "lambda_zip" { + type = "zip" + output_path = "${path.module}/lambda.zip" + + source { + content = <<-EOF + import json + + def handler(event, context): + return { + 'statusCode': 200, + 'headers': { + 'Content-Type': 'application/json' + }, + 'body': json.dumps({ + 'message': 'Hello from Lambda Function URL!', + 'path': event.get('rawPath', '/'), + 'method': event.get('requestContext', {}).get('http', {}).get('method', 'GET') + }) + } + EOF + filename = "index.py" + } +} + +# Lambda function +resource "aws_lambda_function" "main" { + function_name = var.function_name + role = aws_iam_role.lambda.arn + handler = "index.handler" + runtime = "python3.12" + timeout = 30 + memory_size = 128 + + filename = data.archive_file.lambda_zip.output_path + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + + tags = var.tags +} + +# Lambda function URL +resource "aws_lambda_function_url" "main" { + function_name = aws_lambda_function.main.function_name + authorization_type = var.authorization_type + + cors { + allow_origins = var.cors_allow_origins + allow_methods = var.cors_allow_methods + allow_headers = ["*"] + expose_headers = ["*"] + max_age = 86400 + allow_credentials = false + } +} + +# Allow public access when authorization_type is NONE +resource "aws_lambda_permission" "function_url" { + count = var.authorization_type == "NONE" ? 1 : 0 + + statement_id = "AllowFunctionURLPublicAccess" + action = "lambda:InvokeFunctionUrl" + function_name = aws_lambda_function.main.function_name + principal = "*" + function_url_auth_type = "NONE" +} diff --git a/examples/aws/lambda/function-url/outputs.tf b/examples/aws/lambda/function-url/outputs.tf new file mode 100644 index 0000000..9845f16 --- /dev/null +++ b/examples/aws/lambda/function-url/outputs.tf @@ -0,0 +1,24 @@ +output "function_name" { + description = "Name of the Lambda function" + value = aws_lambda_function.main.function_name +} + +output "function_arn" { + description = "ARN of the Lambda function" + value = aws_lambda_function.main.arn +} + +output "function_url" { + description = "URL of the Lambda function" + value = aws_lambda_function_url.main.function_url +} + +output "function_url_id" { + description = "ID of the Lambda function URL" + value = aws_lambda_function_url.main.url_id +} + +output "authorization_type" { + description = "Authorization type of the function URL" + value = aws_lambda_function_url.main.authorization_type +} diff --git a/examples/aws/lambda/function-url/variables.tf b/examples/aws/lambda/function-url/variables.tf new file mode 100644 index 0000000..b8a8c8d --- /dev/null +++ b/examples/aws/lambda/function-url/variables.tf @@ -0,0 +1,41 @@ +variable "region" { + description = "AWS region" + type = string + default = "us-east-1" +} + +variable "function_name" { + description = "Name of the Lambda function" + type = string +} + +variable "authorization_type" { + description = "Authorization type for the function URL (NONE or AWS_IAM)" + type = string + default = "NONE" + + validation { + condition = contains(["NONE", "AWS_IAM"], var.authorization_type) + error_message = "authorization_type must be either NONE or AWS_IAM" + } +} + +variable "cors_allow_origins" { + description = "CORS allowed origins" + type = list(string) + default = ["*"] +} + +variable "cors_allow_methods" { + description = "CORS allowed methods" + type = list(string) + default = ["GET", "POST", "PUT", "DELETE"] +} + +variable "tags" { + description = "Tags for the resources" + type = map(string) + default = { + ManagedBy = "terraform" + } +} diff --git a/examples/aws/lambda/function-with-alias/main.tf b/examples/aws/lambda/function-with-alias/main.tf new file mode 100644 index 0000000..5395e99 --- /dev/null +++ b/examples/aws/lambda/function-with-alias/main.tf @@ -0,0 +1,83 @@ +provider "aws" { + region = var.region +} + +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.72.1" + } + } +} + +# IAM role for Lambda +resource "aws_iam_role" "lambda" { + name = "${var.function_name}-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) + + tags = var.tags +} + +# Attach basic execution policy +resource "aws_iam_role_policy_attachment" "lambda_basic" { + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# Lambda function code (inline Python) +data "archive_file" "lambda_zip" { + type = "zip" + output_path = "${path.module}/lambda.zip" + + source { + content = <<-EOF + def handler(event, context): + return { + 'statusCode': 200, + 'body': 'Hello from Lambda v1!' + } + EOF + filename = "index.py" + } +} + +# Lambda function +resource "aws_lambda_function" "main" { + function_name = var.function_name + role = aws_iam_role.lambda.arn + handler = "index.handler" + runtime = "python3.12" + timeout = 30 + memory_size = 128 + + filename = data.archive_file.lambda_zip.output_path + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + + publish = true + + tags = var.tags +} + +# Lambda alias pointing to published version +resource "aws_lambda_alias" "main" { + name = var.alias_name + function_name = aws_lambda_function.main.function_name + function_version = aws_lambda_function.main.version + + description = "Alias for ${var.alias_name}" +} diff --git a/examples/aws/lambda/function-with-alias/outputs.tf b/examples/aws/lambda/function-with-alias/outputs.tf new file mode 100644 index 0000000..85d5c1a --- /dev/null +++ b/examples/aws/lambda/function-with-alias/outputs.tf @@ -0,0 +1,29 @@ +output "function_name" { + description = "Name of the Lambda function" + value = aws_lambda_function.main.function_name +} + +output "function_arn" { + description = "ARN of the Lambda function" + value = aws_lambda_function.main.arn +} + +output "function_version" { + description = "Published version of the Lambda function" + value = aws_lambda_function.main.version +} + +output "alias_name" { + description = "Name of the Lambda alias" + value = aws_lambda_alias.main.name +} + +output "alias_arn" { + description = "ARN of the Lambda alias" + value = aws_lambda_alias.main.arn +} + +output "alias_invoke_arn" { + description = "Invoke ARN of the Lambda alias" + value = aws_lambda_alias.main.invoke_arn +} diff --git a/examples/aws/lambda/function-with-alias/variables.tf b/examples/aws/lambda/function-with-alias/variables.tf new file mode 100644 index 0000000..114ba08 --- /dev/null +++ b/examples/aws/lambda/function-with-alias/variables.tf @@ -0,0 +1,24 @@ +variable "region" { + description = "AWS region" + type = string + default = "us-east-1" +} + +variable "function_name" { + description = "Name of the Lambda function" + type = string +} + +variable "alias_name" { + description = "Name of the Lambda alias" + type = string + default = "live" +} + +variable "tags" { + description = "Tags for the resources" + type = map(string) + default = { + ManagedBy = "terraform" + } +} diff --git a/features/aws/lambda/lambda_alias.feature b/features/aws/lambda/lambda_alias.feature new file mode 100644 index 0000000..5bbdeb3 --- /dev/null +++ b/features/aws/lambda/lambda_alias.feature @@ -0,0 +1,22 @@ +Feature: Lambda Versions and Aliases + As a DevOps Engineer + I want to create Lambda functions with versions and aliases + So that I can manage deployments safely + + Scenario: Create a Lambda function with an alias + Given I have a Terraform configuration in "../../../examples/aws/lambda/function-with-alias" + And I set the variable "region" to "us-east-1" + And I set variable "function_name" to "test-lambda-alias" with a random suffix + And I set the variable "alias_name" to "live" + When I run Terraform apply + Then the Lambda function from output "function_name" should exist + And the Lambda function from output "function_name" alias "live" should exist + + Scenario: Create a Lambda function with a custom alias name + Given I have a Terraform configuration in "../../../examples/aws/lambda/function-with-alias" + And I set the variable "region" to "us-east-1" + And I set variable "function_name" to "test-lambda-prod" with a random suffix + And I set the variable "alias_name" to "production" + When I run Terraform apply + Then the Lambda function from output "function_name" should exist + And the Lambda function from output "function_name" alias "production" should exist diff --git a/features/aws/lambda/lambda_function.feature b/features/aws/lambda/lambda_function.feature new file mode 100644 index 0000000..d540bfd --- /dev/null +++ b/features/aws/lambda/lambda_function.feature @@ -0,0 +1,31 @@ +Feature: Lambda Function Creation + As a DevOps Engineer + I want to create Lambda functions with proper configuration + So that I can run serverless workloads + + Scenario: Create a basic Lambda function + Given I have a Terraform configuration in "../../../examples/aws/lambda/basic-function" + And I set the variable "region" to "us-east-1" + And I set variable "function_name" to "test-lambda" with a random suffix + And I set the variable "runtime" to "python3.12" + And I set the variable "handler" to "index.handler" + And I set the variable "timeout" to "30" + And I set the variable "memory_size" to "128" + When I run Terraform apply + Then the Lambda function from output "function_name" should exist + And the Lambda function from output "function_name" runtime should be "python3.12" + And the Lambda function from output "function_name" handler should be "index.handler" + And the Lambda function from output "function_name" timeout should be 30 seconds + And the Lambda function from output "function_name" memory should be 128 MB + + Scenario: Create a Lambda function with environment variables + Given I have a Terraform configuration in "../../../examples/aws/lambda/basic-function" + And I set the variable "region" to "us-east-1" + And I set variable "function_name" to "test-lambda-env" with a random suffix + And I set the variable "environment_variables" to + | Key | Value | + | ENVIRONMENT | production | + | LOG_LEVEL | debug | + When I run Terraform apply + Then the Lambda function from output "function_name" should exist + And the Lambda function from output "function_name" should have environment variable "ENVIRONMENT" with value "production" diff --git a/features/aws/lambda/lambda_function_url.feature b/features/aws/lambda/lambda_function_url.feature new file mode 100644 index 0000000..7347573 --- /dev/null +++ b/features/aws/lambda/lambda_function_url.feature @@ -0,0 +1,24 @@ +Feature: Lambda Function URLs + As a DevOps Engineer + I want to create Lambda functions with public URLs + So that I can expose functions as HTTP endpoints + + Scenario: Create a Lambda function with a public URL + Given I have a Terraform configuration in "../../../examples/aws/lambda/function-url" + And I set the variable "region" to "us-east-1" + And I set variable "function_name" to "test-lambda-url" with a random suffix + And I set the variable "authorization_type" to "NONE" + When I run Terraform apply + Then the Lambda function from output "function_name" should exist + And the Lambda function from output "function_name" should have a function URL + And the Lambda function from output "function_name" function URL auth type should be "NONE" + + Scenario: Create a Lambda function with IAM-authenticated URL + Given I have a Terraform configuration in "../../../examples/aws/lambda/function-url" + And I set the variable "region" to "us-east-1" + And I set variable "function_name" to "test-lambda-url-iam" with a random suffix + And I set the variable "authorization_type" to "AWS_IAM" + When I run Terraform apply + Then the Lambda function from output "function_name" should exist + And the Lambda function from output "function_name" should have a function URL + And the Lambda function from output "function_name" function URL auth type should be "AWS_IAM" diff --git a/go.mod b/go.mod index 247905a..1a24a9d 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect diff --git a/go.sum b/go.sum index 7ac7a9a..7758c84 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= +github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0 h1:E5UXxF3vK3JuViwKCHfTJBIiFjvE4aytSucZjI2UAlQ= +github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0/go.mod h1:6f64Y1BEf6e1uCI+LtGbcZSKDK1GvgJ+iI4vP/bbE8s= github.com/aws/aws-sdk-go-v2/service/rds v1.113.1 h1:/vV0g/Su8rCTqT57UUYiFU/aRrPXz//fGDn1dkXblG4= github.com/aws/aws-sdk-go-v2/service/rds v1.113.1/go.mod h1:q02df+DL73LN+jDXzj86tMsI6kKf1kfv61nB684H+o8= github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA= diff --git a/pkg/assertions/aws/lambda.go b/pkg/assertions/aws/lambda.go new file mode 100644 index 0000000..09197f6 --- /dev/null +++ b/pkg/assertions/aws/lambda.go @@ -0,0 +1,312 @@ +package aws + +import ( + "context" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/lambda" + "github.com/aws/aws-sdk-go-v2/service/lambda/types" + + "github.com/robmorgan/infraspec/pkg/awshelpers" +) + +// Ensure the `AWSAsserter` struct implements the `LambdaAsserter` interface. +var _ LambdaAsserter = (*AWSAsserter)(nil) + +// LambdaAsserter defines Lambda-specific assertions +type LambdaAsserter interface { + // Basic + AssertFunctionExists(functionName string) error + AssertFunctionNotExists(functionName string) error + + // Configuration + AssertFunctionRuntime(functionName, runtime string) error + AssertFunctionHandler(functionName, handler string) error + AssertFunctionTimeout(functionName string, timeout int) error + AssertFunctionMemory(functionName string, memory int) error + AssertFunctionEnvironmentVariable(functionName, key, value string) error + + // Versions & Aliases + AssertFunctionVersionExists(functionName, version string) error + AssertFunctionAliasExists(functionName, aliasName string) error + AssertFunctionAliasPointsToVersion(functionName, aliasName, version string) error + + // Function URLs + AssertFunctionURLExists(functionName string) error + AssertFunctionURLAuthType(functionName, authType string) error + + // Layers + AssertFunctionHasLayer(functionName, layerArn string) error + + // Event Source Mappings + AssertEventSourceMappingExists(uuid string) error +} + +// AssertFunctionExists checks if a Lambda function exists +func (a *AWSAsserter) AssertFunctionExists(functionName string) error { + client, err := awshelpers.NewLambdaClientWithDefaultRegion() + if err != nil { + return err + } + + _, err = client.GetFunction(context.TODO(), &lambda.GetFunctionInput{ + FunctionName: aws.String(functionName), + }) + if err != nil { + return fmt.Errorf("Lambda function %s does not exist: %w", functionName, err) + } + + return nil +} + +// AssertFunctionNotExists checks if a Lambda function does not exist +func (a *AWSAsserter) AssertFunctionNotExists(functionName string) error { + client, err := awshelpers.NewLambdaClientWithDefaultRegion() + if err != nil { + return err + } + + _, err = client.GetFunction(context.TODO(), &lambda.GetFunctionInput{ + FunctionName: aws.String(functionName), + }) + if err == nil { + return fmt.Errorf("Lambda function %s exists but should not", functionName) + } + + // Check if the error is a ResourceNotFoundException + var notFoundErr *types.ResourceNotFoundException + if !strings.Contains(err.Error(), "ResourceNotFoundException") { + // If it's a different error, return it + return fmt.Errorf("unexpected error checking Lambda function %s: %w", functionName, err) + } + _ = notFoundErr // Silence unused variable warning + + return nil +} + +// AssertFunctionRuntime checks if a Lambda function has the expected runtime +func (a *AWSAsserter) AssertFunctionRuntime(functionName, runtime string) error { + config, err := a.getFunctionConfiguration(functionName) + if err != nil { + return err + } + + if string(config.Runtime) != runtime { + return fmt.Errorf("expected runtime %s, but got %s", runtime, config.Runtime) + } + + return nil +} + +// AssertFunctionHandler checks if a Lambda function has the expected handler +func (a *AWSAsserter) AssertFunctionHandler(functionName, handler string) error { + config, err := a.getFunctionConfiguration(functionName) + if err != nil { + return err + } + + if aws.ToString(config.Handler) != handler { + return fmt.Errorf("expected handler %s, but got %s", handler, aws.ToString(config.Handler)) + } + + return nil +} + +// AssertFunctionTimeout checks if a Lambda function has the expected timeout +func (a *AWSAsserter) AssertFunctionTimeout(functionName string, timeout int) error { + config, err := a.getFunctionConfiguration(functionName) + if err != nil { + return err + } + + if aws.ToInt32(config.Timeout) != int32(timeout) { + return fmt.Errorf("expected timeout %d, but got %d", timeout, aws.ToInt32(config.Timeout)) + } + + return nil +} + +// AssertFunctionMemory checks if a Lambda function has the expected memory size +func (a *AWSAsserter) AssertFunctionMemory(functionName string, memory int) error { + config, err := a.getFunctionConfiguration(functionName) + if err != nil { + return err + } + + if aws.ToInt32(config.MemorySize) != int32(memory) { + return fmt.Errorf("expected memory %d MB, but got %d MB", memory, aws.ToInt32(config.MemorySize)) + } + + return nil +} + +// AssertFunctionEnvironmentVariable checks if a Lambda function has the expected environment variable +func (a *AWSAsserter) AssertFunctionEnvironmentVariable(functionName, key, value string) error { + config, err := a.getFunctionConfiguration(functionName) + if err != nil { + return err + } + + if config.Environment == nil || config.Environment.Variables == nil { + return fmt.Errorf("Lambda function %s has no environment variables", functionName) + } + + actualValue, exists := config.Environment.Variables[key] + if !exists { + return fmt.Errorf("environment variable %s not found", key) + } + + if actualValue != value { + return fmt.Errorf("expected environment variable %s to have value %s, but got %s", key, value, actualValue) + } + + return nil +} + +// AssertFunctionVersionExists checks if a published version exists for the function +func (a *AWSAsserter) AssertFunctionVersionExists(functionName, version string) error { + client, err := awshelpers.NewLambdaClientWithDefaultRegion() + if err != nil { + return err + } + + _, err = client.GetFunction(context.TODO(), &lambda.GetFunctionInput{ + FunctionName: aws.String(functionName), + Qualifier: aws.String(version), + }) + if err != nil { + return fmt.Errorf("Lambda function %s version %s does not exist: %w", functionName, version, err) + } + + return nil +} + +// AssertFunctionAliasExists checks if an alias exists for the function +func (a *AWSAsserter) AssertFunctionAliasExists(functionName, aliasName string) error { + client, err := awshelpers.NewLambdaClientWithDefaultRegion() + if err != nil { + return err + } + + _, err = client.GetAlias(context.TODO(), &lambda.GetAliasInput{ + FunctionName: aws.String(functionName), + Name: aws.String(aliasName), + }) + if err != nil { + return fmt.Errorf("Lambda function %s alias %s does not exist: %w", functionName, aliasName, err) + } + + return nil +} + +// AssertFunctionAliasPointsToVersion checks if an alias points to the expected version +func (a *AWSAsserter) AssertFunctionAliasPointsToVersion(functionName, aliasName, version string) error { + client, err := awshelpers.NewLambdaClientWithDefaultRegion() + if err != nil { + return err + } + + alias, err := client.GetAlias(context.TODO(), &lambda.GetAliasInput{ + FunctionName: aws.String(functionName), + Name: aws.String(aliasName), + }) + if err != nil { + return fmt.Errorf("Lambda function %s alias %s does not exist: %w", functionName, aliasName, err) + } + + if aws.ToString(alias.FunctionVersion) != version { + return fmt.Errorf("expected alias %s to point to version %s, but got %s", aliasName, version, aws.ToString(alias.FunctionVersion)) + } + + return nil +} + +// AssertFunctionURLExists checks if a function URL exists +func (a *AWSAsserter) AssertFunctionURLExists(functionName string) error { + client, err := awshelpers.NewLambdaClientWithDefaultRegion() + if err != nil { + return err + } + + _, err = client.GetFunctionUrlConfig(context.TODO(), &lambda.GetFunctionUrlConfigInput{ + FunctionName: aws.String(functionName), + }) + if err != nil { + return fmt.Errorf("Lambda function %s does not have a function URL: %w", functionName, err) + } + + return nil +} + +// AssertFunctionURLAuthType checks if a function URL has the expected auth type +func (a *AWSAsserter) AssertFunctionURLAuthType(functionName, authType string) error { + client, err := awshelpers.NewLambdaClientWithDefaultRegion() + if err != nil { + return err + } + + urlConfig, err := client.GetFunctionUrlConfig(context.TODO(), &lambda.GetFunctionUrlConfigInput{ + FunctionName: aws.String(functionName), + }) + if err != nil { + return fmt.Errorf("Lambda function %s does not have a function URL: %w", functionName, err) + } + + if string(urlConfig.AuthType) != authType { + return fmt.Errorf("expected auth type %s, but got %s", authType, urlConfig.AuthType) + } + + return nil +} + +// AssertFunctionHasLayer checks if a function has the specified layer attached +func (a *AWSAsserter) AssertFunctionHasLayer(functionName, layerArn string) error { + config, err := a.getFunctionConfiguration(functionName) + if err != nil { + return err + } + + for _, layer := range config.Layers { + if aws.ToString(layer.Arn) == layerArn { + return nil + } + } + + return fmt.Errorf("Lambda function %s does not have layer %s", functionName, layerArn) +} + +// AssertEventSourceMappingExists checks if an event source mapping exists +func (a *AWSAsserter) AssertEventSourceMappingExists(uuid string) error { + client, err := awshelpers.NewLambdaClientWithDefaultRegion() + if err != nil { + return err + } + + _, err = client.GetEventSourceMapping(context.TODO(), &lambda.GetEventSourceMappingInput{ + UUID: aws.String(uuid), + }) + if err != nil { + return fmt.Errorf("event source mapping %s does not exist: %w", uuid, err) + } + + return nil +} + +// Helper method to get function configuration +func (a *AWSAsserter) getFunctionConfiguration(functionName string) (*lambda.GetFunctionConfigurationOutput, error) { + client, err := awshelpers.NewLambdaClientWithDefaultRegion() + if err != nil { + return nil, err + } + + config, err := client.GetFunctionConfiguration(context.TODO(), &lambda.GetFunctionConfigurationInput{ + FunctionName: aws.String(functionName), + }) + if err != nil { + return nil, fmt.Errorf("error getting Lambda function configuration for %s: %w", functionName, err) + } + + return config, nil +} diff --git a/pkg/awshelpers/lambda.go b/pkg/awshelpers/lambda.go new file mode 100644 index 0000000..3a0c527 --- /dev/null +++ b/pkg/awshelpers/lambda.go @@ -0,0 +1,25 @@ +package awshelpers + +import "github.com/aws/aws-sdk-go-v2/service/lambda" + +// NewLambdaClient creates a Lambda client. +func NewLambdaClient(region string) (*lambda.Client, error) { + s, err := NewAuthenticatedSession(region) + if err != nil { + return nil, err + } + + opts := make([]func(*lambda.Options), 0, 1) + if endpoint, ok := GetVirtualCloudEndpoint("lambda"); ok { + opts = append(opts, func(o *lambda.Options) { + o.EndpointResolver = lambda.EndpointResolverFromURL(endpoint) + }) + } + + return lambda.NewFromConfig(*s, opts...), nil +} + +// NewLambdaClientWithDefaultRegion creates a Lambda client with the default region. +func NewLambdaClientWithDefaultRegion() (*lambda.Client, error) { + return NewLambdaClient(defaultRegion) +} diff --git a/pkg/steps/aws/aws.go b/pkg/steps/aws/aws.go index 4893d2c..b640205 100644 --- a/pkg/steps/aws/aws.go +++ b/pkg/steps/aws/aws.go @@ -26,6 +26,9 @@ func RegisterSteps(sc *godog.ScenarioContext) { // SQS steps registerSQSSteps(sc) + // Lambda steps + registerLambdaSteps(sc) + // Generic AWS steps sc.Step(`^the AWS resource "([^"]*)" should exist$`, newAWSResourceExistsStep) } diff --git a/pkg/steps/aws/lambda.go b/pkg/steps/aws/lambda.go new file mode 100644 index 0000000..cacb70d --- /dev/null +++ b/pkg/steps/aws/lambda.go @@ -0,0 +1,322 @@ +package aws + +import ( + "context" + "fmt" + "strconv" + + "github.com/cucumber/godog" + + "github.com/robmorgan/infraspec/internal/contexthelpers" + "github.com/robmorgan/infraspec/pkg/assertions" + "github.com/robmorgan/infraspec/pkg/assertions/aws" + "github.com/robmorgan/infraspec/pkg/iacprovisioner" +) + +// Lambda Step Definitions +func registerLambdaSteps(sc *godog.ScenarioContext) { + // Basic existence - direct name + sc.Step(`^the Lambda function "([^"]*)" should exist$`, newLambdaFunctionExistsStep) + sc.Step(`^the Lambda function "([^"]*)" should not exist$`, newLambdaFunctionNotExistsStep) + + // Basic existence - from output + sc.Step(`^the Lambda function from output "([^"]*)" should exist$`, newLambdaFunctionFromOutputExistsStep) + sc.Step(`^the Lambda function from output "([^"]*)" should not exist$`, newLambdaFunctionFromOutputNotExistsStep) + + // Configuration - direct name + sc.Step(`^the Lambda function "([^"]*)" runtime should be "([^"]*)"$`, newLambdaFunctionRuntimeStep) + sc.Step(`^the Lambda function "([^"]*)" handler should be "([^"]*)"$`, newLambdaFunctionHandlerStep) + sc.Step(`^the Lambda function "([^"]*)" timeout should be (\d+) seconds$`, newLambdaFunctionTimeoutStep) + sc.Step(`^the Lambda function "([^"]*)" memory should be (\d+) MB$`, newLambdaFunctionMemoryStep) + sc.Step(`^the Lambda function "([^"]*)" should have environment variable "([^"]*)" with value "([^"]*)"$`, newLambdaFunctionEnvVarStep) + + // Configuration - from output + sc.Step(`^the Lambda function from output "([^"]*)" runtime should be "([^"]*)"$`, newLambdaFunctionFromOutputRuntimeStep) + sc.Step(`^the Lambda function from output "([^"]*)" handler should be "([^"]*)"$`, newLambdaFunctionFromOutputHandlerStep) + sc.Step(`^the Lambda function from output "([^"]*)" timeout should be (\d+) seconds$`, newLambdaFunctionFromOutputTimeoutStep) + sc.Step(`^the Lambda function from output "([^"]*)" memory should be (\d+) MB$`, newLambdaFunctionFromOutputMemoryStep) + sc.Step(`^the Lambda function from output "([^"]*)" should have environment variable "([^"]*)" with value "([^"]*)"$`, newLambdaFunctionFromOutputEnvVarStep) + + // Versions & Aliases - direct name + sc.Step(`^the Lambda function "([^"]*)" version "([^"]*)" should exist$`, newLambdaFunctionVersionExistsStep) + sc.Step(`^the Lambda function "([^"]*)" alias "([^"]*)" should exist$`, newLambdaFunctionAliasExistsStep) + sc.Step(`^the Lambda function "([^"]*)" alias "([^"]*)" should point to version "([^"]*)"$`, newLambdaFunctionAliasVersionStep) + + // Versions & Aliases - from output + sc.Step(`^the Lambda function from output "([^"]*)" alias "([^"]*)" should exist$`, newLambdaFunctionFromOutputAliasExistsStep) + sc.Step(`^the Lambda function from output "([^"]*)" alias "([^"]*)" should point to version "([^"]*)"$`, newLambdaFunctionFromOutputAliasVersionStep) + + // Function URLs - direct name + sc.Step(`^the Lambda function "([^"]*)" should have a function URL$`, newLambdaFunctionURLExistsStep) + sc.Step(`^the Lambda function "([^"]*)" function URL auth type should be "([^"]*)"$`, newLambdaFunctionURLAuthTypeStep) + + // Function URLs - from output + sc.Step(`^the Lambda function from output "([^"]*)" should have a function URL$`, newLambdaFunctionFromOutputURLExistsStep) + sc.Step(`^the Lambda function from output "([^"]*)" function URL auth type should be "([^"]*)"$`, newLambdaFunctionFromOutputURLAuthTypeStep) + + // Layers - direct name + sc.Step(`^the Lambda function "([^"]*)" should have layer "([^"]*)"$`, newLambdaFunctionLayerStep) + + // Layers - from output + sc.Step(`^the Lambda function from output "([^"]*)" should have layer "([^"]*)"$`, newLambdaFunctionFromOutputLayerStep) + + // Event Source Mappings + sc.Step(`^the event source mapping "([^"]*)" should exist$`, newEventSourceMappingExistsStep) + sc.Step(`^the event source mapping from output "([^"]*)" should exist$`, newEventSourceMappingFromOutputExistsStep) +} + +// Helper function to get Lambda asserter +func getLambdaAsserter(ctx context.Context) (aws.LambdaAsserter, error) { + asserter, err := contexthelpers.GetAsserter(ctx, assertions.AWS) + if err != nil { + return nil, err + } + + lambdaAssert, ok := asserter.(aws.LambdaAsserter) + if !ok { + return nil, fmt.Errorf("asserter does not implement LambdaAsserter") + } + return lambdaAssert, nil +} + +// Helper function to get function name from Terraform output +func getFunctionNameFromOutput(ctx context.Context, outputName string) (string, error) { + options := contexthelpers.GetIacProvisionerOptions(ctx) + functionName, err := iacprovisioner.Output(options, outputName) + if err != nil { + return "", fmt.Errorf("failed to get function name from output %s: %w", outputName, err) + } + return functionName, nil +} + +// Basic existence steps + +func newLambdaFunctionExistsStep(ctx context.Context, functionName string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionExists(functionName) +} + +func newLambdaFunctionNotExistsStep(ctx context.Context, functionName string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionNotExists(functionName) +} + +func newLambdaFunctionFromOutputExistsStep(ctx context.Context, outputName string) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionExistsStep(ctx, functionName) +} + +func newLambdaFunctionFromOutputNotExistsStep(ctx context.Context, outputName string) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionNotExistsStep(ctx, functionName) +} + +// Configuration steps + +func newLambdaFunctionRuntimeStep(ctx context.Context, functionName, runtime string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionRuntime(functionName, runtime) +} + +func newLambdaFunctionHandlerStep(ctx context.Context, functionName, handler string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionHandler(functionName, handler) +} + +func newLambdaFunctionTimeoutStep(ctx context.Context, functionName string, timeout int) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionTimeout(functionName, timeout) +} + +func newLambdaFunctionMemoryStep(ctx context.Context, functionName string, memory int) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionMemory(functionName, memory) +} + +func newLambdaFunctionEnvVarStep(ctx context.Context, functionName, key, value string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionEnvironmentVariable(functionName, key, value) +} + +func newLambdaFunctionFromOutputRuntimeStep(ctx context.Context, outputName, runtime string) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionRuntimeStep(ctx, functionName, runtime) +} + +func newLambdaFunctionFromOutputHandlerStep(ctx context.Context, outputName, handler string) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionHandlerStep(ctx, functionName, handler) +} + +func newLambdaFunctionFromOutputTimeoutStep(ctx context.Context, outputName string, timeout int) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionTimeoutStep(ctx, functionName, timeout) +} + +func newLambdaFunctionFromOutputMemoryStep(ctx context.Context, outputName string, memory int) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionMemoryStep(ctx, functionName, memory) +} + +func newLambdaFunctionFromOutputEnvVarStep(ctx context.Context, outputName, key, value string) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionEnvVarStep(ctx, functionName, key, value) +} + +// Versions & Aliases steps + +func newLambdaFunctionVersionExistsStep(ctx context.Context, functionName, version string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionVersionExists(functionName, version) +} + +func newLambdaFunctionAliasExistsStep(ctx context.Context, functionName, aliasName string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionAliasExists(functionName, aliasName) +} + +func newLambdaFunctionAliasVersionStep(ctx context.Context, functionName, aliasName, version string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionAliasPointsToVersion(functionName, aliasName, version) +} + +func newLambdaFunctionFromOutputAliasExistsStep(ctx context.Context, outputName, aliasName string) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionAliasExistsStep(ctx, functionName, aliasName) +} + +func newLambdaFunctionFromOutputAliasVersionStep(ctx context.Context, outputName, aliasName, version string) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionAliasVersionStep(ctx, functionName, aliasName, version) +} + +// Function URL steps + +func newLambdaFunctionURLExistsStep(ctx context.Context, functionName string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionURLExists(functionName) +} + +func newLambdaFunctionURLAuthTypeStep(ctx context.Context, functionName, authType string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionURLAuthType(functionName, authType) +} + +func newLambdaFunctionFromOutputURLExistsStep(ctx context.Context, outputName string) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionURLExistsStep(ctx, functionName) +} + +func newLambdaFunctionFromOutputURLAuthTypeStep(ctx context.Context, outputName, authType string) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionURLAuthTypeStep(ctx, functionName, authType) +} + +// Layer steps + +func newLambdaFunctionLayerStep(ctx context.Context, functionName, layerArn string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertFunctionHasLayer(functionName, layerArn) +} + +func newLambdaFunctionFromOutputLayerStep(ctx context.Context, outputName, layerArn string) error { + functionName, err := getFunctionNameFromOutput(ctx, outputName) + if err != nil { + return err + } + return newLambdaFunctionLayerStep(ctx, functionName, layerArn) +} + +// Event Source Mapping steps + +func newEventSourceMappingExistsStep(ctx context.Context, uuid string) error { + lambdaAssert, err := getLambdaAsserter(ctx) + if err != nil { + return err + } + return lambdaAssert.AssertEventSourceMappingExists(uuid) +} + +func newEventSourceMappingFromOutputExistsStep(ctx context.Context, outputName string) error { + options := contexthelpers.GetIacProvisionerOptions(ctx) + uuid, err := iacprovisioner.Output(options, outputName) + if err != nil { + return fmt.Errorf("failed to get event source mapping UUID from output %s: %w", outputName, err) + } + return newEventSourceMappingExistsStep(ctx, uuid) +} + +// Silence unused import warning for strconv +var _ = strconv.Itoa From 617f7fb1d92e773be025322d2fa2d67c0aef09ca Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Mon, 29 Dec 2025 13:09:22 +0800 Subject: [PATCH 2/5] ci: add lambda to integration test matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1849299..ea8df9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,7 +78,7 @@ jobs: strategy: fail-fast: false matrix: - service: [dynamodb, ec2, iam, rds, s3, sqs] + service: [dynamodb, ec2, iam, lambda, rds, s3, sqs] include: - service: terraform path: features/terraform/ From 098e0e9aac919042de3834551f1fb03a3166ef52 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Mon, 29 Dec 2025 13:14:17 +0800 Subject: [PATCH 3/5] docs: update CLAUDE.md to reflect embedded emulator as default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emulation is now the default behavior - no flag needed. Use --live flag when testing against real AWS infrastructure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec56d9a..adf792b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,21 +177,26 @@ Use these scopes when relevant: ## Virtual Cloud Integration -InfraSpec can use Virtual Cloud (infraspec-api) instead of real AWS for fast, cost-free testing. +InfraSpec uses Virtual Cloud (the embedded AWS emulator) by default for fast, cost-free testing. -### Running with Virtual Cloud +**Note:** Emulation is the default behavior. Use `--live` flag to test against real AWS. + +### Running Tests ```bash -# Use the --virtual-cloud flag -./infraspec features/aws/s3/s3_bucket.feature --virtual-cloud +# Default: Uses embedded emulator (no flag needed) +./infraspec features/aws/s3/s3_bucket.feature + +# To test against real AWS, use --live +./infraspec features/aws/s3/s3_bucket.feature --live ``` ### How It Works -1. CLI detects `--virtual-cloud` flag +1. CLI starts the embedded AWS emulator by default 2. Configures Terraform with custom AWS endpoints via environment variables -3. All AWS API calls route to Virtual Cloud instead of real AWS -4. Virtual Cloud emulates AWS responses +3. All AWS API calls route to the emulator instead of real AWS +4. Emulator returns AWS-compatible responses ### Service Endpoint Configuration From 301fccff3b66078d2e5d1a615bb703e07d49a737 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Mon, 29 Dec 2025 18:04:15 +0800 Subject: [PATCH 4/5] feat(iam): add support for top 25 AWS managed policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pre-defined AWS managed policy support to fix Lambda BDD tests that use AWSLambdaBasicExecutionRole and other managed policies. - Add aws_managed_policies.go with 25 common AWS managed policies - Include realistic policy documents for Lambda, S3, DynamoDB, EC2, RDS, IAM, SQS, CloudWatch, and SSM services - Add fallback stub policy for unrecognized managed policy ARNs - Update attachRolePolicy/detachRolePolicy to handle AWS managed policies - Update getPolicy/getPolicyVersion to return managed policy data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../services/iam/aws_managed_policies.go | 632 ++++++++++++++++++ .../iam/policy_attachment_handlers.go | 79 ++- .../emulator/services/iam/policy_handlers.go | 16 + 3 files changed, 694 insertions(+), 33 deletions(-) create mode 100644 internal/emulator/services/iam/aws_managed_policies.go diff --git a/internal/emulator/services/iam/aws_managed_policies.go b/internal/emulator/services/iam/aws_managed_policies.go new file mode 100644 index 0000000..77dbfdf --- /dev/null +++ b/internal/emulator/services/iam/aws_managed_policies.go @@ -0,0 +1,632 @@ +package iam + +import ( + "strings" + "time" +) + +// AWSManagedPolicy represents a pre-defined AWS managed policy +type AWSManagedPolicy struct { + PolicyName string + PolicyId string + Arn string + Path string + Description string + DefaultVersionId string + Document string + CreateDate time.Time + UpdateDate time.Time +} + +// permissiveStubDocument is a permissive policy document used as a fallback +// for AWS managed policies that are not explicitly defined. +const permissiveStubDocument = `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "*", + "Resource": "*" + } + ] +}` + +// awsManagedPolicies contains the top 25 most commonly used AWS managed policies. +// These are pre-defined and available without explicit creation. +var awsManagedPolicies = map[string]AWSManagedPolicy{ + // Lambda policies + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole": { + PolicyName: "AWSLambdaBasicExecutionRole", + PolicyId: "ANPAJNCQGXC42545SKXIK", + Arn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + Path: "/service-role/", + Description: "Provides write permissions to CloudWatch Logs.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole": { + PolicyName: "AWSLambdaVPCAccessExecutionRole", + PolicyId: "ANPAJVTME3YLVNL72YR2K", + Arn: "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole", + Path: "/service-role/", + Description: "Provides minimum permissions for a Lambda function to execute while accessing a resource within a VPC.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "ec2:CreateNetworkInterface", + "ec2:DescribeNetworkInterfaces", + "ec2:DeleteNetworkInterface", + "ec2:AssignPrivateIpAddresses", + "ec2:UnassignPrivateIpAddresses" + ], + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/service-role/AWSLambdaDynamoDBExecutionRole": { + PolicyName: "AWSLambdaDynamoDBExecutionRole", + PolicyId: "ANPAIP7WNAGMIPYNW4WQG", + Arn: "arn:aws:iam::aws:policy/service-role/AWSLambdaDynamoDBExecutionRole", + Path: "/service-role/", + Description: "Provides list and read access to DynamoDB streams and write permissions to CloudWatch logs.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:DescribeStream", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:ListStreams", + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole": { + PolicyName: "AWSLambdaSQSQueueExecutionRole", + PolicyId: "ANPAJFWJZI6LQQTROCBEY", + Arn: "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole", + Path: "/service-role/", + Description: "Provides receive message, delete message, and read attribute access to SQS queues, and write permissions to CloudWatch logs.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/AWSLambda_FullAccess": { + PolicyName: "AWSLambda_FullAccess", + PolicyId: "ANPAZKAPJZG4ONJPM5YFF", + Arn: "arn:aws:iam::aws:policy/AWSLambda_FullAccess", + Path: "/", + Description: "Grants full access to AWS Lambda service, AWS Lambda console features, and other related AWS services.", + DefaultVersionId: "v1", + Document: permissiveStubDocument, + }, + + // Administrator and PowerUser policies + "arn:aws:iam::aws:policy/AdministratorAccess": { + PolicyName: "AdministratorAccess", + PolicyId: "ANPAIWMBCKSKIEE64ZLYK", + Arn: "arn:aws:iam::aws:policy/AdministratorAccess", + Path: "/", + Description: "Provides full access to AWS services and resources.", + DefaultVersionId: "v1", + Document: permissiveStubDocument, + }, + "arn:aws:iam::aws:policy/PowerUserAccess": { + PolicyName: "PowerUserAccess", + PolicyId: "ANPAJYRXTHIB4FOVS3ZXS", + Arn: "arn:aws:iam::aws:policy/PowerUserAccess", + Path: "/", + Description: "Provides full access to AWS services and resources, but does not allow management of Users and groups.", + DefaultVersionId: "v1", + Document: permissiveStubDocument, + }, + "arn:aws:iam::aws:policy/ReadOnlyAccess": { + PolicyName: "ReadOnlyAccess", + PolicyId: "ANPAILL3HVNFSB6DCOWYQ", + Arn: "arn:aws:iam::aws:policy/ReadOnlyAccess", + Path: "/", + Description: "Provides read-only access to AWS services and resources.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "*:Describe*", + "*:Get*", + "*:List*" + ], + "Resource": "*" + } + ] +}`, + }, + + // S3 policies + "arn:aws:iam::aws:policy/AmazonS3FullAccess": { + PolicyName: "AmazonS3FullAccess", + PolicyId: "ANPAIFIR6V6BVTRAHWINE", + Arn: "arn:aws:iam::aws:policy/AmazonS3FullAccess", + Path: "/", + Description: "Provides full access to all buckets via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:*", + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess": { + PolicyName: "AmazonS3ReadOnlyAccess", + PolicyId: "ANPAIZTJ4DXE7G6AGAE6M", + Arn: "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess", + Path: "/", + Description: "Provides read only access to all buckets via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:Get*", + "s3:List*" + ], + "Resource": "*" + } + ] +}`, + }, + + // DynamoDB policies + "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess": { + PolicyName: "AmazonDynamoDBFullAccess", + PolicyId: "ANPAIY2XFNA232OKQCJWC", + Arn: "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess", + Path: "/", + Description: "Provides full access to Amazon DynamoDB via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "dynamodb:*", + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess": { + PolicyName: "AmazonDynamoDBReadOnlyAccess", + PolicyId: "ANPAINUGF2JSOSUY76KYA", + Arn: "arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess", + Path: "/", + Description: "Provides read only access to Amazon DynamoDB via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:ListTables", + "dynamodb:Query", + "dynamodb:Scan" + ], + "Resource": "*" + } + ] +}`, + }, + + // EC2 policies + "arn:aws:iam::aws:policy/AmazonEC2FullAccess": { + PolicyName: "AmazonEC2FullAccess", + PolicyId: "ANPAI3VAJF5ZCRZ7MCQE6", + Arn: "arn:aws:iam::aws:policy/AmazonEC2FullAccess", + Path: "/", + Description: "Provides full access to Amazon EC2 via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "ec2:*", + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess": { + PolicyName: "AmazonEC2ReadOnlyAccess", + PolicyId: "ANPAIGDT4S5U7O6UNL6C2", + Arn: "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess", + Path: "/", + Description: "Provides read only access to Amazon EC2 via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "ec2:Describe*", + "Resource": "*" + } + ] +}`, + }, + + // RDS policies + "arn:aws:iam::aws:policy/AmazonRDSFullAccess": { + PolicyName: "AmazonRDSFullAccess", + PolicyId: "ANPAI2D4VEWVHYVK6PTUM", + Arn: "arn:aws:iam::aws:policy/AmazonRDSFullAccess", + Path: "/", + Description: "Provides full access to Amazon RDS via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "rds:*", + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/AmazonRDSReadOnlyAccess": { + PolicyName: "AmazonRDSReadOnlyAccess", + PolicyId: "ANPAJKTTTYV2IIHKLZ346", + Arn: "arn:aws:iam::aws:policy/AmazonRDSReadOnlyAccess", + Path: "/", + Description: "Provides read only access to Amazon RDS via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "rds:Describe*", + "rds:ListTagsForResource" + ], + "Resource": "*" + } + ] +}`, + }, + + // IAM policies + "arn:aws:iam::aws:policy/IAMFullAccess": { + PolicyName: "IAMFullAccess", + PolicyId: "ANPAI7XKCFMBPM3QQRRVQ", + Arn: "arn:aws:iam::aws:policy/IAMFullAccess", + Path: "/", + Description: "Provides full access to IAM via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "iam:*", + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/IAMReadOnlyAccess": { + PolicyName: "IAMReadOnlyAccess", + PolicyId: "ANPAJKSO7NDY4T57MWDSQ", + Arn: "arn:aws:iam::aws:policy/IAMReadOnlyAccess", + Path: "/", + Description: "Provides read only access to IAM via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:Get*", + "iam:List*" + ], + "Resource": "*" + } + ] +}`, + }, + + // SQS policies + "arn:aws:iam::aws:policy/AmazonSQSFullAccess": { + PolicyName: "AmazonSQSFullAccess", + PolicyId: "ANPAI4UIINUVGB5SEC57G", + Arn: "arn:aws:iam::aws:policy/AmazonSQSFullAccess", + Path: "/", + Description: "Provides full access to Amazon SQS via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "sqs:*", + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/AmazonSQSReadOnlyAccess": { + PolicyName: "AmazonSQSReadOnlyAccess", + PolicyId: "ANPAJFWMCWF2ZYOGKRXZS", + Arn: "arn:aws:iam::aws:policy/AmazonSQSReadOnlyAccess", + Path: "/", + Description: "Provides read only access to Amazon SQS via the AWS Management Console.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + "sqs:ListDeadLetterSourceQueues", + "sqs:ListQueues" + ], + "Resource": "*" + } + ] +}`, + }, + + // CloudWatch policies + "arn:aws:iam::aws:policy/CloudWatchFullAccess": { + PolicyName: "CloudWatchFullAccess", + PolicyId: "ANPAIKEABORKUXN6DEAZU", + Arn: "arn:aws:iam::aws:policy/CloudWatchFullAccess", + Path: "/", + Description: "Provides full access to CloudWatch.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "cloudwatch:*", + "logs:*" + ], + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess": { + PolicyName: "CloudWatchReadOnlyAccess", + PolicyId: "ANPAJN23PDQP7SZQAE3QE", + Arn: "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess", + Path: "/", + Description: "Provides read only access to CloudWatch.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "cloudwatch:Describe*", + "cloudwatch:Get*", + "cloudwatch:List*", + "logs:Describe*", + "logs:Get*", + "logs:FilterLogEvents" + ], + "Resource": "*" + } + ] +}`, + }, + + // SSM policies + "arn:aws:iam::aws:policy/AmazonSSMFullAccess": { + PolicyName: "AmazonSSMFullAccess", + PolicyId: "ANPAJA7V6HI7WXOQIQXNU", + Arn: "arn:aws:iam::aws:policy/AmazonSSMFullAccess", + Path: "/", + Description: "Provides full access to Amazon SSM.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "ssm:*", + "Resource": "*" + } + ] +}`, + }, + "arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess": { + PolicyName: "AmazonSSMReadOnlyAccess", + PolicyId: "ANPAJODSKQGGJTHRYZ6TM", + Arn: "arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess", + Path: "/", + Description: "Provides read only access to Amazon SSM.", + DefaultVersionId: "v1", + Document: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ssm:Describe*", + "ssm:Get*", + "ssm:List*" + ], + "Resource": "*" + } + ] +}`, + }, +} + +// isAWSManagedPolicyArn checks if the given ARN is an AWS managed policy ARN. +// AWS managed policies have the format: arn:aws:iam::aws:policy/... +func isAWSManagedPolicyArn(arn string) bool { + return strings.HasPrefix(arn, "arn:aws:iam::aws:policy/") +} + +// getAWSManagedPolicy returns the AWS managed policy for the given ARN. +// If the policy is not in our predefined list, it returns a stub policy. +// Returns nil if the ARN is not an AWS managed policy ARN. +func getAWSManagedPolicy(arn string) *AWSManagedPolicy { + if !isAWSManagedPolicyArn(arn) { + return nil + } + + // Check if we have an explicit definition + if policy, ok := awsManagedPolicies[arn]; ok { + // Set timestamps if not already set + if policy.CreateDate.IsZero() { + policy.CreateDate = time.Date(2015, 2, 6, 18, 40, 58, 0, time.UTC) + policy.UpdateDate = time.Date(2015, 2, 6, 18, 40, 58, 0, time.UTC) + } + return &policy + } + + // Return a stub policy for unrecognized AWS managed policies + policyName := extractPolicyNameFromArn(arn) + path := extractPolicyPathFromArn(arn) + + return &AWSManagedPolicy{ + PolicyName: policyName, + PolicyId: "ANPA" + generateStubPolicyId(arn), + Arn: arn, + Path: path, + Description: "AWS managed policy (stub)", + DefaultVersionId: "v1", + Document: permissiveStubDocument, + CreateDate: time.Date(2015, 2, 6, 18, 40, 58, 0, time.UTC), + UpdateDate: time.Date(2015, 2, 6, 18, 40, 58, 0, time.UTC), + } +} + +// extractPolicyPathFromArn extracts the path from an AWS managed policy ARN. +// For example: arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole +// returns "/service-role/" +func extractPolicyPathFromArn(arn string) string { + parts := strings.Split(arn, ":policy") + if len(parts) < 2 { + return "/" + } + pathAndName := parts[1] + // Get everything up to and including the last slash + lastSlash := strings.LastIndex(pathAndName, "/") + if lastSlash <= 0 { + return "/" + } + return pathAndName[:lastSlash+1] +} + +// generateStubPolicyId generates a deterministic policy ID suffix based on the ARN. +// This ensures the same ARN always gets the same ID. +func generateStubPolicyId(arn string) string { + // Simple hash-like function to generate a 17-char alphanumeric ID + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result := make([]byte, 17) + hash := uint64(0) + for i, c := range arn { + hash = hash*31 + uint64(c) + uint64(i) + } + for i := range result { + result[i] = chars[hash%uint64(len(chars))] + hash /= uint64(len(chars)) + if hash == 0 { + hash = uint64(i + 1) + } + } + return string(result) +} + +// toXMLPolicy converts an AWSManagedPolicy to XMLPolicy for response serialization. +func (p *AWSManagedPolicy) toXMLPolicy() XMLPolicy { + return XMLPolicy{ + PolicyName: p.PolicyName, + PolicyId: p.PolicyId, + Arn: p.Arn, + Path: p.Path, + Description: p.Description, + DefaultVersionId: p.DefaultVersionId, + CreateDate: p.CreateDate, + UpdateDate: p.UpdateDate, + AttachmentCount: 0, // AWS managed policies don't track this per-account + IsAttachable: true, + } +} + +// toXMLPolicyVersion converts an AWSManagedPolicy to XMLPolicyVersion for response serialization. +func (p *AWSManagedPolicy) toXMLPolicyVersion() XMLPolicyVersion { + return XMLPolicyVersion{ + VersionId: p.DefaultVersionId, + Document: p.Document, + IsDefaultVersion: true, + CreateDate: p.CreateDate, + } +} diff --git a/internal/emulator/services/iam/policy_attachment_handlers.go b/internal/emulator/services/iam/policy_attachment_handlers.go index 502843e..3f0e623 100644 --- a/internal/emulator/services/iam/policy_attachment_handlers.go +++ b/internal/emulator/services/iam/policy_attachment_handlers.go @@ -26,12 +26,16 @@ func (s *IAMService) attachRolePolicy(ctx context.Context, params map[string]int return s.errorResponse(404, "NoSuchEntity", fmt.Sprintf("The role with name %s cannot be found.", roleName)), nil } - // Verify policy exists policyName := extractPolicyNameFromArn(policyArn) - policyKey := fmt.Sprintf("iam:policy:%s:%s", defaultAccountID, policyName) - var policy XMLPolicy - if err := s.state.Get(policyKey, &policy); err != nil { - return s.errorResponse(404, "NoSuchEntity", fmt.Sprintf("Policy %s does not exist.", policyArn)), nil + isAWSManaged := isAWSManagedPolicyArn(policyArn) + + // Verify policy exists (skip for AWS managed policies - they always exist) + if !isAWSManaged { + policyKey := fmt.Sprintf("iam:policy:%s:%s", defaultAccountID, policyName) + var policy XMLPolicy + if err := s.state.Get(policyKey, &policy); err != nil { + return s.errorResponse(404, "NoSuchEntity", fmt.Sprintf("Policy %s does not exist.", policyArn)), nil + } } // Add to role attachments @@ -56,23 +60,27 @@ func (s *IAMService) attachRolePolicy(ctx context.Context, params map[string]int // Add relationship in graph: policy -> role (policy attachment depends on role) // This edge direction means: deleting the role will fail if policies are attached - if err := s.addRelationship("policy", policyName, "role", roleName, graph.RelAssociatedWith); err != nil { - if s.isStrictMode() { - // Rollback: remove the policy from attachments - attachments.PolicyArns = attachments.PolicyArns[:len(attachments.PolicyArns)-1] - s.state.Set(attachKey, &attachments) - return s.errorResponse(500, "InternalFailure", fmt.Sprintf("Failed to create role-policy relationship: %v", err)), nil + // Skip graph relationship for AWS managed policies (they're not in the graph) + if !isAWSManaged { + if err := s.addRelationship("policy", policyName, "role", roleName, graph.RelAssociatedWith); err != nil { + if s.isStrictMode() { + // Rollback: remove the policy from attachments + attachments.PolicyArns = attachments.PolicyArns[:len(attachments.PolicyArns)-1] + s.state.Set(attachKey, &attachments) + return s.errorResponse(500, "InternalFailure", fmt.Sprintf("Failed to create role-policy relationship: %v", err)), nil + } + log.Printf("Warning: failed to add role-policy relationship in graph: %v", err) } - log.Printf("Warning: failed to add role-policy relationship in graph: %v", err) - } - // Increment attachment count on policy atomically - var policyToUpdate XMLPolicy - if err := s.state.Update(policyKey, &policyToUpdate, func() error { - policyToUpdate.AttachmentCount++ - return nil - }); err != nil { - return s.errorResponse(500, "InternalFailure", "Failed to update policy attachment count"), nil + // Increment attachment count on policy atomically (only for customer-managed policies) + policyKey := fmt.Sprintf("iam:policy:%s:%s", defaultAccountID, policyName) + var policyToUpdate XMLPolicy + if err := s.state.Update(policyKey, &policyToUpdate, func() error { + policyToUpdate.AttachmentCount++ + return nil + }); err != nil { + return s.errorResponse(500, "InternalFailure", "Failed to update policy attachment count"), nil + } } return s.successResponse("AttachRolePolicy", EmptyResult{}) @@ -122,22 +130,27 @@ func (s *IAMService) detachRolePolicy(ctx context.Context, params map[string]int return s.errorResponse(500, "InternalFailure", "Failed to detach policy"), nil } - // Remove relationship in graph: policy -> role policyName := extractPolicyNameFromArn(policyArn) - if err := s.removeRelationship("policy", policyName, "role", roleName, graph.RelAssociatedWith); err != nil { - log.Printf("Warning: failed to remove role-policy relationship in graph: %v", err) - } + isAWSManaged := isAWSManagedPolicyArn(policyArn) - // Decrement attachment count on policy atomically - policyKey := fmt.Sprintf("iam:policy:%s:%s", defaultAccountID, policyName) - var policyToUpdate XMLPolicy - // Use Update for atomic decrement; ignore error if policy was deleted - _ = s.state.Update(policyKey, &policyToUpdate, func() error { - if policyToUpdate.AttachmentCount > 0 { - policyToUpdate.AttachmentCount-- + // Skip graph and attachment count updates for AWS managed policies + if !isAWSManaged { + // Remove relationship in graph: policy -> role + if err := s.removeRelationship("policy", policyName, "role", roleName, graph.RelAssociatedWith); err != nil { + log.Printf("Warning: failed to remove role-policy relationship in graph: %v", err) } - return nil - }) + + // Decrement attachment count on policy atomically + policyKey := fmt.Sprintf("iam:policy:%s:%s", defaultAccountID, policyName) + var policyToUpdate XMLPolicy + // Use Update for atomic decrement; ignore error if policy was deleted + _ = s.state.Update(policyKey, &policyToUpdate, func() error { + if policyToUpdate.AttachmentCount > 0 { + policyToUpdate.AttachmentCount-- + } + return nil + }) + } return s.successResponse("DetachRolePolicy", EmptyResult{}) } diff --git a/internal/emulator/services/iam/policy_handlers.go b/internal/emulator/services/iam/policy_handlers.go index e7c606d..1114e39 100644 --- a/internal/emulator/services/iam/policy_handlers.go +++ b/internal/emulator/services/iam/policy_handlers.go @@ -93,6 +93,12 @@ func (s *IAMService) getPolicy(ctx context.Context, params map[string]interface{ return s.errorResponse(400, "InvalidInput", "Invalid PolicyArn format"), nil } + // Check if this is an AWS managed policy + if managedPolicy := getAWSManagedPolicy(policyArn); managedPolicy != nil { + result := GetPolicyResult{Policy: managedPolicy.toXMLPolicy()} + return s.successResponse("GetPolicy", result) + } + var policy XMLPolicy stateKey := fmt.Sprintf("iam:policy:%s:%s", defaultAccountID, policyName) if err := s.state.Get(stateKey, &policy); err != nil { @@ -119,6 +125,16 @@ func (s *IAMService) getPolicyVersion(ctx context.Context, params map[string]int return s.errorResponse(400, "InvalidInput", "Invalid PolicyArn format"), nil } + // Check if this is an AWS managed policy + if managedPolicy := getAWSManagedPolicy(policyArn); managedPolicy != nil { + // AWS managed policies only have v1 + if versionId != "v1" { + return s.errorResponse(404, "NoSuchEntity", fmt.Sprintf("Policy version %s does not exist.", versionId)), nil + } + result := GetPolicyVersionResult{PolicyVersion: managedPolicy.toXMLPolicyVersion()} + return s.successResponse("GetPolicyVersion", result) + } + var version XMLPolicyVersion versionKey := fmt.Sprintf("iam:policy-version:%s:%s:%s", defaultAccountID, policyName, versionId) if err := s.state.Get(versionKey, &version); err != nil { From c95f3fa11d1cc0e7f4e91af154783a4a6039be37 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Mon, 29 Dec 2025 18:25:35 +0800 Subject: [PATCH 5/5] fix(lambda): improve error handling and remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use errors.As() instead of strings.Contains() for proper AWS SDK error type checking in AssertFunctionNotExists - Add null safety check for empty runtime in AssertFunctionRuntime - Remove unused strconv import and associated hack 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pkg/assertions/aws/lambda.go | 14 +++++++++----- pkg/steps/aws/lambda.go | 4 ---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/assertions/aws/lambda.go b/pkg/assertions/aws/lambda.go index 09197f6..e354c2d 100644 --- a/pkg/assertions/aws/lambda.go +++ b/pkg/assertions/aws/lambda.go @@ -2,8 +2,8 @@ package aws import ( "context" + "errors" "fmt" - "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/lambda" @@ -77,11 +77,10 @@ func (a *AWSAsserter) AssertFunctionNotExists(functionName string) error { // Check if the error is a ResourceNotFoundException var notFoundErr *types.ResourceNotFoundException - if !strings.Contains(err.Error(), "ResourceNotFoundException") { + if !errors.As(err, ¬FoundErr) { // If it's a different error, return it return fmt.Errorf("unexpected error checking Lambda function %s: %w", functionName, err) } - _ = notFoundErr // Silence unused variable warning return nil } @@ -93,8 +92,13 @@ func (a *AWSAsserter) AssertFunctionRuntime(functionName, runtime string) error return err } - if string(config.Runtime) != runtime { - return fmt.Errorf("expected runtime %s, but got %s", runtime, config.Runtime) + actualRuntime := string(config.Runtime) + if actualRuntime == "" { + return fmt.Errorf("Lambda function %s has no runtime configured", functionName) + } + + if actualRuntime != runtime { + return fmt.Errorf("expected runtime %s, but got %s", runtime, actualRuntime) } return nil diff --git a/pkg/steps/aws/lambda.go b/pkg/steps/aws/lambda.go index cacb70d..60eacf7 100644 --- a/pkg/steps/aws/lambda.go +++ b/pkg/steps/aws/lambda.go @@ -3,7 +3,6 @@ package aws import ( "context" "fmt" - "strconv" "github.com/cucumber/godog" @@ -317,6 +316,3 @@ func newEventSourceMappingFromOutputExistsStep(ctx context.Context, outputName s } return newEventSourceMappingExistsStep(ctx, uuid) } - -// Silence unused import warning for strconv -var _ = strconv.Itoa