mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user