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
- 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.
167 lines
5.6 KiB
Go
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
|
|
}
|