Skip to content

feat: drs-ec2 -- EC2 DR with AWS DRS and ARC Region switch - #76

Open
mplaws wants to merge 44 commits into
mainfrom
drs-ec2
Open

mplaws wants to merge 44 commits into
mainfrom
drs-ec2

Conversation

@mplaws

@mplaws mplaws commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The repository has no sample for the most common enterprise disaster-recovery shape: a stateful Amazon EC2 application that must fail over to a second AWS Region and, later, fail back onto the same instance with the data it wrote while away. Customers assemble AWS Elastic Disaster Recovery (AWS DRS), Amazon Aurora Global Database, Amazon Route 53 and Amazon Application Recovery Controller (ARC) Region switch by hand, and the fail-back half is where they get stuck.

Why it matters

Fail-back is the step DR drills skip. Without a worked example, teams either never rehearse it or discover the undocumented prerequisites (the launch-into target must be stopped, the disk must re-protect before retire) during a real event.

What changed

New drs-ec2/ pattern. One command (make deploy) builds eleven stacks across us-east-2, us-west-2 and us-east-1: a Flask application on EC2 protected by AWS DRS, an Aurora Global Database, Route 53 failover records, an SSM Session Manager bastion, and an ARC Region switch plan whose steps are seven Lambda functions. make rehearse-cycle LEGS=2 fails over and fails back without operator input; in stateful mode a file written on the DR instance rides reverse replication home onto the original EC2 instance id.

The reusable part is lib/constructs/drs-region-switch-steps.ts (CDK) and the equivalent terraform/ module: the seven step functions, their orchestration role, and the ARC plan wiring. Everything else in drs-ec2/ is the sample application and the harness around it (Makefile, scripts/, cleanup.sh, app/, lambda/, docs/, test/, THIRD-PARTY-LICENSES).

The orchestration role grants the 19 DRS actions it needs, not drs:*. Sixteen are derived from the Lambda source by a contract test; three (drs:CreateRecoveryInstanceForDrs, drs:ListTagsForResource, drs:DescribeReplicationConfigurationTemplates) are calls AWS DRS makes under the caller's identity during a launch, proven from CloudTrail and listed explicitly.

Repository-level edits register the pattern: .projenrc.ts (new pattern entry, per-pattern gitignore option, and pattern build workflows now also run on pushes to main so README badges track main), the generated .github/workflows/drs-ec2-build.yml, drs-ec2-e2e.yml and drs-ec2-cleanup.yml, a /drs-ec2 entry in .github/dependabot.yml, the root README.md row, and projen bookkeeping in .gitattributes, .gitignore and .projen/files.json. aurora-build.yml and s3mrap-crr-build.yml lose their branches-ignore: [main] for the badge reason above; the build job has contents: read only and no id-token, so it cannot reach AWS.

flowchart LR
  subgraph L2["Fail-back leg (stateful)"]
    A[drs-reverse-replicate]:::added --> B["drs-failback-launch<br/>lands on the original instance id"]:::added --> C[register-failback-target]:::added --> D[aurora-switchover-back]:::added --> E[dns-flip-back]:::added --> F[drs-reprotect]:::added --> G[drs-retire]:::added
  end
  classDef added fill:#DCFCE7,stroke:#16A34A
Loading

🟩 added · 🟨 changed · 🟥 removed · 🟦 unchanged

Every step is idempotent and re-entrant under ARC's retry loop; each inspects live state, takes at most one action, and returns RetryLater until its target state holds.

Dependencies: new drs-ec2/package.json and package-lock.json (CDK 2.215.0, cdk-nag 2.x, TypeScript 5.9); the EC2 application pins flask==3.1.3, pg8000==1.31.5, boto3==1.42.97, all listed in drs-ec2/THIRD-PARTY-LICENSES.

Tests

  • test/ (Jest, 181 tests across 11 suites, including the role-policy contract below): stack synthesis in both stateful modes; the DRS action contract (code-derived set plus the three service-forwarded actions, no wildcard, Terraform grants the identical set); IMDSv2 required on both instances; the boot script never traces the database secret and the unit file carries no credential; pinned Python versions match THIRD-PARTY-LICENSES; no compiled byte-code tracked.
  • lambda/tests/ (pytest, 35 tests): each step's state machine against stubbed DRS/EC2/RDS responses, including ownership: an unrelated DRS workload in the same account and Regions is never selected, stopped, terminated or deleted by fail-back or retire.
  • test/green-signals.test.ts, test/app-primary-boot-resilience.test.ts, test/workflow-account-id.test.ts: the changed shell fragments run under a fake aws (drs-setup.sh fails when no source server registers or the tag does not land; rehearse-switchover.sh fails on completedWithExceptions and on any failed post-check); the boot script's retry function runs under bash; the published workflow YAML carries no account id.
  • make lint: cdk-nag (AwsSolutions pack) with documented suppressions, ruff, bash -n on every script.
  • terraform fmt -check and terraform validate on the module and examples/plan.
  • test/github-actions-role-policy.test.ts: derives every AWS call the e2e runner makes from the Makefile, cleanup.sh, scripts/*.sh and the DRS service-role helper, and fails when docs/iam/github-actions-role-policy.json does not grant the matching action by name, carries a wildcard, or widens iam:PassRole.

Manual verification

Proven live in the e2e account on 2026-09-18 from an empty account: make deploy (~60 min), stateless rehearse-cycle LEGS=4, stateful rehearse-cycle LEGS=2 with disk-marker evidence (2 marker(s) present: state carried across leg 2 on the original instance id), and a one-pass make clean (33 min, zero residue). Leg timings are in drs-ec2/README.md.

The e2e workflow assumes arn:aws:iam::563688183446:role/github-actions-drs-ec2; docs/iam/ holds its trust and least-privilege permissions policies and the commands that create it. The runner only assumes the CDK bootstrap roles for cdk deploy (the model github-actions-aurora already uses) plus the direct calls the scripts make, and the contract test pins that surface.

Green end to end under that role on 2026-09-25 (e2e run 36065929282, head 3f591b1, 2h40m): pre-flight cleanup of a full prior estate incl. a CONTINUOUS source server 33m42s; Deploy 70m20s (11 stacks, drs-setup incl. its wait for CONTINUOUS); Resting state 12s; rehearse-cycle LEGS=2 21m51s (baseline invariant held; leg 1 us-west-2/a1d709fcdb2fb5b1 12m11s; leg 2 fail-back us-east-2/960150894322586c 9m04s; invariant held again, primary EC2 identity preserved); cleanup 32m35s. Post-run account check: zero drsdemo-* stacks in all three Regions, zero DRS source servers or recovery instances, zero /aws/lambda/drsdemo-* log groups, zero DRS-created security groups. The five earlier attempts each died one step further along, all in plumbing rather than the DR path (retained log groups, DRS instance-profile path, forwarded-access grants for the replication template, tags-on-create for the DRS security group, initial-sync timing, replication-server SG teardown ordering, and an ARN-scoped DescribeTargetHealth grant IAM ignores); each fix carries a red-then-green test.

Green again on the review-fix head on 2026-09-25 (e2e run 36186235669, head d6e3ff1, 2h16m): pre-flight cleanup 1m30s; Deploy 76m57s (instance replaced by the new boot script, drs-setup asserted SSM Online, source-server registration and the tag read-back, then waited 24 min for CONTINUOUS); Resting state 12s; rehearse-cycle LEGS=2 23m09s (baseline invariant held; leg 1 us-west-2/09f89edbc66f3453 13m10s; leg 2 fail-back us-east-2/aa26784fdc1b6c7f 9m25s; invariant held again; primary EC2 i-079e8d8cdce981293 preserved), which is the first live pass of the database-aware /health behind register-target and of the ownership-scoped drs-retire; cleanup 32m34s. Post-run account check at 23:00Z: zero drsdemo-* stacks in all three Regions, zero DRS source servers or recovery instances, zero /aws/lambda/drsdemo-* log groups, zero DRS-created security groups, zero drsdemo instances, global cluster and app-code bucket gone. The seven commits after 3f591b1 are the review fixes: database-aware health check, boot-script retries, fail-loud drs-setup.sh and rehearse-switchover.sh, logged retire cleanup failures, workload-scoped fail-back and retire, the e2e account id moved to the E2E_ACCOUNT_ID repository variable, and README notes (sandbox account only, demo privilege choices, measured timings).

Security review: this sample orchestrates DRS recovery and holds iam:PassRole; the OSPO security review is requested separately and is a merge gate.

Screenshots

N/A: no user-visible UI. Architecture and fail-back diagrams are in drs-ec2/docs/.

no linked issue: new sample pattern, tracked outside GitHub.

…on module, unit tests

One Python package selected by Lambda Handler string (drs_region_switch.<module>.handler).
Extracted from drs-mr-demo (proven live 2026-09-10..14) with:
- region-explicit clients so the same code deploys to both plan regions
- step table (STEPS) as the contract for the CDK construct / Terraform module / docs
- recover: adopts the cycle's own launch job via a source-server tag; stateful never adopts a
  leftover instance; FAILED launches surface the job-log reason
- register-target / register-failback-target: instance located via DRS, never by tag
- failback-launch: launch-into-source aware (stops the target first, no retag of the original)
- reprotect: 'started?' from the recovery instance's own state; RESCAN completion from real fields
- retire: DRS-documented cleanup order; resting-state invariant; clears the job tag
- 28 unit tests with fake clients, one per live finding
…t with assertion tests

.projenrc.ts: register drs-ec2 (aws-cdk-lib >= 2.215 for the typed aws_arcregionswitch L1;
TypeScript pinned ~5.9 because a fresh resolve pulls TS 6, whose tsconfig defaults drop
@types/*), build/e2e/cleanup workflows (e2e = make deploy -> status -> rehearse-cycle LEGS=2).

lib/constructs/step-table.ts: the seven-step contract (mirrors lambda/drs_region_switch/__init__.py).
lib/constructs/drs-region-switch-steps.ts:
  - DrsRegionSwitchSteps (per region): 7 functions from one asset selected by Handler string,
    explicit 1-day log groups, orchestration role with the policy proven live (ViaAWSService
    EC2 set, tag-scoped direct StopInstances for launch-into, PassRole to the app + DRS roles).
  - DrsRegionSwitchPlanSteps (plan side): typed CfnPlan.StepProperty[] per workflow with the
    in-RegionToRun function ARN (deterministic names, no cross-region refs), interleave helper
    for the consumer's Aurora/DNS steps, grantInvoke for the plan's execution role.
test/: 10 assertion tests.
…step table and Lambda package as the CDK construct

- layer built from lambda/drs_region_switch with archive_file source blocks (no local-exec),
  seven stub functions per region selected by handler string, explicit 1-day log groups;
  per-region submodule instantiated once per provider alias (aws.primary / aws.secondary)
- orchestration role with the proven policy, statement-for-statement with the construct
- outputs: function_arns by region, layer_arns + handlers (layer-only path), plan_role_policy_json,
  activate_secondary_steps / activate_primary_steps as ordered objects for dynamic "step",
  activate_primary_split
- examples/plan: complete plan with Aurora Global + Route 53 blocks interleaved at the split
- terraform fmt clean; validate passes against hashicorp/aws 6.64
…+ scripts

bin/app.ts: context-driven wiring (repo idiom: Makefile reads outputs, passes -c key=value; -c stack= selector).
lib/: network (x2), iam (app role only), database-primary/-secondary, app-primary (t2.small/BIOS, AWSDRS tag,
internal ALB, 3-VPC private zone), alb-secondary, drs-steps (construct per region), plan (typed CfnPlan with
Aurora + Route 53 + construct steps interleaved), observer. 11 stacks synth with placeholder context.
Makefile: one target per deploy step (make expands $(shell) per recipe, so outputs must be read in a later
target than the deploy that produces them); accept-peering via CLI; final pass binds ARC health checks.
cleanup.sh: the proven parallel-wave teardown with the new stack names. scripts/: drs-setup, rehearse-*,
tunnel from the demo (project/regions from env), plus app-code.sh (upload + in-place SSM refresh) and status.sh.
README follows the repo's section order (architecture, what's deployed, prerequisites, deployment,
testing, findings, cleanup, cdk-nag suppressions, license). Diagrams + generator scripts carried over
and re-rendered under the pattern name; all drs-mr-demo self-references renamed; GitLab mention dropped.
pg8000.dbapi: connect(database=, timeout=), paramstyle 'format' so the %s SQL is unchanged; its cursor
is not a context manager, so the request handler closes cursor and connection in a finally.
…rong account

create-drs-service-roles.py used boto3's default credential chain, not the profile the Makefile hands
to drs-setup.sh. In the first live run it therefore inspected another account, printed 'exists role'
for all six roles, and initialize-service then failed in the target account with 'Failed to attach
the following IAM roles to their instance profiles' (found live 2026-09-16; the demo never hit it
because AWS_PROFILE was always exported there). The script now takes [aws-profile], builds its
session from it, and exits non-zero when the resolved account differs from the requested one.
…DB password

The app instance writes the password into a systemd unit (Environment=DB_PASSWORD=<value>) from a
shell heredoc. % is a systemd specifier, ' and \ are systemd quoting, $ and ` are shell expansions;
the original exclude set ('"@/\\ ') allowed all of them, so a redeploy whose password drew a %
produced a corrupted password in the process environment and a DB login failure (seen live in the
original demo's 2026-09-15 redeploy, diagnosed 2026-09-16). Exclude them at generation time.
…re stale source servers on instance replacement

On AL2023 the AWS CLI is a system Python package whose python-dateutil RPM (2.8.1) is older than
pg8000's requirement; 'pip3 install flask pg8000 boto3' upgraded it out from under the CLI and the
next 'aws ssm get-parameter' in UserData died under set -e with "No module named 'dateutil'" -- the
systemd unit was never written and the ALB answered 502 (first live run, 2026-09-16). The app's
dependencies now live in /opt/app/venv and the unit runs the venv interpreter; the system CLI is
untouched. psycopg2-binary never pulled dateutil forward, which is why the original never hit this.

The UserData change replaces the instance, which strands the previous instance's DRS source server
still tagged <project>:role=app -- and the plan's Lambdas select by that tag and must never see two.
drs-setup.sh now disconnects and deletes any forward-replication server whose instance is not the
current app instance before (re)installing the agent.
A UserData change on AWS::EC2::Instance is an in-place stop/start and cloud-init runs UserData only on
first boot, so the venv fix (6835af3) never executed on the existing instance -- same id, still 502
(2026-09-16 14:50Z). Hash the boot script into the instance's logical id so CloudFormation replaces
it, as the L2 Instance's userDataCausesReplacement does; drs-setup.sh already retires the old
instance's source server.
Drop the `on.push.branches-ignore: [main]` override from the per-pattern
build workflow generator. A workflow badge without ?branch= reports the
latest run on the default branch, and with pushes to main excluded the
only run the badge could ever see was a manual dispatch -- the aurora
badge has shown a failed 2026-03-23 dispatch for six months while every
branch build was green.

Build now also fires on a push to main that touches <pattern>/**, i.e.
once per merged change to that pattern. The job has contents:read only
and no id-token, so it cannot reach AWS. The e2e workflows are untouched:
they trigger on pull_request only and still never run on merge.
…nctions

The harness decided whether to run the disk-marker round trip by reading a
CloudFormation parameter named StatefulEc2 from the drs-steps-secondary stack.
That parameter belonged to the original template-based deployment; the CDK
port passes the flag as `-c statefulEc2=true` context and the construct bakes
it into every step function's environment as STATEFUL_EC2. The describe-stacks
query therefore returned an empty string, the log header read `stateful=`
(blank), and a fully stateful plan execution ran with the stateless checks
only -- no marker written on the serving instance, no marker check after
fail-back. Observed live on the 2026-09-18 LEGS=2 run in the e2e account.

Read STATEFUL_EC2 from the deployed drs-reverse-replicate function instead,
which is what the plan actually runs with, and fail loudly if the value is
anything other than true/false so the harness can never silently downgrade
to the stateless assertions again.
…t file

The app instance's boot script ran under `set -euxo pipefail`, so the
Secrets Manager response and the extracted password were echoed into
/var/log/cloud-init-output.log and the EC2 console output (readable with
ec2:GetConsoleOutput). It then wrote `Environment=DB_PASSWORD=...` into
/etc/systemd/system/drsapp.service, a world-readable (0644) file.

Now xtrace is switched off while the secret is in flight, and the
credentials are piped straight into /etc/drsapp.env created under
umask 077, which the unit loads via EnvironmentFile=. The unit itself
carries only non-secret settings. app.py is unchanged: it still reads
DB_USER/DB_PASSWORD from its environment.

Fetching the secret at application start was considered and rejected:
the secret lives only in the primary region, so a recovered instance in
the secondary region would depend on the impaired region to boot. The
on-disk env file rides the DRS replica exactly as the unit file did.

Pinned by test/app-primary-secret-handling.test.ts (2 tests, both red
against the previous script).
23 *.pyc files under app/__pycache__/ and lambda/**/__pycache__/ were
committed because the projen-generated .gitignore had no Python rules.
Byte-code is machine-specific build output and has no place in a
sample repository.

Adds an optional per-pattern `gitignore` list to the projen Pattern
interface, sets `__pycache__/` and `*.pyc` for drs-ec2 (the only
pattern with Python code), regenerates drs-ec2/.gitignore, and removes
the tracked files from the index. `git status` now shows the
scripts/__pycache__ directory as ignored rather than untracked.
`pip install flask pg8000 boto3` resolved to whatever PyPI served on
the day, so two deploys of the same commit could run different code and
a compromised or broken release would be picked up silently.

Pins: flask==3.1.3, pg8000==1.31.5, boto3==1.42.97. AL2023's default
python3 is 3.9 and boto3 1.43+ requires 3.10, so 1.42.97 is the newest
release the venv can take; the comment in the boot script says so for
the next bump. THIRD-PARTY-LICENSES gains a Version column with the
same numbers.

Pinned by test/app-primary-dependency-pins.test.ts: every pip install
in the boot script must be exact-version, and each pinned version must
appear in THIRD-PARTY-LICENSES (red against the unpinned script).
Neither AWS::EC2::Instance set MetadataOptions, so both accepted IMDSv1
requests -- the SSRF-to-credentials path IMDSv2's session tokens close.
Both now carry HttpTokens=required with the endpoint enabled. Every
consumer on the boxes already speaks IMDSv2: app.py fetches a token
before reading the region and instance id, the AWS CLI in the boot
script, cloud-init and the SSM Agent do so natively, and Elastic
Disaster Recovery supports IMDSv2-only instances (the FAQ documents it
for recovery instances via the launch template).

Pinned by test/ec2-imdsv2.test.ts: exactly two instances in the
pattern, each requiring tokens (red against the previous stacks).
… drs:*

The construct (and the Terraform module mirroring it) granted `drs:*`
on `*`, flagged by cfn_nag (F4) and checkov (CKV_AWS_111). The seven
step Lambdas call exactly these APIs:

  DescribeSourceServers, DescribeRecoveryInstances, DescribeJobs,
  DescribeJobLogItems, GetLaunchConfiguration, UpdateLaunchConfiguration,
  StartRecovery, ReverseReplication, StopFailback, StopReplication,
  TerminateRecoveryInstances, DisconnectSourceServer, DeleteSourceServer,
  DeleteRecoveryInstance, TagResource, UntagResource

Resource stays `*`: source servers, recovery instances and jobs are
created by DRS at run time and have no predictable ARN.

test/drs-actions-contract.test.ts derives the list from the Lambda
source (drs* client calls plus paginator names) and asserts the policy
grants each by name with no wildcard, grants nothing the code does not
call, and that terraform/iam.tf carries the identical set -- so a new
boto3 call without a grant fails at `npx projen test`, not as an
AccessDenied halfway through a live fail-over. Red against drs:*.
1,854 -> 1,082 words. Structured in the order the Open Source Program
Office expects for a sample README: architecture, reuse, resources
deployed, prerequisites, deploy, test and rehearse, DRS requirements,
cleanup, security, limits, license.

Removed: the project-history sections (Findings, "proven in", "measured
on", "behaviours worth knowing", cycle-safety comparison) and the
narrative failover story. Added: a Security section that points to the
repository's security-issue process and states the demo's deviations
in plain terms, and a cost notice.

Preserved and re-verified against the previous text: every command
(13 code lines), every timing figure, every stack row, region and
CIDR, the cdk-nag exception list, and the DRS operational requirements.
The DRS permissions bullet now names the enumerated action list instead
of drs:*. Service names are spelled out on first use.
terraform/README.md: full service names on first use, imperative
instructions, no em-dash asides or rhetorical headings ("Layer-only
use" replaces the question), one action per table cell. Module inputs,
resource counts, step table, prerequisites and version floors are
unchanged and re-checked against versions.tf and examples/plan.

Root README: the drs-ec2 row spells out Amazon EC2, AWS Elastic
Disaster Recovery (AWS DRS), Amazon Application Recovery Controller
(ARC) Region switch, Amazon Aurora Global Database and Amazon Route 53,
and drops "cycle-safe" and "incl.".
…omers

Makefile: header names the services in full and drops the "protected,
dashboarded, failover-ready" claim; target descriptions say what each
target does with its duration ("the proven order" -> "deployment
order"; "Everything" -> the stacks, app code, DRS setup and check it
runs; "Tear down everything" -> what is deleted, where, how long).

Scripts: usage lines now match the arguments each script accepts
([aws-profile|-], optional legs/mode) -- cleanup.sh no longer calls
itself teardown.sh, drs-setup.sh loses the systemd-run wrapper note,
rehearse-switchover.sh and rehearse-cycle.sh describe the rehearsal and
its verification conditions instead of "proving" anything, tunnel.sh
keeps the plugin requirement and drops the laptop/ssh aside.

Diagrams: titles and captions spell out Amazon EC2, AWS Elastic
Disaster Recovery (DRS), Amazon Aurora Global Database, Amazon Route 53
and Amazon Application Recovery Controller (ARC) Region switch on first
use; "presenter" becomes "user"; the CLI-flag chatter leaves the ARC
node. Both PNGs regenerated from the edited generators (diagrams 0.x on
Graphviz) and checked visually; the mermaid sources carry the same
wording.
The suppression text still said drs:* and pointed at 'design.md s8', a
planning document that is not part of this repository.
The enumerated grant in e65e362 was derived from the Lambda source, so it
could not see the calls DRS makes under the caller's identity (forwarded
access session) while it services StartRecovery and StartFailbackLaunch.
CloudTrail for the 2026-09-18 rehearsals (run under drs:*) records three
such calls against the orchestration role, invokedBy drs.amazonaws.com:

  drs:CreateRecoveryInstanceForDrs           recover-ec2, failback-launch
  drs:ListTagsForResource                    recover-ec2, failback-launch
  drs:DescribeReplicationConfigurationTemplates  reverse-replicate, reprotect

Without them the recovery launch is denied after conversion. Added to the
CDK construct and the Terraform module. The contract test now keeps two
sets: the code-derived one and a closed SERVICE_FORWARDED list; every grant
must belong to one of them, forwarded actions must not be called by the
code, and a wildcard still fails. Proven red against e65e362's grant
(fails on drs:CreateRecoveryInstanceForDrs) and green with this change.
README count 16 -> 19 with the forwarded-call note for customers writing
their own policy.
@mplaws

mplaws commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Intent: Add drs-ec2/, a customer-deployable sample that fails an EC2 application over to a second Region with AWS DRS under an ARC Region switch plan and fails it back onto the same instance, with the plan-step construct reusable from CDK or Terraform.
Not a goal: Multi-server or multi-tier recovery, production-grade hardening beyond the documented demo choices (HTTP-only internal ALB, AWS-managed keys, Lambdas outside a VPC), or changes to the aurora and s3mrap-crr patterns beyond their build workflow now also running on main.

@mplaws
mplaws requested a review from a team as a code owner September 23, 2026 13:46
…ipts make

The drs-ec2 e2e assumes arn:aws:iam::<account>:role/github-actions-drs-ec2,
which nothing in the repository described; PR #76's first e2e run failed at
credential assumption because the role does not exist yet.

docs/iam/ now carries the role: a GitHub OIDC trust policy in the shape of the
account's existing runner roles, and a least-privilege permissions policy
derived from what the runner itself executes. cdk deploy runs without
--role-arn, so the runner only assumes the CDK bootstrap roles in the three
Regions (the model github-actions-aurora already uses live) and CloudFormation
creates the stacks' resources under the bootstrap execution role. The rest is
the direct surface of the Makefile, cleanup.sh, scripts/*.sh and the DRS
service-role helper: 53 CLI calls across STS, CloudFormation, S3, EC2, IAM,
SSM, DRS, ARC Region switch, RDS, ELBv2 and Lambda, plus the helper's four IAM
writes, each bounded to the project's names where a name exists. The runner
holds none of the DRS launch or reverse-replication authority; the plan's
Lambda functions do, under their own role.

test/github-actions-role-policy.test.ts derives every call from the scripts
(including the Makefile's $(AWS) form and the helper's boto3 calls, excluding
the SSM payloads that run on the instance) and fails when the policy lacks the
matching action, when it carries a wildcard, when iam:PassRole widens beyond
the app instance role, or when the helper's role/policy table and the policy's
fences drift apart. Proven red by removing one grant.

Known limits are in docs/iam/README.md: the DRS service-linked role on a
first-ever initialization, iam:CreateRole on the six DRS service-role names,
and Region-bounded rather than tag-bounded DRS and security-group grants.
…ed ones in cleanup

The seven step log groups have fixed names (/aws/lambda/drsdemo-*) and CDK's
LogGroup default is RemovalPolicy.RETAIN, so `make clean` left them behind and
the next deploy's drs-steps change set failed early validation with 'already
exists' on all seven (e2e run 35878845232, 2026-09-23). Set RemovalPolicy.DESTROY
(Terraform already destroys them) and have cleanup.sh sweep any
/aws/lambda/<project>-* group that outlived its stack, after the stacks are gone.
Pin: every LogGroup in the construct carries DeletionPolicy/UpdateReplacePolicy
Delete (red on Retain before the change).
…t peering accept

cleanup.sh's new sweep needs logs:DescribeLogGroups and logs:DeleteLogGroup,
the latter fenced to /aws/lambda/drsdemo-* in the two AWS DRS Regions (IAM
simulator: project groups allowed, other names and Regions denied).

The runner's ec2:AcceptVpcPeeringConnection grant listed only the
vpc-peering-connection resource; IAM also evaluates the accepter vpc, so the
call was denied (run 35878845232, 19:17Z). It was also redundant: CloudTrail
shows CloudFormation accepting every same-account drs-ec2 peering itself while
creating the AWS::EC2::VPCPeeringConnection, seconds before the Makefile step,
on every deploy since 2026-09-16. Remove the Makefile accept-peering targets and
the grant instead of widening it; pin both in the contract test.
…nstance profiles

The e2e died at drs-setup: iam:AddRoleToInstanceProfile denied on all four DRS
instance profiles (run 35915704468). DRS creates those profiles itself at path
'/' during initialize-service (CloudTrail 2026-09-16 13:31Z, invokedBy
drs.amazonaws.com), while the helper creates them at '/service-role/' and the
grant named only that form; IAM evaluates the existing profile's ARN. The add
call also requires iam:PassRole on the role (IAM API reference).

- policy: GetInstanceProfile/CreateInstanceProfile/AddRoleToInstanceProfile on
  both ARN forms of the four profiles; PassRole of the four EC2-trust DRS roles
  to EC2; CreateServiceLinkedRole for AWSServiceRoleForElasticDisasterRecovery
  only, because CloudTrail shows initialize-service issuing it under the
  caller's identity on EVERY call (InvalidInputException once it exists), along
  with GetInstanceProfile x4. 29 statements. IAM simulator: project profiles
  allowed at both paths, other profiles denied; PassRole allowed to EC2 only and
  only for the four roles; only the DRS SLR.
- helper: read the profile first and skip the add when the role is already
  attached, so an initialized account needs no IAM write here.
- test: helper call set gains GetInstanceProfile; SERVICE_FORWARDED set carries
  the initialize-service calls with the evidence; PassRole pin allows exactly
  the two fenced statements; profile fence covers both path forms. Red x3 on the
  old policy, 96/96 green.
…g a replication template

Third e2e (run 35926926216) reached drs-setup with all 11 stacks up and died at
'drs update-replication-configuration-template': AccessDeniedException 'Unable
to verify subnet ... Ensure ec2:DescribeSubnets permission is granted'. DRS
validates the template under the CALLER's identity. CloudTrail from the 09-16
setup under Admin gives the full forwarded set for create/update of a
replication template: ec2:DescribeSubnets, ec2:DescribeSecurityGroups,
ec2:GetEbsDefaultKmsKeyId, kms:DescribeKey on alias/aws/ebs, and a DRY RUN of
ec2:CreateSecurityGroup when the default replication-server group does not
exist (every run here: the staging VPC is new). IAM authorizes a dry run like
the real call; the real group is created later under the DRS service-linked
role. The 09-18 rehearsal + teardown show no other forwarded calls under the
caller, so cleanup needs nothing more.

The additions pushed the inline policy to 10,700 characters, over IAM's 10,240
aggregate quota for a role (Access Analyzer POLICY_SIZE_EXCEEDS_IDENTITY_POLICY_QUOTA;
put-role-policy would have refused). Rewritten compactly with the same grants:
short Sids, unscoped reads merged, the three DRS resource statements merged,
project reads merged, security-group create+delete merged, and the redundant
sts:GetCallerIdentity grant dropped (no permission needed). 21 statements, 9,352
characters. A grant-by-grant diff against the previous document shows nothing
lost except GetCallerIdentity; the 'added' rows are cross-applications inside
merged statements (IAM simulator confirms they are inert) plus the new grants.

Tests: SERVICE_FORWARDED gains the five template-validation actions with the
evidence; the security-group pin covers the merged statement; a size pin keeps
the rendered policy under 9,800 so the next discovery fails in the test, not at
the terminal; Sid references updated. 101/101. Access Analyzer zero findings.
IAM simulator: every fence unchanged (profiles at both paths, PassRole to EC2
only, DRS SLR only, SG probe and kms read bounded to the two DRS Regions, no
rule writes).
…le validating a template

The dry-run ec2:CreateSecurityGroup that DRS issues under the caller's identity when
creating or updating a replication configuration template carries a tagSpecificationSet
(AWSElasticDisasterRecoveryManaged, aws:drs:managed, Name). EC2 authorizes tags on
creation as a separate ec2:CreateTags on the security group being created, which the
runner policy did not grant, so e2e run 35940960822 (attempt 2) failed at drs-setup:
DRS reported "no permission for ec2:CreateSecurityGroup" while the CloudTrail
errorMessage on the CreateSecurityGroup event names ec2:CreateTags on security-group/*.

Grant ec2:CreateTags on security groups in the two DRS Regions, conditioned on
ec2:CreateAction = CreateSecurityGroup so the runner can never retag an existing
resource. 22 statements, 9,619 characters rendered.

Contract test: ec2:CreateTags joins the SERVICE_FORWARDED set with the evidence; the
no-tag-writes pin becomes a pin on the exact fence (one statement, two Regions'
security-group/*, the CreateAction condition). Red on the previous policy, 103/103 green.
Access Analyzer: zero findings. IAM simulator: allowed at CreateSecurityGroup time in
us-west-2; denied with no CreateAction, in us-east-1, and at RunInstances time.
…re returning

`make deploy` is documented as ending at the resting state ("wait for CONTINUOUS"),
but drs-setup.sh returned as soon as the source server was tagged. The e2e runs
`make rehearse-cycle LEGS=2` immediately after, and its leg-0 baseline asserts the
resting-state invariant with a 5-minute settle budget sized for the minute DRS needs
after a fail-back, not for a fresh install's initial sync. Run 36028055691 (attempt 2,
2026-09-24): agent registered 18:03:04Z, rehearse-cycle started 18:04:22Z, failed at
18:11Z with "forward replication is INITIAL_SYNC" without executing the plan. The
server reached CONTINUOUS at 18:23Z, 20 minutes after registration.

Add step [7] to drs-setup.sh: poll dataReplicationState every 30 s, return on
CONTINUOUS, bounded at 45 min; fail loud (with dataReplicationError and the
non-succeeded initiation steps) on STALLED, DISCONNECTED, STOPPED or PAUSED, which
do not progress on their own. Uses only drs:DescribeSourceServers, already granted
to the runner role, so no policy change.

Contract test runs the exact loop under a fake `aws` that walks scripted states:
progress -> returns 0 at CONTINUOUS; STALLED/DISCONNECTED -> exit 1 fast; no
progress -> exit 1 after 90 polls. Pins the loop's position (after tag-resource)
and the Makefile promise. Red x4 against the previous script, 108/108 green.
… security group

DRS creates the "default Replication Server Security Group" outside CloudFormation and
terminates the replication server asynchronously after delete-source-server. The
wave-3 sweep tried the delete once and moved on, so when DRS was slow the VPC delete
failed with "has dependencies" on both del() attempts and the network stack was left
DELETE_FAILED, which the next `make deploy` cannot update.

e2e run 36040999672 (2026-09-24, the first run whose pre-flight cleanup had to retire a
CONTINUOUS source server): sweep DependencyViolation 19:01:51Z, DRS TerminateInstances
on the replication server 19:12:44Z, VPC DELETE_FAILED 19:31:20Z and 19:48:00Z,
drsdemo-net-secondary left DELETE_FAILED, Deploy failed at net-1 three minutes later.

sweep_drs_sgs now retries the delete every 15 s until every DRS-created group in the
VPC is gone, bounded at 20 min, then warns and continues so the stack deletes still
run. The DependencyViolation is the wait signal, so the runner role stays at
ec2:DescribeSecurityGroups + ec2:DeleteSecurityGroup (no policy change).

Contract test runs the function under a fake aws whose delete refuses N times:
45 refusals -> 46 deletes then success; 0 -> one delete; never -> 80 polls, WARN,
exit 0. Red x3 against the previous script, 113/113 green.
…e types for every grant

elasticloadbalancing:DescribeTargetHealth accepts no resource types, so IAM ignores the
Resource element and an ARN-scoped grant denies at run time. The policy scoped it to the
secondary target group's ARN; the rehearsal's leg-0 baseline (rehearse-cycle.sh, the first
call to reach the target-group check under the runner) failed with AccessDenied on every
one of its 20 polls in e2e run 36051833765 (2026-09-24 21:43Z). The IAM simulator returns
implicitDeny for the previous grant with or without a resource ARN and allowed for the
new one; Access Analyzer did not flag the old grant.

Move the action to UnscopedReads; drop the targetgroup ARN from ProjectReads. 22
statements, 9,530 characters. No other change.

Make the whole class fail at test time: test/fixtures/action-resource-types.json carries
the AWS Service Reference's resource types for all 61 granted actions (refresh with
test/fixtures/refresh-action-resource-types.mjs), and the contract test requires every
granted action to be in the fixture and every ARN-scoped action to accept resource types.
Red on the previous policy (exactly ProjectReads: DescribeTargetHealth), 162/162 green.
README documents the rule and the refresh step.
… data path

register-target waits for the recovered instance to report healthy, but /health returned ok unconditionally, so a recovered instance with a wrong security group, route or writer endpoint was marked healthy and the plan moved on. /health now runs SELECT 1 against the endpoint published in SSM and answers 503 on failure. It checks reachability and authentication only, not the writer role, so the primary does not flap during a planned Aurora switchover.
…etwork-online

The boot script runs under set -e and depends on the package repositories, SSM, S3 and Secrets Manager. One transient failure ended it before the systemd unit existed, leaving nothing for Restart=always to restart. Every network-dependent call is wrapped in a bounded retry (5 attempts, 10 s growing), and the unit orders After=/Wants=network-online.target. The new test runs the retry function extracted from the synthesized script under bash. UserData changed, so the next deploy replaces the instance by design; drs-setup.sh retires the old source server.
drs-setup.sh: exit 1 when the instance is not SSM Online, when the agent never registers a source server (everything after that loop was skipped and make deploy exited 0 with nothing protected), and when the project tag does not land (it is the plan's selection contract; the write was best-effort and is now read back). rehearse-switchover.sh: completedWithExceptions (a skipped step) fails the leg and lists the skipped steps; the post-failover checks are PASS or FAIL and any FAIL exits non-zero after the fail-back has returned the estate to rest. Makefile help: status is informational; drs-setup takes 15 to 25 min live. New tests run the changed fragments under a fake aws; red against the previous scripts.
… discarding them

stop_failback and disconnect were called through _try([], ...), a throwaway list, so their failures vanished. _try now logs every failure with the call and error, and best_effort=True is explicit for the two calls that may fail while DRS is already tearing the instance down. Two tests, red against the previous retire.py.
… DRS timing

Prerequisites: deploy only into a sandbox account, never production or an account with other DRS workloads, because the fail-back retires every recovery instance and FAILBACK server in the two Regions. Security: the app runs as root and as the Aurora master user, and the demo console has no authentication. Deploy: DRS takes 15 to 25 min to CONTINUOUS (measured 2026-09-24/25), make deploy ~75 min. register-target: healthy now means the database is reachable.
The public workflow YAML no longer carries the test account id. E2E_ACCOUNT in .projenrc.ts is now the expression ${{ vars.E2E_ACCOUNT_ID }}, resolved by GitHub in the with: value of configure-aws-credentials; all six generated AWS-facing workflows regenerated. A fork sets one repository variable to point the workflows at its own account. Pin: the drs-ec2 workflows contain no 12-digit id and the IAM docs keep their placeholder.
…sources

drs-retire listed every recovery instance in the secondary and every FAILBACK server in the primary and stopped, terminated, disconnected or deleted them, and register-failback-target picked the highest-id RUNNING recovery instance among all FAILBACK servers; the orchestration role holds those grants on '*'. A second DRS workload in the same account and Regions was retired by this plan's fail-back. Ownership now flows from the project tag: recovery instances are owned through the forward server that launched them (including a server a stateful re-protect tagged app-retired), FAILBACK servers through the owned recovery instance that feeds them, primary-side records through those FAILBACK servers or the protected primary. register-failback-target requires exactly one owned RUNNING fail-back instance instead of guessing by sort order. Five tests with an unrelated workload present; four are red against the previous code. README caveat updated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant