aws2-8 Exercise 1: SAM Templates, What They Expand Into, and What the Checkers Miss ======================================================================== Run with Windows 11, Python 3.14 (py launcher) and a venv with SAM CLI 1.168.0 (AWS SAM Translator 1.114.0, cfn-lint 1.53.3, boto3 1.43.108); a second venv with cfn-lint 1.57.2 plus aws-sam-translator 1.114.0; Docker Desktop 29.8.2 with AWS's public Lambda Python 3.13 images (pulled by sam local). Scratch folder C:/lwaws/c8, nothing installed globally. VERIFICATION LEVEL: 1 (no AWS account, no credentials, nothing deployed); statements about the real service come from the AWS documentation read on 2026-10-13 and are marked as such in the chapter. Outputs below are saved from the real runs of 2026-10-13. --- rest/template.yaml --- AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Description: Chapter 8 REST API experiment Globals: Function: Runtime: python3.13 Timeout: 5 MemorySize: 128 Resources: HelloFn: Type: AWS::Serverless::Function Properties: CodeUri: ../app/hello/ Handler: app.handler Environment: Variables: STAGE_NAME: dev Events: Hello: Type: Api Properties: {RestApiId: !Ref RestApi, Path: /hello, Method: get} Item: Type: Api Properties: {RestApiId: !Ref RestApi, Path: "/items/{id}", Method: get} Secure: Type: Api Properties: RestApiId: !Ref RestApi Path: /secure Method: get Auth: Authorizer: TokenAuth Keyed: Type: Api Properties: RestApiId: !Ref RestApi Path: /keyed Method: get Auth: ApiKeyRequired: true Create: Type: Api Properties: RestApiId: !Ref RestApi Path: /items Method: post RequestModel: Model: ItemModel Required: true ValidateBody: true ValidateParameters: true AuthFn: Type: AWS::Serverless::Function Properties: CodeUri: ../app/auth/ Handler: app.handler BadFn: Type: AWS::Serverless::Function Properties: CodeUri: ../app/bad/ Handler: app.handler Events: Bad: Type: Api Properties: {RestApiId: !Ref RestApi, Path: /bad, Method: get} SlowFn: Type: AWS::Serverless::Function Properties: CodeUri: ../app/slow/ Handler: app.handler Events: Slow: Type: Api Properties: {RestApiId: !Ref RestApi, Path: /slow, Method: get} RestApi: Type: AWS::Serverless::Api Properties: StageName: dev Cors: AllowMethods: "'GET,POST,OPTIONS'" AllowHeaders: "'Content-Type,Authorization'" AllowOrigin: "'https://example.com'" MethodSettings: - ResourcePath: /* HttpMethod: "*" ThrottlingBurstLimit: 5 ThrottlingRateLimit: 2 Models: ItemModel: type: object required: [name] properties: name: {type: string, minLength: 1} qty: {type: integer, minimum: 1} additionalProperties: false Auth: ApiKeyRequired: false UsagePlan: CreateUsagePlan: PER_API Throttle: {BurstLimit: 5, RateLimit: 2} Quota: {Limit: 1000, Period: MONTH} Authorizers: TokenAuth: FunctionArn: !GetAtt AuthFn.Arn Identity: Header: Authorization ReauthorizeEvery: 0 --- http/template.yaml --- AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Description: Chapter 8 HTTP API experiment Globals: Function: Runtime: python3.13 Timeout: 5 MemorySize: 128 Resources: HttpFn: Type: AWS::Serverless::Function Properties: CodeUri: ../app/hello/ Handler: app.handler Events: Hello: Type: HttpApi Properties: {ApiId: !Ref HttpApi, Path: /hello, Method: get} Item: Type: HttpApi Properties: {ApiId: !Ref HttpApi, Path: "/items/{id}", Method: get} Secure: Type: HttpApi Properties: {ApiId: !Ref HttpApi, Path: /secure, Method: get, Auth: {Authorizer: Jwt}} Any: Type: HttpApi Properties: {ApiId: !Ref HttpApi, Path: "/files/{proxy+}", Method: any} HttpApi: Type: AWS::Serverless::HttpApi Properties: StageName: dev CorsConfiguration: AllowOrigins: ["https://example.com"] AllowMethods: [GET, POST] AllowHeaders: [Content-Type] DefaultRouteSettings: ThrottlingBurstLimit: 5 ThrottlingRateLimit: 2 Auth: Authorizers: Jwt: IdentitySource: $request.header.Authorization JwtConfiguration: issuer: https://issuer.example.com/ audience: [my-api] --- app/hello/app.py --- import json, os def handler(event, context): # Echo what API Gateway gave us, so the experiments can see the event shape. ctx = event.get("requestContext", {}) body = { "version": event.get("version", "1.0 (REST)"), "path": event.get("path") or event.get("rawPath"), "method": event.get("httpMethod") or ctx.get("http", {}).get("method"), "pathParameters": event.get("pathParameters"), "query": event.get("queryStringParameters"), "authorizer": ctx.get("authorizer"), "hasBody": event.get("body") is not None, "isBase64Encoded": event.get("isBase64Encoded"), "stage_env": os.environ.get("STAGE_NAME"), } return {"statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": json.dumps(body)} --- app/auth/app.py --- def handler(event, context): token = event.get("authorizationToken", "") effect = "Allow" if token == "allow-me" else "Deny" return { "principalId": "user-1", "policyDocument": {"Version": "2012-10-17", "Statement": [{"Action": "execute-api:Invoke", "Effect": effect, "Resource": event["methodArn"]}]}, "context": {"plan": "gold"}, } --- app/bad/app.py --- def handler(event, context): # Not the proxy response shape: no statusCode. return {"message": "hello"} --- app/slow/app.py --- import time def handler(event, context): time.sleep(8) return {"statusCode": 200, "body": "late"} --- commands --- sam validate --lint # run in rest/ and in http/ (set AWS_EC2_METADATA_DISABLED=true and SAM_CLI_TELEMETRY=0) py transform.py rest/template.yaml out_rest.json # AWS_DEFAULT_REGION must be set; needs no credentials py mutate_sam.py # LINT= changes the lint version Result of sam validate --lint: http: 'is a valid SAM Template' (exit 0). rest with cfn-lint 1.53.3: W3005 warnings on SAM-generated resources, 'Linting failed' (exit 1); with cfn-lint 1.57.2 the same template is clean. --- transform.py --- """Run the SAM translator offline on a template and summarise what it generates.""" import sys, json, collections, yaml from samtranslator.translator.transform import transform from samtranslator.yaml_helper import yaml_parse from samtranslator.translator.managed_policy_translator import ManagedPolicyLoader class FakeIam: # the translator only needs a client for named managed policies; ours use none def get_paginator(self, name): raise RuntimeError("managed-policy lookup attempted") def run(path): with open(path) as f: tpl = yaml_parse(f) for r in tpl["Resources"].values(): # stand-in for what `sam package` does if r["Type"] == "AWS::Serverless::Function": r["Properties"]["CodeUri"] = "s3://example-bucket/code.zip" out = transform(tpl, {}, ManagedPolicyLoader(FakeIam())) return out if __name__ == "__main__": path = sys.argv[1] out = run(path) res = out["Resources"] c = collections.Counter(r["Type"] for r in res.values()) print(f"{path}: {len(res)} resources, {len(c)} types") for t, n in sorted(c.items()): print(f" {n:2d} {t}") if len(sys.argv) > 2: json.dump(out, open(sys.argv[2], "w"), indent=2) --- py transform.py rest/template.yaml (saved output of the real run) --- rest/template.yaml: 22 resources, 9 types 1 AWS::ApiGateway::ApiKey 1 AWS::ApiGateway::Deployment 1 AWS::ApiGateway::RestApi 1 AWS::ApiGateway::Stage 1 AWS::ApiGateway::UsagePlan 1 AWS::ApiGateway::UsagePlanKey 4 AWS::IAM::Role 4 AWS::Lambda::Function 8 AWS::Lambda::Permission --- py transform.py http/template.yaml (saved output of the real run) --- http/template.yaml: 8 resources, 5 types 1 AWS::ApiGatewayV2::Api 1 AWS::ApiGatewayV2::Stage 1 AWS::IAM::Role 1 AWS::Lambda::Function 4 AWS::Lambda::Permission --- generated REST OpenAPI pieces (saved output of the real run) --- REST: paths['/items'].post in the OpenAPI body SAM generates { "x-amazon-apigateway-integration": { "httpMethod": "POST", "type": "aws_proxy", "uri": { "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloFn.Arn}/invocations" } }, "x-amazon-apigateway-request-validator": "body-and-params", "responses": {}, "parameters": [ { "required": true, "in": "body", "name": "itemmodel", "schema": { "$ref": "#/definitions/itemmodel" } } ] } REST: x-amazon-apigateway-request-validators { "body-and-params": { "validateRequestParameters": true, "validateRequestBody": true } } REST: definitions (the model) { "itemmodel": { "additionalProperties": false, "required": [ "name" ], "type": "object", "properties": { "name": { "minLength": 1, "type": "string" }, "qty": { "minimum": 1, "type": "integer" } } } } REST: deployment logical ID: RestApiDeployment2981488895 (the number changes when the API definition changes) --- generated HTTP OpenAPI pieces (saved output of the real run) --- HTTP: components.securitySchemes and the /secure operation in the generated OpenAPI body { "securitySchemes": { "Jwt": { "type": "oauth2", "x-amazon-apigateway-authorizer": { "jwtConfiguration": { "issuer": "https://issuer.example.com/", "audience": [ "my-api" ] }, "identitySource": "$request.header.Authorization", "type": "jwt" } } }, "/secure get security": [ { "Jwt": [] } ], "payloadFormatVersion": "2.0" } HTTP: AWS::ApiGatewayV2::Stage properties { "DefaultRouteSettings": { "ThrottlingBurstLimit": 5, "ThrottlingRateLimit": 2 }, "ApiId": { "Ref": "HttpApi" }, "StageName": "dev", "Tags": { "httpapi:createdBy": "SAM" }, "AutoDeploy": true } --- deployment logical ID before and after one change (saved output of the real run) --- logical IDs of the REST deployment and stage in the template SAM generates as written : RestApiDeployment2981488895, RestApidevStage a route changed (/hello -> /hello2): RestApiDeployment11800fc711, RestApidevStage a function's Timeout 5 -> 7 : RestApiDeployment2981488895, RestApidevStage --- mutate_sam.py --- """Change one thing at a time in the SAM templates; report what `sam validate` (the SAM translator) and cfn-lint (run on the SAM template, which it transforms itself) say.""" import subprocess, os, sys, re, shutil ROOT = "C:/lwaws/c8" env = dict(os.environ, AWS_DEFAULT_REGION="eu-west-1", AWS_EC2_METADATA_DISABLED="true", SAM_CLI_TELEMETRY="0") LINT = os.environ.get("LINT") or f"{ROOT}/venv/Scripts/cfn-lint.exe"; SAM = f"{ROOT}/venv/Scripts/sam.exe" def lint(path): p = subprocess.run([LINT, path], capture_output=True, text=True, env=env) rules = re.findall(r"^([EW]\d{4})", p.stdout, re.M) return p.returncode, (sorted(set(rules)) or ["-"]) def samv(path, d): p = subprocess.run([SAM, "validate", "-t", path], capture_output=True, text=True, env=env, cwd=d) txt = (p.stdout + p.stderr).strip().splitlines() msg = next((l for l in txt[::-1] if l.strip()), "") return p.returncode, re.sub(r"\s+", " ", msg)[:70] M = [ # (dir, label, old, new) ("rest", "Runtime python3.13 -> python3.99", "Runtime: python3.13", "Runtime: python3.99"), ("rest", "Runtime python3.13 -> python3.7 (retired)", "Runtime: python3.13", "Runtime: python3.7"), ("rest", "Timeout 5 -> 901", "Timeout: 5", "Timeout: 901"), ("rest", "MemorySize 128 -> 64", "MemorySize: 128", "MemorySize: 64"), ("rest", "Handler line removed from HelloFn", " Handler: app.handler\n Environment:", " Environment:"), ("rest", "event Method get -> fetch", "Path: /hello, Method: get", "Path: /hello, Method: fetch"), ("rest", "event Path without leading slash", "Path: /hello, Method: get", "Path: hello, Method: get"), ("rest", "StageName dev -> 'dev stage'", "StageName: dev", "StageName: \"dev stage\""), ("rest", "CORS origin without inner quotes", "AllowOrigin: \"'https://example.com'\"", "AllowOrigin: \"https://example.com\""), ("rest", "RequestModel names a model that does not exist", "Model: ItemModel", "Model: NoSuchModel"), ("rest", "route uses an authorizer that is not defined", "Authorizer: TokenAuth", "Authorizer: NoSuchAuth"), ("rest", "ReauthorizeEvery 0 -> 3601", "ReauthorizeEvery: 0", "ReauthorizeEvery: 3601"), ("rest", "authorizer FunctionArn -> plain text", "FunctionArn: !GetAtt AuthFn.Arn", "FunctionArn: not-an-arn"), ("rest", "MethodSettings rate 2 -> 'fast'", "ThrottlingRateLimit: 2\n Models", "ThrottlingRateLimit: fast\n Models"), ("rest", "usage plan Quota Period MONTH -> YEAR", "Period: MONTH", "Period: YEAR"), ("rest", "usage plan burst below rate (1 < 2)", "Throttle: {BurstLimit: 5, RateLimit: 2}", "Throttle: {BurstLimit: 1, RateLimit: 2}"), ("rest", "same path+method declared twice", "Path: /hello, Method: get}\n Item:", "Path: /hello, Method: get}\n Dup: {Type: Api, Properties: {RestApiId: !Ref RestApi, Path: /hello, Method: get}}\n Item:"), ("rest", "model type 'object' -> 'objekt'", "type: object", "type: objekt"), ("http", "JwtConfiguration removed", " JwtConfiguration:\n issuer: https://issuer.example.com/\n audience: [my-api]\n", ""), ("http", "JWT identity source -> 'Authorization'", "IdentitySource: $request.header.Authorization", "IdentitySource: Authorization"), ("http", "JWT issuer not a URL", "issuer: https://issuer.example.com/", "issuer: issuer.example.com"), ("http", "throttle burst 5 -> 'lots'", "ThrottlingBurstLimit: 5", "ThrottlingBurstLimit: lots"), ("http", "{proxy+} not at the end of the path", "Path: \"/files/{proxy+}\"", "Path: \"/files/{proxy+}/x\""), ("http", "route Method any -> anyy", "Method: any}", "Method: anyy}"), ("http", "CORS AllowMethods GET -> FETCH", "AllowMethods: [GET, POST]", "AllowMethods: [FETCH, POST]"), ] print(f"{'change':58s} {'sam validate':14s} {'cfn-lint'}") rows = [] for d, label, old, new in [(None, "the templates as written (control)", None, None)] + M: targets = ["rest", "http"] if d is None else [d] for t in targets: src = open(f"{ROOT}/{t}/template.yaml").read() if old is not None: assert src.count(old) == 1, (label, src.count(old)) src = src.replace(old, new) tmp = f"{ROOT}/{t}/_mut.yaml"; open(tmp, "w").write(src) sc, sm = samv("_mut.yaml", f"{ROOT}/{t}") lc, lr = lint(tmp) rows.append((t, label, sc, sm, lc, lr)) print(f"{(t + ': ' + label)[:58]:58s} {'exit ' + str(sc):14s} exit {lc} {','.join(lr)}") os.remove(tmp) caught = [r for r in rows[2:] if r[2] != 0 or r[4] in (2, 6)] print(f"\nmutations: {len(rows) - 2}; caught as an error by sam validate or cfn-lint: {len(caught)}; by neither: {len(rows) - 2 - len(caught)}") print("missed by both:", [r[1] for r in rows[2:] if r[2] == 0 and r[4] not in (2, 6)]) --- py mutate_sam.py (run once per cfn-lint version, merged) (saved output of the real run) --- change sam validate cfn-lint 1.53.3 cfn-lint 1.57.2 rest: the templates as written (control) pass warning only pass http: the templates as written (control) pass pass pass rest: Runtime python3.13 -> python3.99 pass error E3030 pass rest: Runtime python3.13 -> python3.7 (retired) pass error E2531 error E2531 rest: Timeout 5 -> 901 pass warning only pass rest: MemorySize 128 -> 64 pass error E3034 error E3034 rest: Handler line removed from HelloFn error error E0001 warning only rest: event Method get -> fetch pass warning only pass rest: event Path without leading slash pass warning only pass rest: StageName dev -> 'dev stage' pass warning only pass rest: CORS origin without inner quotes pass warning only pass rest: RequestModel names a model that does not exist error error E0001 pass rest: route uses an authorizer that is not defined error error E0001 pass rest: ReauthorizeEvery 0 -> 3601 pass warning only pass rest: authorizer FunctionArn -> plain text pass warning only pass rest: MethodSettings rate 2 -> 'fast' pass error E3012 error E3012 rest: usage plan Quota Period MONTH -> YEAR pass warning only pass rest: usage plan burst below rate (1 < 2) pass warning only pass rest: same path+method declared twice error error E0001 pass rest: model type 'object' -> 'objekt' pass warning only pass http: JwtConfiguration removed error error E0001 pass http: JWT identity source -> 'Authorization' pass pass pass http: JWT issuer not a URL pass pass pass http: throttle burst 5 -> 'lots' pass error E3012 pass http: {proxy+} not at the end of the path pass pass pass http: route Method any -> anyy pass pass pass http: CORS AllowMethods GET -> FETCH pass pass pass 25 one-change mutations. Errors reported by: sam validate 5; cfn-lint 1.53.3 10; cfn-lint 1.57.2 3. Caught by sam validate or cfn-lint 1.53.3: 10; by sam validate or cfn-lint 1.57.2: 8; by at least one of the three: 10; by none: 15. -------------------------- Reading the result: 25 one-change mutations. sam validate (the SAM translator) reported errors for 5, cfn-lint 1.53.3 for 10 (5 of them the translator's own failures relayed as E0001), cfn-lint 1.57.2 for 3. At least one of the three caught 10; none caught 15. 'Not caught' means these tools were silent: nothing was deployed, so the service may well reject some of them. Mistakes made and fixed during this exercise: (1) my first second-venv comparison installed cfn-lint 1.57.2 WITHOUT aws-sam-translator, so I could not tell whether cfn-lint was transforming the template; I installed the translator at the same version as the first venv (1.114.0) and re-ran, and the differences remained, so they are real differences between the cfn-lint versions. (2) transform.py needs packaged S3 code locations; the script substitutes placeholders in memory, which is not what a real package step produces. How to avoid both: change one variable at a time between environments and check the control row first.