provisioning-catalog/lib/cloudflare-dns.nu

217 lines
10 KiB
Text

#!/usr/bin/env nu
# Cloudflare DNS reconcile — runs on the provisioning machine with native http/json.
# Shared by every cluster component that publishes an A record for its gateway IP.
# The cluster nodes never touch Cloudflare and carry no JSON tooling; the whole
# reconcile lives here, on provisioning, where nushell parses responses natively.
# Derive the apex (registrable) zone from a fully-qualified domain when the
# component does not declare one explicitly: the last two labels.
def _apex_zone [domain: string]: nothing -> string {
$domain | split row "." | last 2 | str join "."
}
# Upsert a single A record to point $domain at $target_ip inside $zone.
# Idempotent: skips when the record already resolves to the target. Never fails
# the deploy — a DNS hiccup is reported as a warning, not a hard error.
export def cf-dns-upsert [
cf_token: string # Cloudflare API token scoped to the zone
domain: string # FQDN to publish (e.g. git.example.com)
target_ip: string # A-record value (the gateway floating IP)
zone: string = "" # registrable zone; derived from domain when empty
]: nothing -> nothing {
if ($cf_token | is-empty) {
print $" [warn] DNS: no Cloudflare token for ($domain) — skipping"
return
}
if ($domain | is-empty) or ($target_ip | is-empty) {
print " [skip] DNS: domain or target_ip empty — skipping"
return
}
let zone = if ($zone | is-not-empty) { $zone } else { _apex_zone $domain }
let base = "https://api.cloudflare.com/client/v4"
let hdr = { Authorization: $"Bearer ($cf_token)" }
let zone_resp = (do -i { http get --headers $hdr $"($base)/zones?name=($zone)&status=active" } | default {})
let zone_id = ($zone_resp | get -o result.0.id | default "")
if ($zone_id | is-empty) {
print $" [warn] DNS: zone '($zone)' not found in Cloudflare"
return
}
let rec_resp = (do -i { http get --headers $hdr $"($base)/zones/($zone_id)/dns_records?name=($domain)&type=A" } | default {})
let record_id = ($rec_resp | get -o result.0.id | default "")
let current_ip = ($rec_resp | get -o result.0.content | default "")
if ($record_id | is-not-empty) and ($current_ip == $target_ip) {
print $" [ok] DNS: ($domain) → ($target_ip) \(already correct\)"
return
}
let payload = { type: "A", name: $domain, content: $target_ip, ttl: 300, proxied: false }
let resp = if ($record_id | is-not-empty) {
do -i {
http put --headers $hdr --content-type application/json $"($base)/zones/($zone_id)/dns_records/($record_id)" $payload
} | default {}
} else {
do -i {
http post --headers $hdr --content-type application/json $"($base)/zones/($zone_id)/dns_records" $payload
} | default {}
}
if (($resp | get -o success | default false) == true) {
let from = if ($current_ip | is-empty) { "none" } else { $current_ip }
print $" [ok] DNS: ($domain) → ($target_ip) \(updated from ($from)\)"
} else {
let err = ($resp | get -o errors.0.message | default "unknown")
print $" [warn] DNS update failed for ($domain): ($err) \(continuing\)"
}
}
# Verify the A record for $domain actually resolves to $target_ip in Cloudflare.
# Read-only (no writes). Returns true ONLY when the record exists AND its content
# equals target_ip. This is the safety net for cf-dns-upsert, which is do -i
# throughout and never returns an error — the caller must confirm the outcome.
export def cf-dns-verify [
cf_token: string
domain: string
target_ip: string
zone: string = ""
]: nothing -> bool {
if ($cf_token | is-empty) or ($domain | is-empty) or ($target_ip | is-empty) { return false }
let zone = if ($zone | is-not-empty) { $zone } else { _apex_zone $domain }
let base = "https://api.cloudflare.com/client/v4"
let hdr = { Authorization: $"Bearer ($cf_token)" }
let zone_resp = (do -i { http get --headers $hdr $"($base)/zones?name=($zone)&status=active" } | default {})
let zone_id = ($zone_resp | get -o result.0.id | default "")
if ($zone_id | is-empty) { return false }
let rec_resp = (do -i { http get --headers $hdr $"($base)/zones/($zone_id)/dns_records?name=($domain)&type=A" } | default {})
($rec_resp | get -o result.0.content | default "") == $target_ip
}
# True when an A record for $domain currently exists in Cloudflare (any value).
# Existence probe for the delete-phase postcondition (record must be ABSENT after
# cf-dns-delete) — distinct from cf-dns-verify, which checks value equality.
export def cf-dns-exists [
cf_token: string
domain: string
zone: string = ""
]: nothing -> bool {
if ($cf_token | is-empty) or ($domain | is-empty) { return false }
let zone = if ($zone | is-not-empty) { $zone } else { _apex_zone $domain }
let base = "https://api.cloudflare.com/client/v4"
let hdr = { Authorization: $"Bearer ($cf_token)" }
let zone_resp = (do -i { http get --headers $hdr $"($base)/zones?name=($zone)&status=active" } | default {})
let zone_id = ($zone_resp | get -o result.0.id | default "")
if ($zone_id | is-empty) { return false }
let rec_resp = (do -i { http get --headers $hdr $"($base)/zones/($zone_id)/dns_records?name=($domain)&type=A" } | default {})
($rec_resp | get -o result.0.id | default "") | is-not-empty
}
# Delete the A record for $domain in Cloudflare. Idempotent: an already-absent
# record is a no-op success. The inverse of cf-dns-upsert for the op delete phase
# (ADR-051 delete-inverts-cleanup) — so a deleted component leaves no orphaned A
# record. Runs on the provisioner; the token never reaches the nodes.
export def cf-dns-delete [
cf_token: string
domain: string
zone: string = ""
]: nothing -> nothing {
if ($cf_token | is-empty) {
print $" [warn] DNS: no Cloudflare token for ($domain) — skipping delete"
return
}
if ($domain | is-empty) {
print " [skip] DNS: domain empty — skipping delete"
return
}
let zone = if ($zone | is-not-empty) { $zone } else { _apex_zone $domain }
let base = "https://api.cloudflare.com/client/v4"
let hdr = { Authorization: $"Bearer ($cf_token)" }
let zone_resp = (do -i { http get --headers $hdr $"($base)/zones?name=($zone)&status=active" } | default {})
let zone_id = ($zone_resp | get -o result.0.id | default "")
if ($zone_id | is-empty) {
print $" [warn] DNS: zone '($zone)' not found in Cloudflare"
return
}
let rec_resp = (do -i { http get --headers $hdr $"($base)/zones/($zone_id)/dns_records?name=($domain)&type=A" } | default {})
let record_id = ($rec_resp | get -o result.0.id | default "")
if ($record_id | is-empty) {
print $" [ok] DNS: ($domain) already absent \(nothing to delete\)"
return
}
let resp = (do -i { http delete --headers $hdr $"($base)/zones/($zone_id)/dns_records/($record_id)" } | default {})
if (($resp | get -o success | default false) == true) {
print $" [ok] DNS: ($domain) A record deleted"
} else {
let err = ($resp | get -o errors.0.message | default "unknown")
print $" [warn] DNS delete failed for ($domain): ($err) \(continuing\)"
}
}
# Upsert many A records — for multi-site components (e.g. rev_proxy, one record
# per proxied site, all pointing at the shared FIP). Reads a JSON file of
# [{token, domain, target_ip, zone}] so per-zone tokens never land in argv.
export def cf-dns-upsert-many [records_file: string]: nothing -> nothing {
if ($records_file | is-empty) or (not ($records_file | path exists)) {
print " [skip] DNS: no records file — skipping"
return
}
let records = (do -i { open $records_file } | default [])
for r in $records {
cf-dns-upsert (
$r | get -o token | default ""
) (
$r | get -o domain | default ""
) (
$r | get -o target_ip | default ""
) (
$r | get -o zone | default ""
)
}
}
# CLI entry — invoked as `nu cloudflare-dns.nu <domain> <target_ip> [zone]` with
# the token supplied out-of-band via $env.CF_API_TOKEN so it never lands in argv
# (and thus never in `ps`). Used by the provisioning orchestrator post-install.
def main [
domain: string
target_ip: string
zone: string = ""
]: nothing -> nothing {
let token = ($env.CF_API_TOKEN? | default "")
cf-dns-upsert $token $domain $target_ip $zone
# Verification check — cf-dns-upsert is do -i throughout and returns no error,
# so a failed publish is otherwise silent. Confirm the record actually landed
# and make a miss LOUD. exit 1 is swallowed by the caller's `do -i` (deploy is
# not broken), but the [ERROR] line surfaces and the non-zero is observable to
# any caller that switches from `do -i` to `complete`.
if ($domain | is-not-empty) and ($target_ip | is-not-empty) {
if (cf-dns-verify $token $domain $target_ip $zone) {
print $" [ok] DNS verified: ($domain) → ($target_ip)"
} else {
print $" [ERROR] DNS NOT confirmed: ($domain) → ($target_ip) absent after reconcile — publish manually or re-run"
exit 1
}
}
}
# `nu cloudflare-dns.nu many <records.json>` — multi-site entry.
def "main many" [records_file: string]: nothing -> nothing {
cf-dns-upsert-many $records_file
}
# `nu cloudflare-dns.nu delete <domain> [zone]` — delete-phase entry. Removes the
# A record and confirms its absence, exiting non-zero if it survives (so the
# caller's `complete` surfaces an unconfirmed delete). Inverse of `main`.
def "main delete" [domain: string, zone: string = ""]: nothing -> nothing {
let token = ($env.CF_API_TOKEN? | default "")
cf-dns-delete $token $domain $zone
if ($domain | is-not-empty) {
if (cf-dns-exists $token $domain $zone) {
print $" [ERROR] DNS delete NOT confirmed: ($domain) still present after delete — remove it manually"
exit 1
} else {
print $" [ok] DNS delete verified: ($domain) absent"
}
}
}