Files
puppet-prod/modules/libs/lib/facter/dns_records.rb
T
unkinben 88fcb97ad1 dns: nsupdate host records to the authoritative server (#475)
Replaces the exported-resources → puppet DNS master zone-file flow with per-host RFC2136 dynamic updates against the k8s **bind-authoritative** write endpoint (198.18.200.9). The master no longer manages zone files.

## Design
Each node assembles its DNS records into a local concat file; a systemd `.path` unit watches it and runs `dns-update` (nsupdate) on change — exactly the watch-a-file model requested.

## Changes
- **profiles::dns::updater** (new): concat records file + TSIG key file + `dns-update` script + `dns-update.service` (oneshot) + `dns-update.path` (watcher). The script sends only the delta since last run and deletes removed records, grouped per zone.
- **profiles::dns::record**: writes a local concat fragment (`zone|name|type|ttl|value`) instead of exporting `@@concat::fragment` to the master.
- **profiles::dns::base**: includes `profiles::dns::updater` (all nodes).
- **hiera**: `profiles::dns::updater` server/key_name/algorithm in common.yaml.

## Inert until keyed
The updater does nothing until `profiles::dns::updater::key_secret` (TSIG) is set in eyaml — records are assembled but not applied, so nodes are safe before the key exists.

## Prerequisites (k8s side, separate)
1. The `bind-authoritative` zones must set `dynamicUpdate: true` + an `updateKeyRef` (a client-update BindTSIGKey) so they accept these updates.
2. The TSIG key must be shared: the operator-generated key value goes into eyaml here (or the planned Vault-sync feature bridges it).

## Validated
puppet parser/epp validate, puppet-lint, and a functional test of the generated per-zone nsupdate message (replace + delete-removed).

Reviewed-on: #475
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-12 22:23:18 +10:00

89 lines
2.6 KiB
Ruby

# frozen_string_literal: true
# lib/facter/dns_records.rb
#
# Reports this host's expected DNS records (assembled by profiles::dns::updater
# into its records file) versus what is currently deployed on the authoritative
# server, so puppet can detect drift and re-apply.
#
# Structured value:
# { server, count, expected => [{zone,fqdn,type,ttl,value}], in_sync,
# drift => [{...,deployed => [...]}] }
# Helpers for the dns_records fact.
module DnsRecordsFact
RECORDS_FILE = '/var/lib/dns-updater/records'
SERVER_FILE = '/var/lib/dns-updater/server'
module_function
# normalise a value for comparison: strip, drop trailing dot, downcase
def norm(value)
value.to_s.strip.chomp('.').downcase
end
def server
File.exist?(SERVER_FILE) ? File.read(SERVER_FILE).strip : nil
end
# a name relative to a zone (or @) as a fully-qualified name
def to_fqdn(name, zone)
return "#{zone}." if name.to_s.empty? || name == '@'
"#{name}.#{zone}."
end
# parse one "zone|name|type|ttl|value" line into a record hash (nil to skip)
def parse_line(line)
line = line.strip
return nil if line.empty? || line.start_with?('#')
zone, name, type, ttl, value = line.split('|', 5)
return nil unless zone && type && value
{ 'zone' => zone, 'fqdn' => to_fqdn(name, zone), 'type' => type, 'ttl' => ttl, 'value' => value }
end
# parse the records file into record hashes
def expected
return [] unless File.exist?(RECORDS_FILE)
File.readlines(RECORDS_FILE).filter_map { |line| parse_line(line) }
end
# the values currently deployed for a record, per the authoritative server
def deployed(record, srv)
cmd = ['dig', '+short', '+time=2', '+tries=1']
cmd << "@#{srv}" if srv && !srv.empty?
cmd += [record['fqdn'], record['type']]
out = Facter::Core::Execution.execute(cmd.join(' '), on_fail: '')
out.to_s.split("\n").map { |line| norm(line) }.reject(&:empty?)
end
def report
srv = server
exp = expected
drift = exp.filter_map do |record|
dep = deployed(record, srv)
record.merge('deployed' => dep) unless dep.include?(norm(record['value']))
end
{ 'server' => srv, 'count' => exp.length, 'expected' => exp, 'in_sync' => drift.empty?, 'drift' => drift }
end
end
Facter.add(:dns_records) do
confine kernel: 'Linux'
setcode do
File.exist?(DnsRecordsFact::RECORDS_FILE) ? DnsRecordsFact.report : nil
end
end
# Convenience boolean for `if $facts['dns_records_insync']` guards.
Facter.add(:dns_records_insync) do
confine kernel: 'Linux'
setcode do
v = Facter.value(:dns_records)
v.nil? ? nil : v['in_sync']
end
end