Skip to content

feat(db): automated migrations, applied to prod in CI and runnable locally - #293

Merged
nourshoreibah merged 3 commits into
mainfrom
worktree-db-migrations
Jul 29, 2026
Merged

feat(db): automated migrations, applied to prod in CI and runnable locally#293
nourshoreibah merged 3 commits into
mainfrom
worktree-db-migrations

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

Why

The whole schema was one file, apps/backend/db/db_setup.sql, whose first line was DROP SCHEMA IF EXISTS branch CASCADE;. There was no migration system — the root package.json's TypeORM migration:* scripts pointed at apps/backend/src/data-source.ts, which doesn't exist.

So:

  • Nothing applied schema changes to production. RDS was built by hand, and running the actual file against it would have destroyed the database.
  • A schema change needed make down-v locally, because db_setup.sql was mounted at docker-entrypoint-initdb.d and only ran on an empty volume.
  • Types could only be regenerated by CI — there was no local script.

BACKEND_ANALYSIS.md item 20 already flagged all of this; this PR closes it.

What

Migrations are plain .sql files in apps/backend/db/migrations/, applied by kysely's Migrator and tracked in branch.kysely_migration (one row per applied migration).

Contributor flow is two commands:

make new-migration NAME=add_thing   # generates migrations/<utc>_add_thing.sql
# ...write SQL...
make migrate                        # applies it, seeds if empty, regenerates types

make migrate folds in type regeneration so it can't be forgotten. make show-migrations reports applied vs pending. Both run inside the migrator container — on the host, localhost:5432 can resolve to a separate postgres install and silently generate types from the wrong database (this bit me while building it).

In CI, a new migrate job in lambda-deploy.yml snapshots RDS and applies pending migrations on merge to main, and deploy needs it — so a failed migration leaves production on the old code and the old schema. Schema-newer-than-code is possible; code-newer-than-schema is not.

Review notes

Why kysely's Migrator and not a hand-rolled runner? The ~30-line provider buys pg_advisory_xact_lock (two concurrent deploys can't interleave), all-or-nothing transaction semantics, and #ensureNoMissingMigrations. That last one I verified fires: deleting an applied migration errors with corrupted migrations: previously executed migration ... is missing rather than silently re-applying.

Why plain SQL and not kysely's TS migration files? Contributors write the SQL they already know, no TS toolchain is needed wherever migrations run, and the baseline is a copy of db_setup.sql rather than a hand-translation — a translation is a chance to silently diverge from the hand-built production database.

Credentials. The migrate job uses a new, narrowly scoped branch-ci-migrate OIDC role, assumable only from the new production-db environment, and reads DB_HOST/USER/PASSWORD/NAME off the deployed branch-auth lambda config — the same trick preview-env.yml already uses. No copy of the production password is created anywhere, so rotating it in Infisical needs no CI change. TLS is verified against the AWS CA bundle. production-db deliberately has no required reviewers: deploy depends on migrate, so a pending approval would strand a merged PR with its schema applied and its code undeployed.

allowUnorderedMigrations: true is a deliberate call, not a default someone left. Out-of-order merges are routine (Alice authors first, Bob merges first); with the default false the next production deploy fails with corrupted migrations, fixable only by hand-editing kysely_migration in RDS.

The baseline is the one risky file. 0000_baseline_schema.sql is the only migration allowed to use IF NOT EXISTS, which is what lets it adopt the existing production database. Please read it by hand — if DROP SCHEMA had survived the split, the first production migrate would destroy the database. Two guards exist anyway: migrations-guard rejects the statement, and the migrate job refuses to run if the baseline still shows as pending.

One-time step before this can merge: production must be adopted by hand — pg_dump diff against the baseline, then npm run migrate against RDS with a human watching. Procedure is in apps/backend/db/README.md. Terraform (aws/oidc.tf, github/main.tf) must also be applied before the workflow runs on main, or the migrate job fails at AssumeRoleWithWebIdentity.

Also in here

  • db_setup.sql split into migrations/0000_baseline_schema.sql (DDL, idempotent) and seed.sql (dev/test only, never applied to prod). Verified lossless: pg_dump of the old file vs migrations+seed is byte-identical for both --schema-only and --data-only, modulo CURRENT_TIMESTAMP defaults.
  • 9 test files that re-ran db_setup.sql per test now use db/testkit.tsensureSchema() in beforeAll rebuilds only when the migration files changed (fingerprint stored as a schema comment), resetData() in beforeEach truncates with RESTART IDENTITY and reseeds. Faster than before (no DDL per test), and the truncate list is discovered from pg_tables so tables added by future migrations reset automatically.
  • A one-shot migrator compose service applies migrations on every make up; the six lambdas gate on service_completed_successfully. make up switches to --abort-on-container-failure--abort-on-container-exit would have torn the stack down the moment the one-shot service succeeded.
  • PR gates fold into the existing lambda-tests check, so branch protection needs no change and there's no risk of wedging the merge queue on a mistyped context. migrations-fresh applies everything to an empty Postgres, asserts idempotency, and fails on stale types; migrations-guard enforces append-only migrations, filenames, and no destructive SQL.
  • A sticky bot comment on any PR with a migration: apply and test locally, keep it separate from unrelated changes, follow expand/contract.
  • regenerate-db-types.yaml now triggers off migrations/** and calls the same npm run types a contributor runs. The kysely-ColumnType rewrite moves out of an inline node -e heredoc into db/src/postprocess-types.ts, so local and CI output are identical by construction. Node 23 → 20 to match the nodejs20.x runtime.
  • backup_retention_period = 7 on RDS. It was unset, so PITR was whatever the API defaulted to — possibly 0. Free at this storage size, and the backstop for automated migrations.
  • Removes the dead TypeORM scripts and the unused kysely-codegen.config.js (its outFile pointed at a nonexistent path).

Verification

Run locally, from an empty volume:

  • full flow: make upmake new-migrationmake migratemake show-migrations
  • baseline adopts a pre-existing hand-built schema as a no-op, recording itself and not duplicating its rows
  • a second migration applies, re-running is a clean no-op, and types regenerate (confirmed a type-changing migration adds the field, and an index-only one produces no diff)
  • generated types are byte-identical to the committed file, and the ledger tables don't leak into them
  • make db-reset, seed --if-empty, and the deleted-migration guard all behave
  • 452 tests pass across all six lambdas

4 tests still fail on my machine (auth 3, donors 1). They fetch("http://localhost:3000/...") and need the lambda dev server, which my own Next.js app occupies. I confirmed they fail identically on the unmodified files — same test names, same counts — so this change is neutral on them. CI starts the dev server properly, so they should pass there.

Not verified, because it needs real AWS: the migrate job's OIDC assume, the snapshot, and the Slack thread step.

🤖 Generated with Claude Code

…cally

The whole schema was one file, apps/backend/db/db_setup.sql, whose first line
was `DROP SCHEMA IF EXISTS branch CASCADE;`. There was no migration system --
the root package.json's TypeORM migration:* scripts pointed at
apps/backend/src/data-source.ts, which does not exist. Consequently nothing
applied schema changes to production: RDS was built by hand, and running the
actual file against it would have destroyed the database. Locally a schema
change needed `make down-v`, because db_setup.sql was mounted at
docker-entrypoint-initdb.d and only ran on an empty volume. Types could only be
regenerated by CI.

Migrations are now plain .sql files in apps/backend/db/migrations/, applied by
kysely's Migrator and tracked in branch.kysely_migration. Chosen over a
hand-rolled runner for the pg advisory lock, all-or-nothing transaction
semantics, and detection of deleted or renamed applied migrations; chosen over
kysely's TS migration files so contributors write the SQL they already know and
so the baseline is a copy of db_setup.sql rather than a hand-translation of it.

Contributor flow, two commands:

    make new-migration NAME=add_thing   # generates migrations/<utc>_add_thing.sql
    make migrate                        # applies, seeds if empty, regenerates types

make migrate folds in type regeneration so it cannot be forgotten, and
make show-migrations reports applied vs pending from the ledger. Both run in the
migrator container rather than on the host: on the host, localhost:5432 may
resolve to a separate postgres install and silently generate types from the
wrong database.

In CI, a new `migrate` job in lambda-deploy.yml snapshots RDS and applies
pending migrations on merge to main, and `deploy` needs it -- so a failed
migration leaves production on the old code AND the old schema. It authenticates
with a new, narrowly scoped branch-ci-migrate OIDC role (assumable only from the
new production-db environment) and reads the DB connection off the deployed
branch-auth lambda config, the same trick preview-env.yml already uses, so no
copy of the production password is created anywhere. TLS is verified against the
AWS CA bundle. The environment has no required reviewers on purpose: a pending
approval would strand a merged PR with its schema applied and its code
undeployed.

PR-time gates fold into the existing lambda-tests check, so branch protection
needs no change: migrations-fresh applies every migration to an empty Postgres,
asserts idempotency, and fails if the committed types are stale;
migrations-guard rejects edits to already-merged migrations, bad filenames, and
statements that would break the currently deployed code. A sticky bot comment
tells the author to apply and test locally, keep the migration separate from
unrelated changes, and follow expand/contract.

Also here:

- db_setup.sql is split into migrations/0000_baseline_schema.sql (idempotent, so
  it adopts the hand-built production database) and seed.sql (dev/test only,
  never applied to prod). Verified lossless: pg_dump of the old file and of
  migrations+seed are byte-identical for both schema and data, modulo
  CURRENT_TIMESTAMP defaults.
- The 9 test files that re-ran db_setup.sql per test now use db/testkit.ts:
  ensureSchema() in beforeAll rebuilds only when the migration files changed
  (fingerprint stored as a schema comment), resetData() in beforeEach truncates
  with RESTART IDENTITY and reseeds. Faster than before (no DDL per test), and
  the truncate list is discovered from pg_tables so future tables reset
  automatically.
- A one-shot `migrator` compose service applies migrations on every `make up`;
  the six lambdas gate on service_completed_successfully. `make up` switches to
  --abort-on-container-failure, since --abort-on-container-exit would tear the
  stack down the moment the one-shot service succeeded.
- regenerate-db-types.yaml triggers off migrations/**, builds the schema via the
  migrator, and calls the same `npm run types` a contributor runs -- the
  kysely-ColumnType rewrite moves out of an inline node -e heredoc into
  db/src/postprocess-types.ts, so local and CI output are identical by
  construction. Node 23 -> 20 to match the nodejs20.x runtime.
- RDS gains backup_retention_period = 7 (it was unset, so PITR was whatever the
  API defaulted to) — free at this storage size, and the backstop for automated
  migrations.
- Removes the dead TypeORM scripts and the unused kysely-codegen.config.js,
  whose outFile pointed at a nonexistent path.

Docs: new apps/backend/db/README.md covers the expand/contract rules and the
one-time production adoption procedure; AGENTS.md, READMEs, and the preview-env
comments are updated (previews still never migrate, and now say why, plus the
consequence that a schema PR cannot be fully previewed).

Verified locally: full flow from an empty volume; baseline adopts a pre-existing
schema as a no-op without touching its rows; a second migration applies, is
idempotent, and regenerates types; deleting an applied migration fails loudly;
452 tests pass across all six lambdas. The 4 remaining failures need the lambda
dev server on port 3000 and fail identically before and after this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

This PR contains a database migration

  • apps/backend/db/migrations/0000_baseline_schema.sql

It will be applied to the production database automatically when this PR merges, before the new lambda code is deployed. Please confirm before requesting review:

  • Applied and tested locally. cd apps/backend && make migrate, then run the affected lambda's tests (cd apps/backend/lambdas/<name> && npm test). make show-migrations shows what applied.
  • Safe for the code that is live right now. During the deploy window -- and indefinitely if the deploy fails -- the currently deployed lambdas run against your new schema. Additive changes (CREATE TABLE, nullable ADD COLUMN, CREATE INDEX) are fine in one PR. DROP COLUMN, renames, ADD COLUMN NOT NULL with no default, and new UNIQUE/CHECK/FOREIGN KEY constraints need two merged PRs -- see the expand/contract rules in apps/backend/db/README.md.
  • Kept separate from unrelated changes. A migration PR should ideally contain the migration, the code that needs it, and nothing else. It changes production state, it is the one thing here that redeploying cannot roll back, and a reviewer should be able to see the whole schema change without scrolling past unrelated work.
  • No already-merged migration was edited. Fix an old migration by adding a new one; there is no down.

shared/types/db-types.d.ts is regenerated and pushed to this branch automatically -- don't hand-edit it. Expect one red migrations-fresh check before that commit lands.

This PR also changes 32 files outside apps/backend/db/. If any are unrelated to this schema change, consider splitting them into a separate PR.

  - Auto-formatted .tf files with terraform fmt
  - Updated README.md with terraform-docs

  Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

Manual snapshots never expire on their own and survive instance deletion, so the
migrate job has to prune them. Five is plenty: anything older than the last few
migrations is better served by the 7-day point-in-time recovery window that the
same PR turns on, and neither lever is a one-click restore anyway.

Also documents both rollback levers in db/README.md, since the snapshot and PITR
cover different things (a named commit-tagged restore point vs any second in a
7-day window).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/github

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.infisical_secrets.infisical_folder: Reading...
data.infisical_secrets.cognito_folder: Reading...
data.infisical_secrets.slack_folder: Reading...
data.infisical_secrets.github_folder: Reading...
data.infisical_secrets.slack_folder: Read complete after 0s
data.infisical_secrets.github_folder: Read complete after 0s
data.infisical_secrets.cognito_folder: Read complete after 0s
data.infisical_secrets.infisical_folder: Read complete after 0s
github_repository.branch: Refreshing state... [id=branch]
github_actions_secret.gh_pat: Refreshing state... [id=branch:GH_PAT]
github_branch_protection.bot_state: Refreshing state... [id=BPR_kwDOPjZxzc4EU_R9]
github_actions_secret.infisical_client_id: Refreshing state... [id=branch:INFISICAL_CLIENT_ID]
github_actions_secret.infisical_client_secret: Refreshing state... [id=branch:INFISICAL_CLIENT_SECRET]
github_branch_protection.main: Refreshing state... [id=BPR_kwDOPjZxzc4D-9hQ]
github_actions_secret.slack_bot_token: Refreshing state... [id=branch:SLACK_BOT_TOKEN]
github_actions_secret.cognito_user_pool_id: Refreshing state... [id=branch:COGNITO_USER_POOL_ID]
github_repository_environment.preview: Refreshing state... [id=branch:preview]
github_actions_secret.cognito_client_id: Refreshing state... [id=branch:COGNITO_CLIENT_ID]
github_branch_default.main: Refreshing state... [id=branch]
github_branch.bot_state: Refreshing state... [id=branch:bot-state]
github_repository_file.bot_state_json: Refreshing state... [id=branch:state.json:bot-state]
github_repository_file.bot_config_json: Refreshing state... [id=branch:config.json:bot-state]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place

Terraform will perform the following actions:

  # github_repository.branch will be updated in-place
  ~ resource "github_repository" "branch" {
      ~ has_downloads                           = false -> true
        id                                      = "branch"
        name                                    = "branch"
        # (40 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }

  # github_repository_environment.production_db will be created
  + resource "github_repository_environment" "production_db" {
      + can_admins_bypass   = true
      + environment         = "production-db"
      + id                  = (known after apply)
      + prevent_self_review = false
      + repository          = "branch"
      + repository_id       = (known after apply)
    }

Plan: 1 to add, 1 to change, 0 to destroy.

Warning: Argument is deprecated

  with github_repository.branch,
  on main.tf line 6, in resource "github_repository" "branch":
   6:   has_downloads   = true

This attribute is no longer in use, but it hasn't been removed yet. It will
be removed in a future version. See
https://github.com/orgs/community/discussions/102145#discussioncomment-8351756

(and 7 more similar warnings elsewhere)

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@github-actions

Copy link
Copy Markdown
Contributor

Database Types Check Complete

The database schema files were modified, but the regenerated TypeScript types are identical to the existing ones.

No changes were needed and the type definitions are already up to date.

@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
data.aws_caller_identity.current: Reading...
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
data.aws_vpc.default: Reading...
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
data.infisical_secrets.github_folder: Reading...
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.github_folder: Read complete after 0s
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3474878989]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_4XX]
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_5XX]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
data.infisical_secrets.rds_folder: Read complete after 0s
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=xkazax]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=elsvn3]
data.aws_vpc.default: Read complete after 0s [id=vpc-0a9ccfb59c8918ce9]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=14khv0]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=kmwcxq]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=4sjlu3]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=srhf9j]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_security_group.rds: Refreshing state... [id=sg-0c8a1ff1676a14edb]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=od3a3y]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place

Terraform will perform the following actions:

  # aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be updated in-place
  ~ resource "aws_api_gateway_gateway_response" "cors" {
        id                  = "aggr-2apxzxb0r8-DEFAULT_4XX"
      ~ response_templates  = {
          - "application/json" = "{\"message\":$context.error.messageString}" -> null
        }
        # (5 unchanged attributes hidden)
    }

  # aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be updated in-place
  ~ resource "aws_api_gateway_gateway_response" "cors" {
        id                  = "aggr-2apxzxb0r8-DEFAULT_5XX"
      ~ response_templates  = {
          - "application/json" = "{\"message\":$context.error.messageString}" -> null
        }
        # (5 unchanged attributes hidden)
    }

  # aws_db_instance.branch_rds will be updated in-place
  ~ resource "aws_db_instance" "branch_rds" {
      ~ backup_retention_period               = 0 -> 7
      ~ backup_window                         = "03:00-03:30" -> "07:00-08:00"
        id                                    = "db-AMMYFTORW6XJGRELV7WQZCNHQI"
        tags                                  = {}
        # (71 unchanged attributes hidden)
    }

  # aws_iam_role.ci_migrate will be created
  + resource "aws_iam_role" "ci_migrate" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRoleWithWebIdentity"
                      + Condition = {
                          + StringEquals = {
                              + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
                              + "token.actions.githubusercontent.com:sub" = "repo:Code-4-Community/branch:environment:production-db"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Federated = "arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "branch-ci-migrate"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + tags_all              = (known after apply)
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # aws_iam_role_policy.ci_migrate will be created
  + resource "aws_iam_role_policy" "ci_migrate" {
      + id          = (known after apply)
      + name        = "db-migrate"
      + name_prefix = (known after apply)
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = [
                          + "lambda:GetFunctionConfiguration",
                        ]
                      + Effect   = "Allow"
                      + Resource = "arn:aws:lambda:us-east-2:489881683177:function:branch-auth"
                      + Sid      = "ReadDbConnectionFromDeployedLambda"
                    },
                  + {
                      + Action   = [
                          + "rds:DescribeDBInstances",
                          + "rds:DescribeDBSnapshots",
                        ]
                      + Effect   = "Allow"
                      + Resource = "*"
                      + Sid      = "ResolveInstanceAndSnapshots"
                    },
                  + {
                      + Action   = [
                          + "rds:CreateDBSnapshot",
                          + "rds:DeleteDBSnapshot",
                          + "rds:AddTagsToResource",
                        ]
                      + Effect   = "Allow"
                      + Resource = [
                          + "arn:aws:rds:us-east-2:489881683177:db:terraform-20251009192006341800000001",
                          + "arn:aws:rds:us-east-2:489881683177:snapshot:branch-premigrate-*",
                        ]
                      + Sid      = "PreMigrationSnapshots"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + role        = (known after apply)
    }

Plan: 2 to add, 3 to change, 0 to destroy.

Changes to Outputs:
  + ci_migrate_role_arn                 = (known after apply)

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@nourshoreibah nourshoreibah added the no-review The PR review bot won't run label Jul 29, 2026
@nourshoreibah
nourshoreibah marked this pull request as ready for review July 29, 2026 04:42
@nourshoreibah
nourshoreibah merged commit ce65896 into main Jul 29, 2026
23 checks passed
@nourshoreibah
nourshoreibah deleted the worktree-db-migrations branch July 29, 2026 04:42
nourshoreibah added a commit that referenced this pull request Jul 29, 2026
terraform-apply failed on main after #293:

    InvalidParameterValue: The backup window and maintenance window must not overlap.

`backup_retention_period = 0 -> 7` was the change that mattered — production had no
point-in-time recovery. The `backup_window = "07:00-08:00"` next to it was an
unnecessary timing tweak, and AWS assigns both the backup and maintenance windows
without Terraform managing either, so pinning one guesses against the other.

Dropping it. The windows AWS assigned already don't overlap, and a backup of a few MB
on a db.t3.micro is seconds of I/O, so there was nothing to gain. If specific times
are ever wanted, pin backup_window and maintenance_window together.

The failed apply had already created aws_iam_role.ci_migrate before erroring;
re-applying reconciles the rest, nothing needs manual cleanup.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-review The PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant