22
33import os
44import sys
5+ import time
56from typing import List , Optional
67from .base import DNSProvider , DNSRecord , CAARecord , RecordType
78
89
9-
1010class Route53DNSProvider (DNSProvider ):
1111 """DNS provider implementation for AWS Route53."""
1212
@@ -40,19 +40,8 @@ def __init__(self):
4040 self .hosted_zone_id : Optional [str ] = None
4141 self .hosted_zone_name : Optional [str ] = None
4242
43-
4443 def setup_certbot_credentials (self ) -> bool :
45- """Setup AWS credentials file for certbot.
46-
47- This container will be provided with aws credentials purely for the purpose
48- of assuming a role. Doing so will enable the boto platform to provision
49- temporary access key and secret keys on demand!
50-
51- Using this strategy we can impose least permissive and fast expiring access
52- to our domain.
53-
54- """
55-
44+ """Setup AWS credentials file for certbot."""
5645 try :
5746 # Pre-fetch hosted zone ID if we have a domain
5847 domain = os .getenv ("DOMAIN" )
@@ -77,11 +66,7 @@ def validate_credentials(self) -> bool:
7766 return False
7867
7968 def _get_hosted_zone_info (self , domain : str ) -> Optional [tuple [str , str ]]:
80- """Get the hosted zone ID and name for a domain.
81-
82- Returns:
83- Tuple of (hosted_zone_id, hosted_zone_name) or None
84- """
69+ """Get the hosted zone ID and name for a domain."""
8570 try :
8671 # List all hosted zones
8772 paginator = self .client .get_paginator ("list_hosted_zones" )
@@ -142,7 +127,7 @@ def _normalize_record_name(self, name: str) -> str:
142127 def get_dns_records (
143128 self , name : str , record_type : Optional [RecordType ] = None
144129 ) -> List [DNSRecord ]:
145- """Get DNS records for a domain."""
130+ """Get DNS records for a domain, including Weighted Record info ."""
146131 hosted_zone_id = self ._ensure_hosted_zone_id (name )
147132 if not hosted_zone_id :
148133 print (
@@ -174,7 +159,13 @@ def get_dns_records(
174159
175160 # Parse record content
176161 content = ""
177- data = None
162+ data = {}
163+
164+ # --- Check for Weighted Routing Params ---
165+ if "Weight" in record_set :
166+ data ["weight" ] = record_set ["Weight" ]
167+ if "SetIdentifier" in record_set :
168+ data ["set_identifier" ] = record_set ["SetIdentifier" ]
178169
179170 if record_type_str == "CAA" :
180171 # CAA records have special format
@@ -187,11 +178,12 @@ def get_dns_records(
187178 tag = parts [1 ]
188179 value = parts [2 ].strip ('"' )
189180 content = caa_value
190- data = {"flags" : flags , "tag" : tag , "value" : value }
181+ data .update (
182+ {"flags" : flags , "tag" : tag , "value" : value }
183+ )
191184 else :
192185 # Standard records
193186 if "ResourceRecords" in record_set :
194- # Get first record value (multiple values would need separate DNSRecord objects)
195187 content = record_set ["ResourceRecords" ][0 ]["Value" ]
196188 # Remove quotes from TXT records
197189 if record_type_str == "TXT" :
@@ -200,8 +192,12 @@ def get_dns_records(
200192 # Alias record (Route53 specific)
201193 content = record_set ["AliasTarget" ]["DNSName" ].rstrip ("." )
202194
203- # Route53 doesn't have persistent record IDs, use name+type as identifier
195+ # Route53 doesn't have persistent record IDs.
196+ # Standard: name:type
197+ # Weighted: name:type:set_identifier
204198 record_id = f"{ record_name } :{ record_type_str } "
199+ if "set_identifier" in data :
200+ record_id = f"{ record_id } :{ data ['set_identifier' ]} "
205201
206202 records .append (
207203 DNSRecord (
@@ -211,8 +207,8 @@ def get_dns_records(
211207 content = content ,
212208 ttl = record_set .get ("TTL" , 60 ),
213209 proxied = False , # Route53 doesn't have proxy feature
214- priority = None , # Would be in record value for MX/SRV
215- data = data ,
210+ priority = None ,
211+ data = data , # Contains weight/id if present
216212 )
217213 )
218214
@@ -223,7 +219,10 @@ def get_dns_records(
223219 return []
224220
225221 def create_dns_record (self , record : DNSRecord ) -> bool :
226- """Create a DNS record."""
222+ """
223+ Create a DNS record.
224+ Injects Weighted Routing if ROUTE53_INITIAL_WEIGHT env var is numeric.
225+ """
227226 hosted_zone_id = self ._ensure_hosted_zone_id (record .name )
228227 if not hosted_zone_id :
229228 print (
@@ -241,17 +240,55 @@ def create_dns_record(self, record: DNSRecord) -> bool:
241240 else :
242241 record_value = record .content
243242
243+ # Build Resource Record Set
244+ resource_record_set = {
245+ "Name" : normalized_name ,
246+ "Type" : record .type .value ,
247+ "TTL" : record .ttl ,
248+ "ResourceRecords" : [{"Value" : record_value }],
249+ }
250+
251+ # --- Handle Weighted Routing Injection ---
252+
253+ # 1. Determine Weight
254+ weight_to_set = None
255+
256+ # Check explicit data first (Controller overrides Env Var)
257+ if record .data and "weight" in record .data :
258+ weight_to_set = record .data ["weight" ]
259+ else :
260+ # Check Env Var
261+ env_weight = os .getenv ("ROUTE53_INITIAL_WEIGHT" )
262+ if env_weight is not None and env_weight .isdigit ():
263+ weight_to_set = int (env_weight )
264+
265+ # 2. Determine Identifier (Required if Weight is set)
266+ set_identifier = None
267+
268+ if weight_to_set is not None :
269+ # Check explicit data first
270+ if record .data and "set_identifier" in record .data :
271+ set_identifier = record .data ["set_identifier" ]
272+ else :
273+ # Generate unique ID if strictly injecting via Env Var
274+ # Format: auto-{timestamp} to prevent collisions on repeated runs
275+ set_identifier = f"auto-{ int (time .time ())} "
276+
277+ # 3. Apply to Record Set
278+ if weight_to_set is not None and set_identifier :
279+ resource_record_set ["Weight" ] = int (weight_to_set )
280+ resource_record_set ["SetIdentifier" ] = str (set_identifier )
281+ print (
282+ f" > Weighted Routing Active: Weight={ weight_to_set } , "
283+ f"ID='{ set_identifier } ' (Source: { 'ENV' if not record .data or 'weight' not in record .data else 'Explicit' } )"
284+ )
285+
244286 # Prepare change batch
245287 change_batch = {
246288 "Changes" : [
247289 {
248290 "Action" : "UPSERT" , # UPSERT creates or updates
249- "ResourceRecordSet" : {
250- "Name" : normalized_name ,
251- "Type" : record .type .value ,
252- "TTL" : record .ttl ,
253- "ResourceRecords" : [{"Value" : record_value }],
254- },
291+ "ResourceRecordSet" : resource_record_set ,
255292 }
256293 ]
257294 }
@@ -281,7 +318,9 @@ def delete_dns_record(self, record_id: str, domain: str) -> bool:
281318 """Delete a DNS record.
282319
283320 Args:
284- record_id: Format is "name:type" since Route53 doesn't have persistent IDs
321+ record_id:
322+ Standard: "name:type"
323+ Weighted: "name:type:set_identifier"
285324 domain: The domain name (for zone lookup)
286325 """
287326 hosted_zone_id = self ._ensure_hosted_zone_id (domain )
@@ -292,10 +331,15 @@ def delete_dns_record(self, record_id: str, domain: str) -> bool:
292331 )
293332 return False
294333
295- # Parse record_id to get name and type
296- try :
297- record_name , record_type = record_id .split (":" , 1 )
298- except ValueError :
334+ # Parse record_id
335+ # Format can be "name:type" OR "name:type:set_identifier"
336+ parts = record_id .split (":" )
337+ if len (parts ) == 2 :
338+ record_name , record_type = parts
339+ set_identifier = None
340+ elif len (parts ) == 3 :
341+ record_name , record_type , set_identifier = parts
342+ else :
299343 print (f"Invalid record_id format: { record_id } " , file = sys .stderr )
300344 return False
301345
@@ -306,12 +350,27 @@ def delete_dns_record(self, record_id: str, domain: str) -> bool:
306350
307351 for page in paginator .paginate (HostedZoneId = hosted_zone_id ):
308352 for record_set in page ["ResourceRecordSets" ]:
309- if (
310- record_set ["Name" ] == record_name
311- and record_set ["Type" ] == record_type
312- ):
353+ # Basic Match
354+ name_match = record_set ["Name" ] == record_name
355+ type_match = record_set ["Type" ] == record_type
356+
357+ # Identifier Match (for weighted records)
358+ id_match = True
359+ if set_identifier :
360+ # If we asked for a specific ID, the record must match it
361+ if record_set .get ("SetIdentifier" ) != set_identifier :
362+ id_match = False
363+
364+ # If we have a set_identifier in the record but none in record_id,
365+ # we must be careful. However, standard deletion usually implies
366+ # non-weighted. If the user passes a simple ID for a weighted record,
367+ # this logic might need to be stricter, but for now strict match on
368+ # requested ID is safest.
369+
370+ if name_match and type_match and id_match :
313371 record_set_to_delete = record_set
314372 break
373+
315374 if record_set_to_delete :
316375 break
317376
@@ -348,10 +407,6 @@ def delete_dns_record(self, record_id: str, domain: str) -> bool:
348407 def create_caa_record (self , caa_record : CAARecord ) -> bool :
349408 """
350409 Create or merge a CAA record set on the apex of the Route53 hosted zone.
351-
352- - Ignores the specific subdomain in caa_record.name for placement
353- - Uses it only to locate the correct hosted zone
354- - Merges hard-coded issuers with any existing CAA values on the apex
355410 """
356411 # Ensure we know which hosted zone this belongs to
357412 hosted_zone_id = self ._ensure_hosted_zone_id (caa_record .name )
0 commit comments