247 lines
8.0 KiB
Go
247 lines
8.0 KiB
Go
//go:build integration
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/conditions"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
"github.com/novox/mesh-controller/internal/testbus"
|
|
)
|
|
|
|
func TestARunKeepsWhatEachProbeFoundAndSaysItsHeartbeat(t *testing.T) {
|
|
failing := conditions.Observation{Scope: conditions.ScopeMachine, ID: "anchor", Token: "refused",
|
|
Severity: conditions.Urgent, Summary: "anchor's node-engine would refuse its declaration"}
|
|
var broken atomic.Bool
|
|
broken.Store(true)
|
|
withProbes(t,
|
|
probe{ID: "P1", Asserts: "passes", Kind: "never", Phase: 1,
|
|
run: func(context.Context, *doctor) ([]conditions.Observation, error) { return nil, nil }},
|
|
probe{ID: "P2", Asserts: "finds a fault", Kind: "declaration-refused", Phase: 1,
|
|
run: func(context.Context, *doctor) ([]conditions.Observation, error) {
|
|
if broken.Load() {
|
|
return []conditions.Observation{failing}, nil
|
|
}
|
|
return nil, nil
|
|
}},
|
|
probe{ID: "P3", Asserts: "cannot run", Kind: "x", Phase: 1,
|
|
run: func(context.Context, *doctor) ([]conditions.Observation, error) {
|
|
if broken.Load() {
|
|
return nil, errors.New("the store is away")
|
|
}
|
|
return nil, nil
|
|
}},
|
|
probe{ID: "P4", Asserts: "hangs", Kind: "x", Phase: 1,
|
|
run: func(ctx context.Context, _ *doctor) ([]conditions.Observation, error) {
|
|
if broken.Load() {
|
|
<-ctx.Done()
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
return nil, nil
|
|
}},
|
|
probe{ID: "P5", Asserts: "later", Kind: "x", Phase: 2, Deferred: "not yet"},
|
|
)
|
|
before := probeWithin
|
|
probeWithin = 200 * time.Millisecond
|
|
t.Cleanup(func() { probeWithin = before })
|
|
|
|
store := conditions.NewInMemory()
|
|
told := &conditions.Told{}
|
|
k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store})
|
|
defer k.Close(context.Background())
|
|
d := &doctor{keeper: k, teller: told, host: "anchor"}
|
|
// A probe that cannot run is said in the verdict at once, and as a condition when the next run cannot
|
|
// run it either (novox/hq issue 277): one timed-out question is not a probe gone blind.
|
|
first := d.runOnce(t.Context(), "a test")
|
|
if first.Counts != (doctorCounts{Passed: 1, Failed: 1, FailedToRun: 2, Deferred: 1}) {
|
|
t.Fatalf("counted %+v", first.Counts)
|
|
}
|
|
if open, _ := k.Open(t.Context()); len(open) != 1 || open[0].Key != "machine.anchor.refused" {
|
|
t.Fatalf("after one run, open: %+v", open)
|
|
}
|
|
run := d.runOnce(t.Context(), "a test")
|
|
if run.Counts != (doctorCounts{Passed: 1, Failed: 1, FailedToRun: 2, Deferred: 1}) {
|
|
t.Fatalf("counted %+v", run.Counts)
|
|
}
|
|
open, err := k.Open(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var keys []string
|
|
for _, c := range open {
|
|
keys = append(keys, c.Key+"="+c.Kind)
|
|
}
|
|
for _, want := range []string{"machine.anchor.refused=declaration-refused", "probe.P3.failed=probe-failed",
|
|
"probe.P4.failed=probe-failed"} {
|
|
if !slices.Contains(keys, want) {
|
|
t.Errorf("%s is not open: %v", want, keys)
|
|
}
|
|
}
|
|
// The heartbeat, in the shape mesh-watcher reads (the contract with the operator's channel).
|
|
if len(told.Names) != 2 || told.Names[1] != conditions.HeartbeatEvent {
|
|
t.Fatalf("said %v", told.Names)
|
|
}
|
|
if d.lastRunEnded().IsZero() || d.lastRun().Run != run.Run {
|
|
t.Fatal("the run is not the last verdict")
|
|
}
|
|
body, _ := json.Marshal(run)
|
|
var shape map[string]any
|
|
_ = json.Unmarshal(body, &shape)
|
|
for _, field := range []string{"run", "at", "interval-seconds", "counts", "probes", "controller"} {
|
|
if _, ok := shape[field]; !ok {
|
|
t.Errorf("the heartbeat carries no %q: %s", field, body)
|
|
}
|
|
}
|
|
|
|
// Mended: the next run clears every one of them.
|
|
broken.Store(false)
|
|
d.runOnce(t.Context(), "a test")
|
|
if open, _ := k.Open(t.Context()); len(open) != 0 {
|
|
t.Fatalf("a passing run left open %+v", open)
|
|
}
|
|
}
|
|
|
|
func TestEveryMachineOfAHealthyMeshComposesAndValidates(t *testing.T) {
|
|
open := aMesh(t)
|
|
got, err := probeDeclarations(t.Context(), &doctor{open: open})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Fatalf("a healthy mesh failed D1: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestAMachineWhoseDeclarationDoesNotComposeIsSaid(t *testing.T) {
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
one, two := rivals()
|
|
register(t, open, one)
|
|
register(t, open, two)
|
|
for _, m := range []string{"rival-one", "rival-two"} {
|
|
if _, err := assign(ctx, open, "laptop", m); err != nil && m == "rival-one" {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
got, err := probeDeclarations(ctx, &doctor{open: open})
|
|
linted(got)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || got[0].Key() != "machine.laptop.uncomposable" || !strings.Contains(got[0].Summary, "the-seat") {
|
|
t.Fatalf("%+v", got)
|
|
}
|
|
}
|
|
|
|
func TestNatsTheBusIsWhatTheControllerDefines(t *testing.T) {
|
|
url := testbus.URL(t)
|
|
open := aMesh(t)
|
|
js, err := broker.Dial(url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(js.Close)
|
|
for _, s := range []string{"CONTROL", "NODES", "ASSIGNMENTS", "EVENTS"} {
|
|
_ = js.Context().DeleteStream(s)
|
|
}
|
|
if _, err := assertBusObjects(t.Context(), open.inventory, js); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := js.EnsureControllerBuckets(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := &doctor{open: open, js: js}
|
|
for _, p := range []func(context.Context, *doctor) ([]conditions.Observation, error){probeConsumers, probeStreams} {
|
|
got, err := p(t.Context(), d)
|
|
if err != nil || len(got) != 0 {
|
|
t.Fatalf("a bus just raised fails: %+v %v", got, err)
|
|
}
|
|
}
|
|
if err := js.Context().DeleteConsumer("NODES", "laptop"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := js.Context().StreamInfo("EVENTS")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := info.Config
|
|
cfg.MaxMsgsPerSubject = 3
|
|
if _, err := js.Context().UpdateStream(&cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
consumers, err := probeConsumers(t.Context(), d)
|
|
if err != nil || len(consumers) != 1 || consumers[0].Key() != "bus.NODES.laptop.missing" {
|
|
t.Fatalf("the deleted consumer: %+v %v", consumers, err)
|
|
}
|
|
streams, err := probeStreams(t.Context(), d)
|
|
if err != nil || len(streams) != 1 || !strings.Contains(streams[0].Summary, "per subject") {
|
|
t.Fatalf("the redefined stream: %+v %v", streams, err)
|
|
}
|
|
}
|
|
|
|
func TestNatsTheBusSaysAConsumerGaveUpAndOneWasDeleted(t *testing.T) {
|
|
url := testbus.URL(t)
|
|
conn, err := nats.Connect(url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
heard := make(chan *nats.Msg, 16)
|
|
for _, subject := range broker.BusAdvisories {
|
|
if _, err := conn.ChanSubscribe(subject, heard); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
api, _ := jetstream.New(conn)
|
|
_ = api.DeleteStream(t.Context(), "SEAT_ADVISED")
|
|
stream, err := api.CreateStream(t.Context(), jetstream.StreamConfig{Name: "SEAT_ADVISED", Subjects: []string{"advised.>"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = api.DeleteStream(context.Background(), "SEAT_ADVISED") }()
|
|
consumer, err := stream.CreateConsumer(t.Context(), jetstream.ConsumerConfig{Durable: "SEAT_ADVISED_worker",
|
|
AckPolicy: jetstream.AckExplicitPolicy, MaxDeliver: 1, AckWait: 100 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := api.Publish(t.Context(), "advised.x", []byte("x")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := consumer.Fetch(1, jetstream.FetchMaxWait(time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A seat's worker, so the deletion is of a consumer the mesh names (link.MeshNamed).
|
|
// Not acknowledged: after its one delivery the consumer gives up on it — on the next fetch.
|
|
time.Sleep(300 * time.Millisecond)
|
|
_, _ = consumer.Fetch(1, jetstream.FetchMaxWait(300*time.Millisecond))
|
|
if err := stream.DeleteConsumer(t.Context(), "SEAT_ADVISED_worker"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
kinds := map[string]string{}
|
|
deadline := time.After(5 * time.Second)
|
|
for len(kinds) < 2 {
|
|
select {
|
|
case m := <-heard:
|
|
if a, ok := link.ReadAdvisory(m.Subject, m.Data); ok {
|
|
kinds[a.Kind] = a.Said
|
|
}
|
|
case <-deadline:
|
|
t.Fatalf("the bus said only %v", kinds)
|
|
}
|
|
}
|
|
if !strings.Contains(kinds["max-deliveries"], "gave up") || !strings.Contains(kinds["consumer-lost"], "was deleted") {
|
|
t.Fatalf("%v", kinds)
|
|
}
|
|
}
|