Files
mesh-catalog/modules/dnsmasq/cmd/dnsmasq-tools/tools.go
T
jschoubben a6a9a818d2
mesh/merge-gate pass: builds dnsmasq, docker, netcheck → ace, g14, novox, shanks; no bus step; 2 wait(s) for a person; every machine composes with the chan…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
Answer the review of hq issue 348's catalogue change
- The dnsmasq watch restarts only a unit that runs, without waiting for
  its start; a unit stopped, starting or failed is held and said (C2).
- dnsmasq_resolve asks only an address the resolver listens on (C3).
- Tests for the watch loop keeping its budget, a watch that wrote nothing
  or stopped looking, and a unit not running (C4).
- A restart is remembered as said only once emitted, restarts made before
  the bundle started are said, and whether one helped is said (C5).
- docker_events reads the runtime's whole buffer: complete when it is not
  full or reaches past the window, never a quiet machine read as lost (C6).
- netcheck: refused read with errors.Is; tests for addresses and ports,
  and for a container named as docker knows it.
2026-10-09 15:25:45 +02:00

167 lines
5.6 KiB
Go

package main
import (
"context"
"fmt"
"slices"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Stale is how old the watch's last look may be before the health tool says it stopped looking.
const Stale = 2 * time.Minute
// Health is the module's health answer (novox/hq ADR 0240): healthy only when the resolver answers its
// real question on every listen address, over UDP and TCP, and the watch is looking.
type Health struct {
Healthy bool `json:"healthy"`
Why string `json:"why,omitempty"`
Look Look `json:"look"`
// Watch is what the watch last saw and did; absent when it has written nothing yet.
Watch *State `json:"watch,omitempty"`
}
// Health asks the resolver now, and reads what the watch did.
func (m *Machine) Health(ctx context.Context) Health {
l := m.Look(ctx)
h := Health{Healthy: l.Healthy, Look: l}
var why []string
if !l.Healthy {
why = append(why, "the resolver does not answer as it must: "+l.Why())
}
s, had, err := m.ReadState()
switch {
case err != nil:
h.Healthy = false
why = append(why, "what the watch saw cannot be read: "+err.Error())
case !had:
h.Healthy = false
why = append(why, "the watch has written nothing yet, so nothing restarts the resolver when it stops answering")
default:
h.Watch = &s
if age := m.Now().Sub(s.Last.At); age > Stale {
h.Healthy = false
why = append(why, fmt.Sprintf("the watch last looked %s ago, so nothing restarts the resolver when it "+
"stops answering", age.Round(time.Second)))
}
if !l.Healthy {
why = append(why, restartWords(s))
}
if s.Unit != "" && s.Unit != "active" {
why = append(why, "the unit is "+s.Unit)
}
}
h.Why = strings.Join(nonEmpty(why), "; ")
return h
}
// restartWords is what the watch did about it, in words.
func restartWords(s State) string {
if len(s.Restarts) == 0 {
return "the watch has not restarted it" + orEmpty("; ", s.Held)
}
r := s.Restarts[len(s.Restarts)-1]
words := fmt.Sprintf("the watch restarted it at %s", r.At.Format("15:04:05 UTC"))
switch {
case r.Error != "":
words += ", and the restart failed: " + r.Error
case r.Helped != nil && !*r.Helped:
words += ", which did not help"
case r.Helped == nil:
words += ", and its next looks decide whether that helped"
}
if s.Held != "" {
words += "; " + s.Held
}
return words
}
func nonEmpty(s []string) []string {
var out []string
for _, x := range s {
if x != "" {
out = append(out, x)
}
}
return out
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func arg(a map[string]any, k string) string {
v, _ := a[k].(string)
return strings.TrimSpace(v)
}
// Tools are the module's tools.
func Tools(m *Machine) []stdio.Tool {
return []stdio.Tool{
{Name: "dnsmasq_health", Description: "Whether this machine's mesh resolver answers: its real question — this " +
"machine's own name — asked now on every address its configuration listens on, over UDP and TCP, each " +
"answer with its code, addresses and time; and what the module's watch last saw, the restarts it made " +
"within the hour, whether each helped, and why it holds back from another. Healthy only when every " +
"question was answered rightly and the watch is looking (novox/hq ADR 0240, issue 348). (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) {
return m.Health(context.Background()), nil
}},
{Name: "dnsmasq_names", Description: "The mesh's machine names this machine's resolver answers, each with its " +
"private address, as the roster wrote them (" + NodesFile + "). (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) {
text, err := m.read(NodesFile)
if err != nil {
return nil, fmt.Errorf("the roster %s cannot be read: %w", NodesFile, err)
}
return map[string]any{"names": AnsweredNames(text)}, nil
}},
{Name: "dnsmasq_resolve", Description: "Ask this machine's resolver one name's IPv4 address, directly, on one " +
"address it listens on (default every one) over udp or tcp (default both): the answer code, the addresses " +
"and the time each took, or why none came — refused when nothing listens there. Replaces dig @address. (r)",
Input: map[string]any{"name": str("the name to ask, e.g. plex.anchor.internal"),
"address": str("one listen address to ask (default: every one in its configuration)"),
"over": str("udp or tcp (default: both)")},
Run: func(a map[string]any) (any, error) {
return m.Resolve(context.Background(), arg(a, "name"), arg(a, "address"), arg(a, "over"))
}},
}
}
// Resolve asks name on one listen address or every one, over one transport or both.
func (m *Machine) Resolve(ctx context.Context, name, address, over string) (any, error) {
if name == "" {
return nil, fmt.Errorf("name is required")
}
config, err := m.read(ConfigFile)
if err != nil {
return nil, fmt.Errorf("its configuration %s cannot be read: %w", ConfigFile, err)
}
addresses := ListenAddresses(config)
if address != "" {
if !slices.Contains(addresses, address) {
return nil, fmt.Errorf("address %q is not one the resolver listens on (%s); netcheck_dns asks any resolver",
address, strings.Join(addresses, ", "))
}
addresses = []string{address}
}
overs := []string{"udp", "tcp"}
switch over {
case "":
case "udp", "tcp":
overs = []string{over}
default:
return nil, fmt.Errorf("over is udp or tcp, not %q", over)
}
var answers []Answer
for _, a := range addresses {
for _, o := range overs {
answers = append(answers, m.Ask(ctx, a+":"+m.Port, o, name, AskWithin))
}
}
return map[string]any{"name": name, "answers": answers}, nil
}