Judge the machine's own networking beside what its modules run (hq ADR 0241)
A VPN client rewrote the laptop's resolver file and every mesh name failed while each module read healthy: nothing asked the machine. The engine now looks every 30 s at the resolver file the uplink holder declared (naming the program that rewrote it), the names through each listed resolver (NXDOMAIN for a mesh name's IPv6 address is a finding, issue 262), the tunnel's handshake with the hub, the bus and the default route; a part is unhealthy on its second failing look, and the statement carries it.
This commit is contained in:
+105
-4
@@ -17,6 +17,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -35,6 +36,7 @@ import (
|
||||
"github.com/novox/mesh-host/internal/inventory"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"github.com/novox/mesh-host/internal/network"
|
||||
"github.com/novox/mesh-host/internal/outward"
|
||||
"github.com/novox/mesh-host/internal/profile"
|
||||
"github.com/novox/mesh-host/internal/reachable"
|
||||
@@ -1168,6 +1170,10 @@ func runLink(ctx context.Context, opts options) error {
|
||||
// spaced to the budget (ADR 0240 Phase B); a tool is asked of this machine's node tools over the
|
||||
// link open at the time.
|
||||
judging.Probes = &liveness.Probes{AskTool: queue.AskTool}
|
||||
// And the machine's own networking (novox/hq ADR 0241): the resolver file the uplink holder
|
||||
// declared, the names through it, the tunnel, the bus and the route — said in the same statement.
|
||||
netJudge.set(network.New(network.Machine{Run: apply.ExecRunner, Linked: queue.Linked},
|
||||
hostOf(mine.Membership.Broker)))
|
||||
go judging.Probe(aside)
|
||||
go judgeWhatRuns(aside, judging, queue, say)
|
||||
}
|
||||
@@ -1361,6 +1367,74 @@ const ReconcileEvery = 5 * time.Minute
|
||||
// reports no health, and in a test.
|
||||
var judging *liveness.Judge
|
||||
|
||||
// netJudge is the serving host's judge of its machine's networking (novox/hq ADR 0241); empty in a
|
||||
// one-shot command and in a test, which say nothing of the network.
|
||||
var netJudge networkJudge
|
||||
|
||||
type networkJudge struct {
|
||||
mu sync.Mutex
|
||||
m *network.Judge
|
||||
// The resolver file the last apply declared, kept for a judge made after it.
|
||||
content, owner string
|
||||
declared bool
|
||||
}
|
||||
|
||||
func (n *networkJudge) get() *network.Judge {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return n.m
|
||||
}
|
||||
|
||||
func (n *networkJudge) set(m *network.Judge) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.m = m
|
||||
m.Declare(n.content, n.owner, n.declared)
|
||||
}
|
||||
|
||||
// declare is the resolver file an apply just applied, for the judge now and any made later.
|
||||
func (n *networkJudge) declare(content, owner string, ok bool) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.content, n.owner, n.declared = content, owner, ok
|
||||
if n.m != nil {
|
||||
n.m.Declare(content, owner, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// hostOf is the bus's name without its port: the mesh name the machine needs most. Empty when the bus
|
||||
// is reached by address, and then no mesh name is asked.
|
||||
func hostOf(broker string) string {
|
||||
host := broker
|
||||
if h, _, err := net.SplitHostPort(broker); err == nil {
|
||||
host = h
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return ""
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// declaredResolvConf is the resolver file a declaration has a module write whole — the uplink holder's
|
||||
// (ADR 0223) — and that module.
|
||||
func declaredResolvConf(d *declaration.Declaration) (string, string, bool) {
|
||||
if d == nil {
|
||||
return "", "", false
|
||||
}
|
||||
for _, r := range d.Resources {
|
||||
f, ok := r.(*declaration.File)
|
||||
if !ok || f.Path != network.ResolvConf || f.CreateOnce || f.Into != "" {
|
||||
continue
|
||||
}
|
||||
module, ok := liveness.ModuleOf(f.ID)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return f.Content, module, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// How often a statement of health is said between reports: again every minute while anything is not
|
||||
// healthy (to-be 48 §4), so a lost event is not a lost fault; and every five minutes anyway, so a
|
||||
// controller that restarted knows a healthy machine's state without waiting for its next apply.
|
||||
@@ -1387,6 +1461,19 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa
|
||||
}
|
||||
st, changed := j.Look(ctx)
|
||||
owed = owed || changed
|
||||
var netSt *network.Statement
|
||||
if n := netJudge.get(); n != nil {
|
||||
ns, netChanged := n.Look(ctx)
|
||||
netSt = &ns
|
||||
owed = owed || netChanged
|
||||
if netChanged {
|
||||
for _, p := range ns.Parts {
|
||||
if p.State == network.Unhealthy {
|
||||
say(fmt.Sprintf("this machine's network is unhealthy: %s: %s (%s)", p.Part, p.Reason, p.Said))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Never more looks than the budget (ADR 0240): said when the engine has to space its own out.
|
||||
if sp := j.Spacing(); sp != spaced {
|
||||
if sp > 1 {
|
||||
@@ -1396,7 +1483,8 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa
|
||||
spaced = sp
|
||||
}
|
||||
since := time.Since(lastSaid)
|
||||
if !owed && !(!st.Healthy() && since >= sayUnhealthyAgain) && since < sayAnyway {
|
||||
healthy := st.Healthy() && (netSt == nil || netSt.State != network.Unhealthy)
|
||||
if !owed && !(!healthy && since >= sayUnhealthyAgain) && since < sayAnyway {
|
||||
continue
|
||||
}
|
||||
if changed {
|
||||
@@ -1407,14 +1495,14 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa
|
||||
}
|
||||
}
|
||||
}
|
||||
if queue.SayHealth(ctx, *healthAsReported(st)) {
|
||||
if queue.SayHealth(ctx, *healthAsReported(st, netSt)) {
|
||||
lastSaid, owed = time.Now(), false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// healthAsReported is a statement as the report and the event carry it.
|
||||
func healthAsReported(st liveness.Statement) *link.Health {
|
||||
func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health {
|
||||
// ReadinessContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase
|
||||
// B), which is what tells the controller it may be sent the field.
|
||||
h := &link.Health{Contract: link.ReadinessContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}}
|
||||
@@ -1423,6 +1511,13 @@ func healthAsReported(st liveness.Statement) *link.Health {
|
||||
Target: r.Target, State: r.State, Reason: r.Reason, Since: r.Since.UTC(), Streak: r.Streak,
|
||||
Restarts: r.Restarts, Check: r.CheckOf(), Needs: r.NeedsOf()})
|
||||
}
|
||||
if ns != nil && ns.State != "" {
|
||||
h.Network = &link.NetworkHealth{State: ns.State, Since: ns.Since.UTC(), Parts: []link.NetworkPart{}}
|
||||
for _, p := range ns.Parts {
|
||||
h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason,
|
||||
Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: p.Streak})
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -1657,7 +1752,13 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto
|
||||
if j := judging; j != nil {
|
||||
j.Set(liveness.LongRunning(declared, held))
|
||||
st, _ := j.Look(ctx)
|
||||
report.Health = healthAsReported(st)
|
||||
netJudge.declare(declaredResolvConf(declared))
|
||||
var netSt *network.Statement
|
||||
if n := netJudge.get(); n != nil {
|
||||
ns := n.Last()
|
||||
netSt = &ns
|
||||
}
|
||||
report.Health = healthAsReported(st, netSt)
|
||||
}
|
||||
// Which of this machine's links face outside, for the filter the mesh writes around them
|
||||
// (novox/hq ADR 0140). Reported whatever the node's mode: a converged node's filter needs it,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"github.com/novox/mesh-host/internal/network"
|
||||
)
|
||||
|
||||
// The machine's networking in the engine's statement (novox/hq ADR 0241): the file judged is the one the
|
||||
// uplink holder declares whole, the mesh name asked is the bus's, and the statement carries the parts.
|
||||
|
||||
func TestTheResolverFileJudgedIsTheOneAModuleDeclaresWhole(t *testing.T) {
|
||||
d := &declaration.Declaration{Resources: []declaration.Resource{
|
||||
&declaration.File{ID: "hosts.file", Type: "file", Path: "/etc/hosts", Content: "x"},
|
||||
&declaration.File{ID: "networkmanager.resolv", Type: "file", Path: network.ResolvConf, Content: "nameserver 10.10.0.1\n"},
|
||||
}}
|
||||
content, owner, ok := declaredResolvConf(d)
|
||||
if !ok || owner != "networkmanager" || content != "nameserver 10.10.0.1\n" {
|
||||
t.Fatalf("got %q %q %v", content, owner, ok)
|
||||
}
|
||||
d.Resources[1].(*declaration.File).CreateOnce = true
|
||||
if _, _, ok := declaredResolvConf(d); ok {
|
||||
t.Fatal("a seed nobody holds the machine to was judged as the declared file")
|
||||
}
|
||||
if _, _, ok := declaredResolvConf(nil); ok {
|
||||
t.Fatal("no declaration declared a file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMeshNameAskedIsTheBuses(t *testing.T) {
|
||||
for broker, want := range map[string]string{"anchor.internal:4222": "anchor.internal", "192.0.2.10:5671": "",
|
||||
"anchor.internal": "anchor.internal", "[2001:db8::1]:4222": ""} {
|
||||
if got := hostOf(broker); got != want {
|
||||
t.Errorf("%s: got %q, want %q", broker, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStatementCarriesTheNetwork(t *testing.T) {
|
||||
at := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
|
||||
h := healthAsReported(liveness.Statement{At: at}, &network.Statement{State: network.Unhealthy, Since: at,
|
||||
Parts: []network.Part{{Part: network.PartResolvConf, State: network.Unhealthy, Writer: "FortiClient",
|
||||
Owner: "networkmanager", Reason: "the resolver file was rewritten by another program", Since: at}}})
|
||||
if h.Network == nil || h.Network.State != network.Unhealthy || len(h.Network.Parts) != 1 ||
|
||||
h.Network.Parts[0].Writer != "FortiClient" || h.Network.Parts[0].Owner != "networkmanager" {
|
||||
t.Fatalf("the statement says %+v", h.Network)
|
||||
}
|
||||
if h := healthAsReported(liveness.Statement{At: at}, nil); h.Network != nil {
|
||||
t.Fatal("an engine with no network judge said a network")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user