diff --git a/local-app/python-tools/route53-migration/README.md b/local-app/python-tools/route53-migration/README.md new file mode 100755 index 00000000..40e2d700 --- /dev/null +++ b/local-app/python-tools/route53-migration/README.md @@ -0,0 +1,520 @@ +# Route 53 Zone Migration Toolset + +A collection of Python scripts for safely migrating Route 53 hosted zones between AWS accounts. The toolset covers every stage of the migration lifecycle: inspection, export, import, VPC disassociation, and zone deletion — with dry-run support, safety backups, and interactive confirmations throughout. + +--- + +## Table of Contents + +1. [Scripts Overview](#scripts-overview) +2. [Prerequisites](#prerequisites) +3. [Migration Workflow](#migration-workflow) + - [Step 1 — Describe the Source Zone](#step-1--describe-the-source-zone) + - [Step 2 — List and Export Records](#step-2--list-and-export-records) + - [Step 3 — Import Records into Destination Account](#step-3--import-records-into-destination-account) + - [Step 4 — Disassociate VPCs from the Source Zone](#step-4--disassociate-vpcs-from-the-source-zone) + - [Step 5 — Delete Records and Zone from Source Account](#step-5--delete-records-and-zone-from-source-account) +4. [Commercial vs GovCloud Considerations](#commercial-vs-govcloud-considerations) +5. [Public vs Private Zone Considerations](#public-vs-private-zone-considerations) +6. [VPC Association Considerations](#vpc-association-considerations) +7. [IAM Permissions Required](#iam-permissions-required) +8. [Script Reference](#script-reference) +9. [Changelog](#changelog) + +--- + +## Scripts Overview + +| Script | Purpose | +|---|---| +| `describe_zone.py` | Inspect a hosted zone: metadata, record count, delegation set, VPC associations, tags, DNSSEC | +| `list_records.py` | List and export all resource records from a hosted zone | +| `import_records.py` | Import records into a destination zone; optionally creates the zone | +| `disassociate_vpcs.py` | Remove VPC associations from a private hosted zone | +| `delete_zone.py` | Delete all records from a zone and optionally delete the zone itself | + +--- + +## Prerequisites + +**Python version:** 3.10 or later (uses `match`/structural pattern syntax in type hints). + +**Dependencies:** + +```bash +pip install boto3 +``` + +**AWS credentials:** All scripts accept a `--profile` argument corresponding to a named profile in `~/.aws/credentials` or `~/.aws/config`. Ensure the profile has the necessary IAM permissions listed in [IAM Permissions Required](#iam-permissions-required). + +**boto3 session behavior:** Route 53 is a global service. The `--region` flag affects the EC2/STS calls only; Route 53 API calls always go to `us-east-1` regardless of region set. + +--- + +## Migration Workflow + +The recommended order of operations is shown below. Always begin with a dry run at each step before executing destructive actions. + +``` +describe_zone.py ← inspect source zone + ↓ +list_records.py ← export all records to JSON + ↓ +import_records.py ← recreate zone and records in destination account + ↓ + [validate DNS] ← manual: test resolution in destination account + ↓ + [update consumers] ← manual: update Terraform, RAM sharing, VPC associations + ↓ +disassociate_vpcs.py ← remove VPC associations from source zone + ↓ +delete_zone.py ← empty and delete source zone +``` + +--- + +### Step 1 — Describe the Source Zone + +Before touching anything, collect a full picture of the zone. This output will guide every subsequent step and should be saved for your change record. + +```bash +python describe_zone.py \ + --zone-id Z1234567890ABC \ + --profile source-account \ + --output-json zone_info.json +``` + +Review the output carefully: + +- **Record count** — the exact paginated count is authoritative; the AWS estimate can lag. +- **Private zone** — if `True`, note all associated VPC IDs and regions. You will need these for Step 3 and Step 4. +- **Associated VPCs** — list the VPC IDs. If they are owned by accounts other than the zone's account (cross-account association via RAM or manual cross-account association), identify those accounts now. You will need to update Terraform or manually re-associate those VPCs after the migration. +- **Delegation set** — note the name servers. For public zones, consumers (registrar, parent zone NS records) must be updated at cutover. +- **Tags** — tags are not automatically carried over by `import_records.py`. Plan to reapply them in the destination account. +- **DNSSEC** — if enabled, DNSSEC must be configured independently in the destination zone after import. Key signing keys cannot be migrated via the API. + +--- + +### Step 2 — List and Export Records + +Export all resource records to JSON. This file is the source of truth for the import step and serves as a backup. + +```bash +python list_records.py \ + --zone-id Z1234567890ABC \ + --profile source-account \ + --output-json zone_records.json \ + --summary +``` + +Review the summary output. Pay attention to: + +- **Alias records** — aliases pointing to resources in the source account (load balancers, CloudFront distributions, S3 endpoints, API Gateways) will need their targets updated or recreated in the destination account. The import script carries the alias target verbatim; it will not automatically repoint to destination-account resources. +- **NS records (non-apex)** — subdomain delegation NS records are exported and will be re-created. Verify these delegations are still valid after migration. +- **SOA and apex NS** — these are intentionally excluded from the export because AWS auto-generates them for every new hosted zone. + +You can also export to CSV for human review alongside the JSON: + +```bash +python list_records.py \ + --zone-id Z1234567890ABC \ + --profile source-account \ + --output-csv zone_records.csv \ + --quiet +``` + +--- + +### Step 3 — Import Records into Destination Account + +#### Option A — Create a new zone and import + +```bash +# Dry run first +python import_records.py \ + --input-json zone_records.json \ + --zone-name example.internal \ + --profile dest-account \ + --dry-run + +# Execute +python import_records.py \ + --input-json zone_records.json \ + --zone-name example.internal \ + --profile dest-account +``` + +For a **private zone**, include VPC details collected in Step 1: + +```bash +python import_records.py \ + --input-json zone_records.json \ + --zone-name example.internal \ + --private \ + --vpc-id vpc-aaa111bbb \ + --vpc-region us-east-1 \ + --profile dest-account +``` + +Note that only a single VPC can be specified at zone creation time. Additional VPC associations must be added afterward via the console, CLI, or Terraform. + +#### Option B — Import into an existing zone + +If the zone already exists in the destination account (e.g., created by Terraform): + +```bash +python import_records.py \ + --input-json zone_records.json \ + --zone-id Z0987654321XYZ \ + --profile dest-account \ + --action UPSERT +``` + +Using `--action UPSERT` is recommended when the destination zone may already contain some records, as it will overwrite existing matching records rather than failing on conflicts. + +#### After import + +Once records are imported: + +1. Test DNS resolution from within the VPCs that will use the new zone. +2. Update any consumers of the zone — application configurations, other Route 53 records, ACM validation records, etc. +3. If the zone is managed in Terraform, update the `zone_id` references and reapply. For private zones managed by RAM sharing, update the shared resource associations to point to the new zone. +4. For cross-account VPC associations: visit each account that had its VPC associated to the source zone and update the association to the new destination zone, then reapply Terraform in those accounts. + +--- + +### Step 4 — Disassociate VPCs from the Source Zone + +Once consumers have been updated and you have validated DNS resolution against the destination zone, remove VPC associations from the source zone. + +**Important:** AWS does not allow you to remove the last VPC association from a private hosted zone without deleting the zone. If only one VPC remains associated, skip directly to Step 5 and use `delete_zone.py` with `--delete-zone` to remove the zone entirely rather than trying to empty it first. + +```bash +# Dry run — see what would be removed +python disassociate_vpcs.py \ + --zone-id Z1234567890ABC \ + --profile source-account \ + --dry-run + +# Interactive — confirm each VPC one at a time (default) +python disassociate_vpcs.py \ + --zone-id Z1234567890ABC \ + --profile source-account + +# Non-interactive — remove all associations +python disassociate_vpcs.py \ + --zone-id Z1234567890ABC \ + --profile source-account \ + --yes + +# Target specific VPCs only +python disassociate_vpcs.py \ + --zone-id Z1234567890ABC \ + --profile source-account \ + --vpc-ids vpc-aaa111 vpc-bbb222 +``` + +The interactive prompt accepts `y` (yes), `n` (no, skip this VPC), or `q` (quit immediately). + +--- + +### Step 5 — Delete Records and Zone from Source Account + +`delete_zone.py` always writes safety backups before performing any destructive action. The backups are timestamped JSON files written to the current directory (or `--output-dir`) and are identical in format to the outputs of `describe_zone.py` and `list_records.py`. + +```bash +# Dry run — see the plan and where backups would go +python delete_zone.py \ + --zone-id Z1234567890ABC \ + --profile source-account \ + --dry-run + +# Empty records only (leave zone in place) +python delete_zone.py \ + --zone-id Z1234567890ABC \ + --profile source-account + +# Empty records and delete zone +python delete_zone.py \ + --zone-id Z1234567890ABC \ + --profile source-account \ + --delete-zone + +# Non-interactive pipeline use +python delete_zone.py \ + --zone-id Z1234567890ABC \ + --profile source-account \ + --delete-zone \ + --yes \ + --output-dir ./backups/ +``` + +Two separate confirmation prompts are presented when running interactively — one for record deletion and one for zone deletion — so it is not possible to accidentally delete the zone when intending only to empty it. + +SOA and apex NS records are automatically excluded from deletion as AWS requires them and will reject attempts to remove them. Non-apex NS records (subdomain delegation records) are deleted along with all other user-created records. + +If any record deletion batch fails, zone deletion is automatically blocked even when `--yes` is specified, preventing a partially-emptied zone from being deleted. + +**Last VPC association:** If the zone still has one VPC associated (because you could not disassociate it in Step 4), `--delete-zone` will handle this correctly. AWS allows zone deletion even when VPCs are associated — it disassociates them implicitly as part of the delete operation. + +--- + +## Commercial vs GovCloud Considerations + +| Area | Commercial | GovCloud | +|---|---|---| +| API endpoints | Standard `route53.amazonaws.com` | `route53.us-gov.amazonaws.com` — boto3 resolves this automatically when a GovCloud profile/region is configured | +| DNSSEC | Supported | Not supported on private zones; `describe_zone.py` handles this gracefully and reports `NOT_APPLICABLE` | +| Alias targets | ALB, NLB, CloudFront, S3, API GW, etc. | Same service set but GovCloud ARNs and hosted zone IDs differ — alias records must be updated to GovCloud-specific targets | +| Cross-partition migration | Not supported via these scripts | If migrating from commercial to GovCloud or vice versa, resources referenced by alias records will not exist in the target partition and must be recreated | +| RAM sharing | Supported | Supported in GovCloud; cross-account VPC associations follow the same pattern | +| Profile configuration | Standard `~/.aws/credentials` | Requires GovCloud-specific profiles with `us-gov-east-1` or `us-gov-west-1` region | + +When working in GovCloud, ensure your named profile explicitly sets the region: + +```ini +[profile govcloud-source] +aws_access_key_id = ... +aws_secret_access_key = ... +region = us-gov-west-1 +``` + +Pass this profile to all scripts via `--profile govcloud-source`. The `--region` flag should match your GovCloud region. + +--- + +## Public vs Private Zone Considerations + +### Public Zones + +- VPC association steps (`disassociate_vpcs.py`) do not apply. The script will exit cleanly with an error if pointed at a public zone. +- Cutover requires updating NS records at your domain registrar or parent zone to point to the new zone's name servers. The new zone's name servers are shown in the output of `describe_zone.py` run against the destination zone. +- Allow for DNS TTL propagation. Lower the TTL on critical records in the source zone before migration if possible. +- DNSSEC, if in use, must be re-established in the destination zone independently. Plan for a DNSSEC gap during migration or arrange for key rollover. + +### Private Zones + +- VPC associations are the primary operational complexity. Each VPC that resolves against the zone must be explicitly re-associated with the destination zone before the source zone is removed. +- Cross-account VPC associations — where the VPC is in a different account than the zone — require action in both accounts. The destination zone account must authorize the association, and the VPC account must create the association. If this is managed in Terraform, update the `aws_route53_vpc_association_authorization` and `aws_route53_zone_association` resources and reapply in the correct order. +- A private zone must always have at least one VPC association. You cannot disassociate the last VPC. In this situation, proceed directly to zone deletion — AWS implicitly removes all associations when the zone is deleted. +- Private zones do not have a delegation set or public name servers. The `describe_zone.py` output will show `(Private zone — no delegation set)` for the delegation section. +- Resolver rules (Route 53 Resolver inbound/outbound rules) are separate from the hosted zone and are not migrated by these scripts. If your environment uses resolver rules to forward queries to or from on-premises resolvers, those rules must be recreated in the destination account and associated with the appropriate VPCs. + +--- + +## VPC Association Considerations + +Cross-account VPC associations are often the most complex part of a private zone migration. The general approach is: + +1. Use `describe_zone.py` to identify all associated VPCs and their regions. +2. For each VPC, determine which account owns it. If this is managed in Terraform, search your codebase for `aws_route53_zone_association` and `aws_route53_vpc_association_authorization` resources referencing the source zone ID. +3. After the destination zone is created and records are imported, update all Terraform references to use the new zone ID and reapply in each affected account. +4. Validate DNS resolution from each VPC before proceeding to disassociation of the source zone. +5. Run `disassociate_vpcs.py` against the source zone. +6. Run `delete_zone.py` against the source zone. + +The key insight is that VPC associations in other accounts cannot be removed programmatically by the zone owner account — they must be removed by the account that created the association. Terraform reapply in those accounts (after updating the zone ID) is the cleanest way to handle this. + +--- + +## IAM Permissions Required + +### Source account (describe, list, disassociate, delete) + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "route53:GetHostedZone", + "route53:ListResourceRecordSets", + "route53:ListTagsForResource", + "route53:GetDNSSEC", + "route53:GetChange", + "route53:ChangeResourceRecordSets", + "route53:DisassociateVPCFromHostedZone", + "route53:DeleteHostedZone" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "sts:GetCallerIdentity" + ], + "Resource": "*" + } + ] +} +``` + +### Destination account (create zone, import records) + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "route53:CreateHostedZone", + "route53:ChangeResourceRecordSets", + "route53:GetChange", + "route53:AssociateVPCWithHostedZone" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "sts:GetCallerIdentity" + ], + "Resource": "*" + } + ] +} +``` + +--- + +## Script Reference + +### `describe_zone.py` + +Collects and displays full metadata about a hosted zone. + +| Argument | Description | +|---|---| +| `--zone-id` | Hosted zone ID (required) | +| `--profile` | AWS named profile | +| `--region` | AWS region (default: `us-east-1`) | +| `--output-json FILE` | Write report to JSON file | +| `--quiet` | Suppress console output (use with `--output-json`) | + +### `list_records.py` + +Lists all resource records in a hosted zone with export options. + +| Argument | Description | +|---|---| +| `--zone-id` | Hosted zone ID (required) | +| `--profile` | AWS named profile | +| `--region` | AWS region (default: `us-east-1`) | +| `--output-json FILE` | Export records to JSON (consumed by `import_records.py`) | +| `--output-csv FILE` | Export records to CSV for human review | +| `--summary` | Print record type counts | +| `--quiet` | Suppress table output | + +### `import_records.py` + +Imports records into a destination zone. Zone creation is optional. + +| Argument | Description | +|---|---| +| `--input-json FILE` | JSON file from `list_records.py` (required) | +| `--zone-id` | Import into an existing zone (mutually exclusive with `--zone-name`) | +| `--zone-name` | Create a new zone with this name (mutually exclusive with `--zone-id`) | +| `--private` | Create a private hosted zone | +| `--vpc-id` | VPC ID for private zone creation | +| `--vpc-region` | VPC region for private zone creation | +| `--zone-comment` | Comment for newly created zone | +| `--action` | `CREATE` (default) or `UPSERT` (safe for re-runs) | +| `--include-ns` | Include apex NS records (not recommended) | +| `--dry-run` | Show what would be imported without changes | +| `--no-wait` | Do not wait for INSYNC after each batch | +| `--profile` | AWS named profile for destination account | +| `--region` | AWS region (default: `us-east-1`) | + +### `disassociate_vpcs.py` + +Removes VPC associations from a private hosted zone. + +| Argument | Description | +|---|---| +| `--zone-id` | Hosted zone ID (required) | +| `--profile` | AWS named profile | +| `--region` | AWS region (default: `us-east-1`) | +| `--vpc-ids` | Only disassociate these specific VPC IDs (default: all) | +| `--dry-run` | Show what would be removed without changes | +| `--yes` | Non-interactive; skip confirmation prompts | +| `--output-json FILE` | Write results summary to JSON | + +### `delete_zone.py` + +Deletes all records from a zone and optionally deletes the zone. Always writes safety backups first. + +| Argument | Description | +|---|---| +| `--zone-id` | Hosted zone ID (required) | +| `--profile` | AWS named profile | +| `--region` | AWS region (default: `us-east-1`) | +| `--delete-zone` | Delete the hosted zone after emptying it | +| `--dry-run` | Show the plan without making changes | +| `--yes` | Non-interactive; skip confirmation prompts | +| `--output-dir DIR` | Directory for backup JSON files (default: `.`) | + +--- + +## Changelog + +### `describe_zone.py` + +#### v1.0.0 +- Initial release +- Collects zone metadata, exact record count (paginated), delegation set, associated VPCs, tags, and DNSSEC status +- Supports JSON export and quiet mode + +--- + +### `list_records.py` + +#### v1.0.1 +- Added `VERSION` constant for consistency with migration toolset + +#### v1.0.0 +- Initial release +- Paginated record listing with table display +- JSON and CSV export +- Record type summary + +--- + +### `import_records.py` + +#### v1.0.0 +- Initial release +- Optional zone creation via `--zone-name` or import into existing zone via `--zone-id` +- SOA and apex NS records automatically skipped +- Batched change submission with INSYNC polling +- Supports `CREATE` and `UPSERT` actions +- Private zone creation with VPC association +- Dry-run mode + +--- + +### `disassociate_vpcs.py` + +#### v1.0.0 +- Initial release +- Interactive per-VPC confirmation with `y`/`n`/`q` prompt +- Non-interactive mode via `--yes` +- Dry-run mode +- `--vpc-ids` filter for targeting specific VPCs +- Graceful handling of `LastVPCAssociation` error +- JSON results export + +--- + +### `delete_zone.py` + +#### v1.0.0 +- Initial release +- Always writes timestamped safety backups (records JSON and zone JSON) before any destructive action +- Correctly distinguishes apex NS (skipped) from non-apex NS delegation records (deleted) +- Two separate confirmation prompts for record deletion and zone deletion +- Batch failure safety: blocks zone deletion if any record batch fails +- Dry-run mode shows plan and backup paths without writing files or making changes +- Non-interactive mode via `--yes` +- Configurable backup output directory via `--output-dir`