diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..8416c23
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,12 @@
+version: 2
+updates:
+ - package-ecosystem: "nuget"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "wednesday"
+ open-pull-requests-limit: 25
+ groups:
+ dotnet:
+ patterns:
+ - "*"
\ No newline at end of file
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 0000000..9b35f63
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -0,0 +1,19 @@
+name: Build
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ - beta
+ - release/*
+ tags:
+ - v*
+permissions: write-all
+jobs:
+ build:
+ uses: LayeredCraft/devops-templates/.github/workflows/package-build.yaml@v5.0
+ with:
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
+ secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml
new file mode 100644
index 0000000..683c92d
--- /dev/null
+++ b/.github/workflows/pr-build.yaml
@@ -0,0 +1,18 @@
+name: PR Build
+
+on:
+ pull_request:
+ branches:
+ - main
+permissions: write-all
+jobs:
+ build:
+ uses: LayeredCraft/devops-templates/.github/workflows/pr-build.yaml@v5.0
+ with:
+ solution: LayeredCraft.Cdk.Constructs.sln
+ hasTests: true
+ dotnetVersion: |
+ 8.0.x
+ 9.0.x
+ runCdk: false
+ secrets: inherit
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index ce89292..77b7f53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -416,3 +416,5 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp
+
+.idea
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..7b5c35d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,105 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+This is a .NET library that provides reusable AWS CDK constructs for serverless applications. The library is designed specifically for LayeredCraft projects and focuses on Lambda functions with integrated OpenTelemetry support, IAM management, and environment configuration.
+
+## Commands
+
+### Build and Test
+```bash
+# Build the solution
+dotnet build
+
+# Run all tests
+dotnet test
+
+# Run tests for a specific project
+dotnet test test/LayeredCraft.Cdk.Constructs.Tests/
+
+# Run a specific test
+dotnet test --filter "Construct_ShouldCreateLambdaFunction"
+```
+
+### Package Management
+```bash
+# Restore packages
+dotnet restore
+
+# Add a package reference
+dotnet add src/LayeredCraft.Cdk.Constructs/ package PackageName
+
+# Add a test package reference
+dotnet add test/LayeredCraft.Cdk.Constructs.Tests/ package PackageName
+```
+
+## Architecture
+
+### Core Components
+
+**LambdaFunctionConstruct** (`src/LayeredCraft.Cdk.Constructs/Constructs/LambdaFunctionConstruct.cs`)
+- Main CDK construct that creates Lambda functions with comprehensive configuration
+- Automatically creates IAM roles and policies with CloudWatch Logs permissions
+- Supports OpenTelemetry layer integration via AWS managed layer
+- Creates function versions and aliases for deployment management
+- Handles Lambda permissions for multiple targets (function, version, alias)
+
+**Configuration Models** (`src/LayeredCraft.Cdk.Constructs/Models/`)
+- `LambdaFunctionConstructProps`: Main configuration interface and record for Lambda construct
+- `LambdaPermission`: Record for defining Lambda invocation permissions
+
+### Key Design Patterns
+
+1. **Construct Pattern**: Uses AWS CDK construct pattern for reusable infrastructure components
+2. **Interface + Record Pattern**: Props classes use both interface and record for flexibility and immutability
+3. **Multi-Target Permissions**: Automatically applies permissions to function, version, and alias
+4. **OpenTelemetry Integration**: Built-in support for AWS OTEL collector layer
+5. **Versioning Strategy**: Creates new versions on every deployment with "live" alias
+
+### Target Frameworks
+- .NET 8.0 and .NET 9.0
+- Uses AWS CDK v2 (Amazon.CDK.Lib 2.203.1)
+
+## Testing Framework
+
+### Test Structure
+- Uses xUnit v3 with AutoFixture for data generation
+- AwesomeAssertions for fluent assertions
+- NSubstitute for mocking (though not heavily used in current tests)
+- AWS CDK Template assertions for infrastructure testing
+
+### Test Architecture
+**Custom Attributes**:
+- `LambdaFunctionConstructAutoDataAttribute`: AutoFixture attribute with customizable parameters
+- `BaseFixtureFactory`: Factory for creating configured AutoFixture instances
+
+**Test Customizations**:
+- `LambdaFunctionConstructCustomization`: Configures realistic test data for Lambda constructs
+- Supports parameterized test scenarios (OTEL layer on/off, permissions included/excluded)
+
+### Test Data Strategy
+- Uses AutoFixture with custom configurations for realistic AWS resource names
+- Test assets include `test-lambda.zip` for Lambda deployment packages
+- Tests verify both resource creation and property configuration
+
+## Development Practices
+
+### Code Style
+- C# 12+ features enabled (records, required properties, collection expressions)
+- Nullable reference types enabled
+- Implicit usings enabled
+- Uses `required` properties for mandatory configuration
+
+### NuGet Package Configuration
+- Configured for NuGet.org publishing with MIT license
+- Includes source linking via Microsoft.SourceLink.GitHub
+- Symbol packages (snupkg) enabled for debugging support
+- Package metadata includes proper tagging for discoverability
+
+### AWS CDK Patterns
+- Uses `PROVIDED_AL2023` runtime for Lambda functions (supports custom runtimes)
+- Hardcoded OpenTelemetry layer ARN for us-east-1 region
+- Default memory: 1024MB, timeout: 6 seconds, log retention: 2 weeks
+- Uses `RemovalPolicy.RETAIN` for Lambda versions to prevent deletion
\ No newline at end of file
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..ec37eea
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,29 @@
+
+
+ 0.1.0-beta
+
+ MIT
+
+
+ https://github.com/LayeredCraft/cdk-constructs
+ git
+ Nick Cipollina
+ https://github.com/LayeredCraft/cdk-constructs
+
+
+ false
+ true
+ true
+ snupkg
+ true
+ true
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/LayeredCraft.Cdk.Constructs.sln b/LayeredCraft.Cdk.Constructs.sln
new file mode 100644
index 0000000..8ab3553
--- /dev/null
+++ b/LayeredCraft.Cdk.Constructs.sln
@@ -0,0 +1,52 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5C4245CB-F794-42A5-8226-AF6AA00D0E5E}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{F802A3E0-E887-4EAA-87FA-AB090DCCB788}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{34177AB8-B46F-4340-8D3A-478B24804E73}"
+ ProjectSection(SolutionItems) = preProject
+ CLAUDE.md = CLAUDE.md
+ Directory.Build.props = Directory.Build.props
+ README.md = README.md
+ LICENSE = LICENSE
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "git", "git", "{32F31203-71F9-4824-A5E0-D326AF212DD4}"
+ ProjectSection(SolutionItems) = preProject
+ .gitignore = .gitignore
+ .github\dependabot.yml = .github\dependabot.yml
+ .github\workflows\build.yaml = .github\workflows\build.yaml
+ .github\workflows\pr-build.yaml = .github\workflows\pr-build.yaml
+ EndProjectSection
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LayeredCraft.Cdk.Constructs", "src\LayeredCraft.Cdk.Constructs\LayeredCraft.Cdk.Constructs.csproj", "{63FCBE95-6714-49D5-A9CD-0BE725BAE259}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LayeredCraft.Cdk.Constructs.Tests", "test\LayeredCraft.Cdk.Constructs.Tests\LayeredCraft.Cdk.Constructs.Tests.csproj", "{7F2D2EA4-D201-4B0E-AE44-6D03B1B7AEBC}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {63FCBE95-6714-49D5-A9CD-0BE725BAE259} = {5C4245CB-F794-42A5-8226-AF6AA00D0E5E}
+ {7F2D2EA4-D201-4B0E-AE44-6D03B1B7AEBC} = {F802A3E0-E887-4EAA-87FA-AB090DCCB788}
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {63FCBE95-6714-49D5-A9CD-0BE725BAE259}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {63FCBE95-6714-49D5-A9CD-0BE725BAE259}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {63FCBE95-6714-49D5-A9CD-0BE725BAE259}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {63FCBE95-6714-49D5-A9CD-0BE725BAE259}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7F2D2EA4-D201-4B0E-AE44-6D03B1B7AEBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7F2D2EA4-D201-4B0E-AE44-6D03B1B7AEBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7F2D2EA4-D201-4B0E-AE44-6D03B1B7AEBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7F2D2EA4-D201-4B0E-AE44-6D03B1B7AEBC}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/README.md b/README.md
index 43722f3..7ed965f 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,214 @@
# cdk-constructs
+
+[](https://github.com/LayeredCraft/cdk-constructs/actions/workflows/build.yaml)
+[](https://www.nuget.org/packages/LayeredCraft.Cdk.Constructs/)
+[](https://www.nuget.org/packages/LayeredCraft.Cdk.Constructs/)
+
A reusable library of AWS CDK constructs for .NET projects, optimized for serverless applications using Lambda, API Gateway (or Lambda URLs), DynamoDB, S3, CloudFront, and OpenTelemetry. Built for speed, observability, and cost efficiency across the LayeredCraft project ecosystem.
+
+## Installation
+
+Install the package via NuGet:
+
+```bash
+dotnet add package LayeredCraft.Cdk.Constructs
+```
+
+Or via Package Manager Console:
+
+```powershell
+Install-Package LayeredCraft.Cdk.Constructs
+```
+
+## Quick Start
+
+### Basic Lambda Function
+
+```csharp
+using Amazon.CDK;
+using Amazon.CDK.AWS.IAM;
+using LayeredCraft.Cdk.Constructs.Constructs;
+using LayeredCraft.Cdk.Constructs.Models;
+
+public class MyStack : Stack
+{
+ public MyStack(Construct scope, string id, IStackProps props = null) : base(scope, id, props)
+ {
+ var lambdaConstruct = new LambdaFunctionConstruct(this, "MyLambda", new LambdaFunctionConstructProps
+ {
+ FunctionName = "my-function",
+ FunctionSuffix = "prod",
+ AssetPath = "./lambda-deployment.zip",
+ RoleName = "my-function-role",
+ PolicyName = "my-function-policy",
+ EnvironmentVariables = new Dictionary
+ {
+ { "ENVIRONMENT", "production" },
+ { "LOG_LEVEL", "info" }
+ }
+ });
+ }
+}
+```
+
+### Lambda with Custom IAM Permissions
+
+```csharp
+var lambdaConstruct = new LambdaFunctionConstruct(this, "MyLambda", new LambdaFunctionConstructProps
+{
+ FunctionName = "my-function",
+ FunctionSuffix = "prod",
+ AssetPath = "./lambda-deployment.zip",
+ RoleName = "my-function-role",
+ PolicyName = "my-function-policy",
+ PolicyStatements = new[]
+ {
+ new PolicyStatement(new PolicyStatementProps
+ {
+ Actions = new[] { "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query" },
+ Resources = new[] { "arn:aws:dynamodb:us-east-1:123456789012:table/MyTable" },
+ Effect = Effect.ALLOW
+ })
+ },
+ Permissions = new List
+ {
+ new LambdaPermission
+ {
+ Principal = "apigateway.amazonaws.com",
+ Action = "lambda:InvokeFunction",
+ SourceArn = "arn:aws:execute-api:us-east-1:123456789012:*/*/GET/my-endpoint"
+ }
+ }
+});
+```
+
+## Features
+
+### 🚀 LambdaFunctionConstruct
+
+The main construct that creates a complete Lambda function setup with:
+
+- **Lambda Function**: Configured for custom runtimes (`PROVIDED_AL2023`) with bootstrap handler
+- **IAM Role & Policy**: Automatic CloudWatch Logs permissions + custom policy statements
+- **CloudWatch Logs**: Explicit log group with configurable retention (default: 2 weeks)
+- **OpenTelemetry**: Optional AWS OTEL Collector layer integration
+- **Versioning**: Automatic version creation with "live" alias
+- **Multi-target Permissions**: Applies permissions to function, version, and alias
+
+### 🔧 Configuration Options
+
+| Property | Type | Description | Default |
+|----------|------|-------------|---------|
+| `FunctionName` | `string` | Base name for the Lambda function | Required |
+| `FunctionSuffix` | `string` | Suffix appended to function name | Required |
+| `AssetPath` | `string` | Path to Lambda deployment package | Required |
+| `RoleName` | `string` | IAM role name | Required |
+| `PolicyName` | `string` | IAM policy name | Required |
+| `PolicyStatements` | `PolicyStatement[]` | Custom IAM policy statements | `[]` |
+| `EnvironmentVariables` | `IDictionary` | Environment variables | `{}` |
+| `IncludeOtelLayer` | `bool` | Enable OpenTelemetry layer | `true` |
+| `Permissions` | `List` | Lambda invocation permissions | `[]` |
+
+### 🔍 OpenTelemetry Integration
+
+When `IncludeOtelLayer` is enabled (default), the construct automatically:
+- Adds the AWS OTEL Collector layer
+- Enables X-Ray tracing
+- Configures the Lambda for observability
+
+### 📋 Automatic IAM Permissions
+
+The construct automatically grants these CloudWatch Logs permissions:
+- `logs:CreateLogStream`
+- `logs:CreateLogGroup`
+- `logs:TagResource`
+- `logs:PutLogEvents`
+
+## Project Structure
+
+```
+├── src/
+│ └── LayeredCraft.Cdk.Constructs/ # Main library package
+│ ├── Constructs/
+│ │ └── LambdaFunctionConstruct.cs # Lambda function construct with IAM and logging
+│ └── Models/
+│ ├── LambdaFunctionConstructProps.cs # Configuration props for Lambda construct
+│ └── LambdaPermission.cs # Lambda permission model
+├── test/
+│ └── LayeredCraft.Cdk.Constructs.Tests/ # Test suite
+│ ├── Constructs/
+│ │ └── LambdaFunctionConstructTests.cs # Unit tests for Lambda construct
+│ ├── TestKit/ # Test utilities and fixtures
+│ │ ├── Attributes/ # Custom AutoFixture attributes
+│ │ └── Customizations/ # Test data customizations
+│ └── TestAssets/
+│ └── test-lambda.zip # Test Lambda deployment package
+└── LayeredCraft.Cdk.Constructs.sln # Solution file
+```
+
+## Requirements
+
+- **.NET 8.0 or .NET 9.0**
+- **AWS CDK v2** (Amazon.CDK.Lib 2.203.1+)
+- **AWS CLI** configured with appropriate permissions
+- **Node.js** (for CDK deployment)
+
+## Runtime Configuration
+
+The Lambda functions created by this construct use:
+- **Runtime**: `PROVIDED_AL2023` (Amazon Linux 2023)
+- **Handler**: `bootstrap` (for custom runtimes)
+- **Memory**: 1024 MB
+- **Timeout**: 6 seconds
+- **Log Retention**: 2 weeks
+
+## Development
+
+### Building the Project
+
+```bash
+# Build the solution
+dotnet build
+
+# Run tests
+dotnet run --project test/LayeredCraft.Cdk.Constructs.Tests/ --framework net8.0
+
+# Pack for local testing
+dotnet pack src/LayeredCraft.Cdk.Constructs/
+```
+
+### Testing
+
+The project uses xUnit v3 with AutoFixture for comprehensive testing:
+
+```bash
+# Run all tests
+cd test/LayeredCraft.Cdk.Constructs.Tests
+dotnet run --framework net8.0
+
+# Run specific test
+dotnet run --framework net8.0 -- --filter "Construct_ShouldCreateLambdaFunction"
+```
+
+## Contributing
+
+1. Fork the repository
+2. Create a feature branch (`git checkout -b feature/amazing-feature`)
+3. Commit your changes (`git commit -m 'Add amazing feature'`)
+4. Push to the branch (`git push origin feature/amazing-feature`)
+5. Open a Pull Request
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## Support
+
+For issues and questions:
+- 🐛 [Report bugs](https://github.com/LayeredCraft/cdk-constructs/issues)
+- 💡 [Request features](https://github.com/LayeredCraft/cdk-constructs/issues)
+- 📖 [Documentation](https://github.com/LayeredCraft/cdk-constructs/wiki)
+
+---
+
+**Built with ❤️ by the LayeredCraft team**
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..17a036b
Binary files /dev/null and b/icon.png differ
diff --git a/src/LayeredCraft.Cdk.Constructs/Constructs/LambdaFunctionConstruct.cs b/src/LayeredCraft.Cdk.Constructs/Constructs/LambdaFunctionConstruct.cs
new file mode 100644
index 0000000..c5cbad7
--- /dev/null
+++ b/src/LayeredCraft.Cdk.Constructs/Constructs/LambdaFunctionConstruct.cs
@@ -0,0 +1,119 @@
+using Amazon.CDK;
+using Amazon.CDK.AWS.IAM;
+using Amazon.CDK.AWS.Lambda;
+using Amazon.CDK.AWS.Logs;
+using Constructs;
+using LayeredCraft.Cdk.Constructs.Models;
+
+namespace LayeredCraft.Cdk.Constructs.Constructs;
+
+public class LambdaFunctionConstruct : Construct
+{
+ public LambdaFunctionConstruct(Construct scope, string id, ILambdaFunctionConstructProps props) : base(scope, id)
+ {
+ var region = Stack.Of(this).Region;
+ var account = Stack.Of(this).Account;
+ var policy = new Policy(this, $"{id}-policy", new PolicyProps
+ {
+ PolicyName = props.PolicyName,
+ Statements =
+ [
+ new PolicyStatement(new PolicyStatementProps
+ {
+ Actions =
+ [
+ "logs:CreateLogStream",
+ "logs:CreateLogGroup",
+ "logs:TagResource"
+ ],
+ Resources = [$"arn:aws:logs:{region}:{account}:log-group:/aws/lambda/{props.FunctionName}*:*"],
+ Effect = Effect.ALLOW
+ }),
+ new PolicyStatement(new PolicyStatementProps
+ {
+ Actions =
+ [
+ "logs:PutLogEvents"
+ ],
+ Resources =
+ [$"arn:aws:logs:{region}:{account}:log-group:/aws/lambda/{props.FunctionName}*:*:*"],
+ Effect = Effect.ALLOW
+ })
+ ]
+ });
+
+ policy.AddStatements(props.PolicyStatements);
+
+ var role = new Role(this, $"{id}-role", new RoleProps
+ {
+ AssumedBy = new ServicePrincipal("lambda.amazonaws.com"),
+ RoleName = props.RoleName
+ });
+
+ role.AttachInlinePolicy(policy);
+
+ var logGroup = new LogGroup(this, $"{id}-log-group", new LogGroupProps
+ {
+ LogGroupName = $"/aws/lambda/{props.FunctionName}-{props.FunctionSuffix}",
+ Retention = RetentionDays.TWO_WEEKS,
+ RemovalPolicy = RemovalPolicy.DESTROY
+ });
+
+ var lambda = new Function(this, $"{id}-function", new FunctionProps
+ {
+ FunctionName = $"{props.FunctionName}-{props.FunctionSuffix}",
+ Runtime = Runtime.PROVIDED_AL2023,
+ Handler = "bootstrap",
+ Code = Code.FromAsset(props.AssetPath),
+ Role = role,
+ MemorySize = 1024,
+ Timeout = Duration.Seconds(6),
+ Environment = props.EnvironmentVariables,
+ LogGroup = logGroup,
+ Tracing = props.IncludeOtelLayer ? Tracing.ACTIVE : Tracing.DISABLED,
+ CurrentVersionOptions = new VersionOptions
+ {
+ RemovalPolicy = RemovalPolicy.RETAIN
+ }
+ });
+ if (props.IncludeOtelLayer)
+ {
+ lambda.AddLayers(LayerVersion.FromLayerVersionArn(this, "OTELLambdaLayer",
+ $"arn:aws:lambda:{region}:901920570463:layer:aws-otel-collector-amd64-ver-0-102-1:1"));
+ }
+
+ // ✅ Create a new version on every deployment
+ var currentVersion = lambda.CurrentVersion;
+
+ var alias = new Alias(this, $"{id}-alias", new AliasProps
+ {
+ AliasName = "live",
+ Version = currentVersion,
+ });
+ AddPermissionsToAllTargets(
+ baseId: $"{id}-permission",
+ function: lambda,
+ version: lambda.CurrentVersion,
+ alias: alias,
+ permissions: props.Permissions
+ );
+ }
+
+ private void AddPermissionsToAllTargets(string baseId, IFunction function, IVersion version, IAlias? alias, List permissions)
+ {
+ foreach (var (perm, index) in permissions.Select((p, i) => (p, i)))
+ {
+ var permission = new Permission
+ {
+ Principal = new ServicePrincipal(perm.Principal),
+ Action = perm.Action,
+ EventSourceToken = perm.EventSourceToken,
+ SourceArn = perm.SourceArn
+ };
+
+ function.AddPermission($"{baseId}-fn-{index}", permission);
+ version.AddPermission($"{baseId}-ver-{index}", permission);
+ alias?.AddPermission($"{baseId}-alias-{index}", permission);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/LayeredCraft.Cdk.Constructs/LayeredCraft.Cdk.Constructs.csproj b/src/LayeredCraft.Cdk.Constructs/LayeredCraft.Cdk.Constructs.csproj
new file mode 100644
index 0000000..cf99c14
--- /dev/null
+++ b/src/LayeredCraft.Cdk.Constructs/LayeredCraft.Cdk.Constructs.csproj
@@ -0,0 +1,25 @@
+
+
+
+ enable
+ enable
+ net8.0;net9.0
+ default
+ LayeredCraft.Cdk.Constructs
+ LayeredCraft.Cdk.Constructs
+ Provides reusable AWS CDK constructs for serverless .NET applications, including Lambda functions with OpenTelemetry, IAM roles, and environment configuration. Built for LayeredCraft projects using AWS CDK in C#.
+ aws;cdk;lambda;constructs;dotnet;opentelemetry;infrastructure;cloudformation;layeredcraft
+ icon.png
+ README.md
+ MIT
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/LayeredCraft.Cdk.Constructs/Models/LambdaFunctionConstructProps.cs b/src/LayeredCraft.Cdk.Constructs/Models/LambdaFunctionConstructProps.cs
new file mode 100644
index 0000000..4002d59
--- /dev/null
+++ b/src/LayeredCraft.Cdk.Constructs/Models/LambdaFunctionConstructProps.cs
@@ -0,0 +1,28 @@
+using Amazon.CDK.AWS.IAM;
+
+namespace LayeredCraft.Cdk.Constructs.Models;
+
+public interface ILambdaFunctionConstructProps
+{
+ string FunctionName { get; set; }
+ string FunctionSuffix { get; set; }
+ string AssetPath { get; set; }
+ string RoleName { get; set; }
+ string PolicyName { get; set; }
+ PolicyStatement[] PolicyStatements { get; set; }
+ IDictionary EnvironmentVariables { get; set; }
+ bool IncludeOtelLayer { get; set; }
+ List Permissions { get; set; }
+}
+public sealed record LambdaFunctionConstructProps : ILambdaFunctionConstructProps
+{
+ public required string FunctionName { get; set; }
+ public required string FunctionSuffix { get; set; }
+ public required string AssetPath { get; set; }
+ public required string RoleName { get; set; }
+ public required string PolicyName { get; set; }
+ public PolicyStatement[] PolicyStatements { get; set; } = [];
+ public IDictionary EnvironmentVariables { get; set; } = new Dictionary();
+ public bool IncludeOtelLayer { get; set; } = true;
+ public List Permissions { get; set; } = [];
+}
\ No newline at end of file
diff --git a/src/LayeredCraft.Cdk.Constructs/Models/LambdaPermission.cs b/src/LayeredCraft.Cdk.Constructs/Models/LambdaPermission.cs
new file mode 100644
index 0000000..290d1bf
--- /dev/null
+++ b/src/LayeredCraft.Cdk.Constructs/Models/LambdaPermission.cs
@@ -0,0 +1,9 @@
+namespace LayeredCraft.Cdk.Constructs.Models;
+
+public sealed record LambdaPermission
+{
+ public required string Principal { get; set; }
+ public required string Action { get; set; }
+ public string? EventSourceToken { get; set; }
+ public string? SourceArn { get; set; }
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/Constructs/LambdaFunctionConstructTests.cs b/test/LayeredCraft.Cdk.Constructs.Tests/Constructs/LambdaFunctionConstructTests.cs
new file mode 100644
index 0000000..6498aa1
--- /dev/null
+++ b/test/LayeredCraft.Cdk.Constructs.Tests/Constructs/LambdaFunctionConstructTests.cs
@@ -0,0 +1,275 @@
+using Amazon.CDK;
+using Amazon.CDK.Assertions;
+using AwesomeAssertions;
+using LayeredCraft.Cdk.Constructs.Constructs;
+using LayeredCraft.Cdk.Constructs.Models;
+using LayeredCraft.Cdk.Constructs.Tests.TestKit.Attributes;
+
+namespace LayeredCraft.Cdk.Constructs.Tests.Constructs;
+
+[Collection("CDK Tests")]
+public class LambdaFunctionConstructTests
+{
+ [Theory]
+ [LambdaFunctionConstructAutoData]
+ public void Construct_ShouldCreateLambdaFunction(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ var construct = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ construct.Should().NotBeNull();
+ template.HasResourceProperties("AWS::Lambda::Function", Match.ObjectLike(new Dictionary
+ {
+ { "FunctionName", $"{props.FunctionName}-{props.FunctionSuffix}" },
+ { "Runtime", "provided.al2023" },
+ { "Handler", "bootstrap" },
+ { "MemorySize", 1024 },
+ { "Timeout", 6 }
+ }));
+ }
+
+ [Theory]
+ [LambdaFunctionConstructAutoData]
+ public void Construct_ShouldCreateIAMRole(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ _ = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ template.HasResourceProperties("AWS::IAM::Role", Match.ObjectLike(new Dictionary
+ {
+ { "RoleName", props.RoleName }
+ }));
+ }
+
+ [Theory]
+ [LambdaFunctionConstructAutoData]
+ public void Construct_ShouldCreateIAMPolicy(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ _ = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ template.HasResourceProperties("AWS::IAM::Policy", Match.ObjectLike(new Dictionary
+ {
+ { "PolicyName", props.PolicyName }
+ }));
+ }
+
+ [Theory]
+ [LambdaFunctionConstructAutoData]
+ public void Construct_ShouldIncludeCloudWatchLogsPermissions(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ _ = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ // Verify the policy exists with correct name
+ template.HasResourceProperties("AWS::IAM::Policy", Match.ObjectLike(new Dictionary
+ {
+ { "PolicyName", props.PolicyName },
+ { "PolicyDocument", Match.ObjectLike(new Dictionary
+ {
+ { "Statement", Match.ArrayWith([
+ // First statement: logs:CreateLogStream, logs:CreateLogGroup, logs:TagResource
+ Match.ObjectLike(new Dictionary
+ {
+ { "Effect", "Allow" },
+ { "Action", Match.ArrayWith([
+ "logs:CreateLogStream",
+ "logs:CreateLogGroup",
+ "logs:TagResource"
+ ]) },
+ { "Resource", $"arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/{props.FunctionName}*:*" }
+ }),
+ // Second statement: logs:PutLogEvents
+ Match.ObjectLike(new Dictionary
+ {
+ { "Effect", "Allow" },
+ { "Action", "logs:PutLogEvents" },
+ { "Resource", $"arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/{props.FunctionName}*:*:*" }
+ })
+ ]) }
+ }) }
+ }));
+ }
+
+ [Theory]
+ [LambdaFunctionConstructAutoData]
+ public void Construct_ShouldSetEnvironmentVariables(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ _ = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ template.HasResourceProperties("AWS::Lambda::Function", Match.ObjectLike(new Dictionary
+ {
+ { "Environment", Match.ObjectLike(new Dictionary
+ {
+ { "Variables", Match.ObjectLike(props.EnvironmentVariables.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value)) }
+ }) }
+ }));
+ }
+
+ [Theory]
+ [LambdaFunctionConstructAutoData(includeOtelLayer: true)]
+ public void Construct_ShouldIncludeOtelLayerWhenEnabled(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ _ = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ template.HasResourceProperties("AWS::Lambda::Function", Match.ObjectLike(new Dictionary
+ {
+ { "TracingConfig", new Dictionary { { "Mode", "Active" } } },
+ { "Layers", new object[] { Match.StringLikeRegexp(".*aws-otel-collector.*") } }
+ }));
+ }
+
+ [Theory]
+ [LambdaFunctionConstructAutoData(includeOtelLayer: false)]
+ public void Construct_ShouldDisableTracingWhenOtelLayerDisabled(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ var construct = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ // Verify construct was created successfully
+ construct.Should().NotBeNull();
+
+ // Verify that there is 1 Lambda function (main function only, no log retention function)
+ template.ResourceCountIs("AWS::Lambda::Function", 1);
+
+ // Verify that the Lambda function has no TracingConfig (tracing disabled)
+ template.HasResourceProperties("AWS::Lambda::Function", Match.ObjectLike(new Dictionary
+ {
+ { "FunctionName", $"{props.FunctionName}-{props.FunctionSuffix}" }
+ }));
+
+ // Verify that TracingConfig is not present (tracing disabled when OTEL layer is disabled)
+ template.HasResourceProperties("AWS::Lambda::Function", Match.Not(Match.ObjectLike(new Dictionary
+ {
+ { "TracingConfig", Match.AnyValue() }
+ })));
+
+ // Verify that Layers property is not present (no OTEL layer when disabled)
+ template.HasResourceProperties("AWS::Lambda::Function", Match.Not(Match.ObjectLike(new Dictionary
+ {
+ { "Layers", Match.AnyValue() }
+ })));
+ }
+
+ [Theory]
+ [LambdaFunctionConstructAutoData]
+ public void Construct_ShouldCreateVersionAndAlias(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ _ = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ // Verify Version resource exists (DeletionPolicy is at resource level, not in Properties)
+ template.ResourceCountIs("AWS::Lambda::Version", 1);
+
+ template.HasResourceProperties("AWS::Lambda::Alias", Match.ObjectLike(new Dictionary
+ {
+ { "Name", "live" }
+ }));
+ }
+
+ [Theory]
+ [LambdaFunctionConstructAutoData]
+ public void Construct_ShouldAddPermissionsToAllTargets(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ _ = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ if (props.Permissions.Count == 0) return;
+ var expectedPermissionCount = props.Permissions.Count * 3; // function + version + alias
+ template.ResourceCountIs("AWS::Lambda::Permission", expectedPermissionCount);
+ }
+
+ [Theory]
+ [LambdaFunctionConstructAutoData(includePermissions: false)]
+ public void Construct_ShouldHandleEmptyPermissionsList(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ _ = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ props.Permissions.Should().BeEmpty();
+ template.ResourceCountIs("AWS::Lambda::Permission", 0);
+ }
+
+ [Theory]
+ [LambdaFunctionConstructAutoData]
+ public void Construct_ShouldCreateLogGroupWithRetention(LambdaFunctionConstructProps props)
+ {
+ var app = new App();
+ var stack = new Stack(app, "test-stack", new StackProps
+ {
+ Env = new Amazon.CDK.Environment { Account = "123456789012", Region = "us-east-1" }
+ });
+
+ _ = new LambdaFunctionConstruct(stack, "test-construct", props);
+ var template = Template.FromStack(stack);
+
+ // Verify the LogGroup is explicitly created with correct retention
+ template.HasResourceProperties("AWS::Logs::LogGroup", Match.ObjectLike(new Dictionary
+ {
+ { "LogGroupName", $"/aws/lambda/{props.FunctionName}-{props.FunctionSuffix}" },
+ { "RetentionInDays", 14 }
+ }));
+ }
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/LayeredCraft.Cdk.Constructs.Tests.csproj b/test/LayeredCraft.Cdk.Constructs.Tests/LayeredCraft.Cdk.Constructs.Tests.csproj
new file mode 100644
index 0000000..674d918
--- /dev/null
+++ b/test/LayeredCraft.Cdk.Constructs.Tests/LayeredCraft.Cdk.Constructs.Tests.csproj
@@ -0,0 +1,52 @@
+
+
+
+ enable
+ enable
+ Exe
+ LayeredCraft.Cdk.Constructs.Tests
+ net8.0;net9.0
+ default
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/test-lambda.zip b/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/test-lambda.zip
new file mode 100644
index 0000000..be7d5c7
Binary files /dev/null and b/test/LayeredCraft.Cdk.Constructs.Tests/TestAssets/test-lambda.zip differ
diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/BaseFixtureFactory.cs b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/BaseFixtureFactory.cs
new file mode 100644
index 0000000..7d97516
--- /dev/null
+++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/BaseFixtureFactory.cs
@@ -0,0 +1,32 @@
+using AutoFixture.AutoNSubstitute;
+
+namespace LayeredCraft.Cdk.Constructs.Tests.TestKit.Attributes;
+
+///
+/// Base class that provides common fixture configuration for AutoData attributes.
+///
+public static class BaseFixtureFactory
+{
+ ///
+ /// Creates a fixture with common configuration and allows custom specimen builders to be added.
+ ///
+ /// Action to customize the fixture with specific specimen builders
+ /// Configured fixture
+ public static IFixture CreateFixture(Action customizeAction)
+ {
+ var fixture = new Fixture();
+
+ // Remove throwing recursion behavior and add omit on recursion behavior
+ fixture.Behaviors.OfType().ToList()
+ .ForEach(b => fixture.Behaviors.Remove(b));
+ fixture.Behaviors.Add(new OmitOnRecursionBehavior());
+
+ // Add AutoNSubstitute customization for automatic interface mocking
+ fixture.Customize(new AutoNSubstituteCustomization { ConfigureMembers = true });
+
+ // Allow customization with specific specimen builders
+ customizeAction(fixture);
+
+ return fixture;
+ }
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/LambdaFunctionConstructAutoDataAttribute.cs b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/LambdaFunctionConstructAutoDataAttribute.cs
new file mode 100644
index 0000000..8a5641a
--- /dev/null
+++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/LambdaFunctionConstructAutoDataAttribute.cs
@@ -0,0 +1,15 @@
+using AutoFixture.Xunit3;
+using LayeredCraft.Cdk.Constructs.Tests.TestKit.Customizations;
+
+namespace LayeredCraft.Cdk.Constructs.Tests.TestKit.Attributes;
+
+public class LambdaFunctionConstructAutoDataAttribute(bool includeOtelLayer = true, bool includePermissions = true) : AutoDataAttribute(() => CreateFixture(includeOtelLayer, includePermissions))
+{
+ private static IFixture CreateFixture(bool includeOtelLayer, bool includePermissions)
+ {
+ return BaseFixtureFactory.CreateFixture(fixture =>
+ {
+ fixture.Customize(new LambdaFunctionConstructCustomization(includeOtelLayer, includePermissions));
+ });
+ }
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Customizations/LambdaFunctionConstructCustomization.cs b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Customizations/LambdaFunctionConstructCustomization.cs
new file mode 100644
index 0000000..7615ad6
--- /dev/null
+++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Customizations/LambdaFunctionConstructCustomization.cs
@@ -0,0 +1,40 @@
+using Amazon.CDK.AWS.IAM;
+using LayeredCraft.Cdk.Constructs.Models;
+
+namespace LayeredCraft.Cdk.Constructs.Tests.TestKit.Customizations;
+
+public class LambdaFunctionConstructCustomization(bool includeOtelLayer = true, bool includePermissions = true)
+ : ICustomization
+{
+ public void Customize(IFixture fixture)
+ {
+ fixture.Customize(transform => transform
+ .With(perm => perm.Principal, "alexa-appkit.amazon.com")
+ .With(perm => perm.Action, "lambda:InvokeFunction")
+ .With(perm => perm.EventSourceToken, "amzn1.ask.skill.test-skill-id"));
+
+ fixture.Customize(transform => transform
+ .With(props => props.FunctionName, "test-function")
+ .With(props => props.FunctionSuffix, "disney-trivia")
+ .With(props => props.AssetPath, "./TestAssets/test-lambda.zip")
+ .With(props => props.RoleName, "test-function-us-east-1-lambdaRole")
+ .With(props => props.PolicyName, "test-function-lambda")
+ .With(props => props.PolicyStatements, new PolicyStatement[]
+ {
+ new(new PolicyStatementProps
+ {
+ Actions = ["dynamodb:Query", "dynamodb:GetItem", "dynamodb:PutItem"],
+ Resources = ["arn:aws:dynamodb:us-east-1:123456789012:table/test-table"],
+ Effect = Effect.ALLOW
+ })
+ })
+ .With(props => props.EnvironmentVariables, new Dictionary
+ {
+ { "TEST_VAR", "test-value" }
+ })
+ .With(props => props.IncludeOtelLayer, includeOtelLayer)
+ .With(props => props.Permissions, includePermissions
+ ? [fixture.Create()]
+ : []));
+ }
+}
\ No newline at end of file
diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/xunit.runner.json b/test/LayeredCraft.Cdk.Constructs.Tests/xunit.runner.json
new file mode 100644
index 0000000..86c7ea0
--- /dev/null
+++ b/test/LayeredCraft.Cdk.Constructs.Tests/xunit.runner.json
@@ -0,0 +1,3 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
+}