Files
DDI/agent/role/dns/health.go
T
sir_melonchenandClaude Opus 5.5 70643f8146 feat(agent/dns): keys file, secondary zones and their health
The DNS role installs ddi-suite-keys.conf (a keys file without keys for
revisions from before M5, so that named.conf can always include it),
includes it before ddi-suite.conf in the validation named.conf, accepts
secondary zone files in <zone_dir>/secondary/ and creates that
directory with the mode and owner of zone_dir without ever touching its
content. The health check queries secondary zones too; a zone still
waiting for its first transfer is reported but does not fail the
deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:30:40 +02:00

190 lines
6.5 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package dns
import (
"context"
"fmt"
"strings"
"sync"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
)
// maxParallelQueries bounds concurrent SOA queries of one health check.
const maxParallelQueries = 8
// Health runs "rndc status" and, for every managed (not create-only) zone
// of expected, an SOA query to dns_address whose serial must equal the
// revision number (the generator writes the revision as SOA serial).
// Dynamic zones are not compared: BIND increments their serial on updates.
//
// Secondary zones are queried too, any serial counts as loaded. They do not
// change the state: right after an activation a secondary zone may still
// wait for its first transfer from a primary that has not got the revision
// yet, which must not roll the revision back. A zone that does not answer
// yet is reported as a degraded check "secondary zone <name>".
//
// State: Failed if rndc status fails or no managed zone serves the expected
// serial; Degraded if only some do or rndc status reports that the last
// reload failed or is still in progress; OK otherwise.
func (r *Role) Health(ctx context.Context, expected *core.Revision) core.Health {
var h core.Health
status := core.HealthCheck{Name: "rndc status", State: core.HealthOK}
out, err := r.run.Run(ctx, r.cfg.Rndc, append(append([]string(nil), r.cfg.RndcArgs...), "status")...)
if err != nil {
status.State = core.HealthFailed
status.Message = err.Error()
} else {
var reloadOK bool
status.Message, reloadOK = summarizeStatus(string(out))
if !reloadOK {
status.State = core.HealthDegraded
}
}
h.Checks = append(h.Checks, status)
var zones []string
if expected != nil {
for _, f := range expected.Files {
if z, ok := strings.CutPrefix(f.Path, zonesPrefix+zonePrefix); ok && !f.CreateOnly && validZoneName(z) {
zones = append(zones, z)
}
}
}
zoneChecks := r.checkZones(ctx, zones, expected)
h.Checks = append(h.Checks, zoneChecks...)
var secondaries []string
if expected != nil {
secondaries = r.secondaryZones(*expected)
}
secondaryChecks := r.checkSecondaries(ctx, secondaries)
h.Checks = append(h.Checks, secondaryChecks...)
good := 0
for _, c := range zoneChecks {
if c.State == core.HealthOK {
good++
}
}
loaded := 0
for _, c := range secondaryChecks {
if c.State == core.HealthOK {
loaded++
}
}
switch {
case status.State == core.HealthFailed:
h.State = core.HealthFailed
h.Message = "named does not answer rndc status"
case status.State != core.HealthOK && (len(zones) == 0 || good > 0):
h.State = core.HealthDegraded
h.Message = status.Message
case len(zones) > 0 && good == 0:
h.State = core.HealthFailed
h.Message = fmt.Sprintf("no zone serves serial %d", uint32(expected.Number)) //nolint:gosec // serials are revision numbers < 2^32 (ADR 0021)
case good < len(zones):
h.State = core.HealthDegraded
h.Message = fmt.Sprintf("%d of %d zones serve serial %d", good, len(zones), uint32(expected.Number)) //nolint:gosec // see above
default:
h.State = core.HealthOK
h.Message = "named is running"
if len(zones) > 0 {
h.Message += fmt.Sprintf("; %d zones serve serial %d", len(zones), uint32(expected.Number)) //nolint:gosec // see above
}
}
if len(secondaries) > 0 && h.State != core.HealthFailed {
h.Message += fmt.Sprintf("; %d of %d secondary zones loaded", loaded, len(secondaries))
}
return h
}
// checkSecondaries queries the SOA of every secondary zone. A zone that
// does not answer authoritatively yet gets a degraded check; it does not
// count for the overall state (see Health).
func (r *Role) checkSecondaries(ctx context.Context, zones []string) []core.HealthCheck {
return r.querySOAs(ctx, zones, func(z string, serial uint32, err error) core.HealthCheck {
c := core.HealthCheck{Name: "secondary zone " + z, State: core.HealthOK, Message: fmt.Sprintf("serial %d", serial)}
if err != nil {
c.State = core.HealthDegraded
c.Message = "not loaded yet, waiting for a zone transfer (SOA query: " + err.Error() + ")"
}
return c
})
}
// checkZones queries the SOA of every managed zone, whose serial must be
// the revision number.
func (r *Role) checkZones(ctx context.Context, zones []string, expected *core.Revision) []core.HealthCheck {
if len(zones) == 0 {
return make([]core.HealthCheck, 0)
}
want := uint32(expected.Number) //nolint:gosec // serials are revision numbers < 2^32 (ADR 0021)
return r.querySOAs(ctx, zones, func(z string, serial uint32, err error) core.HealthCheck {
c := core.HealthCheck{Name: "zone " + z}
switch {
case err != nil:
c.State = core.HealthFailed
c.Message = "SOA query: " + err.Error()
case serial != want:
c.State = core.HealthFailed
c.Message = fmt.Sprintf("serial %d, expected %d", serial, want)
default:
c.State = core.HealthOK
c.Message = fmt.Sprintf("serial %d", serial)
}
return c
})
}
// querySOAs queries the SOA of every zone in parallel (bounded) and turns
// each answer into a check with eval.
func (r *Role) querySOAs(ctx context.Context, zones []string, eval func(zone string, serial uint32, err error) core.HealthCheck) []core.HealthCheck {
checks := make([]core.HealthCheck, len(zones))
sem := make(chan struct{}, maxParallelQueries)
var wg sync.WaitGroup
for i, z := range zones {
wg.Add(1)
sem <- struct{}{}
go func() {
defer func() { <-sem; wg.Done() }()
serial, err := querySOA(ctx, r.dial, r.cfg.DNSAddress, z)
checks[i] = eval(z, serial, err)
}()
}
wg.Wait()
return checks
}
// summarizeStatus picks the version, the reload state and the final status
// line from the output of rndc status, e.g. "BIND 9.20.3 (Stable Release);
// server is up and running". reloadOK is false if named reports
// "reload/reconfig failed" or "reload/reconfig in progress" (printed by
// named_server_status in BIND 9.18 and 9.20 while the last reload is not
// done).
func summarizeStatus(out string) (summary string, reloadOK bool) {
var parts []string
reloadOK = true
for _, l := range strings.Split(out, "\n") {
l = strings.TrimSpace(l)
if v, ok := strings.CutPrefix(l, "version: "); ok {
if i := strings.Index(v, " <id:"); i >= 0 {
v = v[:i]
}
parts = append(parts, v)
}
if strings.HasPrefix(l, "reload/reconfig ") {
parts = append(parts, l)
reloadOK = false
}
if strings.HasPrefix(l, "server is ") {
parts = append(parts, l)
}
}
if len(parts) == 0 {
return command.Excerpt([]byte(out)), reloadOK
}
return strings.Join(parts, "; "), reloadOK
}