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 25abdb5..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: @@ -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: @@ -80,14 +108,22 @@ 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 + --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 +156,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 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 := ` + +
+ +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"}, diff --git a/main.go b/main.go index 09d844d..8b0a810 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,15 @@ var ( commitHash string ) +// 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"` @@ -51,7 +61,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 +73,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 +85,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 +190,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 @@ -234,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 @@ -250,7 +262,67 @@ func (h *universalHandler) Invoke(ctx context.Context, payload []byte) ([]byte, return json.Marshal(resp) } - return nil, fmt.Errorf("unsupported event payload for Lambda handler") + // 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) + } + } + } + } + + // 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: "/json", + HTTPMethod: "GET", + 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.