From c592228268eb506aed2634f123b942390953f940 Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Tue, 16 Sep 2025 22:11:31 +0800 Subject: [PATCH 1/2] use CDK and SAM to serve the Lambda locally --- .air-cdk.toml | 11 +++++++++ README.md | 57 ++++++++++++++++++++++++++++++++++++++++++++ build.sh | 13 ++++++++++ cdk/cdk.context.json | 6 +++++ cdk/cdk.go | 48 +++++++++++++++++++++---------------- cdk/env.json | 8 +++++++ docker-compose.yml | 4 ++-- local.env.example | 3 +-- 8 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 .air-cdk.toml create mode 100755 build.sh create mode 100644 cdk/cdk.context.json create mode 100644 cdk/env.json diff --git a/.air-cdk.toml b/.air-cdk.toml new file mode 100644 index 0000000..cad544b --- /dev/null +++ b/.air-cdk.toml @@ -0,0 +1,11 @@ +root = "." +tmp_dir = "tmp" + +[build] +bin = "" +cmd = './build.sh' +delay = 100 +exclude_dir = ["tmp", "cdk"] +full_bin = "sam local start-api --port 8160 --template cdk/cdk.out/twosv-api-dev.template.json --env-vars cdk/env.json" +include_ext = ["go"] +kill_delay = "0s" diff --git a/README.md b/README.md index d4c7775..a7ccada 100644 --- a/README.md +++ b/README.md @@ -90,3 +90,60 @@ to do with WebAuthn, but is the primary key for finding the right records in Dyn ### Delete one of the user's Webauthn credentials `DELETE /webauthn/credential` + +# Development + +## Unit tests + +To run unit tests, simply run "make test". It will spin up a Docker Compose environment and run the tests using +Docker containers for the API and for DynamoDB. + +## Manual testing + +Unit tests can be run individually, either on the command line or through your IDE. It is also possible to +test the server and Lambda implementations locally. + +### Server + +#### HTTP + +If HTTPS is not needed, simply start the `app` container and exercise the API using localhost and the Docker port +defined in docker-compose.yml (currently 8161). + +#### HTTPS + +To use a "demo UI" that can interact with the API using HTTPS, use Traefik proxy, which is defined in the Docker +Compose environment. Traefik is a proxy that creates a Let's Encrypt certificate and routes traffic to the local +container via a registered DNS record. To configure this, define the following variables in `local.env`: + +- DNS_PROVIDER=cloudflare +- CLOUDFLARE_DNS_API_TOKEN= +- LETS_ENCRYPT_EMAIL= +- LETS_ENCRYPT_CA=production +- TLD= +- SANS=mfa-ui.,mfa-app. +- BACKEND1_URL=http://ui:80 +- FRONTEND1_DOMAIN=mfa-ui. +- BACKEND2_URL=http://app:8080 +- FRONTEND2_DOMAIN=mfa-app. + +Create DNS A records (without Cloudflare proxy enabled) for the values defined in `FRONTEND1_DOMAIN` and +`FRONTEND2_DOMAIN` pointing to 127.0.0.1 and wait for DNS propagation. Once all of the above configuration is in place, +run `make demo`. The first time will take several minutes for all the initialization. You can watch Docker logs on the +proxy container to keep tabs on the progress. + +### Lambda + +To exercise the API as it would be used in AWS Lambda, run this command: `air -c .air-cdk.toml`. This will run a +file watcher that will rebuild the app code and the CDK stack, then run `sam local start-api` using the generated +Cloudformation template. This will listen on port 8160. Any code changes will trigger a rebuild and SAM will restart +using the new code. + +Implementation notes: + +- SAM uses Docker internally, which would make it complicated to run with Docker Compose. +- You will need to install CDK and SAM on your computer for this to work. +- It can use the DynamoDB container in Docker Compose, which can be started using `make dbinit`. +- The `make dbinit` command creates an APIKey (key: `EC7C2E16-5028-432F-8AF2-A79A64CF3BC1` +secret: `1ED18444-7238-410B-A536-D6C15A3C`) +- Some unit tests will delete the APIKey created by `make dbinit`. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2820e42 --- /dev/null +++ b/build.sh @@ -0,0 +1,13 @@ +#!/bin/env bash + +set -e + +set -x + +go build -tags lambda.norpc -ldflags="-s -w" -o bootstrap ./lambda + +( + cd cdk || exit + rm -rf cdk.out/asset.* + cdk synth -q +) diff --git a/cdk/cdk.context.json b/cdk/cdk.context.json new file mode 100644 index 0000000..c3f254d --- /dev/null +++ b/cdk/cdk.context.json @@ -0,0 +1,6 @@ +{ + "cli-telemetry": false, + "acknowledged-issue-numbers": [ + 34892 + ] +} diff --git a/cdk/cdk.go b/cdk/cdk.go index 7572ab3..3dcb8a3 100644 --- a/cdk/cdk.go +++ b/cdk/cdk.go @@ -29,9 +29,6 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw totpTable := getEnv("TOTP_TABLE", "totp") webauthnTable := getEnv("WEBAUTHN_TABLE", "webauthn") lambdaRoleArn := getEnv("LAMBDA_ROLE", "") - if lambdaRoleArn == "" { - panic("LAMBDA_ROLE environment variable must be set") - } functionName := id @@ -41,9 +38,7 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw RemovalPolicy: awscdk.RemovalPolicy_RETAIN, // Retain logs when stack is deleted }) - role := awsiam.Role_FromRoleArn(stack, jsii.String("Role"), jsii.String(lambdaRoleArn), nil) - - function := awslambda.NewFunction(stack, jsii.String("Function"), &awslambda.FunctionProps{ + functionProps := &awslambda.FunctionProps{ Code: awslambda.Code_FromAsset(jsii.String("../"), &awss3assets.AssetOptions{ // include only the bootstrap file Exclude: jsii.Strings("**", "!bootstrap"), @@ -52,16 +47,25 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw "API_KEY_TABLE": jsii.String(apiKeyTable), "TOTP_TABLE": jsii.String(totpTable), "WEBAUTHN_TABLE": jsii.String(webauthnTable), + "AWS_ENDPOINT": jsii.String(""), }, FunctionName: &functionName, Handler: jsii.String("bootstrap"), LoggingFormat: awslambda.LoggingFormat_JSON, LogGroup: logGroup, MemorySize: jsii.Number(1024.0), - Role: role, Runtime: awslambda.Runtime_PROVIDED_AL2023(), Timeout: awscdk.Duration_Seconds(jsii.Number(5)), - }) + } + + if lambdaRoleArn != "" { + functionProps.Role = awsiam.Role_FromRoleArn(stack, jsii.String("Role"), jsii.String(lambdaRoleArn), nil) + } else { + functionProps.Role = awsiam.Role_FromRoleName(stack, jsii.String("Role"), + jsii.String("service-role/AWSLambdaBasicExecutionRole"), nil) + } + + function := awslambda.NewFunction(stack, jsii.String("Function"), functionProps) api := awsapigateway.NewRestApi(stack, jsii.String("API"), &awsapigateway.RestApiProps{ RestApiName: jsii.String(functionName), @@ -93,19 +97,23 @@ func main() { env = "dev" } - NewCdkStack(app, "twosv-api-"+env, &CdkStackProps{ - awscdk.StackProps{ - Env: &awscdk.Environment{ - Region: jsii.String(os.Getenv("AWS_REGION")), - }, - Tags: &map[string]*string{ - "managed_by": jsii.String("cdk"), - "itse_app_name": jsii.String("twosv-api"), - "itse_app_customer": jsii.String("shared"), - "itse_app_env": jsii.String(env), - }, + props := awscdk.StackProps{ + Tags: &map[string]*string{ + "managed_by": jsii.String("cdk"), + "itse_app_name": jsii.String("twosv-api"), + "itse_app_customer": jsii.String("shared"), + "itse_app_env": jsii.String(env), }, - }) + } + + region := os.Getenv("AWS_REGION") + if region != "" { + props.Env = &awscdk.Environment{ + Region: jsii.String(os.Getenv("AWS_REGION")), + } + } + + NewCdkStack(app, "twosv-api-"+env, &CdkStackProps{props}) app.Synth(nil) } diff --git a/cdk/env.json b/cdk/env.json new file mode 100644 index 0000000..9a75735 --- /dev/null +++ b/cdk/env.json @@ -0,0 +1,8 @@ +{ + "Parameters": { + "AWS_ENDPOINT": "http://172.17.0.1:8000", + "API_KEY_TABLE": "ApiKey", + "TOTP_TABLE": "Totp", + "WEBAUTHN_TABLE": "WebAuthn" + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 82e0c89..abf93f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: volumes: - ./:/src ports: - - 8080 + - "8161:8080" environment: AWS_REGION: localhost AWS_ENDPOINT: http://dynamo:8000 @@ -44,4 +44,4 @@ services: volumes: - ./demo-ui:/usr/local/apache2/htdocs ports: - - 80 + - "80" diff --git a/local.env.example b/local.env.example index 0728b78..7f642d5 100644 --- a/local.env.example +++ b/local.env.example @@ -3,8 +3,7 @@ # https proxy config DNS_PROVIDER=cloudflare -CLOUDFLARE_EMAIL= -CLOUDFLARE_API_KEY= +CLOUDFLARE_DNS_API_TOKEN= LETS_ENCRYPT_EMAIL= LETS_ENCRYPT_CA=production TLD= From 677b6439c2f9aee6c0ac366371dacae2cba5cead Mon Sep 17 00:00:00 2001 From: briskt <3172830+briskt@users.noreply.github.com> Date: Wed, 17 Sep 2025 08:29:37 +0800 Subject: [PATCH 2/2] PR feedback: simplify region assignment --- cdk/cdk.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cdk/cdk.go b/cdk/cdk.go index 3dcb8a3..c5d510c 100644 --- a/cdk/cdk.go +++ b/cdk/cdk.go @@ -108,9 +108,7 @@ func main() { region := os.Getenv("AWS_REGION") if region != "" { - props.Env = &awscdk.Environment{ - Region: jsii.String(os.Getenv("AWS_REGION")), - } + props.Env = &awscdk.Environment{Region: ®ion} } NewCdkStack(app, "twosv-api-"+env, &CdkStackProps{props})