325 lines
12 KiB
Go
325 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"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) {
|
|
open, err := openStores(r.Context())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer open.Close()
|
|
asked, err := theThreeQuestions(r.Context(), open)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
body, err := statusAsJSON(asked)
|
|
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) {
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return view{}, err
|
|
}
|
|
defer open.Close()
|
|
|
|
asked, err := theThreeQuestions(ctx, open)
|
|
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
|
|
Waiting []waitingMachine
|
|
// Unresolved is every machine that cannot be worked out at all. Shown above everything else,
|
|
// because a machine here is in none of the other lists: nothing was computed for it, so it is
|
|
// not broken, not quiet and not behind — and a page without this said "all well" about a mesh
|
|
// where nothing could be sent anywhere.
|
|
Unresolved []blockedMachine
|
|
// Network is why the private network could not be computed, when it could not.
|
|
Network string
|
|
// Adopted is every node still adopted (novox/hq ADR 0100). Not broken: nothing forces the
|
|
// flip, so a node left adopted is shown rather than read as converged.
|
|
Adopted []string
|
|
At string
|
|
}
|
|
|
|
type blockedMachine struct {
|
|
Node string
|
|
// Said is the mesh's own words, a line at a time. Every unmet requirement, not the first:
|
|
// a machine is usually blocked by more than one and fixing one of them changes nothing.
|
|
Said []string
|
|
}
|
|
|
|
type waitingMachine struct {
|
|
Node string
|
|
// Never told is not out of date: nobody has ever asked this machine to be anything. Same
|
|
// remedy, different situation, and the page says which.
|
|
Never bool
|
|
}
|
|
|
|
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"),
|
|
Network: asked.network, Adopted: adoptedNodes(asked.nodes)}
|
|
var blocked []string
|
|
for name := range asked.refused {
|
|
blocked = append(blocked, name)
|
|
}
|
|
sort.Strings(blocked)
|
|
for _, name := range blocked {
|
|
one := blockedMachine{Node: name}
|
|
for _, line := range strings.Split(strings.TrimRight(asked.refused[name], "\n"), "\n") {
|
|
one.Said = append(one.Said, strings.TrimSpace(line))
|
|
}
|
|
out.Unresolved = append(out.Unresolved, one)
|
|
}
|
|
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 _, m := range asked.waiting {
|
|
out.Waiting = append(out.Waiting, waitingMachine{Node: m.Node, Never: m.Never})
|
|
}
|
|
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(`<!doctype html>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>the mesh</title>
|
|
<style>
|
|
:root { color-scheme: light dark; }
|
|
body { font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; margin: 2rem auto;
|
|
max-width: 52rem; padding: 0 1rem; }
|
|
h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 1.5rem; }
|
|
h2 { font-size: 1rem; font-weight: 600; margin: 2rem 0 .5rem; }
|
|
.quiet { opacity: .65; }
|
|
.said { opacity: .8; padding-left: 1.5rem; }
|
|
.outcome { display: inline-block; min-width: 4.5rem; }
|
|
.refused { color: #b26b00; }
|
|
.failed { color: #c0392b; }
|
|
ul { list-style: none; padding: 0; margin: 0; }
|
|
li { padding: .15rem 0; }
|
|
footer { margin-top: 3rem; opacity: .6; font-size: .85rem; }
|
|
</style>
|
|
{{if .Unreachable}}
|
|
<h1>the mesh cannot be read</h1>
|
|
<p class="failed">{{.Unreachable}}</p>
|
|
<p class="quiet">This says nothing about whether the mesh is well — only that this page could not
|
|
find out.</p>
|
|
{{else}}
|
|
<h1>{{.Machines}} machine{{if ne .Machines 1}}s{{end}}</h1>
|
|
|
|
{{if .Unresolved}}
|
|
<h2>Can everything be worked out?</h2>
|
|
<ul>
|
|
{{range .Unresolved}}
|
|
<li><span class="outcome failed">blocked</span> <strong>{{.Node}}</strong>
|
|
{{range .Said}}<div class="said">{{.}}</div>{{end}}
|
|
</li>
|
|
{{end}}
|
|
</ul>
|
|
<p class="quiet">Nothing can be sent to a machine here, and it appears in none of the lists below:
|
|
nothing was computed for it, so there is nothing it can be behind.</p>
|
|
{{end}}
|
|
{{if .Network}}
|
|
<p class="failed">The private network could not be computed: {{.Network}}</p>
|
|
{{end}}
|
|
|
|
<h2>Is anything broken?</h2>
|
|
{{if .Broken}}
|
|
<ul>
|
|
{{range .Broken}}
|
|
<li>
|
|
<span class="outcome {{.Outcome}}">{{.Outcome}}</span>
|
|
<strong>{{.Node}}</strong> <span class="quiet">{{.When}}</span>
|
|
{{range .Said}}<div class="said">{{.}}</div>{{end}}
|
|
</li>
|
|
{{end}}
|
|
</ul>
|
|
{{else}}<p class="quiet">No. Every machine is doing what it was told.</p>{{end}}
|
|
|
|
<h2>Is anything not answering?</h2>
|
|
{{if .Quiet}}
|
|
<ul>{{range .Quiet}}<li><strong>{{.Node}}</strong> <span class="quiet">{{.Heard}}</span></li>{{end}}</ul>
|
|
<p class="quiet">Not heard from is not the same as tried and could not — a machine here may be
|
|
new, switched off, or unreachable.</p>
|
|
{{else}}<p class="quiet">No. Every machine has been heard from.</p>{{end}}
|
|
|
|
<h2>Is anything out of date?</h2>
|
|
{{if .Behind}}
|
|
<ul>
|
|
{{range .Behind}}
|
|
<li><strong>{{.Module}}</strong> holds {{.Holds}}, source has {{.Source}}
|
|
{{if .Running}}<div class="said">running on {{range $i, $n := .Running}}{{if $i}}, {{end}}{{$n}}{{end}}</div>
|
|
{{else}}<div class="said quiet">assigned to nothing</div>{{end}}
|
|
</li>
|
|
{{end}}
|
|
</ul>
|
|
{{else}}<p class="quiet">No. Every module is what its source last had.</p>{{end}}
|
|
|
|
{{if .Waiting}}
|
|
<ul>
|
|
{{range .Waiting}}
|
|
<li><strong>{{.Node}}</strong>
|
|
{{if .Never}}<span class="quiet">has never been sent anything</span>
|
|
{{else}}<span class="quiet">is not running what the mesh would send it</span>{{end}}
|
|
</li>
|
|
{{end}}
|
|
</ul>
|
|
<p class="quiet">Never told is not out of date: nobody has asked that machine to be anything yet.
|
|
Both are sent by <code>push --behind</code>.</p>
|
|
{{else}}<p class="quiet">Every machine is running what the mesh would send it.</p>{{end}}
|
|
{{if .Adopted}}
|
|
<h2>Which machines are adopted?</h2>
|
|
<ul>{{range .Adopted}}<li><strong>{{.}}</strong> <span class="quiet">adopted — what was found on it is kept until each module is taken</span></li>{{end}}</ul>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
<footer>Read at {{.At}}. This page holds nothing and changes nothing.</footer>
|
|
`))
|