228 lines
7.5 KiB
Go
228 lines
7.5 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/net/dns/dnsmessage"
|
|
|
|
"github.com/novox/mesh-controller/internal/conditions"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/outward"
|
|
)
|
|
|
|
// unsaid collects, across the suite, every finding whose words the operator's channel would withhold.
|
|
var unsaid unsayable
|
|
|
|
type unsayable struct {
|
|
mu sync.Mutex
|
|
seen map[string]bool
|
|
}
|
|
|
|
func (u *unsayable) note(o conditions.Observation, field string, r outward.Refusal) {
|
|
text := o.Summary
|
|
if field == "key" {
|
|
text = o.Key()
|
|
}
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
if u.seen == nil {
|
|
u.seen = map[string]bool{}
|
|
}
|
|
u.seen[fmt.Sprintf("%s (source %s, kind %s): its %s carries %s — %q", o.Key(), o.Source, o.Kind, field, r.What,
|
|
text)] = true
|
|
}
|
|
|
|
func (u *unsayable) all() []string {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
var out []string
|
|
for s := range u.seen {
|
|
out = append(out, s)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// unworded collects, across the suite, every finding said in borrowed words (ADR 0253).
|
|
var unworded wordless
|
|
|
|
type wordless struct {
|
|
mu sync.Mutex
|
|
seen map[string]bool
|
|
}
|
|
|
|
func (u *wordless) add(o conditions.Observation, why string) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
if u.seen == nil {
|
|
u.seen = map[string]bool{}
|
|
}
|
|
u.seen[fmt.Sprintf("kind %s (scope %s, source %s): %s", o.Kind, o.Scope, o.Source, why)] = true
|
|
}
|
|
|
|
func (u *wordless) all() []string {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
var out []string
|
|
for s := range u.seen {
|
|
out = append(out, s)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// linted is a producer's findings, held to the content rule as a keeper would hold them: for a test
|
|
// that reads a producer's findings without raising them.
|
|
func linted(obs []conditions.Observation) []conditions.Observation {
|
|
for _, o := range obs {
|
|
machines := append([]string{o.Machine}, o.Also...)
|
|
if r, ok := outward.Check(o.Key(), machines...); !ok {
|
|
unsaid.note(o, "key", r)
|
|
}
|
|
if r, ok := outward.Check(o.Summary, machines...); !ok {
|
|
unsaid.note(o, "summary", r)
|
|
}
|
|
switch {
|
|
case o.Headline != "":
|
|
w := conditions.Words{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved}
|
|
if w.Resolved == "" {
|
|
w.Resolved = "Resolved"
|
|
}
|
|
if why, ok := conditions.PlainWords(w, machines...); !ok {
|
|
unworded.add(o, "its source's words are not plain: "+why)
|
|
}
|
|
case o.Kind != "" && !conditions.Worded(o.Kind):
|
|
unworded.add(o, "the kind "+o.Kind+" has no plain words")
|
|
}
|
|
}
|
|
return obs
|
|
}
|
|
|
|
// resolverStandIn is a resolver on loopback that answers as answer says; nil answers nothing.
|
|
func resolverStandIn(t *testing.T, answer func(q dnsmessage.Question, n int64) *dnsmessage.Message) (port string, asked *atomic.Int64) {
|
|
t.Helper()
|
|
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
asked = &atomic.Int64{}
|
|
go func() {
|
|
buf := make([]byte, 1500)
|
|
for {
|
|
n, from, err := conn.ReadFrom(buf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var q dnsmessage.Message
|
|
if q.Unpack(buf[:n]) != nil || len(q.Questions) == 0 {
|
|
continue
|
|
}
|
|
reply := answer(q.Questions[0], asked.Add(1))
|
|
if reply == nil {
|
|
continue
|
|
}
|
|
reply.Header.ID, reply.Header.Response, reply.Questions = q.ID, true, q.Questions
|
|
packed, _ := reply.Pack()
|
|
_, _ = conn.WriteTo(packed, from)
|
|
}
|
|
}()
|
|
_, port, _ = net.SplitHostPort(conn.LocalAddr().String())
|
|
return port, asked
|
|
}
|
|
|
|
// answersRightly answers A with the address the test's machine has, and NODATA for AAAA.
|
|
func answersRightly(q dnsmessage.Question) *dnsmessage.Message {
|
|
reply := &dnsmessage.Message{}
|
|
if q.Type == dnsmessage.TypeA {
|
|
reply.Answers = []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{Name: q.Name, Type: dnsmessage.TypeA,
|
|
Class: dnsmessage.ClassINET}, Body: &dnsmessage.AResource{A: [4]byte{10, 77, 0, 1}}}}
|
|
}
|
|
return reply
|
|
}
|
|
|
|
// quickResolvers makes D2's patience short for a test.
|
|
func quickResolvers(t *testing.T, port string) {
|
|
t.Helper()
|
|
before, within, pause := resolverPort, resolverWithin, resolverPause
|
|
resolverPort, resolverWithin, resolverPause = port, 150*time.Millisecond, 10*time.Millisecond
|
|
t.Cleanup(func() { resolverPort, resolverWithin, resolverPause = before, within, pause })
|
|
}
|
|
|
|
var anchorOnTheNetwork = []inventory.Overlay{{Name: "anchor", Address: "10.77.0.1"}}
|
|
|
|
// **D2 asks again before it says anything** (novox/hq issue 277): a resolver that misses a question on
|
|
// a loaded machine and answers the next try is a resolver that answers.
|
|
func TestAResolverThatMissesOneTryAndAnswersTheNextIsWell(t *testing.T) {
|
|
port, asked := resolverStandIn(t, func(q dnsmessage.Question, n int64) *dnsmessage.Message {
|
|
if n <= 2 {
|
|
return nil // the first question of each kind lost, as under a push and a build starting
|
|
}
|
|
return answersRightly(q)
|
|
})
|
|
quickResolvers(t, port)
|
|
got := askEveryResolver(t.Context(), map[string]string{"anchor.internal": "127.0.0.1"}, anchorOnTheNetwork, "internal")
|
|
if len(got) != 0 {
|
|
t.Fatalf("a resolver that answered its second try was said: %+v", got)
|
|
}
|
|
if asked.Load() < 3 {
|
|
t.Fatalf("asked %d times", asked.Load())
|
|
}
|
|
}
|
|
|
|
// **A resolver that answers nothing is held for the next run, and said in machine names**: the address
|
|
// it was asked at and the socket's words are the evidence, never the summary the operator reads.
|
|
func TestAResolverThatAnswersNothingIsHeldAndSaidInMachineNames(t *testing.T) {
|
|
port, asked := resolverStandIn(t, func(dnsmessage.Question, int64) *dnsmessage.Message { return nil })
|
|
quickResolvers(t, port)
|
|
got := askEveryResolver(t.Context(), map[string]string{"anchor.internal": "127.0.0.1"}, anchorOnTheNetwork, "internal")
|
|
if len(got) != 1 {
|
|
t.Fatalf("%+v", got)
|
|
}
|
|
o := got[0]
|
|
if !o.Confirm || o.Key() != "seat.mesh-dns-resolver.anchor.wrong" || o.Severity != conditions.Urgent {
|
|
t.Fatalf("not held for a second look, or not the resolver's condition: %+v", o)
|
|
}
|
|
if r, ok := outward.Check(o.Summary, "anchor"); !ok {
|
|
t.Fatalf("the summary carries %s: %q", r, o.Summary)
|
|
}
|
|
if !strings.Contains(o.Summary, "anchor's") || !strings.Contains(o.Said, "127.0.0.1") ||
|
|
!strings.Contains(o.Said, "anchor.internal") {
|
|
t.Fatalf("summary %q, evidence %q", o.Summary, o.Said)
|
|
}
|
|
if want := int64(2 * resolverTries); asked.Load() != want {
|
|
t.Fatalf("asked %d times, want %d: each question %d times", asked.Load(), want, resolverTries)
|
|
}
|
|
}
|
|
|
|
// **A resolver that answers wrongly is said at once**, in machine names: an answer is not the absence
|
|
// of one (issue 262's NXDOMAIN for IPv6).
|
|
func TestAResolverAnsweringWronglyIsSaidAtOnceInMachineNames(t *testing.T) {
|
|
port, _ := resolverStandIn(t, func(q dnsmessage.Question, _ int64) *dnsmessage.Message {
|
|
if q.Type == dnsmessage.TypeAAAA {
|
|
return &dnsmessage.Message{Header: dnsmessage.Header{RCode: dnsmessage.RCodeNameError}}
|
|
}
|
|
return answersRightly(q)
|
|
})
|
|
quickResolvers(t, port)
|
|
got := askEveryResolver(t.Context(), map[string]string{"anchor.internal": "127.0.0.1"}, anchorOnTheNetwork, "internal")
|
|
if len(got) != 1 || got[0].Confirm {
|
|
t.Fatalf("%+v", got)
|
|
}
|
|
if r, ok := outward.Check(got[0].Summary, "anchor"); !ok || !strings.Contains(got[0].Summary, "NXDOMAIN") ||
|
|
!strings.Contains(got[0].Summary, "anchor's IPv6 address") {
|
|
t.Fatalf("summary %q (%v)", got[0].Summary, r)
|
|
}
|
|
}
|
|
|
|
// **The self-check raises a finding one look can be wrong about on the second run in a row**, keeps it
|
|
// open while it is seen, and clears it when it is not — and a probe that cannot run is said as a
|
|
// condition only when the next run cannot run it either. Neither is ever a pass in the verdict.
|