feat(dns): add multi-provider DNS CLI

- hanzo dns zones/list/add/rm/update commands
- Multi-provider architecture: Cloudflare + CoreDNS
- Parallel queries across all configured providers
- Provider abstraction for easy extension (Route53, GoDaddy, etc.)
- Thread-safe parallel execution with ThreadPoolExecutor
- Legacy credential format backward compatible
This commit is contained in:
2026-03-03 23:04:29 -08:00
parent f0fab6fe4c
commit 79bc725482
7 changed files with 981 additions and 0 deletions
+2
View File
@@ -12,6 +12,7 @@ from rich.console import Console
from .commands import (
cx,
dns,
fn,
kv,
ml,
@@ -141,6 +142,7 @@ cli.add_command(node.cluster)
cli.add_command(cloud.cloud_group)
cli.add_command(config.config_group)
cli.add_command(cx.cx_group)
cli.add_command(dns.dns_group)
cli.add_command(doc.doc_group)
cli.add_command(env.env_group)
cli.add_command(events.events_group)
+1
View File
@@ -12,6 +12,7 @@ __all__ = [
"cloud",
"config",
"cx",
"dns",
"doc",
"env",
"events",
@@ -0,0 +1,11 @@
"""Hanzo DNS — Multi-provider DNS management.
Unified DNS interface that routes to any configured provider:
Cloudflare, CoreDNS, Route53, GoDaddy, DigitalOcean, etc.
All providers queried in parallel, results merged.
"""
from .cli import dns_group
__all__ = ["dns_group"]
+414
View File
@@ -0,0 +1,414 @@
"""Hanzo DNS CLI — multi-provider DNS management.
Queries all configured DNS providers in parallel, merges results.
Usage:
hanzo dns zones # List zones from all providers
hanzo dns list -z hanzo.ai # List records (all providers)
hanzo dns list -z hanzo.ai --type CNAME # Filter by record type
hanzo dns add -z lux.financial app 1.2.3.4 # Add A record
hanzo dns add -z lux.financial app tgt --type CNAME --provider cloudflare
hanzo dns rm -z lux.financial app # Remove record
hanzo dns update hanzo.ai 1.2.3.4 5.6.7.8 # Batch update IPs
hanzo dns providers # Show configured providers
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
import click
# Import providers to trigger registration
from . import cloudflare as _cf # noqa: F401
from . import coredns as _cd # noqa: F401
from .provider import (
DNSRecord,
DNSZone,
get_active_providers,
list_providers as _list_provider_names,
load_dns_config,
require_providers,
get_provider,
)
def _run_parallel(providers, method: str, *args, **kwargs) -> list[Any]:
"""Run a method on all providers in parallel, collect results."""
results: list[Any] = []
if len(providers) == 1:
try:
val = getattr(providers[0], method)(*args, **kwargs)
if isinstance(val, list):
results.extend(val)
else:
results.append((providers[0].name, val))
except Exception as e:
results.append((providers[0].name, e))
return results
with ThreadPoolExecutor(max_workers=len(providers)) as pool:
futures = {}
for p in providers:
fut = pool.submit(getattr(p, method), *args, **kwargs)
futures[fut] = p.name
for fut in as_completed(futures):
pname = futures[fut]
try:
val = fut.result()
if isinstance(val, list):
results.extend(val)
else:
results.append((pname, val))
except Exception as e:
results.append((pname, e))
return results
def _resolve_provider(provider_name: str | None, providers: list):
"""Pick a single provider by name, or first available."""
if provider_name:
for p in providers:
if p.name == provider_name:
return p
raise click.ClickException(
f"Provider '{provider_name}' not configured. "
f"Active: {', '.join(p.name for p in providers)}"
)
return providers[0]
# ── Click group ───────────────────────────────────────────────────────
@click.group("dns")
def dns_group() -> None:
"""Hanzo DNS — multi-provider DNS management.
\b
Queries all configured DNS providers in parallel.
Supports: Cloudflare, CoreDNS, and more.
\b
Commands:
hanzo dns providers Show configured providers
hanzo dns zones List all zones
hanzo dns list -z hanzo.ai List records
hanzo dns add -z lux.financial app 1.2.3.4 Add A record
hanzo dns rm -z lux.financial app Remove record
hanzo dns update hanzo.ai OLD NEW Batch update IPs
\b
Config: ~/.hanzo/credentials.json
{"dns": {"cloudflare": {"email": "...", "api_key": "..."}}}
{"dns": {"coredns": {"endpoint": "...", "api_key": "..."}}}
"""
# ── providers ────────────────────────────────────────────────────────
@dns_group.command("providers")
def dns_providers() -> None:
"""Show configured DNS providers and their status."""
from rich.table import Table
from ...utils.output import console
configs = load_dns_config()
available = _list_provider_names()
table = Table(title="DNS Providers")
table.add_column("Provider", style="cyan")
table.add_column("Status", style="green")
table.add_column("Details", style="dim")
for name in available:
if name in configs:
cfg = configs[name]
detail_parts = []
if "email" in cfg:
detail_parts.append(f"email={cfg['email']}")
if "endpoint" in cfg:
detail_parts.append(f"endpoint={cfg['endpoint']}")
detail = ", ".join(detail_parts) if detail_parts else "configured"
table.add_row(name, "[green]active[/green]", detail)
else:
table.add_row(name, "[dim]not configured[/dim]", "")
console.print(table)
if not configs:
console.print(
f"\n[yellow]No providers configured.[/yellow]\n"
f"Add credentials to ~/.hanzo/credentials.json"
)
# ── zones ─────────────────────────────────────────────────────────────
@dns_group.command("zones")
@click.option("--provider", "-p", default=None, help="Filter by provider.")
def dns_zones(provider: str | None) -> None:
"""List all DNS zones across all providers."""
from rich.table import Table
from ...utils.output import console
providers = require_providers()
if provider:
providers = [p for p in providers if p.name == provider]
if not providers:
raise click.ClickException(f"Provider '{provider}' not configured.")
results = _run_parallel(providers, "list_zones")
zones: list[DNSZone] = [r for r in results if isinstance(r, DNSZone)]
errors = [r for r in results if isinstance(r, tuple) and isinstance(r[1], Exception)]
if not zones and not errors:
console.print("[yellow]No zones found.[/yellow]")
return
if zones:
table = Table(title="DNS Zones")
table.add_column("Name", style="cyan")
table.add_column("Provider", style="magenta")
table.add_column("ID", style="dim")
table.add_column("Status", style="green")
table.add_column("Plan", style="white")
for z in sorted(zones, key=lambda x: x.name):
table.add_row(z.name, z.provider, z.id[:16], z.status, z.plan)
console.print(table)
for pname, err in errors:
console.print(f" [red]{pname}:[/red] {err}")
# ── list ──────────────────────────────────────────────────────────────
@dns_group.command("list")
@click.option("--zone", "-z", required=True, help="Zone name (e.g. hanzo.ai).")
@click.option("--type", "record_type", default=None, help="Record type filter (A, CNAME, etc.).")
@click.option("--provider", "-p", default=None, help="Filter by provider.")
def dns_list(zone: str, record_type: str | None, provider: str | None) -> None:
"""List DNS records for a zone (queries all providers in parallel)."""
from rich.table import Table
from ...utils.output import console
providers = require_providers()
if provider:
providers = [p for p in providers if p.name == provider]
if not providers:
raise click.ClickException(f"Provider '{provider}' not configured.")
results = _run_parallel(providers, "list_records", zone, record_type)
records: list[DNSRecord] = [r for r in results if isinstance(r, DNSRecord)]
errors = [r for r in results if isinstance(r, tuple) and isinstance(r[1], Exception)]
if not records:
label = f" {record_type}" if record_type else ""
console.print(f"[yellow]No{label} records found for {zone}.[/yellow]")
for pname, err in errors:
console.print(f" [red]{pname}:[/red] {err}")
return
table = Table(title=f"DNS Records — {zone}")
table.add_column("Type", style="white", min_width=6)
table.add_column("Name", style="cyan", min_width=25)
table.add_column("Content", style="white")
table.add_column("TTL", justify="right")
table.add_column("Proxied", justify="center")
table.add_column("Provider", style="magenta")
table.add_column("ID", style="dim")
for r in sorted(records, key=lambda x: (x.provider, x.type, x.name)):
proxied = "[green]yes[/green]" if r.proxied else "no"
table.add_row(
r.type, r.name, r.content, r.ttl_display,
proxied, r.provider, r.id[:12],
)
console.print(table)
console.print(f"\n{len(records)} record(s)")
for pname, err in errors:
console.print(f" [red]{pname}:[/red] {err}")
# ── add ───────────────────────────────────────────────────────────────
@dns_group.command("add")
@click.option("--zone", "-z", required=True, help="Zone name (e.g. lux.financial).")
@click.argument("name")
@click.argument("content")
@click.option("--type", "record_type", default="A", help="Record type (default: A).")
@click.option("--proxied/--no-proxy", default=True, help="Proxy (default: on, CF only).")
@click.option("--ttl", default=1, help="TTL (1 = auto).")
@click.option("--provider", "-p", default=None, help="Target provider (default: first active).")
def dns_add(
zone: str, name: str, content: str,
record_type: str, proxied: bool, ttl: int, provider: str | None
) -> None:
"""Add a DNS record.
\b
Examples:
hanzo dns add -z lux.financial app 1.2.3.4 # A record
hanzo dns add -z lux.financial app tgt --type CNAME # CNAME
hanzo dns add -z hanzo.ai api 10.0.0.1 --no-proxy # Unproxied A
hanzo dns add -z hanzo.ai app 1.2.3.4 -p cloudflare # Specific provider
"""
from ...utils.output import console
providers = require_providers()
target = _resolve_provider(provider, providers)
rec = target.add_record(zone, name, content, record_type, proxied, ttl)
if rec:
console.print(f"[green]✓[/green] Created {record_type.upper()} record via [magenta]{target.name}[/magenta]")
console.print(f" Name: {rec.name}")
console.print(f" Content: {content}")
console.print(f" Proxied: {'yes' if rec.proxied else 'no'}")
console.print(f" ID: {rec.id}")
# ── rm ────────────────────────────────────────────────────────────────
@dns_group.command("rm")
@click.option("--zone", "-z", required=True, help="Zone name.")
@click.argument("name")
@click.option("--type", "record_type", default=None, help="Record type filter.")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation.")
@click.option("--provider", "-p", default=None, help="Target provider (default: all).")
def dns_rm(zone: str, name: str, record_type: str | None, force: bool, provider: str | None) -> None:
"""Remove DNS record(s) matching NAME.
\b
By default searches all providers. Use --provider to target one.
\b
Examples:
hanzo dns rm -z lux.financial app # All providers
hanzo dns rm -z lux.financial app --type A # Only A records
hanzo dns rm -z lux.financial app -p cloudflare # CF only
hanzo dns rm -z lux.financial app -f # Skip confirmation
"""
from ...utils.output import console
providers = require_providers()
if provider:
providers = [p for p in providers if p.name == provider]
if not providers:
raise click.ClickException(f"Provider '{provider}' not configured.")
# Preview what will be deleted
full_name = name if name.endswith(zone) else f"{name}.{zone}"
results = _run_parallel(providers, "list_records", zone, record_type)
matching = [r for r in results if isinstance(r, DNSRecord) and r.name == full_name]
if not matching:
console.print(f"[yellow]No records found for {full_name}[/yellow]")
return
console.print(f"Found [cyan]{len(matching)}[/cyan] record(s) for {full_name}:")
for r in matching:
console.print(f" [{r.provider}] {r.type:6} {r.name} -> {r.content}")
if not force:
click.confirm("Delete these records?", abort=True)
total_deleted = 0
for p in providers:
deleted = p.remove_records(zone, name, record_type)
total_deleted += deleted
console.print(f"[green]✓[/green] Deleted {total_deleted} record(s)")
# ── update (batch IP swap) ───────────────────────────────────────────
@dns_group.command("update")
@click.argument("zone")
@click.argument("old_ip")
@click.argument("new_ip")
@click.option("--type", "record_type", default="A", help="Record type (default: A).")
@click.option("--dry-run", is_flag=True, help="Show changes without applying.")
@click.option("--provider", "-p", default=None, help="Target provider (default: all).")
def dns_update(
zone: str, old_ip: str, new_ip: str,
record_type: str, dry_run: bool, provider: str | None
) -> None:
"""Batch update records: replace OLD_IP with NEW_IP across all providers.
\b
Examples:
hanzo dns update hanzo.ai 1.2.3.4 5.6.7.8 --dry-run
hanzo dns update lux.network 10.0.0.1 10.0.0.2
hanzo dns update hanzo.ai 1.2.3.4 5.6.7.8 -p cloudflare
"""
from rich.table import Table
from ...utils.output import console
providers = require_providers()
if provider:
providers = [p for p in providers if p.name == provider]
if not providers:
raise click.ClickException(f"Provider '{provider}' not configured.")
# Preview: find matching records across all providers
results = _run_parallel(providers, "list_records", zone, record_type)
matching = [
r for r in results
if isinstance(r, DNSRecord) and r.content == old_ip
]
if not matching:
console.print(
f"[yellow]No {record_type} records found in {zone} "
f"pointing to {old_ip}.[/yellow]"
)
return
console.print(
f"Found [cyan]{len(matching)}[/cyan] {record_type} record(s) "
f"pointing to [red]{old_ip}[/red]\n"
)
table = Table(title="Records to Update")
table.add_column("Provider", style="magenta")
table.add_column("Name", style="cyan")
table.add_column("Current", style="red")
table.add_column("New", style="green")
for r in matching:
table.add_row(r.provider, r.name, old_ip, new_ip)
console.print(table)
if dry_run:
console.print("\n[yellow]Dry run — no changes applied.[/yellow]")
return
total_updated = 0
total_failed = 0
for p in providers:
updated, failed = p.update_records(zone, old_ip, new_ip, record_type)
total_updated += updated
total_failed += failed
console.print(
f"\n[green]{total_updated} updated[/green]"
+ (f", [red]{total_failed} failed[/red]" if total_failed else "")
)
@@ -0,0 +1,202 @@
"""Cloudflare DNS provider."""
from __future__ import annotations
from typing import Any
import click
from .provider import DNSProvider, DNSRecord, DNSZone, register_provider
CF_API = "https://api.cloudflare.com/client/v4"
class CloudflareProvider(DNSProvider):
name = "cloudflare"
def __init__(self, config: dict[str, Any]) -> None:
self.email = config.get("email", "")
self.api_key = config.get("api_key", "")
if not self.email or not self.api_key:
raise ValueError("Cloudflare requires 'email' and 'api_key'")
def _headers(self) -> dict[str, str]:
return {
"X-Auth-Email": self.email,
"X-Auth-Key": self.api_key,
"Content-Type": "application/json",
}
def _client(self):
import httpx
return httpx.Client(headers=self._headers(), timeout=30.0)
def _get(self, client, path: str, params: dict[str, Any] | None = None) -> Any:
resp = client.get(f"{CF_API}{path}", params=params)
data = resp.json()
if not data.get("success"):
errors = data.get("errors", [])
msg = "; ".join(e.get("message", str(e)) for e in errors)
raise click.ClickException(f"Cloudflare API error: {msg}")
return data
def _resolve_zone_id(self, client, zone_name: str) -> str:
data = self._get(client, "/zones", params={"name": zone_name, "per_page": "1"})
zones = data.get("result", [])
if not zones:
raise click.ClickException(
f"Zone '{zone_name}' not found in Cloudflare account."
)
return zones[0]["id"]
# ── Interface implementation ─────────────────────────────────────
def list_zones(self) -> list[DNSZone]:
with self._client() as client:
data = self._get(client, "/zones", params={"per_page": "50"})
return [
DNSZone(
id=z.get("id", ""),
name=z.get("name", ""),
status=z.get("status", ""),
plan=z.get("plan", {}).get("name", ""),
provider=self.name,
raw=z,
)
for z in data.get("result", [])
]
def list_records(
self, zone: str, record_type: str | None = None
) -> list[DNSRecord]:
with self._client() as client:
zone_id = self._resolve_zone_id(client, zone)
params: dict[str, str] = {"per_page": "100"}
if record_type:
params["type"] = record_type
data = self._get(client, f"/zones/{zone_id}/dns_records", params=params)
return [
DNSRecord(
id=r.get("id", ""),
zone=zone,
type=r.get("type", ""),
name=r.get("name", ""),
content=r.get("content", ""),
ttl=r.get("ttl", 1),
proxied=r.get("proxied", False),
provider=self.name,
raw=r,
)
for r in data.get("result", [])
]
def add_record(
self,
zone: str,
name: str,
content: str,
record_type: str = "A",
proxied: bool = True,
ttl: int = 1,
) -> DNSRecord | None:
full_name = name if name.endswith(zone) else f"{name}.{zone}"
with self._client() as client:
zone_id = self._resolve_zone_id(client, zone)
payload = {
"type": record_type.upper(),
"name": full_name,
"content": content,
"ttl": ttl,
"proxied": proxied,
}
resp = client.post(
f"{CF_API}/zones/{zone_id}/dns_records", json=payload
)
result = resp.json()
if result.get("success"):
rec = result.get("result", {})
return DNSRecord(
id=rec.get("id", ""),
zone=zone,
type=record_type.upper(),
name=rec.get("name", full_name),
content=content,
ttl=ttl,
proxied=proxied,
provider=self.name,
raw=rec,
)
else:
errors = result.get("errors", [])
msg = "; ".join(e.get("message", str(e)) for e in errors)
raise click.ClickException(f"Cloudflare: {msg}")
def remove_records(
self, zone: str, name: str, record_type: str | None = None
) -> int:
full_name = name if name.endswith(zone) else f"{name}.{zone}"
with self._client() as client:
zone_id = self._resolve_zone_id(client, zone)
params: dict[str, str] = {"name": full_name, "per_page": "100"}
if record_type:
params["type"] = record_type
data = self._get(client, f"/zones/{zone_id}/dns_records", params=params)
records = data.get("result", [])
deleted = 0
for r in records:
resp = client.delete(
f"{CF_API}/zones/{zone_id}/dns_records/{r['id']}"
)
if resp.json().get("success"):
deleted += 1
return deleted
def update_records(
self, zone: str, old_content: str, new_content: str, record_type: str = "A"
) -> tuple[int, int]:
with self._client() as client:
zone_id = self._resolve_zone_id(client, zone)
data = self._get(
client,
f"/zones/{zone_id}/dns_records",
params={
"type": record_type,
"content": old_content,
"per_page": "100",
},
)
records = data.get("result", [])
updated = 0
failed = 0
for r in records:
try:
resp = client.patch(
f"{CF_API}/zones/{zone_id}/dns_records/{r['id']}",
json={"content": new_content},
)
if resp.json().get("success"):
updated += 1
else:
failed += 1
except Exception:
failed += 1
return updated, failed
register_provider("cloudflare", CloudflareProvider)
+168
View File
@@ -0,0 +1,168 @@
"""CoreDNS (Hanzo DNS) provider.
Manages DNS via Hanzo's CoreDNS API (K8s-native DNS for Hanzo infrastructure).
Endpoint: configurable, defaults to https://dns.hanzo.ai/api/v1
"""
from __future__ import annotations
from typing import Any
import click
from .provider import DNSProvider, DNSRecord, DNSZone, register_provider
DEFAULT_ENDPOINT = "https://dns.hanzo.ai/api/v1"
class CoreDNSProvider(DNSProvider):
name = "coredns"
def __init__(self, config: dict[str, Any]) -> None:
self.endpoint = config.get("endpoint", DEFAULT_ENDPOINT).rstrip("/")
self.api_key = config.get("api_key", "")
self.token = config.get("token", self.api_key)
if not self.token:
raise ValueError("CoreDNS requires 'api_key' or 'token'")
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
def _client(self):
import httpx
return httpx.Client(headers=self._headers(), timeout=30.0)
def _get(self, client, path: str, params: dict[str, Any] | None = None) -> Any:
resp = client.get(f"{self.endpoint}{path}", params=params)
resp.raise_for_status()
return resp.json()
# ── Interface implementation ─────────────────────────────────────
def list_zones(self) -> list[DNSZone]:
with self._client() as client:
data = self._get(client, "/zones")
zones = data if isinstance(data, list) else data.get("zones", data.get("result", []))
return [
DNSZone(
id=z.get("id", z.get("name", "")),
name=z.get("name", ""),
status=z.get("status", "active"),
plan="CoreDNS",
provider=self.name,
raw=z,
)
for z in zones
]
def list_records(
self, zone: str, record_type: str | None = None
) -> list[DNSRecord]:
with self._client() as client:
params: dict[str, str] = {}
if record_type:
params["type"] = record_type
data = self._get(client, f"/zones/{zone}/records", params=params)
records = data if isinstance(data, list) else data.get("records", data.get("result", []))
return [
DNSRecord(
id=r.get("id", ""),
zone=zone,
type=r.get("type", ""),
name=r.get("name", ""),
content=r.get("content", r.get("value", "")),
ttl=r.get("ttl", 1),
proxied=False,
provider=self.name,
raw=r,
)
for r in records
]
def add_record(
self,
zone: str,
name: str,
content: str,
record_type: str = "A",
proxied: bool = True,
ttl: int = 1,
) -> DNSRecord | None:
full_name = name if name.endswith(zone) else f"{name}.{zone}"
with self._client() as client:
payload = {
"type": record_type.upper(),
"name": full_name,
"content": content,
"ttl": ttl,
}
resp = client.post(
f"{self.endpoint}/zones/{zone}/records", json=payload
)
resp.raise_for_status()
rec = resp.json()
return DNSRecord(
id=rec.get("id", ""),
zone=zone,
type=record_type.upper(),
name=rec.get("name", full_name),
content=content,
ttl=ttl,
proxied=False,
provider=self.name,
raw=rec,
)
def remove_records(
self, zone: str, name: str, record_type: str | None = None
) -> int:
full_name = name if name.endswith(zone) else f"{name}.{zone}"
records = self.list_records(zone, record_type)
matching = [r for r in records if r.name == full_name]
with self._client() as client:
deleted = 0
for r in matching:
try:
resp = client.delete(
f"{self.endpoint}/zones/{zone}/records/{r.id}"
)
resp.raise_for_status()
deleted += 1
except Exception:
pass
return deleted
def update_records(
self, zone: str, old_content: str, new_content: str, record_type: str = "A"
) -> tuple[int, int]:
records = self.list_records(zone, record_type)
matching = [r for r in records if r.content == old_content]
with self._client() as client:
updated = 0
failed = 0
for r in matching:
try:
resp = client.patch(
f"{self.endpoint}/zones/{zone}/records/{r.id}",
json={"content": new_content},
)
resp.raise_for_status()
updated += 1
except Exception:
failed += 1
return updated, failed
register_provider("coredns", CoreDNSProvider)
@@ -0,0 +1,183 @@
"""DNS provider abstraction and registry."""
from __future__ import annotations
import json
import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
CREDENTIALS_FILE = Path.home() / ".hanzo" / "credentials.json"
@dataclass
class DNSRecord:
"""Normalized DNS record across all providers."""
id: str
zone: str
type: str
name: str
content: str
ttl: int = 1
proxied: bool = False
provider: str = ""
raw: dict[str, Any] = field(default_factory=dict)
@property
def ttl_display(self) -> str:
return "Auto" if self.ttl == 1 else str(self.ttl)
@dataclass
class DNSZone:
"""Normalized DNS zone across all providers."""
id: str
name: str
status: str = ""
plan: str = ""
provider: str = ""
raw: dict[str, Any] = field(default_factory=dict)
class DNSProvider(ABC):
"""Abstract DNS provider interface."""
name: str = "unknown"
@abstractmethod
def list_zones(self) -> list[DNSZone]:
"""List all DNS zones."""
@abstractmethod
def list_records(
self, zone: str, record_type: str | None = None
) -> list[DNSRecord]:
"""List DNS records for a zone."""
@abstractmethod
def add_record(
self,
zone: str,
name: str,
content: str,
record_type: str = "A",
proxied: bool = True,
ttl: int = 1,
) -> DNSRecord | None:
"""Add a DNS record. Returns the created record or None on failure."""
@abstractmethod
def remove_records(
self, zone: str, name: str, record_type: str | None = None
) -> int:
"""Remove DNS records matching name. Returns count deleted."""
@abstractmethod
def update_records(
self, zone: str, old_content: str, new_content: str, record_type: str = "A"
) -> tuple[int, int]:
"""Batch update records. Returns (updated, failed) counts."""
# ── Provider registry ────────────────────────────────────────────────
_REGISTRY: dict[str, type[DNSProvider]] = {}
def register_provider(name: str, cls: type[DNSProvider]) -> None:
_REGISTRY[name] = cls
def get_provider(name: str, config: dict[str, Any]) -> DNSProvider:
cls = _REGISTRY.get(name)
if cls is None:
raise ValueError(
f"Unknown DNS provider: {name}. "
f"Available: {', '.join(sorted(_REGISTRY.keys()))}"
)
return cls(config)
def list_providers() -> list[str]:
return sorted(_REGISTRY.keys())
# ── Config loading ───────────────────────────────────────────────────
def load_dns_config() -> dict[str, dict[str, Any]]:
"""Load all DNS provider configs from ~/.hanzo/credentials.json.
Supports both legacy format and new multi-provider format:
Legacy:
{"cloudflare": {"email": "...", "api_key": "..."}}
Multi-provider:
{"dns": {"cloudflare": {...}, "coredns": {...}, "route53": {...}}}
Both can coexist — legacy cloudflare key is merged into dns.cloudflare.
"""
if not CREDENTIALS_FILE.exists():
return {}
try:
data = json.loads(CREDENTIALS_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
providers: dict[str, dict[str, Any]] = {}
# New format: dns.{provider}
dns_section = data.get("dns", {})
if isinstance(dns_section, dict):
for name, cfg in dns_section.items():
if isinstance(cfg, dict) and cfg:
providers[name] = cfg
# Legacy: top-level cloudflare key
cf_legacy = data.get("cloudflare", {})
if isinstance(cf_legacy, dict) and cf_legacy.get("email") and cf_legacy.get("api_key"):
providers.setdefault("cloudflare", {}).update(cf_legacy)
return providers
def get_active_providers() -> list[DNSProvider]:
"""Load and instantiate all configured DNS providers."""
configs = load_dns_config()
providers = []
for name, cfg in configs.items():
if name not in _REGISTRY:
continue
try:
providers.append(get_provider(name, cfg))
except Exception:
pass
return providers
def require_providers() -> list[DNSProvider]:
"""Like get_active_providers but exits if none configured."""
from ...utils.output import console
providers = get_active_providers()
if not providers:
console.print(
"[red]No DNS providers configured.[/red]\n"
f"Add credentials to {CREDENTIALS_FILE}:\n\n"
' {{"dns": {{"cloudflare": {{"email": "...", "api_key": "..."}}}}}}\n\n'
"Or legacy format:\n"
' {{"cloudflare": {{"email": "...", "api_key": "..."}}}}\n\n'
f"Supported providers: {', '.join(list_providers())}"
)
sys.exit(1)
return providers