2121from netengine .handlers ._base import BasePhaseHandler
2222from netengine .handlers .context import PhaseContext
2323from netengine .handlers .dns import DNSHandler
24+ from netengine .handlers .domain_registry_handler import DomainRegistryHandler
2425from netengine .handlers .docker_handler import DockerHandler
2526from netengine .handlers .gateway_handler import GatewayHandler
2627
@@ -116,9 +117,7 @@ async def execute(self, context: PhaseContext) -> None:
116117 profiles_used .add (and_instance .profile )
117118
118119 # Store in runtime_state for this session
119- if not hasattr (context .runtime_state , "ands_instances" ):
120- context .runtime_state .ands_instances = {} # type: ignore[attr-defined]
121- context .runtime_state .ands_instances [and_instance .name ] = and_data # type: ignore[attr-defined]
120+ context .runtime_state .ands_instances [and_instance .name ] = and_data
122121
123122 ands_output ["ands_provisioned" ] = ands_provisioned
124123 ands_output ["address_allocations" ] = address_allocations
@@ -168,11 +167,7 @@ async def healthcheck(self, context: PhaseContext) -> bool:
168167 logger .warning ("No ANDs provisioned" )
169168 return False
170169
171- if not hasattr (context .runtime_state , "ands_instances" ):
172- logger .warning ("AND instances not tracked in runtime_state" )
173- return False
174-
175- instances = context .runtime_state .ands_instances # type: ignore[attr-defined]
170+ instances = context .runtime_state .ands_instances
176171 if not all (and_name in instances for and_name in ands_provisioned ):
177172 logger .warning ("Some AND instances missing from runtime_state" )
178173 return False
@@ -224,7 +219,7 @@ async def _provision_and(
224219 raise RuntimeError (f"Profile '{ profile_name } ' not found in spec" )
225220
226221 # 2. Allocate subnet
227- cidr = await self ._allocate_address (and_instance .name , profile_name , ands_spec )
222+ cidr = await self ._allocate_address (context , and_instance .name , profile_name , ands_spec )
228223 logger .info (f"Allocated CIDR for { and_instance .name } : { cidr } " )
229224
230225 # 3. Create isolated Docker bridge network
@@ -339,6 +334,9 @@ async def _provision_and(
339334 "bridge_name" : bridge_name ,
340335 "dns_suffix" : dns_suffix ,
341336 "deployed_at" : datetime .now (UTC ).isoformat (),
337+ "dynamic_ip" : bool (profile_obj .dynamic_ip ),
338+ "reverse_dns" : bool (profile_obj .reverse_dns ),
339+ "bgp" : profile_obj .bgp ,
342340 }
343341
344342 try :
@@ -352,6 +350,7 @@ async def _provision_and(
352350
353351 async def _allocate_address (
354352 self ,
353+ context : PhaseContext ,
355354 and_name : str ,
356355 profile : str ,
357356 ands_spec : Any ,
@@ -361,12 +360,167 @@ async def _allocate_address(
361360 if profile_obj is None :
362361 raise RuntimeError (f"Profile '{ profile } ' not found" )
363362
364- # Sequential /24 allocation within 172.16.0.0/12.
365- # Uses ord-sum rather than hash() to avoid collision with >256 ANDs.
366- idx = sum (ord (c ) for c in and_name ) % 4096
367- third_octet = idx % 256
368- second_extra = idx // 256
369- return f"172.{ 16 + second_extra } .{ third_octet } .0/24"
363+ try :
364+ registry = DomainRegistryHandler (context = context )
365+ return await registry .allocate_address (and_name , profile )
366+ except Exception as exc :
367+ # Tests and mock-mode executions may not have a DB. Fall back only when
368+ # the registry output does not expose seeded pools; otherwise surface
369+ # the allocation failure so exhaustion/collisions are not hidden.
370+ output = context .runtime_state .domain_registry_output or {}
371+ if output .get ("address_pools" ) or output .get ("address_pools_seeded" ):
372+ raise RuntimeError (f"Failed to allocate address for { and_name } : { exc } " ) from exc
373+ context .logger .warning ("Registry allocation unavailable; using mock CIDR: %s" , exc )
374+ idx = len (context .runtime_state .ands_instances )
375+ return f"172.31.{ idx } .0/24"
376+
377+ async def reconcile (
378+ self ,
379+ context : PhaseContext ,
380+ docker : DockerHandler | None = None ,
381+ gateway : GatewayHandler | None = None ,
382+ ) -> dict [str , list [str ]]:
383+ """Reconcile desired AND spec against runtime/Docker/gateway/DNS side effects."""
384+ docker = docker or DockerHandler ()
385+ gateway = gateway or GatewayHandler (docker )
386+ desired = {inst .name : inst for inst in context .spec .ands .instances }
387+ existing = context .runtime_state .ands_instances
388+ actions : dict [str , list [str ]] = {
389+ "created" : [],
390+ "updated" : [],
391+ "removed" : [],
392+ "repaired" : [],
393+ }
394+
395+ for and_name in set (existing ) - set (desired ):
396+ await self ._teardown_and (context , docker , gateway , and_name , existing [and_name ])
397+ actions ["removed" ].append (and_name )
398+
399+ for and_name , inst in desired .items ():
400+ current = existing .get (and_name )
401+ if current is None :
402+ data = await self ._provision_and (context , docker , gateway , inst , context .spec .ands )
403+ context .runtime_state .ands_instances [and_name ] = data
404+ actions ["created" ].append (and_name )
405+ continue
406+ if current .get ("profile" ) != inst .profile :
407+ await self ._update_and_profile (context , gateway , inst , current , context .spec .ands )
408+ actions ["updated" ].append (and_name )
409+ repaired = await self ._repair_and (
410+ context , docker , gateway , inst , current , context .spec .ands
411+ )
412+ if repaired :
413+ actions ["repaired" ].append (and_name )
414+ return actions
415+
416+ async def _update_and_profile (
417+ self ,
418+ context : PhaseContext ,
419+ gateway : GatewayHandler ,
420+ and_instance : Any ,
421+ current : dict [str , Any ],
422+ ands_spec : Any ,
423+ ) -> None :
424+ old_profile = ands_spec .profiles .get (current .get ("profile" )) if ands_spec .profiles else None
425+ new_profile = ands_spec .profiles .get (and_instance .profile ) if ands_spec .profiles else None
426+ if new_profile is None :
427+ raise RuntimeError (f"Profile '{ and_instance .profile } ' not found in spec" )
428+ if old_profile and getattr (old_profile , "dynamic_ip" , False ) and not new_profile .dynamic_ip :
429+ await gateway .remove_dhcp (and_instance .name )
430+ if old_profile and getattr (old_profile , "bgp" , None ) and not new_profile .bgp :
431+ await gateway .remove_bgp (and_instance .name )
432+ rules = await gateway .generate_rules (
433+ and_instance .name , and_instance .profile , current ["cidr" ]
434+ )
435+ await gateway .apply_rules (and_instance .name , rules )
436+ if new_profile .dynamic_ip :
437+ await gateway .setup_dhcp (and_instance .name , current ["cidr" ], current ["gateway_ip" ])
438+ if new_profile .reverse_dns :
439+ await DNSHandler ().add_reverse_zone (context , current ["cidr" ], current ["gateway_ip" ])
440+ if new_profile .bgp :
441+ await gateway .setup_bgp (
442+ and_instance .name , current ["cidr" ], current ["gateway_ip" ], new_profile .bgp
443+ )
444+ current .update (
445+ {
446+ "profile" : and_instance .profile ,
447+ "dynamic_ip" : new_profile .dynamic_ip ,
448+ "reverse_dns" : new_profile .reverse_dns ,
449+ "bgp" : new_profile .bgp ,
450+ }
451+ )
452+
453+ async def _teardown_and (
454+ self ,
455+ context : PhaseContext ,
456+ docker : DockerHandler ,
457+ gateway : GatewayHandler ,
458+ and_name : str ,
459+ current : dict [str , Any ],
460+ ) -> None :
461+ await gateway .remove_rules (and_name )
462+ if current .get ("dynamic_ip" ):
463+ await gateway .remove_dhcp (and_name )
464+ if current .get ("reverse_dns" ):
465+ await DNSHandler ().remove_reverse_zone (context , current ["cidr" ])
466+ if current .get ("bgp" ):
467+ await gateway .remove_bgp (and_name )
468+ bridge_name = current .get ("bridge_name" , f"netengines_and_{ and_name } " )
469+ try :
470+ await docker .disconnect_network (gateway .gateway_container , bridge_name )
471+ except Exception :
472+ pass
473+ await docker .remove_network (bridge_name )
474+ context .runtime_state .ands_instances .pop (and_name , None )
475+ try :
476+ db = await self ._get_supabase ()
477+ await db .table ("address_leases" ).delete ().eq ("and_name" , and_name ).execute ()
478+ await db .table ("and_instances" ).delete ().eq ("name" , and_name ).execute ()
479+ except Exception as exc :
480+ context .logger .warning ("Failed to delete AND DB state for %s: %s" , and_name , exc )
481+
482+ async def _repair_and (
483+ self ,
484+ context : PhaseContext ,
485+ docker : DockerHandler ,
486+ gateway : GatewayHandler ,
487+ and_instance : Any ,
488+ current : dict [str , Any ],
489+ ands_spec : Any ,
490+ ) -> bool :
491+ repaired = False
492+ bridge_name = current .get ("bridge_name" , f"netengines_and_{ and_instance .name } " )
493+ try :
494+ docker .client .networks .get (bridge_name )
495+ except Exception :
496+ await docker .create_network (
497+ name = bridge_name , driver = "bridge" , subnet = current ["cidr" ], internal = True
498+ )
499+ repaired = True
500+ try :
501+ await docker .connect_network (
502+ container = gateway .gateway_container , network = bridge_name , ip = current ["gateway_ip" ]
503+ )
504+ except Exception :
505+ pass
506+ profile = ands_spec .profiles .get (and_instance .profile ) if ands_spec .profiles else None
507+ rules = await gateway .generate_rules (
508+ and_instance .name , and_instance .profile , current ["cidr" ]
509+ )
510+ await gateway .apply_rules (and_instance .name , rules )
511+ dns_suffix = getattr (and_instance , "dns_suffix" , None ) or current .get ("dns_suffix" )
512+ await DNSHandler ().add_zone_record (
513+ context , "internal" , "A" , dns_suffix .rstrip ("." ), current ["gateway_ip" ], 300
514+ )
515+ if profile and profile .dynamic_ip :
516+ await gateway .setup_dhcp (and_instance .name , current ["cidr" ], current ["gateway_ip" ])
517+ if profile and profile .reverse_dns :
518+ await DNSHandler ().add_reverse_zone (context , current ["cidr" ], current ["gateway_ip" ])
519+ if profile and profile .bgp :
520+ await gateway .setup_bgp (
521+ and_instance .name , current ["cidr" ], current ["gateway_ip" ], profile .bgp
522+ )
523+ return repaired
370524
371525 # ─────────────────────────────────────────────
372526 # Event-Driven Provisioning
0 commit comments