Skip to content

Commit fe62f4e

Browse files
committed
Strengthen AND provisioning leases and reconciliation
1 parent 81ad8db commit fe62f4e

5 files changed

Lines changed: 433 additions & 22 deletions

File tree

migrations/001_initial.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ CREATE TABLE IF NOT EXISTS address_pools (
2929
CREATE TABLE IF NOT EXISTS address_leases (
3030
and_name TEXT PRIMARY KEY,
3131
cidr CIDR NOT NULL,
32-
assigned_at TIMESTAMPTZ DEFAULT NOW()
32+
assigned_at TIMESTAMPTZ DEFAULT NOW(),
33+
CONSTRAINT address_leases_cidr_unique UNIQUE (cidr)
3334
);
3435

3536
-- Domain records

netengine/core/state.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ class DomainRegistryOutput(PhaseOutputBase, total=False):
164164
tld_delegations: list[dict[str, JsonValue]]
165165

166166

167+
class ANDInstanceState(TypedDict, total=False):
168+
name: str
169+
org: str
170+
profile: str
171+
cidr: str
172+
gateway_ip: str
173+
bridge_name: str
174+
dns_suffix: str
175+
deployed_at: str
176+
dynamic_ip: bool
177+
reverse_dns: bool
178+
bgp: str | None
179+
180+
167181
class GenericPhaseOutput(PhaseOutputBase, total=False):
168182
status: str
169183
healthy: bool
@@ -221,6 +235,7 @@ class RuntimeState:
221235
domain_registry_output: Optional[DomainRegistryOutput] = None
222236
identity_inworld_output: Optional[GenericPhaseOutput] = None
223237
ands_output: Optional[GenericPhaseOutput] = None
238+
ands_instances: Dict[str, ANDInstanceState] = field(default_factory=dict)
224239
world_services_output: Optional[GenericPhaseOutput] = None
225240
org_apps_output: Optional[GenericPhaseOutput] = None
226241

netengine/handlers/domain_registry_handler.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# netengine/handlers/domain_registry_handler.py
2+
import ipaddress
23
from typing import Any, List, cast
34

45
from netengine.core.pgmq_client import PGMQClient
@@ -35,14 +36,70 @@ async def seed_address_pools(self, spec: Any) -> None:
3536
).execute()
3637

3738
async def allocate_address(self, and_name: str, profile: str) -> str:
38-
"""Allocate a CIDR from the pool; row-level lock prevents conflicts."""
39+
"""Allocate a unique AND subnet from the registry phase address pool.
40+
41+
Existing leases are idempotently reused by ``and_name``. New leases are
42+
allocated as /24s within the profile pool (or the pool itself when it is
43+
smaller than /24). The ``address_leases.cidr`` unique constraint is the
44+
database-backed collision guard; if another allocator wins the same CIDR,
45+
this method refreshes leases and tries the next free candidate.
46+
"""
3947
db = await self._get_db()
40-
result = await db.table("address_pools").select("cidr").eq("profile", profile).execute()
41-
if not result.data:
48+
49+
existing = (
50+
await db.table("address_leases")
51+
.select("and_name,cidr")
52+
.eq("and_name", and_name)
53+
.execute()
54+
)
55+
if existing.data and existing.data[0].get("and_name") == and_name:
56+
return cast(str, existing.data[0]["cidr"])
57+
58+
pool_result = (
59+
await db.table("address_pools").select("cidr").eq("profile", profile).execute()
60+
)
61+
if not pool_result.data:
4262
raise RegistryError(f"No address pool for profile {profile}")
43-
pool_cidr = result.data[0]["cidr"]
44-
await db.table("address_leases").upsert({"and_name": and_name, "cidr": pool_cidr}).execute()
45-
return cast(str, pool_cidr)
63+
64+
pool = ipaddress.ip_network(str(pool_result.data[0]["cidr"]), strict=False)
65+
prefix = max(pool.prefixlen, 24)
66+
candidates = [pool] if pool.prefixlen > 24 else list(pool.subnets(new_prefix=prefix))
67+
68+
last_error: Exception | None = None
69+
for _ in range(2):
70+
lease_result = await db.table("address_leases").select("and_name,cidr").execute()
71+
used = {
72+
ipaddress.ip_network(str(row["cidr"]), strict=False)
73+
for row in lease_result.data
74+
if "and_name" in row
75+
}
76+
for candidate in candidates:
77+
if candidate in used:
78+
continue
79+
cidr = str(candidate)
80+
try:
81+
await db.table("address_leases").upsert(
82+
{"and_name": and_name, "cidr": cidr, "profile": profile}
83+
).execute()
84+
except Exception as exc:
85+
last_error = exc
86+
if "profile" in str(exc).lower():
87+
try:
88+
await db.table("address_leases").upsert(
89+
{"and_name": and_name, "cidr": cidr}
90+
).execute()
91+
except Exception as retry_exc:
92+
last_error = retry_exc
93+
continue
94+
else:
95+
continue
96+
return cidr
97+
98+
if last_error is not None:
99+
raise RegistryError(
100+
f"Address pool exhausted for profile {profile}; last collision: {last_error}"
101+
)
102+
raise RegistryError(f"Address pool exhausted for profile {profile}")
46103

47104
async def register_domain(self, domain: str, org_name: str, ns_records: List[str]) -> None:
48105
"""Register a domain; emit DNS update event."""

netengine/phases/phase_ands.py

Lines changed: 169 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from netengine.handlers._base import BasePhaseHandler
2222
from netengine.handlers.context import PhaseContext
2323
from netengine.handlers.dns import DNSHandler
24+
from netengine.handlers.domain_registry_handler import DomainRegistryHandler
2425
from netengine.handlers.docker_handler import DockerHandler
2526
from 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

Comments
 (0)