From 0d429cc9ced0f67207bab6be0b3026c45159b5ca Mon Sep 17 00:00:00 2001 From: novnc Date: Sat, 16 Aug 2025 08:25:46 +0000 Subject: [PATCH 1/9] Add cleanup step for previous failed demo CloudFormation stack --- .github/workflows/deploy-demo.yml | 35 +++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index 25abdb5..e2d7518 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -71,6 +71,34 @@ jobs: publish: true role: ${{ secrets.LAMBDA_EXECUTION_ROLE }} + - name: Cleanup previous failed demo stack (if any) + env: + STACK_NAME: ${{ inputs.stack_name }} + run: | + set -euo pipefail + STACK="$STACK_NAME" + if aws cloudformation describe-stacks --stack-name "$STACK" >/dev/null 2>&1; then + STATUS=$(aws cloudformation describe-stacks --stack-name "$STACK" --query 'Stacks[0].StackStatus' --output text) + echo "Current demo stack status: $STATUS" + case "$STATUS" in + ROLLBACK_FAILED|ROLLBACK_COMPLETE|UPDATE_ROLLBACK_FAILED|UPDATE_ROLLBACK_COMPLETE|CREATE_FAILED|DELETE_FAILED) + echo "Deleting stuck stack $STACK..." + aws cloudformation delete-stack --stack-name "$STACK" + echo "Waiting for deletion to complete..." + aws cloudformation wait stack-delete-complete --stack-name "$STACK" || { + echo "Waiter failed; showing recent events:"; + aws cloudformation describe-stack-events --stack-name "$STACK" --output text | head -n 200 || true; + echo "Retrying deletion..."; + aws cloudformation delete-stack --stack-name "$STACK" || true; + aws cloudformation wait stack-delete-complete --stack-name "$STACK"; + } + ;; + *) echo "Stack exists in status $STATUS; proceeding." ;; + esac + else + echo "Demo stack does not exist; proceeding." + fi + - name: Deploy REST API (demo) id: demo-cfn env: @@ -87,7 +115,8 @@ jobs: FunctionArn="${{ steps.demo-lambda.outputs.function-arn }}" \ ApiName="${API_NAME}" \ StageName="${STAGE_NAME}" \ - --no-fail-on-empty-changeset + --no-fail-on-empty-changeset \ + || { echo "Demo deploy failed; recent stack events:"; aws cloudformation describe-stack-events --stack-name "$STACK" --output text | head -n 200 || true; exit 1; } EP=$(aws cloudformation describe-stacks --stack-name "$STACK" --query 'Stacks[0].Outputs[?OutputKey==`ApiEndpoint`].OutputValue' --output text) echo "Demo API endpoint: $EP" @@ -120,7 +149,9 @@ jobs: exit 0 fi FN_NAME="${FN_ARN##*:function:}" - LG="/aws/lambda/${FN_NAME}" + # Strip version or alias suffix (e.g., myfn:10 -> myfn) + FN_BASE="${FN_NAME%%:*}" + LG="/aws/lambda/${FN_BASE}" echo "Fetching latest log stream from ${LG} ..." LS=$(aws logs describe-log-streams --log-group-name "$LG" --order-by LastEventTime --descending --max-items 1 --query 'logStreams[0].logStreamName' --output text 2>/dev/null || true) if [ -n "${LS:-}" ] && [ "${LS}" != "None" ]; then From b832ad090e6d61ae8987fe58e1ca13691037d8f8 Mon Sep 17 00:00:00 2001 From: novnc Date: Mon, 18 Aug 2025 11:02:02 +0800 Subject: [PATCH 2/9] Refactor Lambda ARN resolution to use base ARN for permissions in deploy workflows --- .github/workflows/deploy-apigw.yml | 10 ++++++++-- .github/workflows/deploy-demo.yml | 9 ++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-apigw.yml b/.github/workflows/deploy-apigw.yml index 06de4fc..a50f95a 100644 --- a/.github/workflows/deploy-apigw.yml +++ b/.github/workflows/deploy-apigw.yml @@ -42,8 +42,14 @@ jobs: exit 1 fi ARN=$(aws lambda get-function --function-name "$FN_NAME" --query 'Configuration.FunctionArn' --output text) - echo "Lambda ARN: $ARN" - echo "arn=$ARN" >> "$GITHUB_OUTPUT" + # If ARN is qualified with version/alias (e.g., :function:name:10), strip the qualifier + if [[ "$ARN" =~ ^(arn:[^:]+:lambda:[^:]+:[0-9]+:function:[^:]+):[^:]+$ ]]; then + BASE_ARN="${BASH_REMATCH[1]}" + else + BASE_ARN="$ARN" + fi + echo "Lambda ARN (base): $BASE_ARN" + echo "arn=$BASE_ARN" >> "$GITHUB_OUTPUT" - name: Cleanup previous failed stack (if any) env: diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index e2d7518..19c92f7 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -108,11 +108,18 @@ jobs: run: | set -euo pipefail STACK="${STACK_NAME}" + # Resolve the unqualified (base) function ARN to avoid per-version permissions + FN_ARN=$(aws lambda get-function --function-name "${{ inputs.function_name }}" --query 'Configuration.FunctionArn' --output text) + case "$FN_ARN" in + arn:aws:lambda:*:*:function:*:*) BASE_ARN="${FN_ARN%%:*}" ;; + *) BASE_ARN="$FN_ARN" ;; + esac + echo "Using base Lambda ARN: ${BASE_ARN}" aws cloudformation deploy \ --stack-name "$STACK" \ --template-file infra/apigw-rest-demo.yaml \ --parameter-overrides \ - FunctionArn="${{ steps.demo-lambda.outputs.function-arn }}" \ + FunctionArn="${BASE_ARN}" \ ApiName="${API_NAME}" \ StageName="${STAGE_NAME}" \ --no-fail-on-empty-changeset \ From 2a7e79a9d3f100e0e7a3df1a0915ceaf2c145dcc Mon Sep 17 00:00:00 2001 From: novnc Date: Mon, 18 Aug 2025 11:14:32 +0800 Subject: [PATCH 3/9] Update default values for function, API, and stack names in deploy workflow --- .github/workflows/deploy-demo.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index 19c92f7..6a120e5 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -6,11 +6,11 @@ on: function_name: description: "Demo Lambda function name" required: true - default: "random-demo" + default: "random" api_name: description: "API Gateway name" required: false - default: "random-demo-api" + default: "random-api" stage_name: description: "Stage name" required: false @@ -18,7 +18,7 @@ on: stack_name: description: "CloudFormation stack name" required: false - default: "random-demo-apigw" + default: "random-apigw" jobs: deploy-demo: From e78519aac60393a4d67e867304b4662412baaf79 Mon Sep 17 00:00:00 2001 From: novnc Date: Mon, 18 Aug 2025 11:53:04 +0800 Subject: [PATCH 4/9] Refactor handler to serve HTML and JSON responses based on request format --- cmd/demo/main.go | 59 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/cmd/demo/main.go b/cmd/demo/main.go index 58ec0d7..5c1297b 100644 --- a/cmd/demo/main.go +++ b/cmd/demo/main.go @@ -3,30 +3,69 @@ package main import ( "context" "encoding/json" + "strings" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" ) -type helloResp struct { - Message string `json:"message"` -} - func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - // Minimal REST v1 proxy handler + // Serve simple HTML for browser-friendly responses at / and /hello path := req.Path - // Serve at /hello and / if path == "/hello" || path == "/" { - b, _ := json.Marshal(helloResp{Message: "hello"}) + // Check query param and Accept header for JSON preference + qs := req.QueryStringParameters["format"] + accept := "" + for k, v := range req.Headers { + if strings.EqualFold(k, "Accept") { + accept = v + break + } + } + + wantJSON := false + if strings.EqualFold(qs, "json") { + wantJSON = true + } else if accept != "" { + if strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html") { + wantJSON = true + } + } + + if wantJSON { + b, _ := json.Marshal(map[string]string{"message": "hello"}) + return events.APIGatewayProxyResponse{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + MultiValueHeaders: map[string][]string{}, + Body: string(b), + IsBase64Encoded: false, + }, nil + } + + html := ` + + + + Hello + + + +

Hello

+

This is the demo Lambda served through API Gateway (REST).

+ +` + return events.APIGatewayProxyResponse{ StatusCode: 200, - Headers: map[string]string{"Content-Type": "application/json"}, + Headers: map[string]string{"Content-Type": "text/html; charset=utf-8"}, MultiValueHeaders: map[string][]string{}, - Body: string(b), + Body: html, IsBase64Encoded: false, }, nil } - // 404 for others + + // 404 for others (JSON) return events.APIGatewayProxyResponse{ StatusCode: 404, Headers: map[string]string{"Content-Type": "application/json"}, From 40ea1696aeb19179a9284d433dbafce316d68c3e Mon Sep 17 00:00:00 2001 From: novnc Date: Mon, 18 Aug 2025 12:12:08 +0800 Subject: [PATCH 5/9] use local rand source (rnd) to satisfy staticcheck --- main.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 09d844d..2ee0f0f 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "strconv" + "time" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" @@ -25,6 +26,9 @@ var ( commitHash string ) +// local random source to avoid using the deprecated global seed +var rnd *rand.Rand + // RandomString struct for individual random strings type RandomString struct { Length int `json:"length"` @@ -51,7 +55,7 @@ func GenerateRandomPrintable(length int) string { specialChars := []rune("!#$%*+-=?@^_") // Determine how many characters to replace (1 to 3, but not more than the string length). - numReplacements := rand.Intn(3) + 1 + numReplacements := rnd.Intn(3) + 1 if numReplacements >= length { numReplacements = 1 } @@ -63,8 +67,8 @@ func GenerateRandomPrintable(length int) string { // Replace characters at random positions. for i := 0; i < numReplacements; i++ { - pos := rand.Intn(length) - runes[pos] = specialChars[rand.Intn(len(specialChars))] + pos := rnd.Intn(length) + runes[pos] = specialChars[rnd.Intn(len(specialChars))] } return string(runes) @@ -75,7 +79,7 @@ func GenerateRandomAlphanumeric(length int) string { letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") result := make([]rune, length) for i := range result { - result[i] = letters[rand.Intn(len(letters))] + result[i] = letters[rnd.Intn(len(letters))] } return string(result) } @@ -180,6 +184,8 @@ func generateStrings(c *gin.Context) { func main() { gin.SetMode(gin.ReleaseMode) + // Initialize a local random source for non-deterministic output across cold starts + rnd = rand.New(rand.NewSource(time.Now().UnixNano())) r := gin.Default() // Define the endpoints From de8061c77d7157798c24de5813b9251dd7b1529a Mon Sep 17 00:00:00 2001 From: novnc Date: Mon, 18 Aug 2025 12:13:54 +0800 Subject: [PATCH 6/9] initialize local rnd in init() so tests don't hit nil --- main.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/main.go b/main.go index 2ee0f0f..8a586f8 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,12 @@ var ( // local random source to avoid using the deprecated global seed var rnd *rand.Rand +func init() { + if rnd == nil { + rnd = rand.New(rand.NewSource(time.Now().UnixNano())) + } +} + // RandomString struct for individual random strings type RandomString struct { Length int `json:"length"` From fe8a8c96e813195a4c06153b6a88588f57aaca57 Mon Sep 17 00:00:00 2001 From: novnc Date: Mon, 18 Aug 2025 12:29:23 +0800 Subject: [PATCH 7/9] add permissive fallback for console/test payloads -> coerce to v1/v2 --- main.go | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 8a586f8..e42284c 100644 --- a/main.go +++ b/main.go @@ -246,7 +246,7 @@ func (h *universalHandler) Invoke(ctx context.Context, payload []byte) ([]byte, // Fallback to API Gateway v1 var v1req events.APIGatewayProxyRequest - if err := json.Unmarshal(payload, &v1req); err == nil && v1req.RequestContext.RequestID != "" { + if err := json.Unmarshal(payload, &v1req); err == nil && (v1req.HTTPMethod != "" || v1req.Path != "" || v1req.RequestContext.RequestID != "") { resp, err := h.v1.ProxyWithContext(ctx, v1req) if err != nil { return nil, err @@ -262,6 +262,45 @@ func (h *universalHandler) Invoke(ctx context.Context, payload []byte) ([]byte, return json.Marshal(resp) } + // Permissive fallback: some console or custom test events use non-standard shapes. + // Try to coerce generic JSON payloads into v2 or v1 shapes by inspecting keys. + var generic map[string]interface{} + if err := json.Unmarshal(payload, &generic); err == nil { + // Detect v2-like (version = "2.0" or requestContext.http present) + if v, ok := generic["version"].(string); ok && v == "2.0" { + if b, err := json.Marshal(generic); err == nil { + var v2req events.APIGatewayV2HTTPRequest + if err := json.Unmarshal(b, &v2req); err == nil { + resp, err := h.v2.ProxyWithContext(ctx, v2req) + if err != nil { + return nil, err + } + return json.Marshal(resp) + } + } + } + + if _, hasHTTPMethod := generic["httpMethod"]; hasHTTPMethod || generic["path"] != nil || generic["resource"] != nil { + if b, err := json.Marshal(generic); err == nil { + var v1 events.APIGatewayProxyRequest + if err := json.Unmarshal(b, &v1); err == nil { + resp, err := h.v1.ProxyWithContext(ctx, v1) + if err != nil { + return nil, err + } + if resp.Headers == nil { + resp.Headers = map[string]string{} + } + if resp.MultiValueHeaders == nil { + resp.MultiValueHeaders = map[string][]string{} + } + resp.IsBase64Encoded = false + return json.Marshal(resp) + } + } + } + } + return nil, fmt.Errorf("unsupported event payload for Lambda handler") } From 5af10991b35a7effb319cc4d2547f18694058fd5 Mon Sep 17 00:00:00 2001 From: novnc Date: Mon, 18 Aug 2025 14:15:57 +0800 Subject: [PATCH 8/9] allow final fallback: coerce arbitrary test payload to v1 POST / body --- main.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index e42284c..a10d546 100644 --- a/main.go +++ b/main.go @@ -301,7 +301,28 @@ func (h *universalHandler) Invoke(ctx context.Context, payload []byte) ([]byte, } } - return nil, fmt.Errorf("unsupported event payload for Lambda handler") + // Final permissive fallback: forward raw payload as v1 POST / body so console tests succeed. + // This keeps the function usable from the Lambda console with arbitrary test events. + v1fallback := events.APIGatewayProxyRequest{ + Path: "/", + HTTPMethod: "POST", + Headers: map[string]string{"Content-Type": "application/json"}, + MultiValueHeaders: map[string][]string{}, + Body: string(payload), + IsBase64Encoded: false, + } + resp, err := h.v1.ProxyWithContext(ctx, v1fallback) + if err != nil { + return nil, fmt.Errorf("unable to coerce generic payload to v1 proxy: %w", err) + } + if resp.Headers == nil { + resp.Headers = map[string]string{} + } + if resp.MultiValueHeaders == nil { + resp.MultiValueHeaders = map[string][]string{} + } + resp.IsBase64Encoded = false + return json.Marshal(resp) } // convertFunctionURLToV2 maps a Lambda Function URL event to an APIGateway v2 HTTP request for the adapter. From 16db58f6aa87b52789c9cc59621215d8c2a05e1c Mon Sep 17 00:00:00 2001 From: novnc Date: Mon, 18 Aug 2025 14:24:12 +0800 Subject: [PATCH 9/9] route generic console test payloads to GET /json to return 200 --- main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index a10d546..8b0a810 100644 --- a/main.go +++ b/main.go @@ -304,8 +304,8 @@ func (h *universalHandler) Invoke(ctx context.Context, payload []byte) ([]byte, // Final permissive fallback: forward raw payload as v1 POST / body so console tests succeed. // This keeps the function usable from the Lambda console with arbitrary test events. v1fallback := events.APIGatewayProxyRequest{ - Path: "/", - HTTPMethod: "POST", + Path: "/json", + HTTPMethod: "GET", Headers: map[string]string{"Content-Type": "application/json"}, MultiValueHeaders: map[string][]string{}, Body: string(payload),