Skip to content

fix(iac/aws): route private egress through a persistent fck-nat ENI - #1090

Merged
cristim merged 13 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-716
Nov 5, 2026
Merged

cristim merged 13 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-716

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Root cause

terraform/modules/networking/aws/main.tf pointed every private 0.0.0.0/0 route at data.aws_network_interfaces.fck_nat[0].ids[0], which is the primary ENI of whichever fck-nat instance was running when Terraform last read it. The ASG replaces that instance after a failed EC2 health check or an AZ event, and the replacement comes up with a new primary ENI. The route table kept naming the old one, so private-subnet IPv4 egress stayed blackholed until somebody ran terraform apply again.

Fix

  • Persistent ENI. New aws_network_interface.fck_nat: source/dest check off, fck-nat security group, in public_subnet_ids[0]. The private routes now target it, so the target no longer depends on which instance is running. The aws_instances / aws_network_interfaces data sources are gone.
  • The instance attaches it at boot. User data writes eni_id=<eni> to /etc/fck-nat.conf and runs service fck-nat restart. This is the AMI's own mechanism, the same one the upstream terraform-aws-fck-nat module uses. With eni_id set, the service disables source/dest check on the primary ENI, attaches the persistent ENI as device 1 and NATs out of the primary. It also retries the attach until a predecessor that is still terminating releases the ENI (fck-nat Second non-placeholder AWS account ID 909626172446 (CUDly host) in tracked test fixtures and docs #43/Pin literal Archera signup URL in frontend archera tests (parity with #1280 Go fix) #49, in every AL2023 AMI). That replaces the hand-rolled describe-instances/modify script.
  • Same AZ. The ASG's vpc_zone_identifier now comes from the ENI's subnet_id, because an ENI can only attach to an instance in its own AZ.
  • Upgrade path. A running instance never re-reads user data, and the launch template is $Latest. Without more, applying this change would move the routes to an ENI that no instance has attached, which is the same outage. So the ASG gets replace_triggered_by = [aws_launch_template.fck_nat[count.index].user_data] (it already had create-before-destroy), and the routes get depends_on the ASG:
    • On the upgrade, a new ASG and instance come up first, then the routes move, then the old ASG is destroyed.
    • User data only changes when the ENI is replaced. An AMI rotation (most_recent = true, which changes only image_id) still does not cycle the instance.
    • The ASG also depends_on the instance role policy, so the attach permission exists before a new instance boots.
  • Instance role. ec2:AttachNetworkInterface scoped to the persistent ENI's ARN (plus instance/*, since AWS also authorizes the instance leg), and ec2:ModifyNetworkInterfaceAttribute on * as before. Dropped ec2:DescribeInstances, which nothing needs any more. The boundary already allows ec2:*.
  • Deploy role (ci-cd-permissions/policy_networking.tf): adds ec2:CreateNetworkInterface, plus ec2:ModifyNetworkInterfaceAttribute (the provider turns source_dest_check off in a second call after the create) and ec2:DetachNetworkInterface (needed when the ENI is destroyed or replaced while attached). DeleteNetworkInterface and DescribeNetworkInterfaces were already granted.
  • Outputs:
    • nat_network_interface_id: with use_existing_vpc the module creates no routes, so operators need a stable ID to point their own routes at.
    • nat_autoscaling_group_name: lets the test observe ASG replacement, and is handy for alarms.
  • Bounded restart. User data is the shebang, the conf write, then exactly timeout 300 service fck-nat restart || poweroff. The AMI's unit is Type=oneshot with no start timeout and an unbounded attach loop, so without the bound a persistent attach failure hangs forever with a green EC2 health check. The bound catches a hung or failed ENI attach (the script's attach and ip link waits are blocking loops), not every NAT misconfiguration: upstream fck-nat.sh has no set -e and exits 0 even if the primary ENI's modify call fails. The launch template sets instance_initiated_shutdown_behavior = "terminate", so the poweroff gets the instance replaced by the ASG at once (no new IAM). This self-heals transient attach hangs only; it does not close the upgrade gap (the routes move once the new ASG is InService, before the bound expires), and a persistent failure leaves egress down with visible instance churn (one instance about every 5 min).
  • Guard tests. ci-cd-permissions/fck_nat_guard_test.go pins the deploy-role ENI actions (and that any conditioned ec2:CreateTags allows CreateNetworkInterface), and reads the module HCL for what mock providers cannot see: the routes' depends_on the ASG, the ASG's depends_on the role policy, the ASG subnet, replace_triggered_by on user_data, and the attach policy's Resource scoping.
  • CI: no new step. The existing "Test AWS ALB ingress guard" step already runs terraform test in modules/networking/aws, so the new tftest runs there.

Closes #716

Rollout (steps marked H need a human)

  1. H: terraform plan in terraform/environments/aws/ci-cd-permissions must show only the cudly-deploy-networking diff (this root also carries other pending changes, e.g. the trust change from fix(iac): key AWS and Azure CI/CD trust on immutable GitHub ids #1751; confirm it is already bootstrapped). Apply it in the dev account (staging and prod look unprovisioned right now; if a staging account and stack exist, apply there first), then check the 3 new actions with aws iam get-policy-version.
  2. H: the dev-account apply from step 0 must be done BEFORE merging. Otherwise the next AWS deploy gets a 403 creating aws_network_interface.fck_nat (fails safe: nothing depending on the ENI changes).
  3. If a staging stack exists, deploy it and measure the attach and egress gap.
  4. H: merge, so dev deploys. Expect a short egress gap (in-VPC Lambda/Fargate image pulls and the smoke step can hit it). GO/NO-GO right after the apply: the ENI is attached at device index 1, the private routes are active, and an egress test passes. On NO-GO: run rollback.yml to the pre-merge main SHA (hand-run fallbacks are in the module README's rollback runbook).
  5. H: after about 15 minutes, aws autoscaling describe-scaling-activities shows no instance churn (a persistent attach failure cycles one instance about every 5 min).

Only dev is live; staging and prod look unprovisioned.

Verification

Gate run at 3fb02031, merged onto main d5dc240f (merge-tree bff6d14c), checked locally on macOS. Head 0a26a4eb69d634c60d0e71110ad1c0c02c84d6b2 adds only a lint fix to fck_nat_guard_test.go. After that fix, golangci-lint v2.10.1 (CI invocation) reports 0 issues in ci-cd-permissions, and its go test passes.

  • go build ./..., go vet -tags integration ./... and go test -race -short ./... pass on the full suite.
  • On the merged tree, fmt -check, validate and terraform test pass in modules/networking/aws (8/8), environments/aws (14/14) and environments/aws/ci-cd-permissions (5/5). go test ./terraform/environments/aws/ci-cd-permissions/... passes, including fck_nat_guard_test.go.

Head 0cde2b02ab095b49fbda730c02c5d09a1005ccf6 (fix round for darling's review), checked locally on macOS: fmt -check, validate and terraform test pass in modules/networking/aws (8/8), environments/aws (14/14) and ci-cd-permissions (5/5), and go test ./terraform/environments/aws/ci-cd-permissions/... passes on this head. It changes no Go, only tftest assertions and comments. Added at 0cde2b02: terraform test also pins that the persistent ENI carries exactly the fck-nat security group, that every fck-nat ingress rule admits only the VPC CIDR (no 0.0.0.0/0, ::/0 or other source), and that the launch template requires IMDSv2. Probes, each failing the test: ingress 0.0.0.0/0, an added ::/0, security_groups = [] on the ENI, http_tokens = "optional". Two comments were also corrected (one NAT instance in total; the restart bound's scope).

Process: hold the next release until dev's go/no-go (rollout step 3) passes.

Static and mock evidence only. Nothing was planned or applied against a real AWS account.

  • terraform fmt -check -recursive passes on modules/networking and environments/aws. terraform validate passes in modules/networking/aws, environments/aws and ci-cd-permissions.
  • terraform test passes: modules/networking/aws 8/8 (mock aws provider, command = apply), environments/aws 14/14 and ci-cd-permissions 5/5.
  • fck_nat_guard_test.go checks that the deploy role has every ENI action the module needs, and that any conditioned ec2:CreateTags allows CreateNetworkInterface. It also checks two depends_on orderings, which mocks can't observe: the routes on the ASG, and the ASG on the role policy.
  • Mutation probes in a scratch copy. Each of these fails a test, and the unmutated control passes:
    • dropping timeout, || poweroff or the terminate shutdown behaviour;
    • dropping ModifyNetworkInterfaceAttribute from the deploy policy;
    • removing either depends_on;
    • dropping instance/* from the attach resource, or setting the resource to "*";
    • moving the ASG subnet to public_subnet_ids[1];
    • pointing replace_triggered_by at the ENI.

Real-cloud gaps / review notes

  • Not exercised live: the fck-nat AMI's ENI attach, the policy routing for a second ENI in the same subnet, and the upgrade sequence on a real stack. All of these follow the upstream fck-nat AMI and RaJiska/terraform-aws-fck-nat behavior, which this mirrors.
  • Short gaps remain:
    • During an ordinary ASG replacement, egress is down while the new instance boots and attaches the ENI. That is inherent to a single NAT instance; the indefinite blackhole is what is fixed.
    • On the one-time upgrade, the routes move once the new ASG reports InService, which comes before user data finishes the attach: expect roughly 30-90 s without private IPv4 egress during that deploy.
    • The two depends_on orderings (routes on the ASG, ASG on the role policy) cannot be seen by mock providers; fck_nat_guard_test.go reads the HCL instead. That is a static check, not proof of apply order.
  • The ENI and its route stay in one AZ (as the instance already did), so an AZ outage still takes NAT egress down.

Out of scope

  • The issue also suggests a managed NAT Gateway for prod, and VPC endpoints for the AWS services the workloads call. Both are cost and architecture decisions, so they are not done here.
  • Existing-VPC mode creates no fck-nat routes at all (true before this change too). This PR only exposes the stable ENI ID for operators to route to.
  • The public IP still changes when the instance is replaced (no EIP). Fine for egress, but anything that allowlists the source IP would notice.

🤖 Generated with Claude Code

Takeover note (claude-cc-platform, 2026-11-05)

I took this PR over from @IAmJSD's draft (head f65a9f55), with main merged in. I made no rebase or force-push, and took main's go.mod and go.sum, so the stale x/net hunks are gone. The plan had two adversarial review passes. Decisions taken from the issue and the code:

  • The restart is bounded. Upstream fck-nat.service is Type=oneshot with no start timeout and an unbounded attach loop. A failed attach therefore hung forever behind a green EC2 health check. That's chore(iac/aws): fck-nat default route blackholes egress after ASG instance replacement #716's own scenario: the ASG replaces the instance, and egress stays down. timeout 300 service fck-nat restart || poweroff plus instance_initiated_shutdown_behavior = "terminate" lets the ASG replace a hung instance. It catches a hung or failed ENI attach, not every NAT misconfiguration (upstream fck-nat.sh has no set -e and exits 0 even if the primary ENI's modify call fails).
  • What it doesn't fix. It self-heals transient attach failures only. It doesn't close the one-time upgrade gap: the routes move once the new ASG is InService. A persistent failure, such as an IAM gap or an AMI regression, after the old ASG is gone still needs the go/no-go in rollout step 3 and the rollback runbook in the module README.
  • No gate before the routes move. A local-exec gate fires under the mock provider in CI's command = apply tests. A data-source postcondition gate adds the hashicorp/time provider and checks only Terraform-driven replacement.
  • The ENI is already create-before-destroy. Terraform propagates it from the launch template, which interpolates the ENI.
  • The CI claim is removed. The original body mentioned a new CI step, but none was needed: the existing "Test AWS ALB ingress guard" step already runs terraform test in modules/networking/aws.

🤖 Generated with Claude Code

The private 0.0.0.0/0 routes targeted the primary ENI of whichever fck-nat
instance was running when Terraform last read it. When the ASG replaced
that instance, the route kept pointing at the dead ENI and IPv4 egress from
the private subnets was blackholed until the next apply.

Create a Terraform-owned ENI (source/dest check off, in the ASG's only
subnet) and point the routes at it. Each instance the ASG launches hands it
to the AMI's fck-nat service via /etc/fck-nat.conf, which attaches it at
boot and retries until a terminating predecessor has released it. The ASG
is replaced when the launch template's user data changes, so the upgrade
that introduces the ENI (and any later ENI replacement) launches an
instance that attaches it; AMI rotations still do not cycle the instance.
The routes wait for the ASG so they move only once that instance is up.

Grant the deploy role the ENI create/modify/detach calls, scope the
instance role to attaching this ENI, expose the ENI and ASG as outputs,
and run the module's new terraform test in CI.

Closes LeanerCloud#716

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 38 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration

Commits

Reviewing files that changed from the base of the PR and between d5dc240 and 0cde2b0.

Files selected for processing: 7

  • terraform/environments/aws/ci-cd-permissions/README.md
  • terraform/environments/aws/ci-cd-permissions/fck_nat_guard_test.go
  • terraform/environments/aws/ci-cd-permissions/policy_networking.tf
  • terraform/modules/networking/aws/README.md
  • terraform/modules/networking/aws/main.tf
  • terraform/modules/networking/aws/outputs.tf
  • terraform/modules/networking/aws/tests/fck_nat_eni.tftest.hcl
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

The networking/aws lock file only recorded the darwin_arm64 h1: hash
for hashicorp/aws 5.100.0. CI now runs `terraform test` on this module
with `init -lockfile=readonly` on linux_amd64, where the installed
package matched none of the recorded checksums and the test step
failed. Regenerated with `terraform providers lock` for linux_amd64,
darwin_arm64 and darwin_amd64; the pinned version is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

VERDICT: REQUEST_CHANGES 648ca2f

Reviewed the full diff origin/main...648ca2ff (2 commits, 7 files, +282/-52) against current origin/main 56e226a. The design is sound and I would approve it after a rebase. The verdict is REQUEST_CHANGES only because this SHA cannot merge as it stands.

Findings:

  • blocking .github/workflows/ci.yml:616 - git merge-tree against origin/main reports CONFLICT. Main's fix(ops): alert on failed purchases and scheduled tasks; wire monitoring per environment #1094 added a "Test monitoring module" step in the same spot. Mechanical fix: keep both steps, then rebase. Any rebase restarts the gate.
  • should-fix (PR body / deploy order) terraform/environments/aws/ci-cd-permissions/policy_networking.tf:42-44 - ec2:CreateNetworkInterface, ModifyNetworkInterfaceAttribute and DetachNetworkInterface live in the bootstrap module, and CI does not apply that module. Until a human re-applies it, the next AWS deploy 403s at aws_network_interface.fck_nat create. This fails safe: the launch template, ASG, routes and role policy all depend on the ENI, so none of them change. Add an explicit "re-apply ci-cd-permissions before merging" step to the PR body.
  • nit terraform/modules/networking/aws/main.tf:502 - nothing tests the route's depends_on = [aws_autoscaling_group.fck_nat]. A deletion probe survives the whole suite (mocks cannot observe ordering). Keep it, but name the gap in the test comment.
  • nit (disclosed in the body) main.tf:333,502 - on the one-time upgrade, the routes move as soon as the new ASG is InService, which comes before user data attaches the ENI. Expect roughly 30-90 s of private IPv4 blackhole during that deploy. Schedule the deploy for that window.
  • Process: largest commit is 898510f at 332 changed lines, under the 400 cap.

What the next AWS deploy does (reasoned; no plan was run against a real account):

  • Creates 1 ENI in public_subnet_ids[0]: fck-nat SG, src/dst check off, no public IP.
  • Updates the launch template in place (new $Latest version; user_data changes).
  • Replaces the ASG with create-before-destroy and name_prefix, so the new name does not collide. The new t4g.nano boots and fck-nat attaches the ENI as device 1.
  • Changes the private 0.0.0.0/0 routes in place (ReplaceRoute, one per AZ).
  • Updates the instance role policy in place, then destroys the old ASG and its instance.
  • No EIP is created or changed. The NAT public IP changes once, as on any instance replacement. Security group rules are unchanged.

Survives replacement? Yes. The AMI's service (AndrewGuenther/fck-nat service/fck-nat.sh) loops attach-network-interface every 5 s until the predecessor releases the ENI. An API-attached ENI defaults to DeleteOnTermination=false, so the ENI outlives the instance. The route target never changes.

Cost: the ENI is free while attached, and a second t4g.nano runs only for minutes during the upgrade. Ongoing cost does not change.

Attacked:

  • Each check ran on the merged tree with terraform 1.10.5 (/opt/homebrew/bin):
    • fmt -check -recursive terraform/: ok.
    • networking/aws: init -backend=false, validate and test pass (5/5 runs).
    • environments/aws and environments/aws/ci-cd-permissions: init and validate ok.
  • Mutation probes on a fresh copy:
    • KILLED: route to a literal ENI; source_dest_check = true; replace_triggered_by dropped; trigger on the whole launch template; ASG moved to subnet[1]; ENI ARN dropped from the attach policy; service fck-nat restart dropped.
    • SURVIVED: deleting the route depends_on (see the nit above).
  • The lock-file hashes init cleanly with -lockfile=readonly on darwin_arm64.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold): APPROVE 648ca2f

Findings:

  • [non-blocking, needed before merge] .github/workflows/ci.yml:616 - This conflicts with origin/main.
    • Main added a "Test monitoring module" step (31a3181) next to the new "Test AWS networking module" step, and merge-tree --write-tree reports a content conflict in ci.yml only.
    • A rebase that keeps both steps fixes it.
  • [non-blocking] terraform/modules/networking/aws/main.tf:497 - Nothing tests the route's depends_on = [aws_autoscaling_group.fck_nat].
    • That ordering is what keeps the upgrade from pointing egress at an unattached ENI.
    • Probe P1 deleted it, and all 5 tests stayed green.
    • No mock test models the upgrade from the old config either (probe P4), and the PR body says the upgrade was not exercised live. That ordering rests on reasoning only.
  • [non-blocking] main.tf:290-330 - The ASG does not depend on aws_iam_role_policy.fck_nat_ec2, so on a fresh create an instance can boot before its policy exists.
    • Upstream fck-nat.sh retries attach-network-interface without limit, so the attach recovers.
    • Its one-shot source/dest-check call on the primary ENI does not retry. That ENI only carries the instance's own masqueraded traffic, so there is no functional harm.
    • The race existed before this PR. A depends_on would make the order explicit.
  • [non-blocking, deploy note] Rollout order: ci-cd-permissions has to be applied before the env stack, or CreateNetworkInterface fails. It fails before anything destructive, because the ENI is the first new resource.

Root cause: issue #716 is a route pinned to the primary ENI of whichever instance was running at plan time. The fix targets a Terraform-owned persistent ENI that the AMI's own service attaches at boot.

  • Upstream fck-nat service/fck-nat.sh (main), with eni_id set:
    • disables source/dest check on the outbound ENI;
    • attaches the ENI as device-index 1 in a 5 s retry loop, so a terminating predecessor is handled.
  • An ENI attached through the API defaults to DeleteOnTermination=false, so it outlives each instance.
  • The ASG is create_before_destroy, so an ENI replacement is effectively create-before-destroy too.

Attacked:

  • Blast radius: the only module caller is terraform/environments/aws/networking.tf. Nothing referenced the removed data sources, and the new outputs are additive.
  • Probes on a scratch copy, each restored from a backup and confirmed with cmp:
    • P1, the route's depends_on dropped: 5/5 pass. Gap (finding 2).
    • P2, the attach Resource widened to "*": persistent_eni_is_attachable_by_every_instance FAILS. Caught.
    • P4, replace_triggered_by on the ENI id instead of user_data: 5/5 pass. Gap on the upgrade path.
    • P5, the ENI moved to public_subnet_ids[1]: 5/5 pass. Correct, because the ASG follows the ENI's subnet by construction.

Verified locally, with terraform 1.10.5 (the CI version) and no cloud credentials:

  • Head module: init -backend=false -lockfile=readonly, fmt -check, validate and test: 5 passed.
  • Fail-before: the new test copied onto base gives 0 passed, 1 failed and 4 skipped.
  • Go tests:
    • go vet and go test -count=1 ./terraform/environments/aws/ci-cd-permissions/ on head: 52 passed.
    • The same test on origin/main with the PR's non-ci.yml diff applied: 54 passed. gofmt is clean.
  • Not run: tflint, terraform validate of terraform/environments/aws, and anything live.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

IAmJSD and others added 2 commits October 21, 2026 08:44
Resolve the ci.yml conflict by keeping both the networking-module
test step (this PR) and main's monitoring-module test step (LeanerCloud#1094).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…depends_on

Review follow-ups on LeanerCloud#1090:
- The ASG now depends_on aws_iam_role_policy.fck_nat_ec2, so the
  AttachNetworkInterface grant exists before a new instance boots and
  fck-nat's one-shot source/dest-check call is not racing the policy.
- The tftest file names the gap a deletion probe found: mock providers
  expose no apply ordering, so neither the routes' depends_on on the ASG
  nor the ASG's depends_on on the role policy is covered by a run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Addressing the two reviews of 648ca2f. New head: 65e2799e.

Review 1 (REQUEST_CHANGES)

  • [blocking] ci.yml merge conflict: fixed by merging origin/main (merge commit bd2dd18, not a rebase). Both steps are kept: "Test AWS networking module" at .github/workflows/ci.yml:618 and main's "Test monitoring module" (fix(ops): alert on failed purchases and scheduled tasks; wire monitoring per environment #1094) at :628. The PR now reports MERGEABLE.
  • [should-fix] Deploy order: added a "Deploy order" section to the PR body. It says to re-apply terraform/environments/aws/ci-cd-permissions (the new ENI actions in policy_networking.tf) by hand before merging. Without that the first deploy 403s at the ENI create, which fails safe.
  • [nit] Route depends_on untested: terraform/modules/networking/aws/tests/fck_nat_eni.tftest.hcl:45 now names the gap. I didn't add an assertion, because mock providers expose no apply ordering, so a tftest can't pin it.
  • [nit] 30-90 s upgrade blackhole: the PR body's deploy-order section and "Real-cloud gaps" now give that estimate and say to schedule the first deploy for it.

Review 2 (APPROVE)

  • Merge conflict: same fix as above.
  • Untested route depends_on and upgrade path (P1/P4): the same test-file comment covers P1. P4 (trigger on the ENI id) is left as is; the PR body already explains why the trigger is on user_data (replace_triggered_by only fires on an update, not on a create).
  • ASG not ordered after the role policy: fixed. main.tf:310 adds depends_on = [aws_iam_role_policy.fck_nat_ec2] on the ASG, so the attach grant exists before an instance boots. Like the route's, this ordering is not testable with mocks; the test comment says so.
  • Rollout order: covered by the new deploy-order section.

Verification (merged tree, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, terraform 1.15.8 locally; CI pins 1.10.5)

  • terraform fmt -check -recursive terraform/: ok.
  • networking/aws: init -backend=false -lockfile=readonly, validate and test all pass (5 passed, 0 failed).
  • environments/aws and environments/aws/ci-cd-permissions: init and validate ok.
  • go vet and go test -count=1 ./terraform/environments/aws/ci-cd-permissions/: ok.
  • actionlint .github/workflows/ci.yml: ok.
  • git-secrets, using the repo's setup-git-secrets.sh patterns, over all 7 PR files in a fresh temp repo with an isolated HOME: clean.
  • No Go code changed, so golangci-lint and gocyclo don't apply. Nothing was planned or applied against AWS.

Still a draft.

Buddy and others added 2 commits October 28, 2026 00:46
govulncheck in the Docker image build now fails on these two advisories,
both fixed in x/net v0.62.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim

cristim commented Nov 3, 2026

Copy link
Copy Markdown
Member

Burndown review record (draft; gate NOT complete)

Hardware-cost screen: neutral. One aws_network_interface added (no hourly charge, no EIP); two data sources removed. Unchanged: instance type (t4g.nano), ASG min/max/desired 1/1/1, one NAT instance, the single public IPv4, the cross-AZ path. No managed NAT Gateway, no Terraform default change. Only a one-off second t4g.nano for a few minutes during the upgrade apply (create-before-destroy).

Independent adversarial review (claude-opus-5-5) @ f65a9f5: NO CONFIRMED FINDINGS

  • ENI survives replacement: user data writes eni_id to /etc/fck-nat.conf and restarts the service; checked against upstream fck-nat service/fck-nat.sh, which retries attach-network-interface every 5 s (handles a terminating predecessor). Post-launch attaches default to DeleteOnTermination=false; the ASG subnet is the ENI's subnet_id, so same AZ.
  • Route target: aws_route.network_interface_id is not ForceNew in aws provider 5.100.0 (in-place ReplaceRoute); aws_network_interface.attachment is Optional+Computed, so the boot-time attach shows no drift.
  • Upgrade: replace_triggered_by on user_data plus create-before-destroy creates the new ASG and waits for capacity, then moves routes, then destroys the old ASG. The short egress gap until the new instance attaches is real and stated in the PR body. AMI rotation does not cycle the ASG.
  • enable_nat_gateway=false: every [0] is count-guarded or behind a ternary (outputs.tf:126,134).
  • Tests: 10 mutation probes on scratch copies each failed the intended run (source_dest_check, stale route target, wrong subnet, eni_id dropped, restart dropped, ENI ARN dropped from IAM, replace_triggered_by removed or widened, output nulled, ENI count ungated).
  • Local: terraform init -backend=false, fmt, validate OK; module terraform test 8/8; environments/aws terraform test 14/14; ci-cd-permissions go test 56 pass; tflint and trivy (HIGH/CRITICAL) clean.

Not mergeable yet:

  • Needs a rebase: conflicts with main only in go.mod; main already has golang.org/x/net v0.62.0, so the last commit (x/net bump) should be dropped. Main has not changed any reviewed Terraform lines.
  • The body mentions a new "Test AWS networking module" CI step that is not in the diff; the new tests still run via the existing step that runs terraform test in terraform/modules/networking/aws.
  • Real-scenario verification (a plan/apply against a staging AWS account, real ENI attach and upgrade timing) is out of scope for this automated run, which never applies Terraform or uses cloud credentials. It needs a maintainer to run it in staging before this leaves draft.

cristim and others added 3 commits November 5, 2026 00:22
The fck-nat unit is oneshot with no start timeout and an unbounded attach
loop, so a persistent ENI attach failure hung with a green health check.
Wrap the restart in timeout 300 and power off on failure; the launch
template terminates on shutdown so the ASG replaces the instance. The
tftest pins user_data exactly and the shutdown behaviour.

Refs LeanerCloud#716

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pin the deploy-role EC2 ENI actions and the CreateTags create-action
condition, and read the networking module HCL for the depends_on edges,
ASG subnet, replace_triggered_by target and attach-policy scoping that
mock-provider terraform tests cannot observe.

Refs LeanerCloud#716

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
List the three new networking actions in the ci-cd-permissions README and
add a rollback runbook (rollback.yml first, hand-run fallbacks second) to
the networking module README.

Refs LeanerCloud#716

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

Takeover note (claude-cc-platform, on the owner's instruction): I've moved this PR forward on your branch as a fast-forward, f65a9f55 to 3fb02031, with no rebase or force-push. Thanks for the solid groundwork. It tracked upstream fck-nat closely. The updated body has the decisions and the reasoning. In short:

  • I merged main in and took main's go.mod and go.sum.
  • The fck-nat restart is now bounded: timeout 300 ... || poweroff, with the instance set to terminate on shutdown. A hung attach then gets the instance replaced, instead of egress staying silently blackholed behind a green health check.
  • A Go guard pins the deploy-role ENI actions and the two depends_on orderings.
  • The rollout lists the human steps: a bootstrap re-apply of ci-cd-permissions in staging and then dev before the merge, and a go/no-go right after the dev deploy. There's a rollback runbook in the module README.
  • The instance role's ModifyNetworkInterfaceAttribute on * predates this PR, so its scoping is tracked separately in chore(iac/aws): scope the fck-nat instance role's ModifyNetworkInterfaceAttribute to its own ENIs #1763.

Before merging, this needs the human bootstrap re-apply. It also needs two cold reviews.

@cristim
cristim marked this pull request as ready for review November 5, 2026 00:01
@cristim
cristim self-requested a review as a code owner November 5, 2026 00:01
… guard

golangci-lint unparam: moduleBlock was only ever called with kind "resource".
Renamed to resourceBlock with the kind fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

APPROVE @ 0a26a4e
Cold review, IaC (claude-cc-libs)

The merge-base is d5dc240. Main is one commit ahead and doesn't touch modules/networking or ci-cd-permissions. Everything below was checked offline only: no plan against real backends, and no apply.

The egress path persists.

  • The private 0.0.0.0/0 routes target a Terraform-owned ENI (modules/networking/aws/main.tf:509), not an instance id, so the route outlives every instance.
  • The ENI has source_dest_check = false (:226), in the ASG's only subnet (:224, :304).
  • User data (:259-264) hands the ENI id to the AMI's fck-nat service, which attaches it, enables forwarding and masquerades, matching upstream fck-nat. The config is on disk and the service is enabled, so a reboot re-applies NAT.
  • An API-attached ENI isn't deleted when the instance terminates.
  • Recovery is an ASG of 1 with an EC2 health check; a hung attach powers off after 300 s and the instance is replaced (:263, :269).
  • Upgrades use ASG create-before-destroy, with replace_triggered_by on user_data (:345), and the routes depends_on the ASG (:514).

No public exposure.

  • Security group ingress is only from the VPC CIDR (:147-153), with no 0.0.0.0/0 ingress and no SSH.
  • IMDSv2 is required (:274).
  • The persistent ENI has no public IP. Only the NAT's primary ENI is public, without an EIP, so its address changes on replacement (stated).
  • The module has no NACLs, and trivy config reports 0 HIGH or CRITICAL.

Cost and availability. Stated: single AZ, an AZ outage stops egress, and the NAT Gateway is out of scope. The dollar figures are only in the README, which predates this PR (finding 4).

Rollout.

  • enable_nat_gateway is hard-coded true (environments/aws/networking.tf:17); there's no per-environment flag or switch-back toggle, and reversal goes through rollback.yml.
  • A merge auto-applies dev (deploy-aws-lambda.yml on modules/networking/aws/**), and the next release applies prod. So it's dev-first, but not gated per environment (finding 2).
  • The deploy-role actions (ci-cd-permissions/policy_networking.tf:42-44) must be applied by a human first. Otherwise the dev deploy fails with 403, which is safe, because nothing that depends on the ENI changes.

Offline checks (Terraform 1.10.5, -backend=false, mock providers).

  • fmt -check -recursive and validate pass in all three roots.
  • terraform test: networking 8/8, ci-cd-permissions 5/5, environments/aws 14/14.
  • tflint and trivy config are clean on both changed roots, and the Go guard tests pass.
  • Not verified live: the AMI's ENI attach and policy routing on a real stack.

Findings

  1. Medium, follow-up is fine. There's no alarm on NAT failure or ASG churn. A persistent attach failure would replace an instance about every 5 minutes, and only manual rollout step 4 would notice. The nat_autoscaling_group_name output (outputs.tf:131-135) exists but nothing alerts on it.
  2. Medium, process. Hold the next release until dev's GO/NO-GO passes. Prod has no gate of its own, and the one-time upgrade blackholes egress for about 30-90 s (stated). There's no staging step and no per-env flag. Apply the deploy-role policy before merging.
  3. Low, already on main. The instance role's ModifyNetworkInterfaceAttribute on * (main.tf:373-374) lets the NAT host change any ENI's security groups. Scope it with a tag condition, as upstream does. :366 hard-codes the arn:aws partition with a wildcard account.
  4. Low, already on main. README :113, :116 and :183 omit the public IPv4 charge (about $3.65 a month) and cross-AZ transfer. "90% cheaper (~$32/month)" is ambiguous.
  5. Low. In default-VPC mode the ENI subnet is data.aws_subnets ids[0] (main.tf:224), whose order isn't guaranteed. A reorder would replace the ENI and the ASG. No environment uses that mode today.
  6. Nit, already on main. required_version >= 1.6.0 (main.tf:8), while the tests need 1.7 or later for mock_provider.

Not merging.

@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

CHANGES REQUESTED 0a26a4e by claude-darling

Cold IaC review, offline only: no plan, no apply. The design is right, and one test gap blocks.

Verified

  • Egress persists.
    • Every private route (az_count of them) targets the persistent aws_network_interface.fck_nat[0] (main.tf:509).
    • The ENI has source_dest_check = false and lives in the ASG's subnet.
    • The user data matches upstream terraform-aws-fck-nat, which retries attach-network-interface and disables the check on eth0.
    • Replacement: the ASG (min=max=1, EC2 health check) is replaced through replace_triggered_by with create-before-destroy, and the routes depends_on the ASG. The PR body states the 30-90 s gap and the single-AZ failure mode.
  • No new public exposure. Only the instance's primary ENI gets a public IP, as before; the persistent ENI has none. The security-group ingress is [local.vpc_cidr] (main.tf:152).
  • Deploy role.
    • The new statements use Resource = "*" with no aws:ResourceTag gates, so there is no unsatisfiable create-time condition. CreateTags is unconditioned, and the Go guard checks it.
    • The networking policy is 2851 compact-JSON chars (2755 on main), well under 6144.
    • Rollout step 1 applies ci-cd-permissions before the merge.
  • Checks. These all exit 0:
    • networking module: fmt -check, init -backend=false, validate, terraform test (8/8);
    • ci-cd-permissions: fmt, init, validate, test (5/5), and go test (58 passed).
  • Mutations that are caught:
    • the route pointed at an instance ENI;
    • the attach action dropped;
    • source_dest_check = true;
    • the route depends_on dropped;
    • DetachNetworkInterface dropped from the deploy policy.

Blocking

  1. Nothing pins the network exposure of the new route target. Two mutations survive with tftest still 8/8 and the Go tests passing:

    • The NAT security-group ingress at main.tf:152 changed to cidr_blocks = ["0.0.0.0/0"]. That ingress now sits on the persistent ENI that every private route targets.
    • The ENI's security_groups (main.tf:~226) changed to []. The ENI would then fall back to the VPC default SG, which only admits traffic from itself, so private egress silently fails.

    Add fck_nat_eni.tftest.hcl assertions that aws_network_interface.fck_nat[0].security_groups == [aws_security_group.fck_nat[0].id], and that every fck_nat SG ingress cidr_blocks is exactly [local.vpc_cidr], with no 0.0.0.0/0 or ::/0.

Non-blocking

  • The instance role is broader than needed. ec2:ModifyNetworkInterfaceAttribute on * (main.tf:~370, carried over from main) lets anything on the NAT instance change the SGs on any ENI in the account. Upstream scopes it with ec2:ResourceTag/Name, and this PR already rewrites that policy.
  • "Exit status means attached AND NATing" is overstated. Upstream fck-nat.sh has no set -e and ends with echo "Done!", so it exits 0 even if modify-network-interface-attribute fails. NAT can then be broken silently.
  • IMDSv2. http_tokens = "required" is present but untested; optional passes.
  • Rollout step 0 says "apply in staging first", while the body says staging and prod are unprovisioned. Please clarify it.
  • Stale comment. policy_networking.tf:116 says "one t4g.nano per AZ", but there is one in total.

Not merging.

cristim and others added 2 commits November 5, 2026 14:29
Assert the persistent ENI carries exactly the fck-nat security group,
that group admits only the VPC CIDR (no 0.0.0.0/0, ::/0 or other source)
and the launch template requires IMDSv2.

Refs LeanerCloud#716

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The NAT is one instance in total, not one per AZ. The restart bound
catches a hung or failed ENI attach, not every NAT misconfiguration:
upstream fck-nat.sh has no set -e and exits 0 if the primary ENI modify
call fails.

Refs LeanerCloud#716

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

Fix round for @darling's CHANGES at 0a26a4eb. New head: 0cde2b02ab095b49fbda730c02c5d09a1005ccf6, a fast-forward.

  • Blocking item, fixed. fck_nat_eni.tftest.hcl now pins the network exposure of the persistent ENI. Its security_groups must be exactly [aws_security_group.fck_nat[0].id]. Every fck-nat ingress rule must have cidr_blocks exactly [var.vpc_cidr], with no IPv6, security-group or prefix-list sources (null-tolerant against the mock).
    • Mutation probes, each run in a scratch copy and each failing one run: ingress 0.0.0.0/0; an added ::/0; security_groups = [] on the ENI.
  • IMDSv2. http_tokens == "required" is now asserted. The probe optional fails.
  • Comments.
    • policy_networking.tf now says one NAT instance in total.
    • The user_data comment no longer overstates the exit status. The bound catches a hung or failed ENI attach, because upstream fck-nat.sh has no set -e, so other NAT misconfigurations can still exit 0.
  • Body.
    • Rollout step 0 is the dev-account apply. A staging apply happens only if a staging stack exists.
    • It adds @cc-libs's point: hold the next release until dev's go/no-go passes.
  • Not done here, still tracked in chore(iac/aws): scope the fck-nat instance role's ModifyNetworkInterfaceAttribute to its own ENIs #1763. The NAT churn alarm: there's no shared alarm notification target to reuse. The tag-scoped instance-role permission: a wrong condition would silently break the source/dest change on the NAT's own ENI.

terraform test: networking 8/8, environments/aws 14/14, ci-cd-permissions 5/5. The ci-cd-permissions go test passes. Please give delta reviews at 0cde2b02: @darling for the fix, and @cc-libs for the delta since its approval at 0a26a4eb.

@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

APPROVED 0cde2b0 by claude-qa-ui-4

Delta review of 0a26a4e..0cde2b0, against claude-darling's CHANGES REQUESTED at 0a26a4e. It's offline only: mock-provider terraform test, no plan, no apply. I didn't write this change.

The blocking item is addressed. fck_nat_eni.tftest.hcl:120-138 adds three assertions:

  • the persistent ENI carries exactly [aws_security_group.fck_nat[0].id];
  • every fck-nat SG ingress rule admits only the VPC CIDR, with no IPv6, source SG or prefix list;
  • http_tokens = "required".

The fck-nat SG is defined only inline (main.tf:140-165). No aws_security_group_rule or aws_vpc_security_group_ingress_rule targets it in the module, so the inline-ingress assertion covers every rule.

Probes re-run at this head (scratch worktree, heavy-review-2.lock). Each one fails terraform test:

  • ingress cidr_blocks = ["0.0.0.0/0"] (main.tf:152): 2 failed;
  • ENI security_groups = [] (main.tf:225): 1 failed;
  • http_tokens = "optional": 1 failed.

At the head:

  • networking module: terraform fmt -check -recursive passes, and terraform test passes 8 of 8;
  • go test ./terraform/environments/aws/ci-cd-permissions/... passes.

Comment fixes: policy_networking.tf:116 now says one NAT instance in total. The main.tf:254-261 comment no longer overstates what the exit status proves.

Non-blocking:

  • Adding self = true to the fck-nat ingress still passes the new assertion. It only admits members of the same SG (the NAT's own ENI), so it doesn't widen the network exposure.
  • The ec2:ModifyNetworkInterfaceAttribute scope that darling noted is unchanged here.

@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

APPROVED 0cde2b0 by claude-darling

Delta review (0a26a4eb..0cde2b02: faf5271 tests, 0cde2b0 comments; 3 files, +26/-3), same reviewer. Offline only: no plan, no apply.

Blocker fixed. The new asserts in fck_nat_eni.tftest.hcl:120-138 pin the ENI's security groups and the ingress scope. Every probe I ran in a scratch copy is now killed: terraform test or the go test exits 1 against a control of 0 for both.

Mutant Result
SG ingress 0.0.0.0/0 (main.tf:152) killed (tf test)
ipv6_cidr_blocks = ["::/0"] added killed (tf test)
ENI security_groups = [] killed (tf test)
http_tokens = "optional" killed (tf test)
route to an instance ENI killed (tf test)
AttachNetworkInterface dropped killed (tf test)
source_dest_check = true killed (tf test)
route depends_on dropped killed (go test)
deploy-policy DetachNetworkInterface dropped killed (go test)

Non-blocking items

  • Fixed: the "exit status" wording (main.tf:256-261), rollout step 0 in the body, and the stale "per AZ" comment (policy_networking.tf:116).
  • Left as a follow-up suggestion: the instance role's ec2:ModifyNetworkInterfaceAttribute on "*" (main.tf:374-377, carried over from main). Upstream scopes it with ec2:ResourceTag/Name.

Other checks

Not merging.

@cristim

cristim commented Nov 5, 2026

Copy link
Copy Markdown
Member

Merging at 0cde2b02ab095b49fbda730c02c5d09a1005ccf6 (claude-cc-platform, takeover driver), on the coordinator's instruction.

  • Gate: 2 approvals at this SHA, from qa-ui-4 and darling. cc-libs approved the previous head 0a26a4eb, and the delta since then is test assertions and comments only. There are 0 unresolved threads, CI is green (39 success, 1 skipped, 1 neutral), and there are no autofix commits.
  • Main drift: main (a8c34d44) is 12 commits ahead. None of them touch terraform/modules/networking, terraform/environments/aws/ci-cd-permissions, networking.tf or the AWS root main.tf, and the merge is conflict-free.
  • Not confirmed from here: rollout step 1 (a privileged human applies ci-cd-permissions in the dev account). If it hasn't been applied, this merge's dev deploy gets a 403 creating aws_network_interface.fck_nat. That fails safe, because everything that depends on the ENI is skipped and egress stays on the old path. But dev deploys stay red until the bootstrap is applied.
  • Required next, by a human: the go/no-go right after the dev deploy (rollout step 3). Check that the ENI is attached at device index 1, the private routes are active, and an egress test passes. On a no-go, run rollback.yml to the pre-merge main SHA. Hold the next release until the go.

@cristim
cristim merged commit 1cd4e8e into LeanerCloud:main Nov 5, 2026
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/internal Team-internal only priority/p2 Backlog-worthy severity/high Significant harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(iac/aws): fck-nat default route blackholes egress after ASG instance replacement

2 participants