Delivery and Operations

AWS Intermediate/Advanced

Chapter 11 ยท Delivery and Operations: CI/CD and Day-2 Operations

The earlier chapters described what to build. This one is about how a change gets from a commit to AWS without a person's access keys in the middle, how a new version is introduced without taking everyone with it when it is wrong, and how you find out afterwards who did what and whether anything has drifted. It leans on CI/CD Pipelines for the general ideas (stages, artifacts, approvals), on Observability for alarms and logs, on AWS Intermediate/Advanced 2 for the IAM evaluation model and on AWS Intermediate/Advanced 4, 5 and 8 for CloudFormation, CDK and SAM.

How every example in this chapter was verified
Nothing was deployed, no pipeline ran, and no token was exchanged with AWS or GitHub. There is no AWS account, no AWS CLI and no GitHub repository wired to one on the machine that wrote this course.
  • Run here (level 1): a GitHub Actions workflow file checked by actionlint 1.7.12 and by the workflow schema in check-jsonschema 0.38.2, and by a checker I wrote; a model of the AWS trust policy for GitHub's identity tokens; the SAM translator (aws-sam-translator 1.114.0) producing the CodeDeploy resources; cfn-lint 1.57.2 on a pipeline template; arithmetic for CodeDeploy traffic shifting and ECS rolling updates; the moto 5.2.4 emulator for Parameter Store; and a checker for CloudTrail records, run on records I wrote myself.
  • From the documentation, not run (level 3): what the real services do. The pages were read on 2026-10-13 through a page-fetch tool that summarises pages; some came back thin and each gap is named where it matters.
  • Needs an account, not run (level 2): every deploy, every real role assumption, every real canary.
Docker was running but was not needed.

Two Ways to Reach AWS from a Pipeline

The outline's question for this chapter is "CodePipeline, CodeBuild and CodeDeploy, or GitHub Actions with OIDC?". They are not exclusive: a team that keeps its code on GitHub can run builds in GitHub Actions and deploy with a role it assumes over OIDC, or hand the commit to CodePipeline and let AWS run the stages. The picture:

A. GitHub Actions + OIDC B. CodePipeline + CodeBuild (+ CodeDeploy) commit -> workflow runs on a GitHub runner commit -> GitHub connection -> CodePipeline (V1/V2) | asks GitHub for an identity token (JWT) | Source stage (artifact in S3) | sends it to AWS STS | Build stage (CodeBuild project, service role) v | Approval (manual, not charged) AWS checks the token against the role's v trust policy -> short-lived credentials Deploy stage (CloudFormation, CodeDeploy, ...) | v deploy with those credentials No stored keys either: each service has a role.

What follows checks what can be checked of each, in this order: the identity link in A (workflow file, then the trust policy), the pipeline in B as a template, how new versions are shifted (CodeDeploy, ECS), and then the day-2 tools.

GitHub Actions and OIDC

What GitHub sends

From the documentation (GitHub, read 2026-10-13): a job that sets permissions: id-token: write can ask GitHub for a signed token. Its claims: iss is https://token.actions.githubusercontent.com; aud "defaults to the repository owner's URL" and a workflow can ask for another; sub depends on the context. The default sub formats:

WhenDefault sub
Job references an environmentrepo:ORG/REPO:environment:NAME (whatever the trigger)
Pull request, job without an environmentrepo:ORG/REPO:pull_request
Branch, no environment, not a pull requestrepo:ORG/REPO:ref:refs/heads/BRANCH
Tag, same conditions as branchrepo:ORG/REPO:ref:refs/tags/TAG
Repositories created after 2026-07-15 (and renamed or transferred ones after that date)repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH (example repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main); older repositories keep the previous format unless they opt in; not on GitHub Enterprise Server

A : inside a value (a branch called fix:thing) is written %3A. The GitHub page I fetched first did not define the claims or the formats (it only had examples); the reference page it pointed to did, and the table above is from that second page. It did not say what happens with forks or pull_request_target, and it gave no AWS trust policy or thumbprint advice.

What AWS needs

From the AWS documentation: an IAM OIDC provider has a URL (which must be https://, case-sensitive and unique in the account), at least one and at most 100 audiences, and thumbprints (1 to 5). AWS says it verifies the provider's JWKS endpoint using its library of trusted root CAs and falls back to the configured thumbprints only when the certificate is not signed by one of them (or the certificate cannot be retrieved, or TLS 1.3 is required). The aud in the token must match an audience registered on the provider, and an OIDC provider used in a role trust policy must be in the same account as the role. The action aws-actions/configure-aws-credentials (README read 2026-10-13) recommends a StringEquals condition on both token.actions.githubusercontent.com:aud (sts.amazonaws.com) and :sub, warns against ForAllValues: in an Allow statement, gives a default session of 1 hour (900 seconds to 12 hours allowed), and says that role chaining needs both sts:AssumeRole and sts:TagSession on the first role (sts:TagSession can be left out if session tagging is skipped).

A workflow file, checked

Run here: a deploy workflow that tests on every push and pull request and deploys from main using an environment and an assumed role. It has no stored AWS keys:

name: deploy on: push: branches: [main] pull_request: branches: [main] permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: echo "run the tests here" deploy: needs: test if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: production permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/gha-deploy aws-region: eu-west-2 - run: aws sts get-caller-identity

I then made 26 one-change mistakes and four harmless changes, and ran four checkers: PyYAML (does it parse?), the workflow JSON schema through check-jsonschema, actionlint, and wfcheck.py, a checker for the OIDC-specific rules. I wrote the rules in wfcheck.py from the pages above before this run (a role without id-token: write, write-all, stored keys, an action not pinned to a tag or SHA, pull_request_target with the pull request's code, a role ARN that is not a role ARN, untrusted event text in a run: step). One rule, R2, was meant to flag an id-token: write granted at workflow level, but I implemented it so that it never fired; the first run missed that change, and I fixed the rule afterwards. The result below is after the fix.

py mutate_wf.py
change yaml schema alint wfchk (control: unchanged good.yml) 0 0 0 0 remove id-token: write from the deploy job 0 0 0 1 R1 id-token: write at workflow level instead of the job 0 0 0 1 R2 id-token: writ (typo in the value) 0 1 1 1 "writ" is invalid as permission of scope "id-token". available values permissions: write-all at the top 0 0 0 1 R3 permission key typo: id-tokens 0 1 1 1 unknown permission scope "id-tokens". all available permission scopes contents: reads (typo in the value) 0 1 1 0 "reads" is invalid as permission of scope "contents". available values remove role-to-assume 0 0 0 1 R9 input typo: role-to-asume 0 0 1 1 input "role-to-asume" is not defined in action "aws-actions/configure- static key beside the role 0 0 0 1 R4 action pinned to @main 0 0 0 1 R5 action with no ref 0 0 1 1 specifying action "actions/checkout" in invalid format because ref is role ARN with 11-digit account 0 0 0 1 R7 role ARN with partition typo (arn:ws) 0 0 0 1 R7 needs names a job that does not exist 0 0 1 0 job "deploy" needs job "tset" which does not exist in this workflow [j runs-on missing on deploy 0 1 1 0 "runs-on" section is missing in job "deploy" [syntax-check] expression syntax error in if 0 0 1 0 got unexpected character ' ' while lexing == operator, expecting '=' [ unclosed expression in run 0 0 1 0 got unexpected character '"' while lexing expression, expecting 'a'..' script injection: PR title in run 0 0 1 1 "github.event.pull_request.title" is potentially untrusted. avoid usin step with uses and run together 0 1 1 0 unexpected key "uses" for step to run shell command. expected one of " timeout-minutes: abc 0 1 1 0 expecting a single ${{...}} expression or float number literal, but fo duplicate step id 0 0 1 0 step ID "a" duplicates. previously defined at line:15,col:13. step ID pull_request_target checks out the PR head 0 0 0 1 R6,R6b unknown trigger event name 0 1 1 0 unknown Webhook event "pull_requests". see https://docs.github.com/en/ environment name differs from the trust policy (production -> prod) 0 0 0 0 aws-region eu-west-9 (not a region) 0 0 0 0 both: role in the wrong account (0000...) 0 0 0 0 OK: region changed to us-east-1 0 0 0 0 OK: job renamed 0 0 0 0 OK: extra echo step 0 0 0 0 OK: checkout pinned to a full commit SHA 0 0 0 0 26 mistakes: caught by PyYAML 0, schema 7, actionlint 14, wfcheck 14 caught by at least one of the three off-the-shelf checks: 14; by wfcheck only: 9; by nothing: 3; false alarms on the 4 OK changes: 0

actionlint caught 14 of 26, the schema 7 and PyYAML none (they parse). actionlint's list includes things I did not expect from a linter: a typo in an action's input name (role-to-asume), a script-injection warning for a pull request title used in a run: step, and an action with no version. wfcheck.py caught 14, and 9 of them were missed by all three off-the-shelf checks: a missing id-token: write (which fails only at run time), write-all, a stored key beside the role, an action pinned to @main, a role ARN with an 11-digit account, a missing role-to-assume, and pull_request_target checking out the pull request. Three stay unseen by everything: a Region that does not exist (eu-west-9; I did not read the Region list), a role in the wrong account, and an environment name that no longer matches the trust policy, which only a check across the workflow and the policy can see (below). None of the four harmless changes raised an alarm.

The trust policy, modelled

The workflow is only half of the contract: the role's trust policy decides which tokens may use it. Run here: a model of sts:AssumeRoleWithWebIdentity for GitHub's tokens, built from the three pages above. It generates the documented sub formats and evaluates StringEquals and StringLike on :sub and :aud; any other operator makes it stop with NotModelled.

def sub_for(repo, event, ref=None, environment=None, immutable_ids=None): """Default sub claim. repo is 'owner/name'; immutable_ids=(owner_id, repo_id) switches to the immutable format.""" owner, name = repo.split("/") head = f"repo:{owner}@{immutable_ids[0]}/{name}@{immutable_ids[1]}" if immutable_ids else f"repo:{repo}" esc = lambda s: s.replace(":", "%3A") if environment: return f"{head}:environment:{esc(environment)}" if event == "pull_request": return f"{head}:pull_request" if ref: return f"{head}:ref:{esc(ref)}" raise ValueError("need environment, a pull_request event or a ref")
def glob_match(pattern, value): # IAM StringLike: * = any run of characters, ? = one character (case-sensitive) rx = "".join(".*" if c == "*" else "." if c == "?" else re.escape(c) for c in pattern) return re.fullmatch(rx, value, re.S) is not None def cond_ok(cond, claims): for op, kv in cond.items(): if op not in ("StringEquals", "StringLike"): raise NotModelled(op) for key, want in kv.items(): claim = key.split(":", 1)[1] vals = want if isinstance(want, list) else [want] have = claims.get(claim) if have is None: return False if op == "StringEquals": ok = have in vals else: ok = any(glob_match(v, have) for v in vals) if not ok: return False return True

Then a matrix of eight trust policies against ten tokens (the intended repository on a branch, a feature branch, a pull request, two environments, a tag, a different repository of the same owner, a different owner with the same repository name, a lookalike name, and a correct token with the wrong audience). My expected answers came from the documented rules, so the matrix tests the model against my reading, not against AWS.

py oidc_tests.py
0 1 2 3 4 5 6 7 8 9 0 = main 1 = feature 2 = PR 3 = env production 4 = env staging 5 = tag v1.0 6 = other repo acme/blog main 7 = other owner evil/shop main 8 = lookalike acme/shop-evil main 9 = main, wrong audience ok A aud only, no sub condition Y Y Y Y Y Y Y Y Y . 9 allowed ok B sub = main branch (StringEquals) Y . . . . . . . . . 1 allowed ok C sub = environment production . . . Y . . . . . . 1 allowed ok D sub like repo:acme/shop:* Y Y Y Y Y Y . . . . 6 allowed ok E sub like repo:acme/* Y Y Y Y Y Y Y . Y . 8 allowed ok F sub like repo:acme/shop* Y Y Y Y Y Y . . Y . 7 allowed ok G sub = main OR production (list) Y . . Y . . . . . . 2 allowed ok H no aud condition, sub = main Y . . . . . . . . . 1 allowed 8 policies x 10 tokens = 80 cells; policies whose row differs from my expectation: 0 -- immutable format (repos created after 2026-07-15) -- token sub: repo:acme@123456/shop@456789:ref:refs/heads/main policy B (old-format sub) allows it: False policy with owner/repo IDs allows it: True policy with owner/repo IDs allows the OLD-format token for the same repo: False policy D (repo:acme/shop:*) allows it: False -- ':' in a ref (documented as %3A) -- sub: repo:acme/shop:ref:refs/heads/fix%3Athing policy D allows it: True -- NotModelled -- ForAllValues:StringLike -> NotModelled ForAllValues:StringLike

Read the rows. Policy A, with an audience condition and no sub condition, lets in all nine tokens that carry the right audience, including a repository owned by someone else. The audience is a fixed string that every GitHub workflow can request, so it does not identify you; the sub condition does. Policy E (repo:acme/*) lets in every repository of the organisation, including a lookalike such as acme/shop-evil (an organisation may well own that name), and F (repo:acme/shop*) admits the lookalike as well. Policy D (repo:acme/shop:*, with the colon) is the narrowest wildcard that is still one repository. The exact-match policies B and C admit one token each, and G lets a list of two values in. The last lines of the output show two further effects: a policy written for the old subject format stops matching a repository that uses the new, ID-based format (it fails closed, so your deployment breaks rather than your security), and the %3A encoding passes a wildcard that was written without thinking about it.

The workflow and the policy meet at the environment name. I derived the sub that the deploy job above would send, and evaluated it against the trust policy that the pipeline template (below) contains:

py cross_check.py
good workflow, push to main sub=repo:acme/shop:environment:production ASSUMES good workflow, pull_request event (job has environment:) sub=repo:acme/shop:environment:production ASSUMES environment: line removed, push to main sub=repo:acme/shop:ref:refs/heads/main refused environment: line removed, pull_request event sub=repo:acme/shop:pull_request refused environment: prod (renamed), push to main sub=repo:acme/shop:environment:prod refused note: the job's 'if: github.ref == refs/heads/main' is what keeps the pull_request run away from the deploy step in the good workflow; the trust policy alone does not stop it, because the environment sub is the same for every trigger. (GitHub environment protection rules are a separate control: not read this session.)

Renaming the environment to prod in the workflow makes AWS refuse the token while every file is individually valid, and removing the environment: line has the same effect. The second row is the one to think about: because a job that names an environment sends the environment sub whatever triggered it, a pull_request run of that job holds a token the policy accepts. In this workflow what stops a pull request from deploying is the job's if: github.ref == 'refs/heads/main', not AWS. GitHub's environment protection rules are a further control that I did not read.

CodePipeline and CodeBuild as a Template

From the documentation (pricing pages, 2026-10-13): CodePipeline V1 is $1.00 per active pipeline per month (first 30 days free, one free active pipeline); V2 is $0.002 per action execution minute rounded up, with 100 free minutes shared by the account, and manual approvals are not charged; an active pipeline is one that is older than 30 days and had a code change that month. CodeBuild's page gave $0.005 per minute for the small Linux on-demand type only in a worked example, with 100 free minutes a month. Neither page named a Region. Prices for GitHub Actions were not read.

Run here: one template with the GitHub provider, a deploy role whose trust policy matches the workflow above, a site bucket, an artifact bucket, a build role, a CodeBuild project, a pipeline role and a three-stage V2 pipeline (source from a GitHub connection, build, manual approval). The trust policy and the pipeline's wiring:

DeployRole: Type: AWS::IAM::Role Properties: MaxSessionDuration: 3600 AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: sts:AssumeRoleWithWebIdentity Principal: Federated: !Ref GitHubOidc Condition: StringEquals: token.actions.githubusercontent.com:aud: sts.amazonaws.com token.actions.githubusercontent.com:sub: !Sub "repo:${GitHubRepo}:environment:production"
Pipeline: Type: AWS::CodePipeline::Pipeline Properties: PipelineType: V2 RoleArn: !GetAtt PipelineRole.Arn ArtifactStore: Type: S3 Location: !Ref ArtifactBucket Stages: - Name: Source Actions: - Name: GitHub ActionTypeId: Category: Source Owner: AWS Provider: CodeStarSourceConnection Version: "1" Configuration: ConnectionArn: !Ref GitHubConnectionArn FullRepositoryId: !Ref GitHubRepo BranchName: main OutputArtifacts: - Name: src - Name: Build Actions: - Name: Build ActionTypeId: Category: Build Owner: AWS Provider: CodeBuild Version: "1" Configuration: ProjectName: !Ref BuildProject InputArtifacts: - Name: src OutputArtifacts: - Name: built - Name: Approve Actions: - Name: Manual ActionTypeId: Category: Approval Owner: AWS Provider: Manual Version: "1"

It linted clean with cfn-lint 1.57.2. Then 26 one-change mistakes and three harmless changes, with cfn-lint on one side and pipecheck.py on the other. pipecheck.py evaluates the template's own trust policy with the model above against eight tokens that should be refused and one that should be accepted, and checks permissions and wiring. As with the workflow checker, its rules were written before this run, from the pages above and the design's intent (only the production environment of this repository may assume the role).

py mutate_pipe.py
change lint mine (control) 0 0 sub condition: StringLike repo:acme/* (whole organisation) 0 1 T2 sub condition removed 0 1 T2 aud condition removed 0 0 aud typo sts.amazon.com 0 1 T1 environment production -> prod 0 1 T1 sub is the main branch, not the environment 0 1 T1,T2 provider ClientIdList has the wrong audience 0 1 T3 trust Action sts:AssumeRole instead of AssumeRoleWithWebIdentity 0 1 T1 Principal Federated Ref to a missing resource 1 1 E1020 T1 deploy policy s3:* on Resource * 0 1 P1 CodeBuild PrivilegedMode true 0 1 B1 site bucket without PublicAccessBlock 0 1 B2 Build input artifact renamed (src -> source) 1 1 E3701 W1 Build action InputArtifacts removed 1 1 E3702 W2 pipeline role loses codebuild:StartBuild 0 1 W5 build role loses logs:PutLogEvents 0 1 W6 pipeline RoleArn points at BuildRole 0 1 W4 pipeline artifact store is the site bucket 0 1 W3 BuildProject TimeoutInMinutes 15 -> 0 1 0 E3034 ComputeType typo 0 0 PipelineType V3 (does not exist) 1 0 E3030 MaxSessionDuration 100 1 0 E3034 lifecycle ExpirationInDays 0 0 0 GitHubRepo default breaks AllowedPattern 1 0 E2015 crashed:ValueError Source BranchName removed 0 0 Output artifact name used twice 1 0 E3701 OK: repo default changed to acme/blog 0 0 OK: build timeout 30 0 0 OK: build command changed 0 0 26 mistakes: cfn-lint 8, pipecheck 17, both 3, only pipecheck 14, neither 4; false alarms on 3 OK changes: 0

cfn-lint reported an error for 8 of 26; pipecheck.py for 17; both for 3; neither for 4. cfn-lint found out-of-range values (a build timeout of 0, a session duration of 100, a pipeline type that does not exist), a dangling reference, an artifact name that no action produces or one produced twice, and a pattern that the parameter default breaks. It passed every other trust-policy change, a role with s3:* on every resource, a privileged build container, a bucket without a public-access block and a pipeline role without the right to start the build. pipecheck.py crashed on one change (the repository default that breaks the parameter pattern: my helper cannot split it) and I do not count that as a catch. The four that got through are: removing the audience condition (in my model the provider rejects tokens whose audience is not registered, so the condition adds nothing, but the README recommends both and I cannot say whether AWS treats them as equivalent), a typo in the build compute type, a lifecycle expiry of 0 days, and a missing branch name on the source action. The same caution as in chapter 10 applies: the checker is evidence that these rules can be written, not that it would catch a mistake it was not written for.

Deploying a New Version Safely

The predefined configurations

From the documentation (CodeDeploy, 2026-10-13): for Lambda there are four canary configurations (10 percent first, the rest after 5, 10, 15 or 30 minutes), four linear ones (10 percent every 1, 2, 3 or 10 minutes) and all-at-once. For ECS there are two linear ones (10 percent every 1 or 3 minutes), two canary ones (10 percent, rest after 5 or 15 minutes) and all-at-once; with a Network Load Balancer only all-at-once is supported. For EC2 and on-premises instances the predefined choices are all-at-once, half-at-a-time and one-at-a-time (the default), which count healthy hosts rather than traffic. You can define your own canary or linear configuration for Lambda and ECS, except that with CloudFormation-managed ECS blue/green you cannot.

What SAM generates

Run here: a SAM template with one function that has AutoPublishAlias: live and a DeploymentPreference (canary, an alarm and a pre-traffic hook), the hook function, and the alarm. SAM's page says this creates a CodeDeploy application and deployment group by itself, and that the first gradual deployment should be done in two steps (publish the alias first, add the preference second) because CodeDeploy needs a previous version to shift from. The translator, run offline, turned the three declared resources into 10:

Properties: Handler: app.handler CodeUri: s3://example-bucket/code.zip AutoPublishAlias: live DeploymentPreference: Type: Canary10Percent5Minutes Alarms: - !Ref AliasErrorsAlarm Hooks: PreTraffic: !Ref PreTrafficFn
py transform.py sam/template.yaml
sam/template.yaml: 10 resources, 7 types 1 AWS::CloudWatch::Alarm 1 AWS::CodeDeploy::Application 1 AWS::CodeDeploy::DeploymentGroup 3 AWS::IAM::Role 1 AWS::Lambda::Alias 2 AWS::Lambda::Function 1 AWS::Lambda::Version
{ "AppFnAliaslive": { "Type": "AWS::Lambda::Alias", "UpdatePolicy": { "CodeDeployLambdaAliasUpdate": { "ApplicationName": { "Ref": "ServerlessDeploymentApplication" }, "DeploymentGroupName": { "Ref": "AppFnDeploymentGroup" }, "BeforeAllowTrafficHook": { "Ref": "PreTrafficFn" } } }, "Properties": { "Name": "live", "FunctionName": { "Ref": "AppFn" }, "FunctionVersion": { "Fn::GetAtt": [ "AppFnVersionc17514d5e0", "Version" ] } } }, "AppFnDeploymentGroup": { "Type": "AWS::CodeDeploy::DeploymentGroup", "Properties": { "AlarmConfiguration": { "Enabled": true, "Alarms": [ { "Name": { "Ref": "AliasErrorsAlarm" } } ] }, "ApplicationName": { "Ref": "ServerlessDeploymentApplication" }, "AutoRollbackConfiguration": { "Enabled": true, "Events": [ "DEPLOYMENT_FAILURE", "DEPLOYMENT_STOP_ON_ALARM", "DEPLOYMENT_STOP_ON_REQUEST" ] }, "DeploymentConfigName": { "Fn::Sub": [ "CodeDeployDefault.Lambda${ConfigName}", { "ConfigName": "Canary10Percent5Minutes" } ] }, "DeploymentStyle": { "DeploymentType": "BLUE_GREEN", "DeploymentOption": "WITH_TRAFFIC_CONTROL" }, "ServiceRoleArn": { "Fn::GetAtt": [ "CodeDeployServiceRole", "Arn" ] } } } }

The alias carries an UpdatePolicy that points at the deployment group; the group names the configuration (CodeDeployDefault.Lambda followed by Canary10Percent5Minutes), turns on rollback for failure, stop-on-alarm and stop-on-request, and is a blue/green deployment with traffic control. I changed one thing at a time (16 mistakes and two valid changes) and ran the SAM translator and cfn-lint:

py mutate_sam11.py
change translator cfn-lint (control: unchanged template) 0 0 Type Canary10Percent5Minutes -> Canary10Percent7Minutes 0 0 Type -> Linear10PercentEvery1Minute (valid, slower) 0 0 Type -> AllAtOnce (valid, no gradual shift) 0 0 remove AutoPublishAlias 1 0 [InvalidResourceException('AppFn', "'DeploymentPreference' r Alarms entry names a resource that does not exist 0 0 PreTraffic hook names a resource that does not exist 0 1 E1020,E3030,E3031 hook function name lacks the CodeDeployHook_ prefix 0 0 hook role lacks PutLifecycleEventHookExecutionStatus 0 0 alarm watches the function, not the alias (Dimension removed) 0 0 alarm Threshold 0 -> text 0 1 E3012 alarm ComparisonOperator typo 0 1 E3030 alarm Period 60 -> 45 (not a valid period) 0 1 E3615 Alarms list empty 0 0 DeploymentPreference on the hook function too (hook shifts traffic itself) 0 0 Runtime python3.13 -> python3.99 0 0 Timeout 5 -> 901 0 0 alarm EvaluationPeriods 1 -> 0 0 1 E3034 alarm TreatMissingData breaching (alarm fires on idle alias) 0 0 16 mistakes: translator 1, cfn-lint 5, both 0, neither 10; false alarms on 2 valid changes: 0

The translator caught 1 of 16 (a deployment preference without AutoPublishAlias), cfn-lint 5, both 0, and 10 got past both. The two tools did catch alarm-property and reference problems, but not Canary10Percent7Minutes, which is not in the documented list of four canary options (I did not see what CodeDeploy does with a name that does not exist, so that miss is judged against the docs' list). They also did not notice a missing alarm resource, a hook function without the CodeDeployHook_ prefix that the SAM-created role policy expects (my understanding, not read this session), a hook role without the permission to report back, an alarm that watches the function instead of the alias, or an alarm with TreatMissingData: breaching. In my test, cfn-lint 1.57.2 also passed Runtime: python3.99 and a 901-second timeout in this template; chapter 8 saw the same weak result with 1.57.2 and a stronger one with 1.53.3, and I did not investigate why. I reinstalled the translator at the version pinned for chapter 8 to rule out a version mismatch before accepting that result.

Arithmetic: how much goes wrong before the stop?

The pages give the configurations; they do not say how bad a bad release is under each. Run here: a small model. Its assumptions, which are mine and not on the pages: a linear configuration sends its first 10 percent at minute 0 and finishes at nine times its interval; requests are spread evenly; the bug fails every request on the new version; and the alarm stops the deployment a fixed number of minutes after traffic first reaches the new version. The first block checks the model against the wording of the pages:

py deploy_tests.py # schedules and page checks
== schedules (my assumption: linear starts at minute 0 and ends at 9 x interval) == LambdaCanary10Percent5Minutes steps 2 full after 5 min 0m:10% 5m:100% LambdaCanary10Percent10Minutes steps 2 full after 10 min 0m:10% 10m:100% LambdaCanary10Percent15Minutes steps 2 full after 15 min 0m:10% 15m:100% LambdaCanary10Percent30Minutes steps 2 full after 30 min 0m:10% 30m:100% LambdaLinear10PercentEvery1Minute steps 10 full after 9 min 0m:10% 1m:20% 2m:30% 3m:40% ... LambdaLinear10PercentEvery2Minutes steps 10 full after 18 min 0m:10% 2m:20% 4m:30% 6m:40% ... LambdaLinear10PercentEvery3Minutes steps 10 full after 27 min 0m:10% 3m:20% 6m:30% 9m:40% ... LambdaLinear10PercentEvery10Minutes steps 10 full after 90 min 0m:10% 10m:20% 20m:30% 30m:40% ... LambdaAllAtOnce steps 1 full after 0 min 0m:100% ECSLinear10PercentEvery1Minutes steps 10 full after 9 min 0m:10% 1m:20% 2m:30% 3m:40% ... ECSLinear10PercentEvery3Minutes steps 10 full after 27 min 0m:10% 3m:20% 6m:30% 9m:40% ... ECSCanary10Percent5Minutes steps 2 full after 5 min 0m:10% 5m:100% ECSCanary10Percent15Minutes steps 2 full after 15 min 0m:10% 15m:100% ECSAllAtOnce steps 1 full after 0 min 0m:100% == checks against the page's wording == ok Canary10Percent5Minutes: 10% first, the rest 5 minutes later ok Linear10PercentEvery3Minutes: 10 equal steps of 10% ok AllAtOnce: one step of 100% ok ECS rolling 50% / desired 4: scheduler can stop 2 ok ECS rolling 75% / desired 2: cannot stop any (minimum healthy rounds up to 2) ok ECS rolling max 200% / desired 4: can start 4 ok ECS rolling max 125% / desired 3: cannot start any (limit rounds down to 3)

Then the failed requests at 100 requests a second, for an alarm that needs 2 minutes and then for one that needs 10:

py deploy_tests.py # failed requests before the stop
== failed requests before a stop (100 rps, a bug that fails every request on the new version, alarm needs 2 minutes) == LambdaAllAtOnce 12,000 failed requests of 12,000 in the window LambdaCanary10Percent5Minutes 1,200 failed requests of 12,000 in the window LambdaCanary10Percent30Minutes 1,200 failed requests of 12,000 in the window LambdaLinear10PercentEvery1Minute 1,800 failed requests of 12,000 in the window LambdaLinear10PercentEvery10Minutes 1,200 failed requests of 12,000 in the window same, but the alarm needs 10 minutes (a slow metric): LambdaAllAtOnce 60,000 failed requests of 60,000 in the window LambdaCanary10Percent5Minutes 33,000 failed requests of 60,000 in the window LambdaCanary10Percent30Minutes 6,000 failed requests of 60,000 in the window LambdaLinear10PercentEvery1Minute 33,000 failed requests of 60,000 in the window LambdaLinear10PercentEvery10Minutes 6,000 failed requests of 60,000 in the window

The rows teach two things. With a quick alarm every gradual configuration holds the damage to 1,200 to 1,800 requests against 12,000 for all-at-once, whatever its length. With a slow alarm a short canary does not help: the 5-minute canary ends before the 10-minute alarm has decided, the remaining 90 percent arrives, and 33,000 requests fail, as many as the 1-minute linear. A 30-minute canary or a 10-minute linear step keeps it to 6,000. The window of the check has to be longer than the alarm's time to detect. And a canary only protects against what its alarm can see:

py deploy_tests.py # can a canary see the bug at all?
== can a canary see the bug at all? (bug fails 1 request in 1,000 on the new version) == expected failures on the new version during the first canary step (10% of traffic): 1 rps, 5-minute canary: expect 0.03 failures, chance of seeing none = 97.0% 1 rps, 15-minute canary: expect 0.09 failures, chance of seeing none = 91.4% 10 rps, 5-minute canary: expect 0.30 failures, chance of seeing none = 74.1% 10 rps, 15-minute canary: expect 0.90 failures, chance of seeing none = 40.7% 100 rps, 5-minute canary: expect 3.00 failures, chance of seeing none = 5.0% 100 rps, 15-minute canary: expect 9.00 failures, chance of seeing none = 0.0% (Poisson arithmetic; the real alarm's threshold and evaluation period decide what counts as 'seen'.)

A bug that fails one request in a thousand, at 1 request a second, is invisible in a 5-minute canary 97 percent of the time (Poisson arithmetic, for a simple count of failures; the real alarm's threshold and evaluation periods decide what counts as seen). At 100 requests a second the same canary would almost always see it. Low-traffic services need a synthetic test (the pre-traffic hook) more than they need a long canary. A last check that the weighted split is a split: 13 percent of 100 requests went to the new version, 10.4 percent of 1,000 and 10.0 percent of 100,000 (random draws with a fixed seed, which says nothing about how Lambda chooses).

ECS rolling updates

From the documentation (ECS rolling update page): minimumHealthyPercent is "rounded up" and maximumPercent "rounded down"; the page's four examples (50 percent of 4 tasks lets the scheduler stop 2; 75 percent of 2 tasks lets it stop none; 200 percent of 4 lets it start 4; 125 percent of 3 lets it start none) are in the checks above. It also says to make sure the scheduler can stop or start at least one task, or the deployment is stuck. The circuit breaker and CloudWatch-alarm failure detection are named on the page and link to pages I did not read; the page says both can roll back and that when both are set, "the deployment is set to failed as soon as the failure criteria for either" is met. Blue/green and the newer linear and canary options for ECS were not read beyond the CodeDeploy table. My table of waves uses a simple model of the scheduler (start up to the maximum, stop down to the minimum), not the real one:

py deploy_tests.py # rolling update table
== ECS rolling update: waves and stuck settings (my simple model of the scheduler, not the real one) == desired min% max% min healthy max total can stop can start waves 4 100 200 4 8 0 4 1 4 50 100 2 4 2 0 2 4 100 100 4 4 0 0 STUCK (cannot stop or start) 4 0 100 0 4 4 0 1 2 75 200 2 4 0 2 1 3 100 125 3 3 0 0 STUCK (cannot stop or start) 1 100 200 1 2 0 1 1 1 0 100 0 1 1 0 1 10 90 110 9 11 1 1 5 10 100 110 10 11 0 1 10

A setting of 100 and 100 percent is stuck for any task count, as is 100 and 125 percent with 3 tasks; with one task, 100 and 200 percent gives a start-first rollout (a second task is started before the first is stopped), while 0 and 100 stops the only task first, which means an outage for as long as the new task takes to become healthy. This is the arithmetic of the page's rule, not an observation of ECS.

Day-2 Operations

Systems Manager

From the documentation (Session Manager prerequisites): managed nodes need SSM Agent 2.3.68.0 or later; KMS encryption of session data needs 2.3.539.0; shell profiles 3.0.161.0; port forwarding and SSH sessions 3.0.222.0; streaming session data to CloudWatch Logs 3.0.284.0. The node must reach ec2messages, ssm and ssmmessages endpoints for its Region over HTTPS on port 443 (outbound), or through interface endpoints (AWS Fundamentals 5 covers VPC endpoints). Using the AWS CLI to start a session needs the Session Manager plugin and CLI 1.16.12 or later. The managed policy AmazonSSMManagedInstanceCore provides the permissions for a node. The page text I read did not discuss inbound ports. Session Manager and Patch Manager need real instances with the agent, so I could not run them here, and I did not read the Patch Manager pages.

Run here: Parameter Store through moto, against the limits read for chapter 3 (standard 4 KB, advanced 8 KB), then CloudTrail records (below):

py ops_checks.py # Parameter Store
== (a) moto Parameter Store vs the documented size limits == ok standard parameter, 4096 bytes docs say accept moto accepted DIFF standard parameter, 4097 bytes docs say reject moto accepted ok advanced parameter, 8192 bytes docs say accept moto accepted DIFF advanced parameter, 8193 bytes docs say reject moto accepted DIFF standard tier, 4097 bytes, Tier given explicitly docs say reject moto accepted ok Type 'Secret' (not String/StringList/SecureString) docs say reject moto rejected: ValidationException observed: SecureString with decryption -> 'hunter2'; without -> 'kms:alias/aws/ssm:hunter2' observed: second put without Overwrite -> ParameterAlreadyExists (my understanding is that the service refuses this; not read) scored cases: 6; moto agrees with the page on 3

moto agreed with the page on 3 of 6. It accepted a 4,097-byte standard parameter, an 8,193-byte advanced parameter and a 4,097-byte value with Tier given explicitly as Standard, and it rejected a type that does not exist. Two things I observed about its SecureString: reading it without decryption returned kms:alias/aws/ssm:hunter2, so the "encrypted" value is the plain text with a prefix, and a second put without Overwrite was refused with ParameterAlreadyExists (that last behaviour I believe matches the service, but I did not read a page for it). The emulator is fine for testing that your code calls the API; it cannot tell you that a value is too large.

CloudTrail

From the documentation: event history is "a viewable, searchable, downloadable, and immutable record of the past 90 days of CloudTrail management events in an AWS Region"; a trail delivers events to an S3 bucket, optionally to CloudWatch Logs and EventBridge; by default trails log management events but not data events or Insights events; and "CloudTrail log files aren't an ordered stack trace of the public API calls, so events don't appear in any specific order". The record-contents page lists the fields. The ones marked as always present (Optional: False) are eventTime, eventVersion, userIdentity, eventSource, eventName, awsRegion, sourceIPAddress, requestParameters, responseElements, eventID, eventType and eventCategory. The current eventVersion is 1.11; the page tells you to compare the major version for equality and the minor version with greater-or-equal. responseElements is null for read-only APIs, and some services put error details in responseElements rather than in errorCode and errorMessage. The page did not say how long delivery to a trail takes, and I did not read the pricing page.

Run here: a checker for those rules, run on ten synthetic records that I wrote (not real CloudTrail output), including the denied AssumeRoleWithWebIdentity event that a wrong trust policy would produce:

py ops_checks.py # CloudTrail records
== (b) CloudTrail record checker (fields marked 'Optional: False' on the record-contents page) == complete record ok oidc assume, success ok oidc assume, denied ok no eventID missing eventID no awsRegion and no sourceIPAddress missing awsRegion; missing sourceIPAddress eventVersion 1.08 minor 8 < 11: newer fields (eventContext) cannot be assumed eventVersion 2.0 major version 2 differs from 1: do not parse errorCode without errorMessage errorCode without errorMessage (page: some services put error info in responseElements) readOnly but responseElements present readOnly event with responseElements (page: null for readOnly APIs) no eventCategory (older record) missing eventCategory == (c) questions answered from the synthetic records == events by name (records that passed the check): {'PutObject': 1, 'AssumeRoleWithWebIdentity': 2} denied assume-role events: [('AssumeRoleWithWebIdentity', 'AccessDenied')]

Use this as the shape of a real audit query, not as a test of CloudTrail: the question "who assumed the deploy role, from which repository?" is answered from the requestParameters and the token claims that appear in the event, and I did not read the page that lists the claims CloudTrail records for a web identity, so I wrote none.

Config, GuardDuty and Security Hub, in a paragraph each

AWS Config (from its overview page) "provides a detailed view of the configuration of AWS resources in your AWS account", including how they are related and how they were configured in the past; it needs an S3 bucket and an IAM role (and an SNS topic if you want notifications) and lets you choose which resource types to record. Config rules evaluate the recorded configuration and flag a resource as noncompliant; conformance packs deploy collections of rules; aggregators collect data from several accounts and Regions. Where CloudTrail answers "who called what", Config answers "what did this resource look like then, and does it comply now" (my summary of the two pages). Amazon GuardDuty "continuously monitors, analyzes, and processes AWS data sources and logs": foundational sources are CloudTrail management events, VPC flow logs and DNS logs; optional protection plans cover S3, EKS, Runtime Monitoring, RDS, Lambda, malware and others. A first-time enable includes a 30-day free trial and turns on all protection plans except Runtime Monitoring. AWS Security Hub CSPM aggregates findings (in the AWS Security Finding Format) from GuardDuty, Inspector, Macie and partners and runs checks against standards (AWS Foundational Security Best Practices, CIS, PCI DSS, NIST); most of its controls use AWS Config rules, so Config must be enabled and recording; it only sees findings generated after it is enabled, and only in the Region where it is enabled. I did not read pricing for any of the three, and none of them can be run without an account.

What a Real Run Involves (not run)

Level 2: needs an account and a repository. In outline: create the IAM OIDC provider (the AWS CLI command on the IAM page is aws iam create-open-id-connect-provider), create the role with a trust policy like the one in the template, set the repository's workflow to assume it, and look at the CloudTrail event for the assumption. I give no further commands because I did not read the CLI pages for them. Set a budget alert first (AWS Fundamentals 9), keep every test small, and delete the stack afterwards; the provider and role are free to hold, the pipeline and build minutes are not.

The one cost sketch I can make from the pages is for 10 builds a day on 22 working days at 3 minutes each:

py cost11.py
220 builds a month x 3 minutes = 660 build minutes CodeBuild general1.small: (660 - 100 free) x $0.005 = $2.80 (free-tier minutes apply to new and existing accounts? not read: I assume every month) CodePipeline V1: $1.00 for one active pipeline (the first one is free: $0.00) CodePipeline V2: (660 build-action minutes - 100 free) x $0.002 = $1.12 (counts only the build action; Source and Approval not counted: unread for Source) total, V2 pipeline + CodeBuild, assumptions above: $3.92 a month sensitivity: builds of 6 minutes instead of 3 CodeBuild $6.10, V2 $2.44, total $8.54 not priced: GitHub Actions minutes, CloudTrail, GuardDuty, Config, Security Hub, Session Manager, Patch Manager: pages not read this session

Cost and Clean-Up

Nothing in this chapter created anything in AWS. On this machine: the scratch folder C:/lwaws/c11 (a Python environment, files and saved outputs). The moto emulator ran in-process, so no server was left running. Docker was not used.

What was and was not checked
Run here: a workflow file against three checkers and 26 mistakes; a trust-policy model (8 policies by 10 tokens, the new subject format, a %3A value, one unmodelled operator); a workflow-to-policy cross-check; a pipeline template against cfn-lint and a rule checker on 26 mistakes; a SAM template translated and checked on 16 mistakes; deployment arithmetic (14 configurations, 7 checks against the pages, failed-request and canary-visibility tables, an ECS rolling-update table); Parameter Store in moto (6 scored cases); a CloudTrail record checker on 10 synthetic records; a cost sketch. From the documentation, not run: everything the real services do, including how CodeDeploy, ECS, STS and CloudTrail behave. Not read: GitHub Actions pricing and environment protection rules, fork and pull_request_target behaviour, the CodeBuild buildspec reference, CodePipeline's execution and stage rules, CodeDeploy's AppSpec reference and hooks, the ECS circuit breaker, early-success and blue/green pages, Lambda alias routing, Patch Manager, Parameter Store naming and SecureString pages, CloudTrail pricing and delivery times, AWS Config, GuardDuty and Security Hub pricing, the claims CloudTrail records for web identities. Not checked at all: any real token exchange, build, deployment, session, alarm or finding.

Practice

Exercise 1

Write a GitHub Actions deploy workflow that assumes an AWS role with OIDC and no stored keys. Check it with actionlint and the workflow schema, then make 26 one-change mistakes and write a checker for the OIDC-specific rules that those tools do not cover (write the rules before you run it, and say which you fixed afterwards). Model the AWS trust policy for GitHub's tokens, test eight policies against ten tokens, including the new subject format, and cross-check the workflow's environment against the policy.

๐Ÿ“„ View solution
Exercise 2

Model CodeDeploy's predefined Lambda and ECS traffic-shifting schedules and ECS rolling-update percentages from the documentation, test them on the pages' own examples, then measure how many requests fail before a stop for different configurations and alarm delays and how likely a canary is to see a rare bug. Write a SAM template with a canary deployment preference, translate it offline, and mutate it 16 ways against the translator and cfn-lint.

๐Ÿ“„ View solution
Exercise 3

Write a CloudFormation template for the delivery path (OIDC provider, deploy role, CodeBuild, a V2 CodePipeline), lint it, make 26 mutations, and write a checker that evaluates the template's trust policy with your model and checks permissions and wiring (write the rules first, and count a crash as a miss). Check Parameter Store in an emulator against the documented size limits, write a CloudTrail record checker from the documented fields, and sketch a monthly cost from the pricing pages.

๐Ÿ“„ View solution

Chapter 11 Quick Reference

  • GitHub to AWS without keys: the job needs permissions: id-token: write, the role's trust policy must name the provider and restrict :sub (and :aud = sts.amazonaws.com) with StringEquals; an audience-only policy admits every GitHub repository
  • Default sub: environment wins (repo:O/R:environment:E, for every trigger), else :pull_request, else :ref:refs/heads/B or refs/tags/T; repositories created after 2026-07-15 use repo:OWNER@ID/REPO@ID:..., so a policy for the old form silently stops matching
  • Wildcards: repo:acme/shop:* is one repository; repo:acme/shop* and repo:acme/* are wider; never ForAllValues: in an Allow
  • Linters check syntax and some inputs, not your intent: actionlint caught 14 of 26 workflow mistakes, cfn-lint 8 of 26 pipeline-template mistakes and 5 of 16 SAM-template mistakes; a rule checker needs rules written before you see the results
  • Lambda: AutoPublishAlias plus DeploymentPreference; the first gradual deployment needs an earlier version; configurations are Canary10Percent{5,10,15,30}Minutes, Linear10PercentEvery{1,2,3,10}Minute(s), AllAtOnce; ECS has linear 1 and 3 minutes, canary 5 and 15 minutes
  • A canary shorter than your alarm's detection time protects nothing; a bug that fails 1 in 1,000 at 1 request a second is unseen in a 5-minute canary 97 percent of the time (under my assumptions); low traffic needs a pre-traffic test
  • ECS rolling: minimum healthy rounds up, maximum rounds down, and 100/100 can never move
  • CloudTrail: 90 days of management events in event history; trails go to S3; check eventVersion major equal and minor at least what you need; the always-present fields are on the record-contents page
  • Config records configuration over time and evaluates rules; GuardDuty detects threats from CloudTrail, flow and DNS logs and more; Security Hub CSPM aggregates findings and runs standard checks that need Config
  • moto accepted values over the Parameter Store size limits and stores SecureString as prefixed plain text; use emulators to test your calls, not to find validity errors
Coming next
Chapter 12 is the capstone: a small system assembled from the previous chapters (account structure, IaC, a container service, an API, messaging, edge delivery and a pipeline) and reviewed against the Well-Architected Framework, with one combined course PDF at the end. Start with AWS Intermediate/Advanced 12.