From fae6bd0a878bfe00b4d26668c9560450b012aa57 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 11 Oct 2026 20:40:48 +0200 Subject: [PATCH] Say each machine's last send in status and node show, and as fields in status --json (novox/hq issue 485) The send record of issue 234 is now readable only through the controller's verbs (issue 506), and only plan said it. status says per machine when it was last sent a declaration, by whom, from which generation and what it answered; node show says it in full with the refusal's words. A machine's answer is read from its report only when that report is about the send and came after it. --- cmd/mesh-controller/build.go | 4 + cmd/mesh-controller/generation.go | 98 ++++++++++++++-- cmd/mesh-controller/last_send_test.go | 160 ++++++++++++++++++++++++++ cmd/mesh-controller/nodes.go | 13 +++ cmd/mesh-controller/readable.go | 45 ++++++++ cmd/mesh-controller/status.go | 13 ++- internal/inventory/sends.go | 65 +++++++++-- internal/inventory/sends_test.go | 75 ++++++++++++ testdata/store-tests | 2 + 9 files changed, 455 insertions(+), 20 deletions(-) create mode 100644 cmd/mesh-controller/last_send_test.go diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index ccdd8293..23cdcfd3 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -866,6 +866,10 @@ type answers struct { // applied, is not well: an assignment somebody made is not made yet, or will not be. pending []inventory.PendingAssignment pendingUnread string + // lastSent is every machine's last recorded send, by name, with what the machine answered of it (novox/hq + // issue 485); lastSentUnread why it could not be read. + lastSent map[string]inventory.Send + lastSentUnread string } // heldBy is every artifact this mesh has built, for a build that may need one as its base. diff --git a/cmd/mesh-controller/generation.go b/cmd/mesh-controller/generation.go index 15f79cbb..abf9ff0e 100644 --- a/cmd/mesh-controller/generation.go +++ b/cmd/mesh-controller/generation.go @@ -202,21 +202,99 @@ func modulesWords(modules []string) string { return strings.Join(modules, ", ") } -// writeLastSend says what a machine was last told, by whom and from which generation (novox/hq issue 234): -// nothing when the mesh has not recorded a send to it. +// writeLastSend says what a machine was last told, by whom and from which generation (novox/hq issue 234), and +// what it answered (issue 485): nothing when the mesh has not recorded a send to it. func writeLastSend(ctx context.Context, w io.Writer, inv *inventory.Inventory, node string) error { s, found, err := inv.LastSend(ctx, node) if err != nil || !found { return err } - generation := "no generation recorded" - if s.Generation > 0 { - generation = fmt.Sprintf("assignment generation %d", s.Generation) - } - fmt.Fprintf(w, "%s was last sent sequence %d at %s by %s, composed from %s, naming %s\n", node, s.Sequence, s.SentAt.Local().Format("2006-01-02 15:04:05"), s.Sender, generation, modulesWords(s.Modules)) - if s.RefusedAt != nil { - fmt.Fprintf(w, " and refused it at %s: it had applied generation %d\n", - s.RefusedAt.Local().Format("2006-01-02 15:04:05"), s.RefusedApplied) + fmt.Fprintf(w, "%s:\n", node) + for _, line := range lastSendLines(s) { + fmt.Fprintln(w, line) } return nil } + +// lastSendLines is a machine's last send in full, as `node show` and `plan` say it (novox/hq issue 485): when, +// by whom, under which lease epoch, from which assignment generation, naming which modules, and what the +// machine answered of it — the refusal in its own words. +func lastSendLines(s inventory.Send) []string { + const stamp = "2006-01-02 15:04:05" + epoch := "under no lease epoch" + if s.Epoch > 0 { + epoch = fmt.Sprintf("under lease epoch %d", s.Epoch) + } + generation := "no assignment generation recorded" + if s.Generation > 0 { + generation = fmt.Sprintf("assignment generation %d", s.Generation) + } + lines := []string{ + fmt.Sprintf(" last sent sequence %d at %s", s.Sequence, s.SentAt.Local().Format(stamp)), + fmt.Sprintf(" by %s", s.Sender), + fmt.Sprintf(" %s, composed from %s", epoch, generation), + fmt.Sprintf(" naming %s", modulesWords(s.Modules)), + } + answer := func(words string) { lines = append(lines, " its answer "+words) } + switch a := s.Answer; { + case s.RefusedAt != nil: + // Before the report: a refusal for the generation is kept on the send itself and is the machine's + // answer to this send in particular. + answer(fmt.Sprintf("refused it at %s: it had applied assignment generation %d, newer than this one", + s.RefusedAt.Local().Format(stamp), s.RefusedApplied)) + case a.Outcome == "" || a.At == nil: + answer("the machine has not reported on it yet") + case a.Outcome == inventory.OutcomeApplied: + answer("applied it at " + a.At.Local().Format(stamp)) + case a.Outcome == inventory.OutcomeRefused: + answer("refused it at " + a.At.Local().Format(stamp) + ", in its words:") + for _, line := range strings.Split(strings.TrimRight(a.Refused, "\n"), "\n") { + lines = append(lines, " "+strings.TrimSpace(line)) + } + default: + answer(fmt.Sprintf("%s at %s: %d resource(s) failed — `status` says which", a.Outcome, + a.At.Local().Format(stamp), a.Failed)) + } + return lines +} + +// sendAnswerWord is what a machine answered of a send, in a word or few, as status says it in one line. +func sendAnswerWord(s inventory.Send) string { + switch a := s.Answer; { + case s.RefusedAt != nil: + return fmt.Sprintf("refused: it had applied generation %d", s.RefusedApplied) + case a.Outcome == "" || a.At == nil: + return "not reported on yet" + case a.Outcome == inventory.OutcomeFailed: + return fmt.Sprintf("failed (%d resource(s))", a.Failed) + default: + return a.Outcome + } +} + +// printLastSends says, one line per machine, when it was last sent a declaration, by whom, from which assignment +// generation and what it answered (novox/hq issue 485). `node show ` says the send in full. +func printLastSends(nodes []inventory.Node, sends map[string]inventory.Send, unread string) { + if unread != "" { + fmt.Printf("what each machine was last sent could NOT be read: %s\n\n", unread) + return + } + if len(nodes) == 0 { + return + } + fmt.Println("last sent, per machine:") + for _, n := range nodes { + s, found := sends[n.Name] + if !found { + fmt.Printf(" %-12s no send recorded\n", n.Name) + continue + } + generation := "no generation" + if s.Generation > 0 { + generation = fmt.Sprintf("generation %d", s.Generation) + } + fmt.Printf(" %-12s %s by %s, %s — %s\n", n.Name, s.SentAt.Local().Format("2006-01-02 15:04"), s.Sender, + generation, sendAnswerWord(s)) + } + fmt.Printf("\n `node show ` says a machine's last send in full\n\n") +} diff --git a/cmd/mesh-controller/last_send_test.go b/cmd/mesh-controller/last_send_test.go new file mode 100644 index 00000000..0fecd717 --- /dev/null +++ b/cmd/mesh-controller/last_send_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// novox/hq issue 485: the mesh records every send — sender, lease epoch, generation, modules — and only `plan` +// said it. `status` says per machine when it was last sent a declaration and by whom, in one line; `node show` +// says the last send in full and what the machine answered; and `status --json` carries it as fields, since +// the store's reader may not read the controller's tables and these verbs are the only window onto them. + +func aLastSend(answer inventory.SendAnswer) inventory.Send { + sent := time.Date(2026, 10, 11, 18, 40, 5, 0, time.Local) + return inventory.Send{NodeName: "laptop", Sequence: 12, Epoch: 57, + Sender: "a one-shot push by jochen at a shell on anchor", Generation: 40, Digest: "d12", + Modules: []string{"docker", "sudo"}, SentAt: sent, Recorded: true, Answer: answer} +} + +func TestStatusSaysEachMachinesLastSendInOneLine(t *testing.T) { + at := time.Date(2026, 10, 11, 18, 41, 0, 0, time.Local) + asked := answers{ + nodes: []inventory.Node{{Name: "anchor", LastSeen: time.Now()}, {Name: "laptop", LastSeen: time.Now()}}, + lastSent: map[string]inventory.Send{"laptop": aLastSend(inventory.SendAnswer{ + Outcome: inventory.OutcomeApplied, At: &at})}, + } + shown := printed(t, func() error { return printStatus(asked) }) + var laptop, anchor []string + for _, line := range strings.Split(shown, "\n") { + fields := strings.Fields(line) + if len(fields) > 0 && fields[0] == "laptop" { + laptop = append(laptop, line) + } + if len(fields) > 0 && fields[0] == "anchor" { + anchor = append(anchor, line) + } + } + if len(laptop) != 1 || !strings.Contains(laptop[0], "2026-10-11 18:40") || + !strings.Contains(laptop[0], "by a one-shot push by jochen at a shell on anchor") || + !strings.Contains(laptop[0], "generation 40") || !strings.Contains(laptop[0], "applied") { + t.Fatalf("status does not say laptop's last send in one line:\n%s", shown) + } + if len(anchor) != 1 || !strings.Contains(anchor[0], "no send recorded") { + t.Fatalf("status does not say anchor has no send recorded, in one line:\n%s", shown) + } +} + +func TestStatusSaysTheLastSendsCouldNotBeRead(t *testing.T) { + asked := answers{nodes: []inventory.Node{{Name: "laptop", LastSeen: time.Now()}}, + lastSentUnread: "the store went away"} + if shown := printed(t, func() error { return printStatus(asked) }); !strings.Contains(shown, "the store went away") { + t.Fatalf("a record of sends that could not be read is not said:\n%s", shown) + } + if asked.well() { + t.Error("a mesh whose sends could not be read is called well") + } +} + +func TestNodeShowSaysTheLastSendInFull(t *testing.T) { + at := time.Date(2026, 10, 11, 18, 41, 0, 0, time.Local) + refused := strings.Join(lastSendLines(aLastSend(inventory.SendAnswer{Outcome: inventory.OutcomeRefused, + Refused: "unknown field \"generation\"\nsecond line", At: &at})), "\n") + for _, want := range []string{"sequence 12", "2026-10-11 18:40:05", "a one-shot push by jochen at a shell on anchor", + "lease epoch 57", "generation 40", "docker, sudo", "refused", "unknown field \"generation\""} { + if !strings.Contains(refused, want) { + t.Errorf("node show's last send does not say %q:\n%s", want, refused) + } + } + if applied := strings.Join(lastSendLines(aLastSend(inventory.SendAnswer{Outcome: inventory.OutcomeApplied, + At: &at})), "\n"); !strings.Contains(applied, "applied it at 2026-10-11 18:41:00") { + t.Errorf("an applied send is not said applied:\n%s", applied) + } + if waiting := strings.Join(lastSendLines(aLastSend(inventory.SendAnswer{})), "\n"); !strings.Contains(waiting, + "not reported on it yet") { + t.Errorf("a send not reported on is not said so:\n%s", waiting) + } + stale := aLastSend(inventory.SendAnswer{}) + when := at + stale.RefusedAt, stale.RefusedApplied = &when, 44 + if s := strings.Join(lastSendLines(stale), "\n"); !strings.Contains(s, "refused it at 2026-10-11 18:41:00") || + !strings.Contains(s, "generation 44") { + t.Errorf("a send refused for its generation is not said so:\n%s", s) + } +} + +func TestStatusJSONCarriesEachMachinesLastSend(t *testing.T) { + at := time.Date(2026, 10, 11, 18, 41, 0, 0, time.UTC) + asked := answers{nodes: []inventory.Node{{Name: "anchor"}, {Name: "laptop"}}, + lastSent: map[string]inventory.Send{"laptop": aLastSend(inventory.SendAnswer{Outcome: inventory.OutcomeRefused, + Refused: "unknown field", At: &at})}} + body, err := statusAsJSON(asked) + if err != nil { + t.Fatal(err) + } + var got struct { + LastSent []map[string]any `json:"lastSent"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + if len(got.LastSent) != 1 { + t.Fatalf("status --json carries %d last sends, want laptop's only:\n%s", len(got.LastSent), body) + } + s := got.LastSent[0] + if s["node"] != "laptop" || s["sequence"] != float64(12) || s["epoch"] != float64(57) || + s["generation"] != float64(40) || s["sender"] != "a one-shot push by jochen at a shell on anchor" || + s["digest"] != "d12" || s["sentAt"] == nil || s["answer"] != "refused" || s["refused"] != "unknown field" || + s["answeredAt"] == nil { + t.Fatalf("laptop's last send reads %v", s) + } + if modules, _ := s["modules"].([]any); len(modules) != 2 || modules[0] != "docker" { + t.Fatalf("the modules it named read %v", s["modules"]) + } +} + +// And through the store: a send recorded and refused by the machine is what `node show` and `status --json` +// read back. +func TestNodeShowAndStatusReadTheLastSendFromTheStore(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + record, err := open.inventory.NodeByName(ctx, "laptop") + if err != nil { + t.Fatal(err) + } + if err := open.inventory.RecordSentWith(ctx, record.ID, "d12", nil, 57, 40, inventory.Send{Sequence: 12, + Epoch: 57, Sender: "a one-shot push by jochen at a shell on anchor", Generation: 40, Digest: "d12", + Modules: []string{"docker", "sudo"}}); err != nil { + t.Fatal(err) + } + if _, err := open.inventory.RecordDoing(ctx, record.ID, inventory.Doing{Outcome: inventory.OutcomeRefused, + Declared: "d12", Refused: "unknown field \"generation\""}); err != nil { + t.Fatal(err) + } + shown := printed(t, func() error { return showNode(ctx, open, "laptop") }) + for _, want := range []string{"a one-shot push by jochen at a shell on anchor", "lease epoch 57", "generation 40", + "docker, sudo", "unknown field \"generation\""} { + if !strings.Contains(shown, want) { + t.Errorf("node show laptop does not say %q:\n%s", want, shown) + } + } + body := printed(t, func() error { return statusFor(ctx, open, true) }) + var got struct { + LastSent []struct { + Node string `json:"node"` + Sender string `json:"sender"` + Answer string `json:"answer"` + } `json:"lastSent"` + } + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("%v:\n%s", err, body) + } + if len(got.LastSent) != 1 || got.LastSent[0].Node != "laptop" || got.LastSent[0].Answer != "refused" || + got.LastSent[0].Sender != "a one-shot push by jochen at a shell on anchor" { + t.Fatalf("status --json's last sends read %+v", got.LastSent) + } +} diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index b39f2cb7..def9e9a3 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -804,6 +804,19 @@ func showNode(ctx context.Context, stored *stores, name string) error { if err := showMode(ctx, inv, node); err != nil { return err } + // What it was last sent, by whom, under which epoch, from which generation, naming which modules, and what + // it answered (novox/hq issue 485): the record of sends is read through this verb, never the store's reader. + // Unreadable is said, not taken for none. + switch last, found, err := inv.LastSend(ctx, name); { + case err != nil: + fmt.Printf(" what it was last sent could NOT be read: %v\n", err) + case !found: + fmt.Printf(" last sent no send recorded since the mesh began keeping them\n") + default: + for _, line := range lastSendLines(last) { + fmt.Println(line) + } + } // Whom agents run as here, and whether that account can become root without a person (ADR 0266). for _, line := range agentAccountLines(ctx, inv, node) { fmt.Println(line) diff --git a/cmd/mesh-controller/readable.go b/cmd/mesh-controller/readable.go index a5b86497..32155fbc 100644 --- a/cmd/mesh-controller/readable.go +++ b/cmd/mesh-controller/readable.go @@ -109,6 +109,33 @@ type meshStatus struct { // why they could not be read. Pending []pendingStatus `json:"pending,omitempty"` PendingUnread string `json:"pendingUnread,omitempty"` + // LastSent is every machine's last recorded send — who sent it, under which lease epoch, from which + // assignment generation, naming which modules — and what the machine answered of it (novox/hq issue 485). + // A machine the mesh has recorded no send to is absent. LastSentUnread says why it could not be read. + LastSent []machineLastSent `json:"lastSent"` + LastSentUnread string `json:"lastSentUnread,omitempty"` +} + +// machineLastSent is one machine's last send as status says it. +type machineLastSent struct { + Node string `json:"node"` + Sequence int64 `json:"sequence"` + SentAt time.Time `json:"sentAt"` + Sender string `json:"sender"` + Epoch int64 `json:"epoch"` + Generation int64 `json:"generation"` + Digest string `json:"digest"` + Modules []string `json:"modules"` + // Answer is what the machine said of it: applied, failed or refused, as its report has it; empty when it + // has not reported on it. Refused is its words when it refused it whole, Failed how many resources failed. + Answer string `json:"answer"` + Refused string `json:"refused,omitempty"` + Failed int `json:"failed,omitempty"` + AnsweredAt *time.Time `json:"answeredAt,omitempty"` + // RefusedForGeneration is when the machine refused it for coming from an older assignment generation than + // it applied, and AppliedGeneration that generation; absent when it did not. + RefusedForGeneration *time.Time `json:"refusedForGeneration,omitempty"` + AppliedGeneration int64 `json:"appliedGeneration,omitempty"` } // pendingStatus is one pending assignment as status says it. @@ -267,6 +294,7 @@ func statusAsJSON(asked answers) ([]byte, error) { out.Pending = append(out.Pending, pendingStatus{Node: p.Node, Module: p.Module, State: p.State, Build: p.Build, Repository: p.Repository, Path: p.Path, Since: p.Since, Settled: p.Settled, Note: p.Note}) } + out.LastSent, out.LastSentUnread = lastSentStatus(asked.lastSent), asked.lastSentUnread for name := range asked.refused { out.Unresolved = append(out.Unresolved, machineUnresolved{ Node: name, Problem: asked.refused[name]}) @@ -323,3 +351,20 @@ func say(value any) error { fmt.Println(string(body)) return nil } + +// lastSentStatus is every machine's last send as status --json says it, by machine name (novox/hq issue 485). +func lastSentStatus(sends map[string]inventory.Send) []machineLastSent { + out := []machineLastSent{} + for _, s := range sends { + modules := s.Modules + if modules == nil { + modules = []string{} + } + out = append(out, machineLastSent{Node: s.NodeName, Sequence: s.Sequence, SentAt: s.SentAt, Sender: s.Sender, + Epoch: s.Epoch, Generation: s.Generation, Digest: s.Digest, Modules: modules, Answer: s.Answer.Outcome, + Refused: s.Answer.Refused, Failed: s.Answer.Failed, AnsweredAt: s.Answer.At, + RefusedForGeneration: s.RefusedAt, AppliedGeneration: s.RefusedApplied}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Node < out[j].Node }) + return out +} diff --git a/cmd/mesh-controller/status.go b/cmd/mesh-controller/status.go index 91c44c9c..af68b7f5 100644 --- a/cmd/mesh-controller/status.go +++ b/cmd/mesh-controller/status.go @@ -341,6 +341,10 @@ func printStatus(asked answers) error { "lists them, and each is in its condition's tried\n\n", asked.heals.Acts, asked.heals.Escalated) } + // What each machine was last sent and by whom, one line each (novox/hq issue 485). Not a fault, so it does not + // break "all well"; a record that could not be read does. + printLastSends(nodes, asked.lastSent, asked.lastSentUnread) + if adopted := adoptedNodes(nodes); len(adopted) > 0 { // Said, because nothing forces the flip: a node left adopted is visible here rather than // read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well". @@ -485,6 +489,12 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) { out.pendingUnread = err.Error() err = nil } + // And what each machine was last sent, by whom (novox/hq issue 485): the record of sends is read through + // the controller's verbs alone, the store's reader being shut out of its tables. Unreadable is said. + if out.lastSent, err = inv.LastSends(ctx); err != nil { + out.lastSentUnread = err.Error() + err = nil + } out.plans, err = inv.RecentPlans(ctx, 5) if err != nil { return answers{}, err @@ -617,7 +627,8 @@ func (a answers) well() bool { return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 && len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 && len(a.leftOut) == 0 && len(a.filtered) == 0 && len(a.unheld) == 0 && len(a.overflowing) == 0 && - len(a.conditions) == 0 && a.conditionsUnread == "" && a.pendingUnread == "" && !pendingOpen(a.pending) + len(a.conditions) == 0 && a.conditionsUnread == "" && a.pendingUnread == "" && !pendingOpen(a.pending) && + a.lastSentUnread == "" } // pendingOpen is whether any pending assignment is still to be made. One that ended without being made is diff --git a/internal/inventory/sends.go b/internal/inventory/sends.go index 8a6efa36..fd13eda3 100644 --- a/internal/inventory/sends.go +++ b/internal/inventory/sends.go @@ -100,6 +100,23 @@ type Send struct { CounterRaisedTo int64 // Recorded is false for a refusal of a sequence the mesh has no send for: its sender is unknown. Recorded bool + // Answer is what the machine last reported of this send; filled only where a last send is read back + // (LastSend, LastSends), the zero answer elsewhere. + Answer SendAnswer +} + +// SendAnswer is what a machine reported of one send, read from its last report when that report is about the +// send's declaration (novox/hq issue 485): whether it applied it, failed part of it or refused it, and why. +// The zero answer is a machine that has not reported on this send — yet, or since a later report replaced it. +type SendAnswer struct { + // Outcome is applied, failed or refused, as the machine reported it; empty when it has not reported on it. + Outcome string + // Refused is the machine's own words when it refused the declaration whole. + Refused string + // Failed is how many of its resources failed, when some did. + Failed int + // At is when the machine reported it; nil when it has not. + At *time.Time } // unknownSender is the sender of a refused sequence the mesh has no record of sending. @@ -209,20 +226,50 @@ func scanSends(rows pgx.Rows) ([]Send, error) { return out, rows.Err() } -// LastSend is the last declaration a machine was sent, by its name; false when none was recorded. A refusal -// of a sequence the mesh has no send for is not a send, and is not it. +// LastSend is the last declaration a machine was sent, by its name, with what the machine answered of it; false +// when none was recorded. A refusal of a sequence the mesh has no send for is not a send, and is not it. func (i *Inventory) LastSend(ctx context.Context, name string) (Send, bool, error) { - rows, err := i.store.Pool().Query(ctx, `select `+sendColumns+` - from declaration_send s join node n on n.id = s.node - where n.name = $1 and s.recorded order by s.id desc limit 1`, name) + sends, err := i.lastSends(ctx, `n.name = $1`, name) if err != nil { return Send{}, false, err } - sends, err := scanSends(rows) - if err != nil || len(sends) == 0 { - return Send{}, false, err + s, found := sends[name] + return s, found, nil +} + +// LastSends is every machine's last send, by machine name, each with what the machine answered of it: what +// `status` says per machine (novox/hq issue 485). A machine never sent anything has no entry. +func (i *Inventory) LastSends(ctx context.Context) (map[string]Send, error) { + return i.lastSends(ctx, `true`) +} + +// lastSends reads the newest recorded send of each machine matching where, beside the machine's last report +// when that report is about the send: the same declaration (its digest), reported after it was sent — both +// times the store's own, so no machine's clock decides it — so a machine sent back a declaration it once +// applied does not read as having applied the new send before it said so (novox/hq issue 485). +func (i *Inventory) lastSends(ctx context.Context, where string, args ...any) (map[string]Send, error) { + rows, err := i.store.Pool().Query(ctx, `select distinct on (n.name) `+sendColumns+`, + coalesce(r.outcome, ''), coalesce(r.refused, ''), case when jsonb_typeof(r.failed) = 'array' then jsonb_array_length(r.failed) else 0 end, r.at + from declaration_send s join node n on n.id = s.node + left join node_report r on r.node = s.node and r.declared <> '' and r.declared = s.digest + and r.at >= s.sent_at + where s.recorded and `+where+` + order by n.name, s.id desc`, args...) + if err != nil { + return nil, err } - return sends[0], true, nil + defer rows.Close() + out := map[string]Send{} + for rows.Next() { + var s Send + if err := rows.Scan(&s.Node, &s.NodeName, &s.Sequence, &s.Epoch, &s.Sender, &s.Generation, &s.Digest, + &s.Modules, &s.SentAt, &s.RefusedAt, &s.RefusedApplied, &s.CounterRaisedTo, &s.Recorded, + &s.Answer.Outcome, &s.Answer.Refused, &s.Answer.Failed, &s.Answer.At); err != nil { + return nil, err + } + out[s.NodeName] = s + } + return out, rows.Err() } // RefusedSend keeps a machine's refusal of the send of a sequence for the generation it came from, and diff --git a/internal/inventory/sends_test.go b/internal/inventory/sends_test.go index 96c98d15..6b70fdac 100644 --- a/internal/inventory/sends_test.go +++ b/internal/inventory/sends_test.go @@ -205,3 +205,78 @@ func TestWhetherAMachineReadsAGenerationIsItsLatestWord(t *testing.T) { } } } + +// **The last send says what the machine answered of it** (novox/hq issue 485): applied, refused with its +// words, or not answered yet — read from the machine's report only when that report is about the send's +// declaration and came after it, so a report about an earlier send is never this one's answer. And every +// machine's last send is read at once, for status, by name; a machine never sent anything has none. +func TestTheLastSendSaysWhatTheMachineAnswered(t *testing.T) { + inv, node := aNodeWithModules(t) + ctx := t.Context() + record, err := inv.NodeByName(ctx, node) + if err != nil { + t.Fatal(err) + } + if _, err := inv.AddNode(ctx, "laptop"); err != nil { + t.Fatal(err) + } + send := func(sequence int64, digest string) { + t.Helper() + if err := inv.RecordSentWith(ctx, record.ID, digest, nil, 57, 40, Send{Sequence: sequence, Epoch: 57, + Sender: "the controller's daemon (pid 7 on anchor)", Generation: 40, Digest: digest, + Modules: []string{"docker", "sudo"}}); err != nil { + t.Fatal(err) + } + } + report := func(d Doing) { + t.Helper() + if _, err := inv.RecordDoing(ctx, record.ID, d); err != nil { + t.Fatal(err) + } + } + answer := func() SendAnswer { + t.Helper() + last, found, err := inv.LastSend(ctx, node) + if err != nil || !found { + t.Fatalf("the last send is not read back: %v, %v", found, err) + } + return last.Answer + } + + send(11, "d11") + if a := answer(); a.Outcome != "" || a.At != nil { + t.Fatalf("a send not reported on reads as answered: %+v", a) + } + report(Doing{Outcome: OutcomeApplied, Declared: "d11", Applied: 3}) + if a := answer(); a.Outcome != OutcomeApplied || a.At == nil { + t.Fatalf("a send the machine reported applying reads %+v", a) + } + + send(12, "d12") + if a := answer(); a.Outcome != "" { + t.Fatalf("the report about the send before is taken for this one's answer: %+v", a) + } + report(Doing{Outcome: OutcomeRefused, Declared: "d12", Refused: "unknown field \"generation\"\nand more"}) + if a := answer(); a.Outcome != OutcomeRefused || a.Refused != "unknown field \"generation\"\nand more" { + t.Fatalf("a send the machine refused reads %+v", a) + } + + // Sent again a declaration it reported applying before the send: not answered until it reports again. + report(Doing{Outcome: OutcomeApplied, Declared: "d12", Applied: 3}) + send(13, "d12") + if a := answer(); a.Outcome != "" { + t.Fatalf("a report from before the send is taken for its answer: %+v", a) + } + + all, err := inv.LastSends(ctx) + if err != nil { + t.Fatal(err) + } + if len(all) != 1 || all[node].Sequence != 13 || all[node].Epoch != 57 || all[node].Generation != 40 || + !slices.Equal(all[node].Modules, []string{"docker", "sudo"}) { + t.Fatalf("every machine's last send reads %+v", all) + } + if _, found := all["laptop"]; found { + t.Error("a machine never sent anything has a last send") + } +} diff --git a/testdata/store-tests b/testdata/store-tests index 1a66198b..0b574af4 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -1,3 +1,5 @@ # The tests that need the throwaway store and must have run against it (novox/hq issue 459): merge-check.sh runs # each one named here, by package, and fails unless every one passed — a test that needs the store skips without # one, and a skip passes unseen. One per line: . Lines starting with # are said nothing of. +./internal/inventory TestTheLastSendSaysWhatTheMachineAnswered +./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore