lambda-forge-cli-aws 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lambda_forge_cli_aws-1.0.0.dist-info/METADATA +352 -0
- lambda_forge_cli_aws-1.0.0.dist-info/RECORD +80 -0
- lambda_forge_cli_aws-1.0.0.dist-info/WHEEL +4 -0
- lambda_forge_cli_aws-1.0.0.dist-info/entry_points.txt +2 -0
- lambda_forge_cli_aws-1.0.0.dist-info/licenses/LICENSE +189 -0
- lambdaforge/__init__.py +10 -0
- lambdaforge/bedrock_client.py +109 -0
- lambdaforge/cli.py +428 -0
- lambdaforge/deploy.py +308 -0
- lambdaforge/logs.py +185 -0
- lambdaforge/renderer.py +72 -0
- lambdaforge/spec_generator.py +171 -0
- lambdaforge/status.py +111 -0
- lambdaforge/templates.py +106 -0
- lambdaforge/upgrade.py +231 -0
- templates/python-api/cookiecutter.json +12 -0
- templates/python-api/{{cookiecutter.project_slug}}/.github/workflows/dependabot.yml +22 -0
- templates/python-api/{{cookiecutter.project_slug}}/.github/workflows/deploy.yml +90 -0
- templates/python-api/{{cookiecutter.project_slug}}/.gitignore +65 -0
- templates/python-api/{{cookiecutter.project_slug}}/Dockerfile +11 -0
- templates/python-api/{{cookiecutter.project_slug}}/README.md +130 -0
- templates/python-api/{{cookiecutter.project_slug}}/infrastructure/app.js +26 -0
- templates/python-api/{{cookiecutter.project_slug}}/infrastructure/cdk.json +20 -0
- templates/python-api/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +155 -0
- templates/python-api/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
- templates/python-api/{{cookiecutter.project_slug}}/pyproject.toml +66 -0
- templates/python-api/{{cookiecutter.project_slug}}/requirements-dev.txt +8 -0
- templates/python-api/{{cookiecutter.project_slug}}/requirements.txt +3 -0
- templates/python-api/{{cookiecutter.project_slug}}/src/handler.py +93 -0
- templates/python-api/{{cookiecutter.project_slug}}/src/utils/logger.py +39 -0
- templates/python-api/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
- templates/python-api/{{cookiecutter.project_slug}}/tests/test_handler.py +68 -0
- templates/python-cron/cookiecutter.json +13 -0
- templates/python-cron/{{cookiecutter.project_slug}}/.gitignore +65 -0
- templates/python-cron/{{cookiecutter.project_slug}}/Dockerfile +11 -0
- templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
- templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
- templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +55 -0
- templates/python-cron/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
- templates/python-cron/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
- templates/python-cron/{{cookiecutter.project_slug}}/requirements.txt +2 -0
- templates/python-cron/{{cookiecutter.project_slug}}/src/handler.py +53 -0
- templates/python-cron/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
- templates/python-cron/{{cookiecutter.project_slug}}/tests/test_handler.py +48 -0
- templates/python-queue/cookiecutter.json +12 -0
- templates/python-queue/{{cookiecutter.project_slug}}/.gitignore +65 -0
- templates/python-queue/{{cookiecutter.project_slug}}/Dockerfile +11 -0
- templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
- templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
- templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +79 -0
- templates/python-queue/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
- templates/python-queue/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
- templates/python-queue/{{cookiecutter.project_slug}}/requirements.txt +2 -0
- templates/python-queue/{{cookiecutter.project_slug}}/src/handler.py +64 -0
- templates/python-queue/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
- templates/python-queue/{{cookiecutter.project_slug}}/tests/test_handler.py +60 -0
- templates/python-s3/cookiecutter.json +13 -0
- templates/python-s3/{{cookiecutter.project_slug}}/.gitignore +65 -0
- templates/python-s3/{{cookiecutter.project_slug}}/Dockerfile +11 -0
- templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
- templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
- templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +89 -0
- templates/python-s3/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
- templates/python-s3/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
- templates/python-s3/{{cookiecutter.project_slug}}/requirements.txt +2 -0
- templates/python-s3/{{cookiecutter.project_slug}}/src/handler.py +86 -0
- templates/python-s3/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
- templates/python-s3/{{cookiecutter.project_slug}}/tests/test_handler.py +71 -0
- templates/python-stream/cookiecutter.json +12 -0
- templates/python-stream/{{cookiecutter.project_slug}}/.gitignore +65 -0
- templates/python-stream/{{cookiecutter.project_slug}}/Dockerfile +11 -0
- templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/app.js +25 -0
- templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/cdk.json +3 -0
- templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/lib/stack.js +81 -0
- templates/python-stream/{{cookiecutter.project_slug}}/infrastructure/package.json +17 -0
- templates/python-stream/{{cookiecutter.project_slug}}/pyproject.toml +56 -0
- templates/python-stream/{{cookiecutter.project_slug}}/requirements.txt +2 -0
- templates/python-stream/{{cookiecutter.project_slug}}/src/handler.py +69 -0
- templates/python-stream/{{cookiecutter.project_slug}}/tests/conftest.py +22 -0
- templates/python-stream/{{cookiecutter.project_slug}}/tests/test_handler.py +64 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const cdk = require('aws-cdk-lib');
|
|
2
|
+
const lambda = require('aws-cdk-lib/aws-lambda');
|
|
3
|
+
const sqs = require('aws-cdk-lib/aws-sqs');
|
|
4
|
+
const dynamodb = require('aws-cdk-lib/aws-dynamodb');
|
|
5
|
+
const logs = require('aws-cdk-lib/aws-logs');
|
|
6
|
+
const { SqsEventSource } = require('aws-cdk-lib/aws-lambda-event-sources');
|
|
7
|
+
|
|
8
|
+
class {{ cookiecutter.project_name|replace('-', '')|capitalize }}Stack extends cdk.Stack {
|
|
9
|
+
constructor(scope, id, props) {
|
|
10
|
+
super(scope, id, props);
|
|
11
|
+
const envName = props.envName || 'dev';
|
|
12
|
+
|
|
13
|
+
// Dead Letter Queue
|
|
14
|
+
const dlq = new sqs.Queue(this, 'DeadLetterQueue', {
|
|
15
|
+
queueName: `{{ cookiecutter.project_name }}-dlq-${envName}`,
|
|
16
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Main SQS Queue
|
|
20
|
+
const queue = new sqs.Queue(this, 'ProcessingQueue', {
|
|
21
|
+
queueName: `{{ cookiecutter.project_name }}-queue-${envName}`,
|
|
22
|
+
visibilityTimeout: cdk.Duration.seconds({{ cookiecutter.timeout_seconds }} * 6),
|
|
23
|
+
deadLetterQueue: {
|
|
24
|
+
queue: dlq,
|
|
25
|
+
maxReceiveCount: 3,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// DynamoDB Table
|
|
30
|
+
const table = new dynamodb.Table(this, 'ProcessingTable', {
|
|
31
|
+
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
|
|
32
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
33
|
+
pointInTimeRecovery: true,
|
|
34
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Lambda Function
|
|
38
|
+
const fn = new lambda.Function(this, 'ProcessorFunction', {
|
|
39
|
+
runtime: lambda.Runtime.PYTHON_3_12,
|
|
40
|
+
architecture: lambda.Architecture.ARM_64,
|
|
41
|
+
handler: 'handler.handler',
|
|
42
|
+
code: lambda.Code.fromAsset('../src'),
|
|
43
|
+
memorySize: {{ cookiecutter.memory_mb }},
|
|
44
|
+
timeout: cdk.Duration.seconds({{ cookiecutter.timeout_seconds }}),
|
|
45
|
+
tracing: lambda.Tracing.ACTIVE,
|
|
46
|
+
logRetention: logs.RetentionDays.ONE_MONTH,
|
|
47
|
+
environment: {
|
|
48
|
+
TABLE_NAME: table.tableName,
|
|
49
|
+
STAGE: envName,
|
|
50
|
+
POWERTOOLS_SERVICE_NAME: '{{ cookiecutter.project_slug }}',
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Grant permissions
|
|
55
|
+
table.grantReadWriteData(fn);
|
|
56
|
+
queue.grantConsumeMessages(fn);
|
|
57
|
+
|
|
58
|
+
// SQS Event Source with partial batch response
|
|
59
|
+
fn.addEventSource(new SqsEventSource(queue, {
|
|
60
|
+
batchSize: 10,
|
|
61
|
+
maxBatchingWindow: cdk.Duration.seconds(5),
|
|
62
|
+
reportBatchItemFailures: true,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// Tags
|
|
66
|
+
cdk.Tags.of(this).add('Project', '{{ cookiecutter.project_name }}');
|
|
67
|
+
cdk.Tags.of(this).add('ManagedBy', 'LambdaForge');
|
|
68
|
+
cdk.Tags.of(this).add('Environment', envName);
|
|
69
|
+
|
|
70
|
+
// Outputs
|
|
71
|
+
new cdk.CfnOutput(this, 'FunctionName', { value: fn.functionName });
|
|
72
|
+
new cdk.CfnOutput(this, 'QueueUrl', { value: queue.queueUrl });
|
|
73
|
+
new cdk.CfnOutput(this, 'QueueArn', { value: queue.queueArn });
|
|
74
|
+
new cdk.CfnOutput(this, 'TableName', { value: table.tableName });
|
|
75
|
+
new cdk.CfnOutput(this, 'DlqUrl', { value: dlq.queueUrl });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { {{ cookiecutter.project_name|replace('-', '')|capitalize }}Stack };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{ cookiecutter.project_slug }}-infrastructure",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AWS CDK infrastructure for {{ cookiecutter.project_name }}",
|
|
5
|
+
"private": true,
|
|
6
|
+
"scripts": {
|
|
7
|
+
"cdk": "cdk",
|
|
8
|
+
"synth": "cdk synth",
|
|
9
|
+
"deploy": "cdk deploy --require-approval never",
|
|
10
|
+
"destroy": "cdk destroy --force",
|
|
11
|
+
"diff": "cdk diff"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"aws-cdk-lib": "^2.170.0",
|
|
15
|
+
"constructs": "^10.4.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "{{ cookiecutter.project_slug }}"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "{{ cookiecutter.description }}"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
keywords = ["aws", "lambda", "serverless", "sqs", "queue"]
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aws-lambda-powertools[tracer,logger]>=3.0.0",
|
|
15
|
+
"boto3>=1.34.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0.0",
|
|
21
|
+
"pytest-cov>=4.1.0",
|
|
22
|
+
"moto[dynamodb,sqs]>=5.0.0",
|
|
23
|
+
"ruff>=0.1.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
python_files = ["test_*.py"]
|
|
36
|
+
python_functions = ["test_*"]
|
|
37
|
+
addopts = "-v --strict-markers --cov=src --cov-report=term-missing"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 100
|
|
41
|
+
target-version = "py312"
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "I", "B", "UP", "N", "S", "RUF"]
|
|
45
|
+
ignore = ["S101"]
|
|
46
|
+
|
|
47
|
+
[tool.coverage.run]
|
|
48
|
+
source = ["src"]
|
|
49
|
+
omit = ["tests/*"]
|
|
50
|
+
|
|
51
|
+
[tool.coverage.report]
|
|
52
|
+
exclude_lines = [
|
|
53
|
+
"pragma: no cover",
|
|
54
|
+
"if __name__ == .__main__.:",
|
|
55
|
+
"raise NotImplementedError",
|
|
56
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""{{ cookiecutter.project_name }} — SQS batch processor handler.
|
|
2
|
+
|
|
3
|
+
Processes messages from SQS with partial batch failure support.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import boto3
|
|
12
|
+
from aws_lambda_powertools import Logger, Tracer
|
|
13
|
+
from aws_lambda_powertools.utilities.batch import (
|
|
14
|
+
BatchProcessor,
|
|
15
|
+
EventType,
|
|
16
|
+
process_partial_response,
|
|
17
|
+
)
|
|
18
|
+
from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord
|
|
19
|
+
from aws_lambda_powertools.utilities.typing import LambdaContext
|
|
20
|
+
|
|
21
|
+
logger = Logger(service="{{ cookiecutter.project_slug }}")
|
|
22
|
+
tracer = Tracer(service="{{ cookiecutter.project_slug }}")
|
|
23
|
+
|
|
24
|
+
TABLE_NAME = os.environ.get("TABLE_NAME", "")
|
|
25
|
+
STAGE = os.environ.get("STAGE", "dev")
|
|
26
|
+
|
|
27
|
+
processor = BatchProcessor(event_type=EventType.SQS)
|
|
28
|
+
dynamodb = boto3.resource("dynamodb")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@tracer.capture_method
|
|
32
|
+
def process_record(record: SQSRecord) -> dict[str, Any]:
|
|
33
|
+
"""Process a single SQS record.
|
|
34
|
+
|
|
35
|
+
Replace this with your business logic.
|
|
36
|
+
"""
|
|
37
|
+
body = json.loads(record.body)
|
|
38
|
+
message_id = record.message_id
|
|
39
|
+
|
|
40
|
+
logger.info("processing_record", extra={"message_id": message_id, "body": body})
|
|
41
|
+
|
|
42
|
+
result = {
|
|
43
|
+
"message_id": message_id,
|
|
44
|
+
"status": "processed",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if TABLE_NAME:
|
|
48
|
+
table = dynamodb.Table(TABLE_NAME)
|
|
49
|
+
table.put_item(Item={"id": message_id, **result})
|
|
50
|
+
|
|
51
|
+
logger.info("record_processed", extra=result)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@logger.inject_lambda_context
|
|
56
|
+
@tracer.capture_lambda_handler
|
|
57
|
+
def handler(event: dict, context: LambdaContext) -> dict[str, Any]:
|
|
58
|
+
"""Lambda handler — processes SQS batch with partial failure support."""
|
|
59
|
+
return process_partial_response(
|
|
60
|
+
event=event,
|
|
61
|
+
record_handler=process_record,
|
|
62
|
+
processor=processor,
|
|
63
|
+
context=context,
|
|
64
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Test configuration and fixtures."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def aws_credentials(monkeypatch):
|
|
11
|
+
"""Mock AWS credentials for moto."""
|
|
12
|
+
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
|
|
13
|
+
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
|
|
14
|
+
monkeypatch.setenv("AWS_SECURITY_TOKEN", "testing")
|
|
15
|
+
monkeypatch.setenv("AWS_SESSION_TOKEN", "testing")
|
|
16
|
+
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def table_name():
|
|
21
|
+
"""Provide a test table name."""
|
|
22
|
+
return os.environ.get("TABLE_NAME", "test-table")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tests for {{ cookiecutter.project_name }} SQS processor handler."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from src.handler import handler
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def sqs_event():
|
|
14
|
+
"""Sample SQS batch event."""
|
|
15
|
+
return {
|
|
16
|
+
"Records": [
|
|
17
|
+
{
|
|
18
|
+
"messageId": "msg-001",
|
|
19
|
+
"receiptHandle": "test-receipt-handle",
|
|
20
|
+
"body": json.dumps({"action": "process", "data": "test-payload"}),
|
|
21
|
+
"attributes": {
|
|
22
|
+
"ApproximateReceiveCount": "1",
|
|
23
|
+
"SentTimestamp": "1704067200000",
|
|
24
|
+
"SenderId": "123456789012",
|
|
25
|
+
"ApproximateFirstReceiveTimestamp": "1704067200001",
|
|
26
|
+
},
|
|
27
|
+
"messageAttributes": {},
|
|
28
|
+
"md5OfBody": "test-md5",
|
|
29
|
+
"eventSource": "aws:sqs",
|
|
30
|
+
"eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:test-queue",
|
|
31
|
+
"awsRegion": "us-east-1",
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_handler_processes_batch(sqs_event):
|
|
38
|
+
"""Test handler processes SQS batch without error."""
|
|
39
|
+
context = MagicMock()
|
|
40
|
+
context.function_name = "test-function"
|
|
41
|
+
context.aws_request_id = "test-request-id"
|
|
42
|
+
|
|
43
|
+
with patch("src.handler.dynamodb"):
|
|
44
|
+
response = handler(sqs_event, context)
|
|
45
|
+
|
|
46
|
+
assert "batchItemFailures" in response
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_handler_handles_empty_batch():
|
|
50
|
+
"""Test handler handles empty records list."""
|
|
51
|
+
event = {"Records": []}
|
|
52
|
+
context = MagicMock()
|
|
53
|
+
context.function_name = "test-function"
|
|
54
|
+
context.aws_request_id = "test-request-id"
|
|
55
|
+
|
|
56
|
+
with patch("src.handler.dynamodb"):
|
|
57
|
+
response = handler(event, context)
|
|
58
|
+
|
|
59
|
+
assert "batchItemFailures" in response
|
|
60
|
+
assert response["batchItemFailures"] == []
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"project_name": "my-s3-processor",
|
|
3
|
+
"project_slug": "{{ cookiecutter.project_name|replace('-', '_') }}",
|
|
4
|
+
"module_name": "{{ cookiecutter.project_slug }}",
|
|
5
|
+
"description": "S3 event processor Lambda",
|
|
6
|
+
"author_name": "LambdaForge User",
|
|
7
|
+
"author_email": "user@example.com",
|
|
8
|
+
"runtime": "python3.12",
|
|
9
|
+
"aws_region": "us-east-1",
|
|
10
|
+
"memory_mb": 1024,
|
|
11
|
+
"timeout_seconds": 120,
|
|
12
|
+
"source_bucket_prefix": "uploads/"
|
|
13
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib64/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
*.egg-info/
|
|
19
|
+
.installed.cfg
|
|
20
|
+
*.egg
|
|
21
|
+
MANIFEST
|
|
22
|
+
|
|
23
|
+
# Virtual environments
|
|
24
|
+
.venv/
|
|
25
|
+
venv/
|
|
26
|
+
env/
|
|
27
|
+
ENV/
|
|
28
|
+
|
|
29
|
+
# Testing
|
|
30
|
+
.pytest_cache/
|
|
31
|
+
.coverage
|
|
32
|
+
.coverage.*
|
|
33
|
+
htmlcov/
|
|
34
|
+
.tox/
|
|
35
|
+
.nox/
|
|
36
|
+
coverage.xml
|
|
37
|
+
*.cover
|
|
38
|
+
|
|
39
|
+
# Type checking
|
|
40
|
+
.mypy_cache/
|
|
41
|
+
.ruff_cache/
|
|
42
|
+
|
|
43
|
+
# IDEs
|
|
44
|
+
.idea/
|
|
45
|
+
.vscode/
|
|
46
|
+
*.swp
|
|
47
|
+
*.swo
|
|
48
|
+
.DS_Store
|
|
49
|
+
|
|
50
|
+
# CDK
|
|
51
|
+
cdk.out/
|
|
52
|
+
cdk.context.json
|
|
53
|
+
|
|
54
|
+
# Lambda
|
|
55
|
+
*.zip
|
|
56
|
+
|
|
57
|
+
# Environment
|
|
58
|
+
.env
|
|
59
|
+
.env.local
|
|
60
|
+
.env.*.local
|
|
61
|
+
|
|
62
|
+
# Secrets
|
|
63
|
+
*.pem
|
|
64
|
+
*.key
|
|
65
|
+
secrets.yml
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
FROM public.ecr.aws/lambda/python:3.12-arm64
|
|
2
|
+
|
|
3
|
+
# Install dependencies first for better layer caching
|
|
4
|
+
COPY requirements.txt ${LAMBDA_TASK_ROOT}/
|
|
5
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
6
|
+
|
|
7
|
+
# Copy function code
|
|
8
|
+
COPY src/ ${LAMBDA_TASK_ROOT}/
|
|
9
|
+
|
|
10
|
+
# Set the handler
|
|
11
|
+
CMD [ "handler.handler" ]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const cdk = require('aws-cdk-lib');
|
|
2
|
+
const { {{ cookiecutter.project_name|replace('-', '')|capitalize }}Stack } = require('./lib/stack');
|
|
3
|
+
|
|
4
|
+
const app = new cdk.App();
|
|
5
|
+
|
|
6
|
+
// Environment from CDK context (default: dev)
|
|
7
|
+
const envName = app.node.tryGetContext('env') || 'dev';
|
|
8
|
+
|
|
9
|
+
new {{ cookiecutter.project_name|replace('-', '')|capitalize }}Stack(app, `{{ cookiecutter.project_name }}-${envName}`, {
|
|
10
|
+
stackName: `{{ cookiecutter.project_name }}-${envName}`,
|
|
11
|
+
env: {
|
|
12
|
+
account: process.env.CDK_DEFAULT_ACCOUNT,
|
|
13
|
+
region: process.env.CDK_DEFAULT_REGION || '{{ cookiecutter.aws_region }}',
|
|
14
|
+
},
|
|
15
|
+
description: `{{ cookiecutter.description }} (${envName})`,
|
|
16
|
+
tags: {
|
|
17
|
+
Project: '{{ cookiecutter.project_name }}',
|
|
18
|
+
Environment: envName,
|
|
19
|
+
ManagedBy: 'LambdaForge',
|
|
20
|
+
CostCenter: '{{ cookiecutter.project_name }}',
|
|
21
|
+
},
|
|
22
|
+
envName: envName,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
app.synth();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const cdk = require('aws-cdk-lib');
|
|
2
|
+
const lambda = require('aws-cdk-lib/aws-lambda');
|
|
3
|
+
const s3 = require('aws-cdk-lib/aws-s3');
|
|
4
|
+
const s3n = require('aws-cdk-lib/aws-s3-notifications');
|
|
5
|
+
const dynamodb = require('aws-cdk-lib/aws-dynamodb');
|
|
6
|
+
const sqs = require('aws-cdk-lib/aws-sqs');
|
|
7
|
+
const logs = require('aws-cdk-lib/aws-logs');
|
|
8
|
+
|
|
9
|
+
class {{ cookiecutter.project_name|replace('-', '')|capitalize }}Stack extends cdk.Stack {
|
|
10
|
+
constructor(scope, id, props) {
|
|
11
|
+
super(scope, id, props);
|
|
12
|
+
const envName = props.envName || 'dev';
|
|
13
|
+
|
|
14
|
+
// S3 Source Bucket
|
|
15
|
+
const bucket = new s3.Bucket(this, 'SourceBucket', {
|
|
16
|
+
bucketName: `{{ cookiecutter.project_name }}-source-${envName}-${this.account}`,
|
|
17
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
18
|
+
enforceSSL: true,
|
|
19
|
+
versioned: true,
|
|
20
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
21
|
+
autoDeleteObjects: true,
|
|
22
|
+
lifecycleRules: [
|
|
23
|
+
{
|
|
24
|
+
expiration: cdk.Duration.days(90),
|
|
25
|
+
noncurrentVersionExpiration: cdk.Duration.days(30),
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Dead Letter Queue
|
|
31
|
+
const dlq = new sqs.Queue(this, 'DeadLetterQueue', {
|
|
32
|
+
queueName: `{{ cookiecutter.project_name }}-dlq-${envName}`,
|
|
33
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// DynamoDB Table for tracking processed objects
|
|
37
|
+
const table = new dynamodb.Table(this, 'ProcessingTable', {
|
|
38
|
+
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
|
|
39
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
40
|
+
pointInTimeRecovery: true,
|
|
41
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Lambda Function
|
|
45
|
+
const fn = new lambda.Function(this, 'S3ProcessorFunction', {
|
|
46
|
+
runtime: lambda.Runtime.PYTHON_3_12,
|
|
47
|
+
architecture: lambda.Architecture.ARM_64,
|
|
48
|
+
handler: 'handler.handler',
|
|
49
|
+
code: lambda.Code.fromAsset('../src'),
|
|
50
|
+
memorySize: {{ cookiecutter.memory_mb }},
|
|
51
|
+
timeout: cdk.Duration.seconds({{ cookiecutter.timeout_seconds }}),
|
|
52
|
+
tracing: lambda.Tracing.ACTIVE,
|
|
53
|
+
deadLetterQueue: dlq,
|
|
54
|
+
retryAttempts: 2,
|
|
55
|
+
logRetention: logs.RetentionDays.ONE_MONTH,
|
|
56
|
+
environment: {
|
|
57
|
+
TABLE_NAME: table.tableName,
|
|
58
|
+
SOURCE_BUCKET: bucket.bucketName,
|
|
59
|
+
STAGE: envName,
|
|
60
|
+
POWERTOOLS_SERVICE_NAME: '{{ cookiecutter.project_slug }}',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Grant permissions
|
|
65
|
+
table.grantReadWriteData(fn);
|
|
66
|
+
bucket.grantRead(fn);
|
|
67
|
+
|
|
68
|
+
// S3 Event Notification — trigger on PutObject with prefix
|
|
69
|
+
bucket.addEventNotification(
|
|
70
|
+
s3.EventType.OBJECT_CREATED_PUT,
|
|
71
|
+
new s3n.LambdaDestination(fn),
|
|
72
|
+
{ prefix: '{{ cookiecutter.source_bucket_prefix }}' }
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Tags
|
|
76
|
+
cdk.Tags.of(this).add('Project', '{{ cookiecutter.project_name }}');
|
|
77
|
+
cdk.Tags.of(this).add('ManagedBy', 'LambdaForge');
|
|
78
|
+
cdk.Tags.of(this).add('Environment', envName);
|
|
79
|
+
|
|
80
|
+
// Outputs
|
|
81
|
+
new cdk.CfnOutput(this, 'FunctionName', { value: fn.functionName });
|
|
82
|
+
new cdk.CfnOutput(this, 'BucketName', { value: bucket.bucketName });
|
|
83
|
+
new cdk.CfnOutput(this, 'BucketArn', { value: bucket.bucketArn });
|
|
84
|
+
new cdk.CfnOutput(this, 'TableName', { value: table.tableName });
|
|
85
|
+
new cdk.CfnOutput(this, 'DlqUrl', { value: dlq.queueUrl });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { {{ cookiecutter.project_name|replace('-', '')|capitalize }}Stack };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{ cookiecutter.project_slug }}-infrastructure",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AWS CDK infrastructure for {{ cookiecutter.project_name }}",
|
|
5
|
+
"private": true,
|
|
6
|
+
"scripts": {
|
|
7
|
+
"cdk": "cdk",
|
|
8
|
+
"synth": "cdk synth",
|
|
9
|
+
"deploy": "cdk deploy --require-approval never",
|
|
10
|
+
"destroy": "cdk destroy --force",
|
|
11
|
+
"diff": "cdk diff"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"aws-cdk-lib": "^2.170.0",
|
|
15
|
+
"constructs": "^10.4.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "{{ cookiecutter.project_slug }}"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "{{ cookiecutter.description }}"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
keywords = ["aws", "lambda", "serverless", "s3", "file-processing"]
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aws-lambda-powertools[tracer,logger]>=3.0.0",
|
|
15
|
+
"boto3>=1.34.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0.0",
|
|
21
|
+
"pytest-cov>=4.1.0",
|
|
22
|
+
"moto[dynamodb,s3]>=5.0.0",
|
|
23
|
+
"ruff>=0.1.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
python_files = ["test_*.py"]
|
|
36
|
+
python_functions = ["test_*"]
|
|
37
|
+
addopts = "-v --strict-markers --cov=src --cov-report=term-missing"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 100
|
|
41
|
+
target-version = "py312"
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "I", "B", "UP", "N", "S", "RUF"]
|
|
45
|
+
ignore = ["S101"]
|
|
46
|
+
|
|
47
|
+
[tool.coverage.run]
|
|
48
|
+
source = ["src"]
|
|
49
|
+
omit = ["tests/*"]
|
|
50
|
+
|
|
51
|
+
[tool.coverage.report]
|
|
52
|
+
exclude_lines = [
|
|
53
|
+
"pragma: no cover",
|
|
54
|
+
"if __name__ == .__main__.:",
|
|
55
|
+
"raise NotImplementedError",
|
|
56
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""{{ cookiecutter.project_name }} — S3 event processor handler.
|
|
2
|
+
|
|
3
|
+
Processes S3 PutObject events for objects matching prefix: {{ cookiecutter.source_bucket_prefix }}
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import boto3
|
|
12
|
+
from aws_lambda_powertools import Logger, Tracer
|
|
13
|
+
from aws_lambda_powertools.utilities.typing import LambdaContext
|
|
14
|
+
|
|
15
|
+
logger = Logger(service="{{ cookiecutter.project_slug }}")
|
|
16
|
+
tracer = Tracer(service="{{ cookiecutter.project_slug }}")
|
|
17
|
+
|
|
18
|
+
TABLE_NAME = os.environ.get("TABLE_NAME", "")
|
|
19
|
+
STAGE = os.environ.get("STAGE", "dev")
|
|
20
|
+
|
|
21
|
+
s3_client = boto3.client("s3")
|
|
22
|
+
dynamodb = boto3.resource("dynamodb")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@tracer.capture_method
|
|
26
|
+
def process_s3_object(bucket: str, key: str) -> dict[str, Any]:
|
|
27
|
+
"""Process a single S3 object.
|
|
28
|
+
|
|
29
|
+
Downloads the object and performs processing.
|
|
30
|
+
Replace this with your business logic.
|
|
31
|
+
"""
|
|
32
|
+
logger.info("processing_object", extra={"bucket": bucket, "key": key})
|
|
33
|
+
|
|
34
|
+
# Download the object
|
|
35
|
+
response = s3_client.get_object(Bucket=bucket, Key=key)
|
|
36
|
+
content_length = response["ContentLength"]
|
|
37
|
+
content_type = response.get("ContentType", "unknown")
|
|
38
|
+
|
|
39
|
+
result = {
|
|
40
|
+
"bucket": bucket,
|
|
41
|
+
"key": key,
|
|
42
|
+
"content_length": content_length,
|
|
43
|
+
"content_type": content_type,
|
|
44
|
+
"status": "processed",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if TABLE_NAME:
|
|
48
|
+
table = dynamodb.Table(TABLE_NAME)
|
|
49
|
+
table.put_item(Item={"id": f"{bucket}/{key}", **result})
|
|
50
|
+
|
|
51
|
+
logger.info("object_processed", extra=result)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@logger.inject_lambda_context
|
|
56
|
+
@tracer.capture_lambda_handler
|
|
57
|
+
def handler(event: dict, context: LambdaContext) -> dict[str, Any]:
|
|
58
|
+
"""Lambda handler — processes S3 PutObject events."""
|
|
59
|
+
results = []
|
|
60
|
+
errors = []
|
|
61
|
+
|
|
62
|
+
for record in event.get("Records", []):
|
|
63
|
+
try:
|
|
64
|
+
bucket = record["s3"]["bucket"]["name"]
|
|
65
|
+
key = urllib.parse.unquote_plus(record["s3"]["object"]["key"])
|
|
66
|
+
|
|
67
|
+
result = process_s3_object(bucket, key)
|
|
68
|
+
results.append(result)
|
|
69
|
+
except Exception:
|
|
70
|
+
logger.exception("record_processing_failed", extra={"record": record})
|
|
71
|
+
errors.append(record)
|
|
72
|
+
|
|
73
|
+
response = {
|
|
74
|
+
"statusCode": 200,
|
|
75
|
+
"body": {
|
|
76
|
+
"processed": len(results),
|
|
77
|
+
"failed": len(errors),
|
|
78
|
+
"results": results,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if errors:
|
|
83
|
+
response["statusCode"] = 207 # Multi-Status
|
|
84
|
+
logger.warning("partial_failure", extra={"failed_count": len(errors)})
|
|
85
|
+
|
|
86
|
+
return response
|