fix(dns): remove legacy zones after migration
Build and publish Docker image / build-and-publish (push) Successful in 7s

This commit is contained in:
wfalkowski committed 2026-10-20 18:23:15 +00:00
1 parent 079140ce49
commit d8c1f0c604
1 file changed
+43 -8
+43 -8
View File
@@ -2,8 +2,8 @@
"""Move legacy per-user deSEC zones into the parent zone's CNAME wildcard model. """Move legacy per-user deSEC zones into the parent zone's CNAME wildcard model.
Run from the backend environment. The default is a read-only dry run; pass Run from the backend environment. The default is a read-only dry run; pass
``--apply`` to copy records and cut over delegations. Child zones are retained ``--apply`` to copy records, cut over delegations, export the old zonefiles,
so their records remain available for rollback. and remove the child zones that would otherwise override the parent records.
""" """
import argparse import argparse
@@ -117,7 +117,7 @@ def _validate_owner(owner: str) -> None:
raise ValueError(f'Unsupported legacy namespace owner: {owner!r}') raise ValueError(f'Unsupported legacy namespace owner: {owner!r}')
def build_plan(api: DeSec) -> Tuple[List[str], List[Dict], Dict[Tuple[str, str], Dict]]: def build_plan(api: DeSec) -> Tuple[List[str], List[str], List[Dict], Dict[Tuple[str, str], Dict]]:
zones_response = api.request('GET', f'{api.base_url}/domains/') zones_response = api.request('GET', f'{api.base_url}/domains/')
if zones_response.status_code != 200: if zones_response.status_code != 200:
raise RuntimeError(f'Could not list deSEC zones ({zones_response.status_code})') raise RuntimeError(f'Could not list deSEC zones ({zones_response.status_code})')
@@ -183,7 +183,7 @@ def build_plan(api: DeSec) -> Tuple[List[str], List[Dict], Dict[Tuple[str, str],
continue continue
raise RuntimeError(f'Conflicting parent RRset at {owner} {item["type"]}') raise RuntimeError(f'Conflicting parent RRset at {owner} {item["type"]}')
return sorted(namespaces), copies, parent_map return sorted(namespaces), legacy_zones, copies, parent_map
def _delete_rrset(api: DeSec, subname: str, record_type: str) -> None: def _delete_rrset(api: DeSec, subname: str, record_type: str) -> None:
@@ -200,8 +200,34 @@ def _create_rrset(api: DeSec, payload: Dict) -> None:
) )
def _backup_and_remove_legacy_zones(api: DeSec, legacy_zones: Iterable[str], backup_dir: str) -> None:
"""Export old zones for rollback, then remove their competing authority."""
os.makedirs(backup_dir, mode=0o700, exist_ok=True)
zonefiles = {}
for zone in legacy_zones:
url = f'{api.base_url}/domains/{quote(zone, safe=".-")}/zonefile/'
response = api.request('GET', url)
if response.status_code != 200:
raise RuntimeError(f'Could not export legacy zone {zone} ({response.status_code})')
zonefiles[zone] = response.text
for zone, zonefile in zonefiles.items():
filename = os.path.join(backup_dir, f'{zone}.zone')
with open(filename, 'w', encoding='utf-8') as backup:
backup.write(zonefile)
print(f'Exported {len(zonefiles)} legacy zone backups to {backup_dir}')
for zone in legacy_zones:
url = f'{api.base_url}/domains/{quote(zone, safe=".-")}/'
response = api.request('DELETE', url)
if response.status_code not in {204, 404}:
raise RuntimeError(f'Could not remove legacy zone {zone} ({response.status_code})')
print(f'Removed legacy child zone {zone}')
def apply_plan(api: DeSec, namespaces: Iterable[str], copies: List[Dict], def apply_plan(api: DeSec, namespaces: Iterable[str], copies: List[Dict],
parent_map: Dict[Tuple[str, str], Dict]) -> None: legacy_zones: Iterable[str], parent_map: Dict[Tuple[str, str], Dict],
backup_dir: str) -> None:
# Copy child records and create wildcard records first; old delegations stay # Copy child records and create wildcard records first; old delegations stay
# authoritative until all non-destructive preparation has completed. # authoritative until all non-destructive preparation has completed.
for payload in copies: for payload in copies:
@@ -246,6 +272,11 @@ def apply_plan(api: DeSec, namespaces: Iterable[str], copies: List[Dict],
}) })
raise raise
# Hosted child zones continue to answer authoritatively even after their
# parent NS/DS records are removed. Back them up and remove them so the
# new parent-zone CNAMEs and wildcards are actually served.
_backup_and_remove_legacy_zones(api, legacy_zones, backup_dir)
from database_service import database_connection from database_service import database_connection
with database_connection() as connection: with database_connection() as connection:
@@ -268,6 +299,10 @@ def apply_plan(api: DeSec, namespaces: Iterable[str], copies: List[Dict],
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--apply', action='store_true', help='Apply the reviewed DNS migration') parser.add_argument('--apply', action='store_true', help='Apply the reviewed DNS migration')
parser.add_argument(
'--backup-dir', default='/tmp/anchorhost-delegated-dns-backup',
help='Directory for exported legacy zonefiles before their zones are deleted',
)
args = parser.parse_args() args = parser.parse_args()
token = os.environ.get('DESEC_API_TOKEN') token = os.environ.get('DESEC_API_TOKEN')
base_domain = os.environ.get('BASE_DOMAIN') base_domain = os.environ.get('BASE_DOMAIN')
@@ -276,15 +311,15 @@ def main() -> int:
api = DeSec(token, base_domain) api = DeSec(token, base_domain)
try: try:
namespaces, copies, parent_map = build_plan(api) namespaces, legacy_zones, copies, parent_map = build_plan(api)
print(f'Namespaces to migrate: {len(namespaces)}') print(f'Namespaces to migrate: {len(namespaces)}')
print(f'Legacy RRsets to copy: {len(copies)}') print(f'Legacy RRsets to copy: {len(copies)}')
print('Namespaces:', ', '.join(namespaces)) print('Namespaces:', ', '.join(namespaces))
print('Record copies:', ', '.join(f'{item["type"]}:{item["subname"]}' for item in copies) or 'none') print('Record copies:', ', '.join(f'{item["type"]}:{item["subname"]}' for item in copies) or 'none')
print('Mode:', 'APPLY' if args.apply else 'DRY RUN') print('Mode:', 'APPLY' if args.apply else 'DRY RUN')
if args.apply: if args.apply:
apply_plan(api, namespaces, copies, parent_map) apply_plan(api, namespaces, copies, legacy_zones, parent_map, args.backup_dir)
print('DNS migration completed. Legacy child zones were retained for rollback.') print('DNS migration completed. Legacy zonefiles are backed up; old child zones are removed.')
else: else:
print('No changes made. Run with --apply to perform the migration.') print('No changes made. Run with --apply to perform the migration.')
except Exception as error: except Exception as error: