diff --git a/cmd/mesh-control/board.go b/cmd/mesh-control/board.go new file mode 100644 index 0000000..1b8ae60 --- /dev/null +++ b/cmd/mesh-control/board.go @@ -0,0 +1,248 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "html/template" + "net/http" + "time" +) + +// boardCommand serves the three questions as a page. +// +// **It reads through the same functions everything else does and holds nothing** +// (novox/hq 03-DESIGN/01-to-be/11-a-board.md). The board being replaced is one service that reads +// every context's database directly — [ADR 0008](novox/hq) violated by the one component with a +// reason to violate it, and the cost is that a boundary nothing may cross can move, while one +// thing crossing it is enough to freeze it. A board that reads the provisioning tables is a board +// that breaks when provisioning changes its tables, and the change then gets weighed against the +// board. +// +// **It stores nothing of its own.** No cache that can disagree, no table of what the mesh looked +// like last time. Every request reads the mesh now; if that is slow, the answer belongs in the +// context that owns it, where everything else asking gets it too. +// +// **Reading is the whole of it.** Every action a board could offer already exists as a command, +// and a button that does something no command does is a second implementation of a decision. +func boardCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("board", flag.ContinueOnError) + // The private network, not everything. A board says which machines are broken and what they + // are running, which is exactly the map somebody attacking this would like — and there is no + // reason for it to be reachable from further away than the mesh. + listen := set.String("listen", "127.0.0.1:8080", "where to serve it") + if _, err := parseAround(set, args); err != nil { + return err + } + + server := &http.Server{ + Addr: *listen, + ReadHeaderTimeout: 10 * time.Second, + Handler: board(), + } + fmt.Printf("the board is on http://%s\n", *listen) + fmt.Printf(" it reads the mesh on every request and keeps nothing\n") + + go func() { + <-ctx.Done() + closing, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(closing) + }() + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +// board is the handler, separate so a test can drive it without a listener. +func board() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + asked, err := ask(r.Context()) + if err != nil { + // **Said, not blank.** A board that cannot reach the mesh and renders an empty page + // says "nothing is wrong" in the one situation where nobody can know that. + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + _ = page.Execute(w, view{Unreachable: err.Error()}) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := page.Execute(w, asked); err != nil { + // The page is half-written by now; there is nothing useful left to say to the + // browser, and saying it here is what stops the failure being silent. + fmt.Printf("the board could not render: %v\n", err) + } + }) + // The same answers for something that is not a person, from the same read. A board and a + // script disagreeing about which machine is broken would be worse than either alone. + mux.HandleFunc("/mesh.json", func(w http.ResponseWriter, r *http.Request) { + inv, err := openInventory(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + defer inv.Close() + asked, err := theThreeQuestions(r.Context(), inv) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + body, err := statusAsJSON(asked.wrong, asked.nodes, asked.quiet, asked.behind, asked.sources) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(append(body, '\n')) + }) + return mux +} + +// ask reads the mesh for one request. +func ask(ctx context.Context) (view, error) { + inv, err := openInventory(ctx) + if err != nil { + return view{}, err + } + defer inv.Close() + + asked, err := theThreeQuestions(ctx, inv) + if err != nil { + return view{}, err + } + return viewOf(asked), nil +} + +// view is what the page is given. Nothing is derived here that the reader could not derive. +type view struct { + Unreachable string + Machines int + Broken []brokenMachine + Quiet []quietMachine + Behind []staleModule + At string +} + +type brokenMachine struct { + Node string + // Outcome is refused or failed, and stays distinct all the way to the page. **Refused means + // the machine is exactly as it was and what is wrong is in what was sent; failed means it is + // in a state nobody declared and what is wrong is on the machine.** They are fixed in + // different places, so one word for both would send half the readers to the wrong one. + Outcome string + Said []string + When string +} + +type quietMachine struct { + Node string + // Heard is "never" or how long ago. Never heard from is not the same as quiet for a while: + // one may be a machine that was never sent anything. + Heard string +} + +type staleModule struct { + Module string + Holds string + Source string + Running []string +} + +func viewOf(asked answers) view { + out := view{Machines: len(asked.nodes), At: time.Now().Format("15:04:05")} + for _, d := range asked.wrong { + one := brokenMachine{Node: d.Node, Outcome: d.Outcome, + When: d.At.Local().Format("2006-01-02 15:04")} + if d.Refused != "" { + // The host's own words. It says exactly what it could not accept, and nothing + // written here would say it better. + one.Said = append(one.Said, firstLine(d.Refused)) + } + for _, f := range d.Failed { + one.Said = append(one.Said, f.ID+": "+firstLine(f.Error)) + } + out.Broken = append(out.Broken, one) + } + for _, n := range asked.quiet { + out.Quiet = append(out.Quiet, quietMachine{Node: n.Name, Heard: heardFrom(n)}) + } + for module, on := range asked.behind { + from := asked.sources[module] + out.Behind = append(out.Behind, staleModule{ + Module: module, Holds: short(from.BuiltFrom), Source: short(from.Head), Running: on, + }) + } + return out +} + +// The page. Deliberately one file with no assets: a board that cannot render without fetching +// something is a board that is blank exactly when the mesh is unwell. +var page = template.Must(template.New("board").Parse(` + + +
{{.Unreachable}}
+This says nothing about whether the mesh is well — only that this page could not +find out.
+{{else}} +No. Every machine is doing what it was told.
{{end}} + +Not heard from is not the same as tried and could not — a machine here may be +new, switched off, or unreachable.
+{{else}}No. Every machine has been heard from.
{{end}} + +No. Every module is what its source last had.
{{end}} +{{end}} + + +`)) diff --git a/cmd/mesh-control/board_test.go b/cmd/mesh-control/board_test.go new file mode 100644 index 0000000..e257080 --- /dev/null +++ b/cmd/mesh-control/board_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/novox/mesh-control/internal/inventory" +) + +// Refused and failed stay distinct all the way to the page. +// +// **Refused means the machine is exactly as it was and what is wrong is in what was sent; failed +// means it is in a state nobody declared and what is wrong is on the machine.** They are fixed in +// different places, so a page saying "error" for both sends half its readers to the wrong one. +func TestRefusedAndFailedReachThePageAsDifferentThings(t *testing.T) { + rendered := render(t, viewOf(answers{ + nodes: []inventory.Node{{Name: "anchor"}, {Name: "laptop"}}, + wrong: []inventory.Doing{ + {Node: "anchor", Outcome: "refused", Refused: "not a declaration this host speaks", + At: time.Now()}, + {Node: "laptop", Outcome: "failed", At: time.Now(), + Failed: []inventory.FailedResource{{ID: "web.container", Error: "no such image"}}}, + }, + })) + // The rendered outcome, not the word anywhere on the page: both words appear in the + // stylesheet, so a plain Contains passes whatever the machine actually said. It did. + for _, want := range []string{`refused`, + `failed`} { + if !strings.Contains(rendered, want) { + t.Fatalf("the page does not distinguish the two — missing %s:\n%s", want, rendered) + } + } + // And the host's own words, which say exactly what it could not accept. + for _, want := range []string{"not a declaration this host speaks", "web.container", "no such image"} { + if !strings.Contains(rendered, want) { + t.Fatalf("the page does not say %q, so a reader must go and ask:\n%s", want, rendered) + } + } +} + +// Nothing wrong is said, not left blank. An empty page and a well mesh must not look alike. +func TestAWellMeshSaysSoRatherThanShowingNothing(t *testing.T) { + rendered := render(t, viewOf(answers{nodes: []inventory.Node{{Name: "anchor"}}})) + for _, want := range []string{ + "Every machine is doing what it was told", + "Every machine has been heard from", + "Every module is what its source last had", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("a well mesh does not say %q:\n%s", want, rendered) + } + } +} + +// A board that cannot reach the mesh must not render an empty page: that says "nothing is wrong" +// in the one situation where nobody can know it. +func TestABoardThatCannotReadTheMeshSaysSo(t *testing.T) { + rendered := render(t, view{Unreachable: "the store did not answer"}) + if !strings.Contains(rendered, "the store did not answer") { + t.Fatalf("the reason is not on the page:\n%s", rendered) + } + if strings.Contains(rendered, "Every machine is doing what it was told") { + t.Fatal("a board that could not read the mesh reported that the mesh is well") + } +} + +// Quiet is not broken, and the page says which it is. +func TestQuietIsNotReportedAsBroken(t *testing.T) { + rendered := render(t, viewOf(answers{ + nodes: []inventory.Node{{Name: "laptop"}}, + quiet: []inventory.Node{{Name: "laptop"}}, + })) + if !strings.Contains(rendered, "not the same as tried and could not") { + t.Fatalf("the page does not separate quiet from broken:\n%s", rendered) + } + if !strings.Contains(rendered, "Every machine is doing what it was told") { + t.Fatalf("a quiet machine was counted as broken:\n%s", rendered) + } +} + +// The page renders what a machine said, and a hostile string in it is not markup. +// +// What is on this page comes from machines, and a machine's own words are the whole reason the +// page is useful. They are also the one thing here that nobody in this repository wrote. +func TestWhatAMachineSaidIsNotMarkup(t *testing.T) { + rendered := render(t, viewOf(answers{ + nodes: []inventory.Node{{Name: "anchor"}}, + wrong: []inventory.Doing{{Node: "anchor", Outcome: "refused", + Refused: ``, At: time.Now()}}, + })) + if strings.Contains(rendered, "