Skip to content

Commit

Permalink
rename
Browse files Browse the repository at this point in the history
  • Loading branch information
badra001 committed Feb 27, 2026
1 parent c2b671b commit 7d5a417
Show file tree
Hide file tree
Showing 6 changed files with 1,378 additions and 0 deletions.
395 changes: 395 additions & 0 deletions local-app/python-tools/route53-migration/delete_zone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""
delete_zone.py v1.0.0
Remove all deletable resource records from a Route 53 hosted zone,
then optionally delete the zone itself.
SOA and apex NS records are skipped — AWS requires them to be present
and will reject attempts to delete them.
Safety backups of all records and zone metadata are always written
before any destructive action is taken.
Usage:
# Dry run — show what would be deleted, write backups
python delete_zone.py --zone-id Z1234567890ABC --profile source-account --dry-run
# Empty the zone (delete records, keep zone)
python delete_zone.py --zone-id Z1234567890ABC --profile source-account
# Empty and delete the zone
python delete_zone.py --zone-id Z1234567890ABC --profile source-account --delete-zone
# Non-interactive (no confirmation prompts)
python delete_zone.py --zone-id Z1234567890ABC --profile source-account --delete-zone --yes
"""

import argparse
import boto3
import json
import os
import sys
import time
from datetime import datetime, timezone
from typing import Optional

VERSION = "1.0.0"

SKIP_TYPES = {'SOA', 'NS'}
BATCH_SIZE = 100


def get_client(profile: Optional[str], region: str, service: str = 'route53'):
session = boto3.Session(profile_name=profile, region_name=region)
return session.client(service)


def get_account_id(profile: Optional[str]) -> str:
try:
session = boto3.Session(profile_name=profile)
return session.client('sts').get_caller_identity()['Account']
except Exception as e:
return f"UNKNOWN ({e})"


def get_zone_details(client, zone_id: str) -> dict:
try:
response = client.get_hosted_zone(Id=zone_id)
except client.exceptions.NoSuchHostedZone:
print(f"ERROR: Hosted zone '{zone_id}' not found.", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"ERROR fetching zone details: {e}", file=sys.stderr)
sys.exit(1)

return {
'HostedZone': response['HostedZone'],
'DelegationSet': response.get('DelegationSet', {}),
'VPCs': response.get('VPCs', []),
}


def get_all_records(client, zone_id: str) -> list:
records = []
paginator = client.get_paginator('list_resource_record_sets')
for page in paginator.paginate(HostedZoneId=zone_id):
records.extend(page['ResourceRecordSets'])
return records


def get_tags(client, zone_id: str) -> dict:
try:
response = client.list_tags_for_resource(
ResourceType='hostedzone',
ResourceId=zone_id
)
return {tag['Key']: tag['Value'] for tag in response['ResourceTagSet']['Tags']}
except Exception as e:
print(f"WARNING: Could not fetch tags: {e}", file=sys.stderr)
return {}


# ---------------------------------------------------------------------------
# Safety backups
# ---------------------------------------------------------------------------

def backup_filename(zone_id: str, suffix: str, output_dir: str) -> str:
ts = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
return os.path.join(output_dir, f"{zone_id}_{suffix}_{ts}.json")


def write_records_backup(records: list, zone_id: str, output_dir: str) -> str:
path = backup_filename(zone_id, 'records', output_dir)
data = {
'SchemaVersion': '1.0',
'ToolVersion': VERSION,
'HostedZoneId': zone_id,
'RecordCount': len(records),
'ExportedAt': datetime.now(timezone.utc).isoformat(),
'ResourceRecordSets': records,
}
with open(path, 'w') as f:
json.dump(data, f, indent=2, default=str)
print(f" Records backup : {path}")
return path


def write_zone_backup(details: dict, tags: dict, account_id: str,
zone_id: str, output_dir: str) -> str:
path = backup_filename(zone_id, 'zone', output_dir)
hz = details['HostedZone']
config = hz.get('Config', {})
delegation = details.get('DelegationSet', {})

data = {
'SchemaVersion': '1.0',
'ToolVersion': VERSION,
'AccountId': account_id,
'ExportedAt': datetime.now(timezone.utc).isoformat(),
'HostedZone': {
'Id': zone_id,
'Name': hz.get('Name', ''),
'Comment': config.get('Comment', ''),
'PrivateZone': config.get('PrivateZone', False),
'CallerReference': hz.get('CallerReference', ''),
'ResourceRecordSetCount': hz.get('ResourceRecordSetCount', 'N/A'),
},
'DelegationSet': {
'Id': delegation.get('Id', ''),
'CallerReference': delegation.get('CallerReference', ''),
'NameServers': delegation.get('NameServers', []),
},
'AssociatedVPCs': [
{'VPCId': v.get('VPCId', ''), 'VPCRegion': v.get('VPCRegion', '')}
for v in details.get('VPCs', [])
],
'Tags': tags,
}
with open(path, 'w') as f:
json.dump(data, f, indent=2, default=str)
print(f" Zone backup : {path}")
return path


# ---------------------------------------------------------------------------
# Record deletion
# ---------------------------------------------------------------------------

def partition_records(records: list, zone_name: str) -> tuple[list, list]:
"""
Split records into deletable and skipped (SOA + apex NS).
zone_name should be the fully-qualified zone apex (e.g. 'example.com.').
"""
deletable = []
skipped = []

for record in records:
rtype = record['Type']
rname = record['Name']

# Always skip SOA
if rtype == 'SOA':
skipped.append(record)
continue

# Skip apex NS (AWS-managed); non-apex NS delegations are deletable
if rtype == 'NS' and rname == zone_name:
skipped.append(record)
continue

deletable.append(record)

return deletable, skipped


def build_delete_batch(records: list) -> dict:
return {
'Changes': [
{'Action': 'DELETE', 'ResourceRecordSet': r}
for r in records
]
}


def wait_for_change(client, change_id: str, timeout: int = 120):
print(f" Waiting for change {change_id}", end='', flush=True)
deadline = time.time() + timeout
while time.time() < deadline:
status = client.get_change(Id=change_id)['ChangeInfo']['Status']
if status == 'INSYNC':
print(" done.")
return
print('.', end='', flush=True)
time.sleep(5)
print(f"\n WARNING: Change {change_id} did not reach INSYNC within {timeout}s.")


def delete_records(client, zone_id: str, records: list, dry_run: bool) -> dict:
results = {'succeeded': [], 'failed': []}
total = len(records)

if total == 0:
print("\n No deletable records found.")
return results

print(f"\n Deleting {total} records in batches of {BATCH_SIZE}...")

for i in range(0, total, BATCH_SIZE):
chunk = records[i:i + BATCH_SIZE]
batch_num = (i // BATCH_SIZE) + 1
print(f"\n Batch {batch_num} ({len(chunk)} records):")
for r in chunk:
print(f" DELETE {r['Type']:<8} {r['Name']}")

if dry_run:
print(f" [DRY RUN] Skipping actual deletion.")
results['succeeded'].extend([r['Name'] for r in chunk])
continue

try:
batch = build_delete_batch(chunk)
response = client.change_resource_record_sets(
HostedZoneId=zone_id,
ChangeBatch=batch
)
change_id = response['ChangeInfo']['Id'].split('/')[-1]
wait_for_change(client, change_id)
results['succeeded'].extend([r['Name'] for r in chunk])
except Exception as e:
print(f" ERROR in batch {batch_num}: {e}", file=sys.stderr)
results['failed'].extend([r['Name'] for r in chunk])

return results


# ---------------------------------------------------------------------------
# Zone deletion
# ---------------------------------------------------------------------------

def delete_hosted_zone(client, zone_id: str, dry_run: bool) -> bool:
if dry_run:
print(f"\n [DRY RUN] Would delete hosted zone {zone_id}.")
return True
try:
client.delete_hosted_zone(Id=zone_id)
print(f"\n Hosted zone {zone_id} deleted successfully.")
return True
except client.exceptions.HostedZoneNotEmpty as e:
print(f"\n ERROR: Zone is not empty — cannot delete: {e}", file=sys.stderr)
return False
except Exception as e:
print(f"\n ERROR deleting zone: {e}", file=sys.stderr)
return False


# ---------------------------------------------------------------------------
# Confirmation prompt
# ---------------------------------------------------------------------------

def confirm(prompt: str) -> bool:
while True:
answer = input(prompt + " [yes/no]: ").strip().lower()
if answer == 'yes':
return True
elif answer == 'no':
return False
print(" Please type 'yes' or 'no'.")


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
parser = argparse.ArgumentParser(
description=f'Empty a Route 53 hosted zone and optionally delete it. v{VERSION}'
)
parser.add_argument('--zone-id', required=True, help='Hosted Zone ID (e.g., Z1234567890ABC)')
parser.add_argument('--profile', help='AWS profile name')
parser.add_argument('--region', default='us-east-1', help='AWS region (default: us-east-1)')
parser.add_argument('--delete-zone', action='store_true',
help='Delete the hosted zone after emptying it')
parser.add_argument('--dry-run', action='store_true',
help='Show what would happen without making any changes')
parser.add_argument('--yes', action='store_true',
help='Skip confirmation prompts')
parser.add_argument('--output-dir', default='.',
help='Directory for backup JSON files (default: current directory)')
args = parser.parse_args()

zone_id = args.zone_id.split('/')[-1]
client = get_client(args.profile, args.region)
account_id = get_account_id(args.profile)
details = get_zone_details(client, zone_id)
tags = get_tags(client, zone_id)
records = get_all_records(client, zone_id)

hz = details['HostedZone']
zone_name = hz.get('Name', '')
is_private = hz.get('Config', {}).get('PrivateZone', False)

print(f"\ndelete_zone.py v{VERSION}")
print("=" * 60)
print("ZONE DELETION PLAN")
print("=" * 60)
print(f"\n{'Zone ID':<22} {zone_id}")
print(f"{'Zone Name':<22} {zone_name}")
print(f"{'Account ID':<22} {account_id}")
print(f"{'Private Zone':<22} {is_private}")
print(f"{'Total Records':<22} {len(records)}")

deletable, skipped = partition_records(records, zone_name)
print(f"{'Deletable Records':<22} {len(deletable)}")
print(f"{'Skipped (SOA/NS)':<22} {len(skipped)}")
print(f"{'Delete Zone':<22} {args.delete_zone}")
print(f"{'Dry Run':<22} {args.dry_run}")

# Always write safety backups before touching anything
print("\n--- Safety Backups " + "-" * 40)
if args.dry_run:
print(" [DRY RUN] Backups would be written here:")
print(f" Records backup : {backup_filename(zone_id, 'records', args.output_dir)}")
print(f" Zone backup : {backup_filename(zone_id, 'zone', args.output_dir)}")
else:
os.makedirs(args.output_dir, exist_ok=True)
write_records_backup(records, zone_id, args.output_dir)
write_zone_backup(details, tags, account_id, zone_id, args.output_dir)

# Confirm record deletion
print("\n--- Record Deletion " + "-" * 40)
if deletable:
for r in sorted(deletable, key=lambda x: (x['Name'], x['Type'])):
print(f" DELETE {r['Type']:<8} {r['Name']}")
else:
print(" Nothing to delete.")

if skipped:
print(f"\n Skipping (AWS-managed):")
for r in skipped:
print(f" KEEP {r['Type']:<8} {r['Name']}")

if deletable:
if not args.dry_run and not args.yes:
if not confirm(f"\nDelete {len(deletable)} records from zone {zone_id}?"):
print("Aborted.")
sys.exit(0)

delete_results = delete_records(client, zone_id, deletable, args.dry_run)
else:
delete_results = {'succeeded': [], 'failed': []}

# Confirm zone deletion
if args.delete_zone:
print("\n--- Zone Deletion " + "-" * 42)
if delete_results['failed']:
print(" WARNING: Some record deletions failed. Skipping zone deletion to avoid data loss.")
else:
if not args.dry_run and not args.yes:
if not confirm(f"Permanently delete hosted zone {zone_id} ({zone_name})?"):
print(" Zone deletion skipped.")
else:
delete_hosted_zone(client, zone_id, args.dry_run)
else:
delete_hosted_zone(client, zone_id, args.dry_run)

# Summary
print("\n--- Summary " + "-" * 47)
if args.dry_run:
print(f" Would delete records : {len(deletable)}")
print(f" Would keep (SOA/NS) : {len(skipped)}")
if args.delete_zone:
print(f" Would delete zone : {zone_id}")
else:
print(f" Records succeeded : {len(delete_results['succeeded'])}")
print(f" Records failed : {len(delete_results['failed'])}")
print(f" Kept (SOA/NS) : {len(skipped)}")
if delete_results['failed']:
print(f"\n Failed records:")
for name in delete_results['failed']:
print(f" {name}")
print()


if __name__ == '__main__':
main()
Loading

0 comments on commit 7d5a417

Please sign in to comment.