//go:build integration package link import ( "context" "encoding/json" "errors" "sync" "testing" "time" "github.com/nats-io/nats.go" "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/testbus" ) func aBus(t *testing.T) *broker.JetStream { t.Helper() url := testbus.URL(t) js, err := broker.Dial(url) if err != nil { t.Fatal(err) } t.Cleanup(js.Close) // **Streams purged, consumers removed.** Both halves, and each was learned by getting it wrong. // // The streams are emptied rather than deleted and recreated, because delete-then-add is not a // reset: the server's teardown races the creation, and a test then inherits the previous one's // messages — which reads as a redelivery bug in the code under test. // // The consumers are removed, because deleting a stream used to take them with it and purging // does not. A durable *push* consumer that survives between tests keeps pushing to a delivery // subject the previous test's subscription has gone from: the messages count as delivered, go // nowhere, and the next test waits out its timeout for an announcement the server believes it // already sent. The controller recreates what it needs on start, so leaving none is correct. if err := broker.AssertMeshStreams(js); err != nil { t.Fatal(err) } clean := func() { for _, c := range broker.MeshConsumers() { _ = js.Context().DeleteConsumer(c.Stream, c.Name) } for _, s := range broker.MeshStreams() { _ = js.Context().PurgeStream(s.Name) } } clean() t.Cleanup(clean) return js } func TestNatsAReportIsHeardAndLeavesTheStream(t *testing.T) { js := aBus(t) store := &counted{} _, stop := servingOn(t, js, store) defer stop() body, _ := json.Marshal(Report{Node: "anchor", Declared: "d1", Applied: []string{"store"}}) if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil { t.Fatal(err) } eventually(t, "a report being recorded", func() bool { return store.count() == 1 }) eventually(t, "the report leaving the work queue", func() bool { info, err := js.Context().StreamInfo("CONTROL") return err == nil && info.State.Msgs == 0 }) } func TestNatsAReportTheStoreCannotTakeIsHeldByTheServerAndComesBack(t *testing.T) { js := aBus(t) store := &counted{} store.refusing(errors.Join(ErrTryAgain, errors.New("the database system is starting up"))) _, stop := servingOn(t, js, store) defer stop() body, _ := json.Marshal(Report{Node: "anchor", Declared: "d1", Applied: []string{"store"}}) if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil { t.Fatal(err) } // Held: the message is the server's, unacknowledged, and still in the stream. eventually(t, "the report being redelivered at least once", func() bool { info, err := js.Context().ConsumerInfo("CONTROL", broker.ControllerName) return err == nil && info.NumRedelivered >= 1 }) info, err := js.Context().StreamInfo("CONTROL") if err != nil || info.State.Msgs != 1 { t.Fatalf("a held report did not stay on the server: %+v, %v", info, err) } if store.count() != 0 { t.Fatalf("a report was recorded by a store that was refusing it") } store.refusing(nil) eventually(t, "the report being recorded once the store was back", func() bool { return store.count() == 1 }) eventually(t, "the recorded report leaving the work queue", func() bool { info, err := js.Context().StreamInfo("CONTROL") return err == nil && info.State.Msgs == 0 }) } func TestNatsASupersededReportIsSettledAndNotActedOn(t *testing.T) { js := aBus(t) store := &sentAndHeardSafely{sent: "d2"} _, stop := servingOn(t, js, store) defer stop() body, _ := json.Marshal(Report{Node: "anchor", Declared: "d1", Applied: []string{"store"}}) if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil { t.Fatal(err) } eventually(t, "the superseded report leaving the stream", func() bool { info, err := js.Context().StreamInfo("CONTROL") return err == nil && info.State.Msgs == 0 }) if store.count() != 0 { t.Fatalf("a report about a superseded declaration was acted on") } } func TestNatsAnEnrolmentIsAnsweredOnTheAddressInItsPayload(t *testing.T) { js := aBus(t) s := &Server{inbound: Nats(js), bus: OverNATS{Conn: js.Conn(), JS: js.Context()}, enroller: enrolsAs{reply: EnrolReply{Accepted: true, Node: "anchor"}}, log: quiet()} ctx, stop := context.WithCancel(context.Background()) defer stop() go func() { _ = s.Serve(ctx) }() inbox := nats.NewInbox() answers, err := js.Conn().SubscribeSync(inbox) if err != nil { t.Fatal(err) } body, _ := json.Marshal(EnrolRequest{Node: "anchor", Secret: "t", ReplyTo: inbox}) if _, err := js.Context().Publish(EnrolSubject, body); err != nil { t.Fatal(err) } msg, err := answers.NextMsg(8 * time.Second) if err != nil { t.Fatalf("no answer reached the address the request named: %v", err) } var reply EnrolReply if err := json.Unmarshal(msg.Data, &reply); err != nil { t.Fatal(err) } if !reply.Accepted || reply.Node != "anchor" { t.Fatalf("the answer was not the mesh's: %+v", reply) } // And the address really is not the one the transport carried: what the consumer saw was its // own ack subject, which is why this had to travel in the payload. if msg.Subject != inbox { t.Fatalf("the answer arrived on %s, not the address the request named", msg.Subject) } } func TestNatsAHeartbeatIsHeardAndNothingIsKept(t *testing.T) { js := aBus(t) store := &counted{} _, stop := servingOn(t, js, store) defer stop() // Given time to subscribe: a core subscription that is not yet up misses what is published, // which is the guarantee a heartbeat has and not a fault. eventually(t, "the heartbeat subscription coming up", func() bool { body, _ := json.Marshal(Alive{Node: "anchor"}) _ = js.Conn().Publish(AliveSubject("anchor"), body) _ = js.Conn().Flush() return store.count() >= 1 }) info, err := js.Context().StreamInfo("CONTROL") if err != nil || info.State.Msgs != 0 { t.Fatalf("a heartbeat was persisted, and the mesh's least valuable message now competes "+ "for retention with its most valuable: %+v, %v", info, err) } } func TestNatsTheEventsTheControllerFollowsArriveAndAreAcknowledged(t *testing.T) { js := aBus(t) told := &toldAbout{} s := &Server{inbound: Nats(js), bus: OverNATS{Conn: js.Conn(), JS: js.Context()}, log: quiet()} if err := s.Follows(told); err != nil { t.Fatal(err) } if err := s.Answers(replaysWith{}); err != nil { t.Fatal(err) } ctx, stop := context.WithCancel(context.Background()) defer stop() go func() { _ = s.Serve(ctx) }() // The controller's event consumer is made from now (novox/hq issue 248): what was published before // it existed is history it never replays. So the announcements are made once it is there. eventually(t, "the controller's event consumer being made", func() bool { _, err := js.Context().ConsumerInfo("EVENTS", broker.ControllerName) return err == nil }) moved, _ := json.Marshal(Upgraded{Module: "gitea", Commit: "abcdef0123"}) if _, err := js.Context().Publish(broker.ControllerFollows[0], moved); err != nil { t.Fatal(err) } if _, err := js.Context().Publish(broker.ControllerFollows[1], []byte(`{}`)); err != nil { t.Fatal(err) } eventually(t, "the catalogue's upgrade reaching the controller", func() bool { return told.count() == 1 }) eventually(t, "both announcements being acknowledged", func() bool { info, err := js.Context().ConsumerInfo("EVENTS", broker.ControllerName) return err == nil && info.NumAckPending == 0 && info.Delivered.Consumer == 2 }) } func TestNatsAReportIsLetGoOnceTheStoreHasBeenGoneTooLong(t *testing.T) { js := aBus(t) store := &counted{} store.refusing(errors.Join(ErrTryAgain, errors.New("connection refused"))) s := &Server{inbound: Nats(js), bus: OverNATS{Conn: js.Conn(), JS: js.Context()}, listener: store, log: quiet(), giveUp: 1500 * time.Millisecond} ctx, stop := context.WithCancel(context.Background()) defer stop() go func() { _ = s.Serve(ctx) }() body, _ := json.Marshal(Report{Node: "anchor", Declared: "d1", Applied: []string{"store"}}) if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil { t.Fatal(err) } eventually(t, "the report being let go once the bound passed", func() bool { info, err := js.Context().StreamInfo("CONTROL") return err == nil && info.State.Msgs == 0 }) if store.count() != 0 { t.Fatalf("a report was recorded by a store that never came back") } } func TestNatsWorkSlowerThanTheWindowIsNotHandedOverAgain(t *testing.T) { // Work that outlives a window of a second three times over, said while it runs four times a window (novox/hq // issue 717: the test once set its window before the serving loop asserted the controller's own of thirty // seconds, so it waited nine seconds and passed with the heartbeat removed). before := heartbeatWhileWorking heartbeatWhileWorking = 250 * time.Millisecond t.Cleanup(func() { heartbeatWhileWorking = before }) js := aBus(t) var mu sync.Mutex handled := 0 slow := make(chan struct{}) s, stop := servingOn(t, js, nil) defer stop() s.listener = slowly{func() { mu.Lock() handled++ first := handled == 1 mu.Unlock() if first { time.Sleep(3 * time.Second) close(slow) } }} // The controller's own consumer, given a window short enough to outlive in a test once the serving loop has // asserted it and bound to it: asserted after this, it would be the controller's window of thirty seconds, // which is how this test once waited out a window it never had. for deadline := time.Now().Add(10 * time.Second); ; { info, err := js.Context().ConsumerInfo("CONTROL", broker.ControllerName) if err == nil && info.PushBound { break } if time.Now().After(deadline) { t.Fatalf("the serving loop never bound the controller's consumer: %v", err) } time.Sleep(10 * time.Millisecond) } if err := js.EnsureConsumer(broker.Consumer{ Name: broker.ControllerName, Stream: "CONTROL", Push: true, AckWaitSeconds: 1, Why: "a window short enough to outlive in a test", }); err != nil { t.Fatal(err) } if info, err := js.Context().ConsumerInfo("CONTROL", broker.ControllerName); err != nil || info.Config.AckWait != time.Second { t.Fatalf("the controller's consumer does not have the test's window of a second: %v", err) } body, err := json.Marshal(Report{Node: "anchor", Declared: "d1", Applied: []string{"store"}}) if err != nil { t.Fatal(err) } if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil { t.Fatal(err) } select { case <-slow: case <-time.After(30 * time.Second): t.Fatal("the slow work never finished") } // A moment for a redelivery to arrive, if the bus were going to send one. time.Sleep(1500 * time.Millisecond) mu.Lock() defer mu.Unlock() if handled != 1 { t.Fatalf("one report was handled %d times, so slow work is run again while it is running", handled) } // Not handed over again at all: the loop drops a copy of what it is already doing, so the count above alone // would pass on a bus that redelivered the work every window; the bus's own count says it did not. info, err := js.Context().ConsumerInfo("CONTROL", broker.ControllerName) if err != nil { t.Fatal(err) } if info.NumRedelivered != 0 { t.Fatalf("the bus handed the report over again %d time(s) while it was being worked on", info.NumRedelivered) } } func TestNatsTheMeshSaysWhatAMachineApplied(t *testing.T) { js := aBus(t) heard := make(chan *nats.Msg, 4) sub, err := js.Conn().Subscribe(SeatEventSubject(MeshControllerSeat, ">"), func(m *nats.Msg) { heard <- m }) if err != nil { t.Fatal(err) } defer sub.Unsubscribe() //nolint:errcheck // the subscription dies with the connection _, stop := servingOn(t, js, &counted{}) defer stop() // A report that changed something: the store says it was news. body, err := json.Marshal(Report{Node: "anchor", Declared: "d1", Applied: []string{"store", "broker"}}) if err != nil { t.Fatal(err) } if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil { t.Fatal(err) } select { case m := <-heard: if m.Subject != SeatEventSubject(MeshControllerSeat, KeyApplied) { t.Fatalf("the mesh stated %q", m.Subject) } var said Applied if err := json.Unmarshal(m.Data, &said); err != nil { t.Fatal(err) } if said.Node != "anchor" || said.Declared != "d1" || said.Resources != 2 { t.Fatalf("it said %+v", said) } case <-time.After(10 * time.Second): t.Fatal("the mesh said nothing about a machine that now runs something else") } // A refusal is its own fact, with the reason in it rather than only in a log. refusal, err := json.Marshal(Report{Node: "anchor", Declared: "d2", Failed: map[string]string{"gitea.server": "no such image"}}) if err != nil { t.Fatal(err) } if _, err := js.Context().Publish(ReportSubject("anchor"), refusal); err != nil { t.Fatal(err) } select { case m := <-heard: if m.Subject != SeatEventSubject(MeshControllerSeat, KeyRefused) { t.Fatalf("a refusal was stated as %q", m.Subject) } var said Refused if err := json.Unmarshal(m.Data, &said); err != nil { t.Fatal(err) } if said.Failed["gitea.server"] == "" { t.Fatalf("the refusal does not say which resource or why: %+v", said) } case <-time.After(10 * time.Second): t.Fatal("the mesh said nothing about a machine that refused what it was sent") } } func TestNatsAReportThatIsNotNewsIsNotStated(t *testing.T) { js := aBus(t) heard := make(chan *nats.Msg, 4) sub, err := js.Conn().Subscribe(SeatEventSubject(MeshControllerSeat, ">"), func(m *nats.Msg) { heard <- m }) if err != nil { t.Fatal(err) } defer sub.Unsubscribe() //nolint:errcheck // the subscription dies with the connection // A store that records the report and says it was nothing new — which is what the mesh's own // store says about a machine repeating itself. _, stop := servingOn(t, js, sameAgain{}) defer stop() body, err := json.Marshal(Report{Node: "anchor", Declared: "d1", Applied: []string{"store"}}) if err != nil { t.Fatal(err) } if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil { t.Fatal(err) } select { case m := <-heard: t.Fatalf("the mesh stated %q about a machine that changed nothing", m.Subject) case <-time.After(3 * time.Second): } } func TestNatsAMergeHeldMoreThanFiveTimesIsStillHandedOverAndRecorded(t *testing.T) { first, most := MergeHeldFirst, MergeHeldMost MergeHeldFirst, MergeHeldMost = 5*time.Millisecond, 40*time.Millisecond t.Cleanup(func() { MergeHeldFirst, MergeHeldMost = first, most }) js := aBus(t) up := &manyTimesBusy{busy: 8} s := &Server{inbound: Nats(js), bus: OverNATS{Conn: js.Conn(), JS: js.Context()}, log: quiet()} if err := s.Follows(up); err != nil { t.Fatal(err) } ctx, stop := context.WithCancel(context.Background()) defer stop() go func() { _ = s.Serve(ctx) }() eventually(t, "the merges consumer being made", func() bool { _, err := js.Context().ConsumerInfo("EVENTS", broker.MergesConsumerName) return err == nil }) body, _ := json.Marshal(SourceMoved{Owner: "novox", Repo: "one", Base: "main", Commit: "abcdef0123"}) if _, err := js.Context().Publish(MergedSubject, body); err != nil { t.Fatal(err) } eventually(t, "the ninth hand-over recording the merge", func() bool { return up.called() == 9 }) eventually(t, "the merge being acknowledged", func() bool { info, err := js.Context().ConsumerInfo("EVENTS", broker.MergesConsumerName) return err == nil && info.NumAckPending == 0 && info.NumPending == 0 }) if info, err := js.Context().StreamInfo(broker.DeadLettersStream); err == nil && info.State.Msgs != 0 { t.Fatalf("%d message(s) in dead letters: a merge was given up on", info.State.Msgs) } }