Skip to content

feat(app-yaml): adopt an existing RDS instance and Route53 record - #155

Merged
Alexanderamiri merged 1 commit into
mainfrom
feat/moresleep-adopt
Oct 26, 2026
Merged

Alexanderamiri merged 1 commit into
mainfrom
feat/moresleep-adopt

Conversation

@Alexanderamiri

@Alexanderamiri Alexanderamiri commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Why

Today app.yaml can only create resources. javaBin/moresleep (sleepingpill.javazone.no) is moving onto the platform with no architectural change, so it has to keep two existing things:

  • RDS dbmoresleep (Postgres 17, public endpoint, serves the program API since 2021). Not in any Terraform state.
  • The hand-made Route53 A record sleepingpill.javazone.no. routing.host would fail on apply because the record already exists.

moresleep also reads DBHOST and DBPORT separately, but the RDS modules only expose endpoint (host:port).

What

app.yaml (validated in scripts/generate-modules.py):

routing:
  host: sleepingpill.javazone.no
  priority: 120
  adopt_dns: true              # take over the existing record
resources:
  databases:
    - name: main
      engine: postgres
      adopt: dbmoresleep       # import, never create
      host_env: DBHOST         # new, postgres only (created or adopted)
      port_env: DBPORT
      deletion_protection: true          # default true
      backup_retention_period: 14        # optional; unset = keep the live value
  • databases[].adopt generates module "rds_<name>" from the new terraform/modules/service-rds-adopted, plus:
    import {
      to = module.rds_main.aws_db_instance.this
      id = "dbmoresleep"
    }
    • The module manages only tags, deletion_protection, an optional backup_retention_period, and the Terraform-only final-snapshot settings: skip_final_snapshot=false, a final snapshot id, delete_automated_backups=false.
    • instance_class (required by the provider) is read from the live instance. Every other argument is in ignore_changes, and prevent_destroy is set.
    • instance_class, allocated_storage, engine_version and multi_az are rejected together with adopt. adopt is rejected for dynamodb or an invalid identifier.
    • No task-role policy is added, because the app keeps its own password auth.
    • Once the resource is in state, Terraform skips the import block, so it stays in the generated file.
  • host_env / port_env map to address / port outputs. service-rds gains an address output, and the adopted module's port is a string.
  • routing.adopt_dns: true passes allow_dns_overwrite = true to service-routing, which sets allow_overwrite on the record, so Route53 UPSERTs the existing record instead of failing on CREATE. The value is null when unset, so existing apps' records and generated files do not change.
    • I chose this over an import block on purpose. moresleep runs in parallel on moresleep.javazone.no first and then changes host. At that point the routing record address is already in state, so Terraform would skip an import and the replacement CREATE would fail. allow_overwrite covers both the fresh case and the host-change case.
  • Each adoption is approved in a platform PR (review fix). adopt and adopt_dns only validate when the identifier or host is listed for the app's team in ADOPTIONS in scripts/registry.py. This PR lists moresleep: rds dbmoresleep, dns sleepingpill.javazone.no. Why: Route53 records in the shared zones have no team ABAC, and DenyXTeam only fires on resources tagged with another team. Without the allowlist, any team could take over another app's hostname (host: submit.javazone.no + adopt_dns: true), or adopt, tag away and later delete an untagged legacy database with one app.yaml line. The generator always runs from platform main, so a team can't edit the list itself.
  • The adopted instance's final snapshot is <identifier>-final-<random_id hex> (stable per state), so a later delete/re-adopt cycle can't collide with an old snapshot. The module pins hashicorp/aws >= 5.40, < 6.0 because dedicated_log_volume is in ignore_changes.
  • Docs: app-yaml-reference.md (an "Adopting an existing PostgreSQL instance" section, adopt_dns, host_env/port_env), reusable-modules.md, terraform/modules/README.md and the module table in CLAUDE.md.
  • scripts/registry.py: MODULE_SOURCES gains service-rds-adopted.

How verified

  • python3 -m unittest discover -s scripts/tests: 59 tests OK, 16 of them new in AdoptTest (3 cover the approval list: another team's database, another team adopting dbmoresleep, an unapproved host). The existing fmt test now also runs on the moresleep fixture. terraform/lambda-src/tests: 64 OK.
  • terraform fmt -check -recursive terraform/modules is clean. terraform validate passes for service-rds-adopted, service-routing and service-rds (init -backend=false).
  • A local, read-only terraform plan against the real account (local backend, admin profile, never applied):
    • Minimal root with the module plus the import block:
      Plan: 1 to import, 0 to add, 1 to change, 0 to destroy (before the review fix; the random_id suffix now adds 1 to add). The only diffs are deletion_protection false -> true, tags_all (+5 default tags), and the Terraform-only skip_final_snapshot, final_snapshot_identifier and delete_automated_backups. Engine, class, storage, security group, publicity, PI and monitoring are untouched.
    • The generated moresleep phase-1 root (moresleep.javazone.no, all modules, local module sources):
      Plan: 1 to import, 22 to add, 1 to change, 0 to destroy. It stops only at reading IAM Role (javabin-ecs-exec-moresleep), which is created when the moresleep team is registered (javaBin/registry#40).
  • This PR's own Terraform Plan / Unit Tests checks are below.

Follow-ups

  • Companion PR feat(ci): deploy from the default branch and offer Postgres to JVM tests #154: deploy from the default branch (moresleep uses master) and Postgres for JVM tests.
  • Security workstream: dbmoresleep sits in the default VPC with the default SG open to 0.0.0.0/0. This PR deliberately leaves the SG alone.
  • An adopted instance is only reachable if its network allows the platform NAT. This is documented, not automated. The docs now say to allow only the NAT address (plus legacy clients) on a public instance's security group, never 0.0.0.0/0. All traffic to such an instance goes through the NAT and pays data processing.
  • Tagging the remaining untagged legacy resources (so DenyXTeam protects them) is still a security-workstream item. The approval list closes the app.yaml path, not hand-written Terraform.

🤖 Generated with Claude Code

@Alexanderamiri
Alexanderamiri requested a review from a team as a code owner October 24, 2026 13:02
@github-actions

Copy link
Copy Markdown

Terraform Plan

✅ No changes — infrastructure is up to date.

Plan output
Acquiring state lock. This may take a few moments...
aws_ssm_parameter.atlassian_org_id: Refreshing state... [id=/javabin/platform/atlassian-org-id]
aws_ssm_parameter.atlassian_api_key: Refreshing state... [id=/javabin/platform/atlassian-api-key]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

@github-actions

Copy link
Copy Markdown

Terraform Plan

✅ No changes — infrastructure is up to date.

Plan output
Acquiring state lock. This may take a few moments...
aws_ssm_parameter.atlassian_org_id: Refreshing state... [id=/javabin/platform/atlassian-org-id]
aws_ssm_parameter.atlassian_api_key: Refreshing state... [id=/javabin/platform/atlassian-api-key]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
Releasing state lock. This may take a few moments...

databases[].adopt imports an existing Postgres instance through the new
service-rds-adopted module (tags, deletion protection, final-snapshot
settings only; prevent_destroy), host_env/port_env expose address and
port, and routing.adopt_dns takes over an existing A record. Each
adoption must be listed in ADOPTIONS in scripts/registry.py.

Rebased onto routing.login (#158): routing accepts both login and
adopt_dns, and service-routing takes login_rules and allow_dns_overwrite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 0 to add, 1 to change, 0 to destroy.

Plan output
Acquiring state lock. This may take a few moments...
aws_ssm_parameter.atlassian_org_id: Refreshing state... [id=/javabin/platform/atlassian-org-id]
aws_ssm_parameter.atlassian_api_key: Refreshing state... [id=/javabin/platform/atlassian-api-key]

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

Terraform will perform the following actions:

  # module.identity.aws_cognito_identity_provider.internal_google[0] will be updated in-place
  ~ resource "aws_cognito_identity_provider" "internal_google" {
        id                = "eu-central-1_Icikv3dtD:Google"
      ~ provider_details  = {
          ~ "authorize_url"                 = "https://accounts.google.com/o/oauth2/v2/auth" -> "https://accounts.google.com/o/oauth2/v2/auth?hd=java.no&prompt=select_account"
            # (8 unchanged elements hidden)
        }
        # (5 unchanged attributes hidden)
    }

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

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

Saved the plan to: tfplan

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

LLM Review

Risk: 🟢 LOW

Plan makes one in-place update to a Cognito Google identity provider, changing only the authorize_url to add hd=java.no and prompt=select_account query parameters; no creates or destroys.

  • ✅ [routine] In-place update to module.identity.aws_cognito_identity_provider.internal_google[0] (Plan: 0 to add, 1 to change, 0 to destroy). Only provider_details.authorize_url changes; the resource is not replaced and the provider ID is unchanged.
  • 🔒 [security] The new hd=java.no parameter asks Google to restrict sign-in to java.no Google Workspace accounts (a hint only; Google enforces it on the consent screen). This is consistent with an internal-only login and does not open access to the public or widen the Cognito user pool. Worth confirming the hd restriction is intended, since users outside the java.no domain will be unable to sign in through this provider.
  • 🔒 [security] prompt=select_account forces the Google account chooser on each login. This is a UX change with no security downside.
  • 💰 [cost] No new billable resources are created. Cognito pricing is unaffected by changing an identity provider's URL.
  • 💥 [destruction] No resources are destroyed and no data loss is possible from this change.

@Alexanderamiri
Alexanderamiri merged commit a2388e8 into main Oct 26, 2026
4 checks passed
@Alexanderamiri
Alexanderamiri deleted the feat/moresleep-adopt branch October 26, 2026 14:26
Alexanderamiri added a commit that referenced this pull request Oct 26, 2026
app.yaml calls/provides declare private app-to-app edges, optionally with
OAuth scopes. Every app gets its own task security group and a Cloud Map
name in the new javabin.internal namespace; app-peers-sync opens ingress
only where both sides agree and manages the M2M resource servers and
clients. The developer boundary protects the shared namespace and the
private zone's records (6,067 of 6,144 bytes).

Rebased onto routing.login (#158), RDS/DNS adoption (#155), Lambda log
retention (#159, adds the app-peers-sync log group) and the app-auth-sync
restart rules (#167).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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