From 225bdc6020b86b67886b41634c8cc171a83dc5a6 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 5 Jul 2026 17:14:54 +1000 Subject: [PATCH] dns: dual-write toggles + drift fact Publish records both ways during the k8s cutover, and expose expected vs deployed records for drift detection. - profiles::dns::updater + ::record: manage_nsupdate and manage_export booleans (both default on); export keeps the legacy master flow, so disable it once k8s is authoritative - dns_records fact: parses the expected records file and digs the authoritative server for each, reporting expected / in_sync / drift (plus dns_records_insync boolean); updater writes the server address to /var/lib/dns-updater/server for the fact - hiera: manage_export/manage_nsupdate = true (cutover) --- hieradata/common.yaml | 14 +- modules/libs/lib/facter/dns_records.rb | 88 ++++++++++++ site/profiles/manifests/dns/record.pp | 31 +++-- site/profiles/manifests/dns/updater.pp | 186 ++++++++++++++----------- 4 files changed, 221 insertions(+), 98 deletions(-) create mode 100644 modules/libs/lib/facter/dns_records.rb diff --git a/hieradata/common.yaml b/hieradata/common.yaml index 5a8ba80..e0e007b 100644 --- a/hieradata/common.yaml +++ b/hieradata/common.yaml @@ -209,10 +209,16 @@ profiles::dns::base::nameservers: - 198.18.19.16 profiles::dns::master::basedir: '/var/named/sources' -# dns::updater nsupdates host records to the k8s authoritative write endpoint -# (bind-authoritative-primary). Inert until the TSIG key is set in eyaml: -# profiles::dns::updater::key_secret: ENC[...] (must match the key the -# bind-authoritative zones allow-update with; algorithm hmac-sha256) +# dns record publishing. During the k8s cutover both methods run; set +# manage_export false once k8s is authoritative. +# - export: legacy exported-resources -> puppet DNS master +# - nsupdate: RFC2136 to the k8s bind-authoritative write endpoint (.9), +# inert until the TSIG key is set in eyaml: +# profiles::dns::updater::key_secret: ENC[...] +# (must match the key the bind-authoritative zones allow-update +# with; algorithm hmac-sha256) +profiles::dns::updater::manage_export: true +profiles::dns::updater::manage_nsupdate: true profiles::dns::updater::server: '198.18.200.9' profiles::dns::updater::key_name: 'client-update' profiles::dns::updater::key_algorithm: 'hmac-sha256' diff --git a/modules/libs/lib/facter/dns_records.rb b/modules/libs/lib/facter/dns_records.rb new file mode 100644 index 0000000..6b92db6 --- /dev/null +++ b/modules/libs/lib/facter/dns_records.rb @@ -0,0 +1,88 @@ +# 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 diff --git a/site/profiles/manifests/dns/record.pp b/site/profiles/manifests/dns/record.pp index d3eb438..fa54dea 100644 --- a/site/profiles/manifests/dns/record.pp +++ b/site/profiles/manifests/dns/record.pp @@ -1,9 +1,10 @@ # profiles::dns::record # -# Declares a DNS record for this host. The record is written to the local -# dns-updater records file (profiles::dns::updater), which nsupdates it to the -# authoritative DNS server. This replaces the old flow that exported a -# @@concat::fragment to the puppet DNS master. +# Declares a DNS record for this host. Publishes it via either or both methods, +# controlled by profiles::dns::updater's toggles (both on during cutover): +# - nsupdate: a local concat fragment consumed by profiles::dns::updater, +# which nsupdates it to the authoritative server. +# - export: the legacy @@concat::fragment exported to the puppet DNS master. define profiles::dns::record ( String $record, Enum[ @@ -22,10 +23,22 @@ define profiles::dns::record ( ) { include profiles::dns::updater - # zone|name|type|ttl|value (parsed by the dns-update script) - concat::fragment { "dns-record-${name}": - target => $profiles::dns::updater::records_file, - content => "${zone}|${record}|${type}|${ttl}|${value}\n", - order => sprintf('%03d', $order), + # new: local records file consumed by the nsupdate service + if $profiles::dns::updater::manage_nsupdate { + # zone|name|type|ttl|value (parsed by the dns-update script) + concat::fragment { "dns-record-${name}": + target => $profiles::dns::updater::records_file, + content => "${zone}|${record}|${type}|${ttl}|${value}\n", + order => sprintf('%03d', $order), + } + } + + # legacy: export the fragment to the puppet DNS master + if $profiles::dns::updater::manage_export { + @@concat::fragment { "${zone}_${name}": + target => "${profiles::dns::updater::master_basedir}/${zone}.conf", + content => "${record} IN ${type} ${value}\n", + order => $order, + } } } diff --git a/site/profiles/manifests/dns/updater.pp b/site/profiles/manifests/dns/updater.pp index 65d985c..f2523fb 100644 --- a/site/profiles/manifests/dns/updater.pp +++ b/site/profiles/manifests/dns/updater.pp @@ -1,111 +1,127 @@ # profiles::dns::updater # -# Applies this host's DNS records to the authoritative DNS server via TSIG -# nsupdate, replacing the old exported-resources -> master zone-file flow. +# Publishes this host's DNS records. Two methods, independently toggled so both +# can run during the k8s cutover (profiles::dns::record honours the same flags): # -# profiles::dns::record fragments are assembled into $records_file; a systemd -# .path unit watches that file and runs dns-update.service (nsupdate) whenever -# it changes. nsupdate comes from bind-utils (installed via bind::updater in +# - nsupdate ($manage_nsupdate): assemble the records into a local file and +# nsupdate them to the k8s authoritative write endpoint via a systemd .path +# unit that watches the file. Inert until $key_secret (TSIG) is set. +# - export ($manage_export): the legacy exported-resources flow to the puppet +# DNS master. Kept during cutover; disable once k8s is authoritative. +# +# nsupdate comes from bind-utils (installed via bind::updater in # profiles::dns::base). -# -# Inert until $key_secret is set (the shared TSIG key that the k8s -# bind-authoritative zones allow-update with): the records file is still -# assembled, but the updater service is not managed, so nodes are safe before -# the key is provisioned. class profiles::dns::updater ( - String $server = '198.18.200.9', - String $key_name = 'client-update', - String $key_algorithm = 'hmac-sha256', - Optional[Sensitive[String]] $key_secret = undef, - Integer $default_ttl = 300, - Stdlib::AbsolutePath $records_file = '/var/lib/dns-updater/records', - Stdlib::AbsolutePath $state_dir = '/var/lib/dns-updater', - Stdlib::AbsolutePath $config_dir = '/etc/dns-updater', + Boolean $manage_nsupdate = true, + Boolean $manage_export = true, + String $server = '198.18.200.9', + String $key_name = 'client-update', + String $key_algorithm = 'hmac-sha256', + Optional[Sensitive[String]] $key_secret = undef, + Integer $default_ttl = 300, + Stdlib::AbsolutePath $records_file = '/var/lib/dns-updater/records', + Stdlib::AbsolutePath $state_dir = '/var/lib/dns-updater', + Stdlib::AbsolutePath $config_dir = '/etc/dns-updater', + Stdlib::AbsolutePath $master_basedir = lookup('profiles::dns::master::basedir'), ) { - $state_file = "${state_dir}/applied" - $key_file = "${config_dir}/key" + $state_file = "${state_dir}/applied" + $server_file = "${state_dir}/server" + $key_file = "${config_dir}/key" - file { $state_dir: - ensure => directory, - owner => 'root', - group => 'root', - mode => '0755', - } + if $manage_nsupdate { - # Records file, assembled from profiles::dns::record fragments. - concat { $records_file: - ensure => present, - owner => 'root', - group => 'root', - mode => '0644', - ensure_newline => true, - warn => false, - require => File[$state_dir], - } - - concat::fragment { 'dns-update-header': - target => $records_file, - content => "# Managed by puppet (profiles::dns::record): zone|name|type|ttl|value\n", - order => '00', - } - - if $key_secret =~ Undef { - notify { 'dns-updater-inert': - message => 'profiles::dns::updater: key_secret unset; records assembled but not applied.', - loglevel => 'info', - } - } else { - file { $config_dir: + file { $state_dir: ensure => directory, owner => 'root', group => 'root', - mode => '0700', + mode => '0755', } - file { $key_file: - ensure => file, - owner => 'root', - group => 'root', - mode => '0600', - show_diff => false, - content => Sensitive(epp('profiles/dns/tsig-key.epp', { - 'name' => $key_name, - 'algorithm' => $key_algorithm, - 'secret' => $key_secret.unwrap, - })), - } - - file { '/usr/local/bin/dns-update': + # Server address, read by the dns_records fact for drift detection. + file { $server_file: ensure => file, owner => 'root', group => 'root', - mode => '0755', - content => epp('profiles/dns/dns-update.sh.epp', { - 'server' => $server, - 'key_file' => $key_file, - 'records_file' => $records_file, - 'state_file' => $state_file, - }), + mode => '0644', + content => "${server}\n", + require => File[$state_dir], } - systemd::unit_file { 'dns-update.service': - content => epp('profiles/dns/dns-update.service.epp', { 'script' => '/usr/local/bin/dns-update' }), + # Records file, assembled from profiles::dns::record fragments. + concat { $records_file: + ensure => present, + owner => 'root', + group => 'root', + mode => '0644', + ensure_newline => true, + warn => false, + require => File[$state_dir], } - # The .path unit watches the records file and triggers the service. - systemd::unit_file { 'dns-update.path': - content => epp('profiles/dns/dns-update.path.epp', { 'records_file' => $records_file }), - active => true, - enable => true, + concat::fragment { 'dns-update-header': + target => $records_file, + content => "# Managed by puppet (profiles::dns::record): zone|name|type|ttl|value\n", + order => '00', } - # Also apply within the puppet run whenever the records change. - exec { 'dns-update-apply': - command => '/usr/local/bin/dns-update', - refreshonly => true, - subscribe => Concat[$records_file], - require => [File['/usr/local/bin/dns-update'], File[$key_file]], + if $key_secret =~ Undef { + notify { 'dns-updater-inert': + message => 'profiles::dns::updater: key_secret unset; records assembled but not applied.', + loglevel => 'info', + } + } else { + file { $config_dir: + ensure => directory, + owner => 'root', + group => 'root', + mode => '0700', + } + + file { $key_file: + ensure => file, + owner => 'root', + group => 'root', + mode => '0600', + show_diff => false, + content => Sensitive(epp('profiles/dns/tsig-key.epp', { + 'name' => $key_name, + 'algorithm' => $key_algorithm, + 'secret' => $key_secret.unwrap, + })), + } + + file { '/usr/local/bin/dns-update': + ensure => file, + owner => 'root', + group => 'root', + mode => '0755', + content => epp('profiles/dns/dns-update.sh.epp', { + 'server' => $server, + 'key_file' => $key_file, + 'records_file' => $records_file, + 'state_file' => $state_file, + }), + } + + systemd::unit_file { 'dns-update.service': + content => epp('profiles/dns/dns-update.service.epp', { 'script' => '/usr/local/bin/dns-update' }), + } + + # The .path unit watches the records file and triggers the service. + systemd::unit_file { 'dns-update.path': + content => epp('profiles/dns/dns-update.path.epp', { 'records_file' => $records_file }), + active => true, + enable => true, + } + + # Also apply within the puppet run whenever the records change. + exec { 'dns-update-apply': + command => '/usr/local/bin/dns-update', + refreshonly => true, + subscribe => Concat[$records_file], + require => [File['/usr/local/bin/dns-update'], File[$key_file]], + } } } }