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>
190 lines
6.5 KiB
Go
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
|
|
}
|