Coordination with the Lux universe DX cleanup — Cloudflare zone YAMLs
are pure brand assets and belong with their owning universe. Moved
2026-06-03 from lux/universe/k8s/dns/{zoo-exchange,zoo-network,zoo-ngo}.yaml.
k8s/cf-zones/sync.sh is the per-brand drift-detect-and-PATCH script
(same logic as the legacy lux/universe scripts/dns-sync.sh, scoped to
this directory). Dry-run by default; set CF_API_TOKEN and pass --apply
to push changes.
Single-zone usage:
./k8s/cf-zones/sync.sh --zone zoo-ngo
149 lines
4.9 KiB
Bash
Executable File
149 lines
4.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# infra/cf-zones/sync.sh — Zoo brand Cloudflare zone sync.
|
|
#
|
|
# Mirrors the per-zone YAML files in this directory into the matching
|
|
# Cloudflare zone via the CF API. Skips proxied records (managed via
|
|
# the Cloudflare UI). Only updates records that have drifted from the
|
|
# YAML source-of-truth.
|
|
#
|
|
# Usage:
|
|
# ./sync.sh # Dry run (show what would change)
|
|
# ./sync.sh --apply # Apply changes to Cloudflare
|
|
# ./sync.sh --zone hanzo-id # Sync a single zone (filename stem)
|
|
#
|
|
# Prerequisites:
|
|
# CF_API_TOKEN env var (Cloudflare API token with DNS edit permission)
|
|
# python3 with stdlib only (no PyYAML — uses inline parser)
|
|
#
|
|
# Moved 2026-06-03 from lux/universe/scripts/dns-sync.sh as part of
|
|
# brand-boundary cleanup. Lux universe keeps its own copy that only
|
|
# reads lux-*.yaml; per-brand zones are owned by per-brand universes.
|
|
set -euo pipefail
|
|
|
|
DNS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
DRY_RUN=true
|
|
SINGLE_ZONE=""
|
|
|
|
for arg in "$@"; do
|
|
case $arg in
|
|
--apply) DRY_RUN=false ;;
|
|
--zone=*) SINGLE_ZONE="${arg#*=}" ;;
|
|
--zone) shift; SINGLE_ZONE="${1:-}" ;;
|
|
esac
|
|
done
|
|
|
|
CF_TOKEN="${CF_API_TOKEN:-${CLOUDFLARE_DNS_TOKEN:-}}"
|
|
if [ -z "$CF_TOKEN" ] && [ "$DRY_RUN" = false ]; then
|
|
echo "ERROR: CF_API_TOKEN or CLOUDFLARE_DNS_TOKEN required for --apply"
|
|
exit 1
|
|
fi
|
|
|
|
echo "=== Zoo CF Zone Sync $([ "$DRY_RUN" = true ] && echo '(DRY RUN)' || echo '(APPLYING)') ==="
|
|
echo "Zone dir: $DNS_DIR"
|
|
echo ""
|
|
|
|
for zone_file in "$DNS_DIR"/*.yaml; do
|
|
zone_name=$(basename "$zone_file" .yaml)
|
|
|
|
if [ -n "$SINGLE_ZONE" ] && [ "$zone_name" != "$SINGLE_ZONE" ]; then
|
|
continue
|
|
fi
|
|
|
|
python3 - "$zone_file" "$CF_TOKEN" "$DRY_RUN" <<'PY'
|
|
import json, os, subprocess, sys, urllib.request
|
|
|
|
zone_file = sys.argv[1]
|
|
token = sys.argv[2]
|
|
dry_run = sys.argv[3] == "true"
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
sys.stderr.write("pyyaml not installed; install with: pip install pyyaml\n")
|
|
sys.exit(2)
|
|
|
|
with open(zone_file) as f:
|
|
d = yaml.safe_load(f) or {}
|
|
|
|
zone = d.get("zone", "")
|
|
zone_id = d.get("zone_id", "")
|
|
records = d.get("records", [])
|
|
|
|
if not zone or not zone_id:
|
|
print(f"SKIP: {zone_file} (no zone/zone_id)")
|
|
sys.exit(0)
|
|
|
|
print(f"--- {zone} (ID: {zone_id[:8]}...) ---")
|
|
|
|
checked = drifted = updated = 0
|
|
for r in records:
|
|
rtype = r.get("type", "")
|
|
name = r.get("name", "")
|
|
content = r.get("content", "")
|
|
proxied = r.get("proxied", False)
|
|
|
|
if rtype not in ("A", "CNAME"):
|
|
continue
|
|
if name == "@":
|
|
continue
|
|
if proxied:
|
|
continue
|
|
|
|
fqdn = f"{name}.{zone}" if name != zone else zone
|
|
try:
|
|
result = subprocess.run(
|
|
["dig", "+short", fqdn, "@1.1.1.1"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
live = result.stdout.strip().split("\n")[0] if result.stdout.strip() else ""
|
|
except Exception:
|
|
live = ""
|
|
|
|
checked += 1
|
|
if live == content:
|
|
continue
|
|
|
|
drifted += 1
|
|
print(f" DRIFT: {fqdn} live={live or '(none)'} yaml={content}")
|
|
|
|
if dry_run or not token:
|
|
continue
|
|
|
|
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|
url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type={rtype}&name={fqdn}"
|
|
try:
|
|
req = urllib.request.Request(url, headers=headers)
|
|
cf_data = json.loads(urllib.request.urlopen(req, timeout=10).read())
|
|
cf_records = cf_data.get("result", [])
|
|
if cf_records:
|
|
rec_id = cf_records[0]["id"]
|
|
patch_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec_id}"
|
|
patch_data = json.dumps({"content": content, "proxied": proxied}).encode()
|
|
patch_req = urllib.request.Request(patch_url, data=patch_data, headers=headers, method="PATCH")
|
|
patch_result = json.loads(urllib.request.urlopen(patch_req, timeout=10).read())
|
|
if patch_result.get("success"):
|
|
print(f" UPDATED: {fqdn} -> {content}")
|
|
updated += 1
|
|
else:
|
|
print(f" FAILED: {patch_result.get('errors')}")
|
|
else:
|
|
create_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records"
|
|
create_data = json.dumps({"type": rtype, "name": fqdn, "content": content, "proxied": proxied}).encode()
|
|
create_req = urllib.request.Request(create_url, data=create_data, headers=headers, method="POST")
|
|
create_result = json.loads(urllib.request.urlopen(create_req, timeout=10).read())
|
|
if create_result.get("success"):
|
|
print(f" CREATED: {fqdn} -> {content}")
|
|
updated += 1
|
|
else:
|
|
print(f" FAILED: {create_result.get('errors')}")
|
|
except Exception as e:
|
|
print(f" ERROR: {e}")
|
|
|
|
print(f" Checked: {checked}, Drifted: {drifted}, Updated: {updated}")
|
|
PY
|
|
echo ""
|
|
done
|
|
|
|
echo "=== Done ==="
|
|
[ "$DRY_RUN" = true ] && echo "Run with --apply to push changes to Cloudflare"
|