On 2026-10-09 the control node's dnsmasq stayed active and running, its journal silent, and refused every question on its private address for twenty minutes; nothing in the module looked past the unit. The module is now Go (its TypeScript tools were never served): a root watch asks the machine's own name on every listen address over UDP and TCP every 15s, restarts the unit after two failing looks (2 min apart, 3 an hour), and says whether that helped. dnsmasq_health is the watch process's declared check, so a restart that did not help becomes the module's unhealthy condition; the service is judged by its unit.
217 lines
7.1 KiB
Go
217 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The watch is the module's long-running process, as root. Every LookEvery it asks the resolver its real
|
|
// question on every listen address (look.go); after Confirm failing looks in a row it restarts the
|
|
// resolver's unit, within a budget, and writes what it saw and did for the tools, which say it to the mesh
|
|
// (novox/hq issue 348).
|
|
//
|
|
// **Why the module restarts its own resolver.** A resolver that stops answering on a machine can cut that
|
|
// machine off from the bus by name, and then nothing outside the machine — neither the gate's rollback nor
|
|
// a healer in the controller — reaches it (ADR 0236 on the network path). The repair has to be made where
|
|
// the resolver runs, as the identity provider repairs its own administrator (ADR 0224 §5). It is braked,
|
|
// and it never says it worked: its next look does. A restart that did not help is said by the health
|
|
// tool as the resolver's failure, which the node-engine states and the controller raises — the operator
|
|
// hears of it only then.
|
|
|
|
// The watch's clocks and its budget.
|
|
var (
|
|
LookEvery = 15 * time.Second
|
|
// Confirm failing looks in a row are a resolver that does not answer: one look can be wrong (issue 277).
|
|
Confirm = 2
|
|
// RestartGap is the least time between two restarts; RestartsPerHour the most within an hour.
|
|
RestartGap = 2 * time.Minute
|
|
RestartsPerHour = 3
|
|
// HelpedWithin is how soon after a restart a healthy look says the restart helped.
|
|
HelpedWithin = time.Minute
|
|
)
|
|
|
|
// Unit is the resolver's service.
|
|
const Unit = "dnsmasq.service"
|
|
|
|
// StateFile is where the watch writes what it saw, for the tools to read: readable by the operator
|
|
// account the tools run as, written only by the watch.
|
|
const StateFile = "/var/lib/dnsmasq-watch/watch.json"
|
|
|
|
// Restart is one restart the watch made.
|
|
type Restart struct {
|
|
At time.Time `json:"at"`
|
|
Why string `json:"why"`
|
|
// Error is what the service manager said when the restart itself failed.
|
|
Error string `json:"error,omitempty"`
|
|
// Helped is the next looks' word: true once a look within HelpedWithin answered rightly, false once
|
|
// HelpedWithin passed without one, absent until then.
|
|
Helped *bool `json:"helped,omitempty"`
|
|
}
|
|
|
|
// State is the watch's last look and what it did, as the tools read it.
|
|
type State struct {
|
|
Last Look `json:"last"`
|
|
// Failing is how many looks in a row failed; Since when the first of them looked.
|
|
Failing int `json:"failing"`
|
|
Since *time.Time `json:"since,omitempty"`
|
|
// Restarts are the restarts within the last hour and the last one ever, newest last.
|
|
Restarts []Restart `json:"restarts,omitempty"`
|
|
// Held is why a restart that was due was not made: the budget is spent.
|
|
Held string `json:"held,omitempty"`
|
|
}
|
|
|
|
// Runner runs one command; injected, so the watch is tested over recorded answers.
|
|
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
|
|
|
// ExecRunner runs a command on the machine, bounded to a minute.
|
|
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
|
defer cancel()
|
|
var out bytes.Buffer
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Stdout, cmd.Stderr = &out, &out
|
|
err := cmd.Run()
|
|
return strings.TrimSpace(out.String()), err
|
|
}
|
|
|
|
// Step is one look of the watch and what follows from it: the state after, and whether to restart and why.
|
|
// Pure but for the look it is given.
|
|
func Step(s State, l Look) (State, string) {
|
|
now := l.At
|
|
s.Last = l
|
|
// What the earlier restarts are now known to have done.
|
|
for i := range s.Restarts {
|
|
r := &s.Restarts[i]
|
|
if r.Helped != nil || r.Error != "" {
|
|
continue
|
|
}
|
|
switch {
|
|
case l.Healthy && now.Sub(r.At) <= HelpedWithin:
|
|
yes := true
|
|
r.Helped = &yes
|
|
case now.Sub(r.At) > HelpedWithin:
|
|
no := false
|
|
r.Helped = &no
|
|
}
|
|
}
|
|
// The budget's window: the last hour, and always the newest.
|
|
kept := s.Restarts[:0]
|
|
for i, r := range s.Restarts {
|
|
if now.Sub(r.At) <= time.Hour || i == len(s.Restarts)-1 {
|
|
kept = append(kept, r)
|
|
}
|
|
}
|
|
s.Restarts = kept
|
|
if l.Healthy {
|
|
s.Failing, s.Since, s.Held = 0, nil, ""
|
|
return s, ""
|
|
}
|
|
if s.Failing == 0 {
|
|
at := now
|
|
s.Since = &at
|
|
}
|
|
s.Failing++
|
|
if s.Failing < Confirm || len(l.Problems) > 0 && len(l.Answers) == 0 {
|
|
// Not yet confirmed; or the look could not ask at all — a restart answers nothing a missing
|
|
// configuration or roster is missing.
|
|
return s, ""
|
|
}
|
|
within := 0
|
|
var newest *Restart
|
|
for i, r := range s.Restarts {
|
|
if now.Sub(r.At) <= time.Hour {
|
|
within++
|
|
}
|
|
newest = &s.Restarts[i]
|
|
}
|
|
switch {
|
|
case newest != nil && now.Sub(newest.At) < RestartGap:
|
|
s.Held = ""
|
|
return s, "" // the last restart is still settling
|
|
case within >= RestartsPerHour:
|
|
s.Held = fmt.Sprintf("%d restart(s) within the last hour, the most the watch makes; it restarts nothing "+
|
|
"more until the oldest is an hour old, and the resolver's failure is said as its health", within)
|
|
return s, ""
|
|
}
|
|
s.Held = ""
|
|
return s, fmt.Sprintf("%d look(s) in a row since %s failed: %s", s.Failing, s.Since.Format("15:04:05 UTC"), l.Why())
|
|
}
|
|
|
|
// Watch runs the watch until ctx ends.
|
|
func (m *Machine) Watch(ctx context.Context, run Runner, write func(State) error) error {
|
|
var s State
|
|
if raw, err := m.read(StateFile); err == nil {
|
|
_ = json.Unmarshal([]byte(raw), &s) // the budget survives the watch's own restart
|
|
}
|
|
tick := time.NewTicker(LookEvery)
|
|
defer tick.Stop()
|
|
for {
|
|
l := m.Look(ctx)
|
|
next, why := Step(s, l)
|
|
if why != "" {
|
|
r := Restart{At: m.Now().UTC(), Why: why}
|
|
if out, err := run(ctx, "systemctl", "restart", Unit); err != nil {
|
|
r.Error = strings.TrimSpace(err.Error() + " " + out)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "dnsmasq-watch: restarted %s: %s%s\n", Unit, why, orEmpty(" — the restart failed: ", r.Error))
|
|
next.Restarts = append(next.Restarts, r)
|
|
} else if !l.Healthy && next.Failing == Confirm {
|
|
fmt.Fprintf(os.Stderr, "dnsmasq-watch: the resolver does not answer: %s%s\n", l.Why(), orEmpty("; ", next.Held))
|
|
}
|
|
s = next
|
|
if err := write(s); err != nil {
|
|
fmt.Fprintln(os.Stderr, "dnsmasq-watch: what it saw could not be written:", err)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-tick.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func orEmpty(prefix, s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
return prefix + s
|
|
}
|
|
|
|
// WriteState writes the state whole, replacing the file in one rename.
|
|
func (m *Machine) WriteState(s State) error {
|
|
path := filepath.Join(m.Root, StateFile)
|
|
body, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".new"
|
|
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
// ReadState is the watch's last state, and false when it has written none.
|
|
func (m *Machine) ReadState() (State, bool, error) {
|
|
raw, err := m.read(StateFile)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return State{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return State{}, false, err
|
|
}
|
|
var s State
|
|
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
|
return State{}, false, fmt.Errorf("%s is not the watch's: %w", StateFile, err)
|
|
}
|
|
return s, true, nil
|
|
}
|