472 lines
16 KiB
Go
472 lines
16 KiB
Go
package view
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
)
|
|
|
|
// ListenSocket listens on the unix socket at path, in a directory of mode 0755, with mode 0666 on the socket: the
|
|
// status it carries is no secret and the socket can change nothing.
|
|
func ListenSocket(path string) (net.Listener, error) {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.Chmod(dir, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
l, err := net.Listen("unix", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.Chmod(path, 0o666); err != nil {
|
|
l.Close()
|
|
return nil, err
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
type ask struct {
|
|
Ask string `json:"ask"`
|
|
Key string `json:"key,omitempty"`
|
|
Option string `json:"option,omitempty"`
|
|
Words string `json:"words,omitempty"`
|
|
}
|
|
|
|
// ServeSocket answers {"ask":"rows"} on l, line-delimited JSON, until ctx ends, and {"ask":"answer","key":…,"option":…,"words":…}
|
|
// with the model's Answer (the one thing the view writes, issue 1297 B9). Any other ask is refused.
|
|
func ServeSocket(ctx context.Context, l net.Listener, m *Model) error {
|
|
go func() { <-ctx.Done(); l.Close() }()
|
|
for {
|
|
c, err := l.Accept()
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
go answer(c, m)
|
|
}
|
|
}
|
|
|
|
func answer(c net.Conn, m *Model) {
|
|
defer c.Close()
|
|
c.SetDeadline(time.Now().Add(10 * time.Second))
|
|
line, err := bufio.NewReader(io.LimitReader(c, 4096)).ReadBytes('\n')
|
|
if err != nil && len(line) == 0 {
|
|
return
|
|
}
|
|
var a ask
|
|
if json.Unmarshal(line, &a) != nil || (a.Ask != "rows" && a.Ask != "answer") {
|
|
json.NewEncoder(c).Encode(map[string]string{"error": `refused: this socket answers {"ask":"rows"} and {"ask":"answer"} and nothing else`})
|
|
return
|
|
}
|
|
if a.Ask == "answer" {
|
|
if err := m.Answer(a.Key, a.Option, a.Words); err != nil {
|
|
json.NewEncoder(c).Encode(map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
json.NewEncoder(c).Encode(map[string]string{"answered": a.Key})
|
|
return
|
|
}
|
|
json.NewEncoder(c).Encode(m.Snapshot())
|
|
}
|
|
|
|
// AnswerAsk asks the socket at path to write the view's answer to the open ask at key. The view's refusal is the error.
|
|
func AnswerAsk(path, key, option, words string) error {
|
|
c, err := net.DialTimeout("unix", path, 5*time.Second)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot reach the view at %s: %w", path, err)
|
|
}
|
|
defer c.Close()
|
|
c.SetDeadline(time.Now().Add(10 * time.Second))
|
|
req, _ := json.Marshal(ask{Ask: "answer", Key: key, Option: option, Words: words})
|
|
if _, err := c.Write(append(req, '\n')); err != nil {
|
|
return err
|
|
}
|
|
var reply struct {
|
|
Answered string `json:"answered"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(c).Decode(&reply); err != nil {
|
|
return fmt.Errorf("the view's reply is not an answer: %w", err)
|
|
}
|
|
if reply.Error != "" {
|
|
return errors.New(reply.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Ask asks the socket at path for the rows.
|
|
func Ask(path string) (Snapshot, error) {
|
|
c, err := net.DialTimeout("unix", path, 5*time.Second)
|
|
if err != nil {
|
|
return Snapshot{}, fmt.Errorf("cannot reach the view at %s: %w", path, err)
|
|
}
|
|
defer c.Close()
|
|
c.SetDeadline(time.Now().Add(10 * time.Second))
|
|
if _, err := c.Write([]byte(`{"ask":"rows"}` + "\n")); err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
var reply struct {
|
|
Snapshot
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(c).Decode(&reply); err != nil {
|
|
return Snapshot{}, fmt.Errorf("the view's answer is not rows: %w", err)
|
|
}
|
|
if reply.Error != "" {
|
|
return Snapshot{}, errors.New(reply.Error)
|
|
}
|
|
return reply.Snapshot, nil
|
|
}
|
|
|
|
func since(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.Local().Format("2006-01-02 15:04:05")
|
|
}
|
|
|
|
func rowText(r Row) []string {
|
|
what := r.Waits
|
|
if r.Error != "" {
|
|
what = "unreadable: " + r.Error
|
|
}
|
|
if (r.Status == StatusLeftBehind || r.Status == StatusRemoved) && what == "" {
|
|
what = r.Status
|
|
} else if r.Status == StatusLeftBehind || r.Status == StatusRemoved {
|
|
what = r.Status + "; " + what
|
|
}
|
|
return []string{r.Node, r.Module, r.Assigned, r.Have, r.Phase, r.Health, since(r.Since), r.Detail, what}
|
|
}
|
|
|
|
// groupText is one line per pull request of a group: the branch, `<repository>#<number>`, its head, its modules.
|
|
func groupText(s Snapshot) [][]string {
|
|
var out [][]string
|
|
for _, g := range s.Groups {
|
|
for i, p := range g.Pulls {
|
|
group := ""
|
|
if i == 0 {
|
|
group = g.Group
|
|
}
|
|
out = append(out, []string{group, fmt.Sprintf("%s#%d", p.Repository, p.Number), p.Head, strings.Join(p.Modules, ", ")})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fingerprintText is one line per module: the indexer's fingerprint beside the build's, or the reason it has none.
|
|
func fingerprintText(s Snapshot) [][]string {
|
|
var out [][]string
|
|
for _, f := range s.Fingerprints {
|
|
what := f.Indexer
|
|
switch f.State {
|
|
case FPDiffers:
|
|
what = "indexer " + f.Indexer + "; build " + f.Build
|
|
case FPNone:
|
|
what = f.Reason
|
|
case FPNoBuild:
|
|
what = "indexer " + f.Indexer + "; no successful trunk build recorded a fingerprint"
|
|
case FPBuildFailed, FPBuilding:
|
|
what = "indexer " + f.Indexer + "; builder: " + f.Reason
|
|
case FPNoKey:
|
|
what = "indexer " + f.Indexer + "; the builder holds no key of it"
|
|
}
|
|
out = append(out, []string{f.Module, f.State, what})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// askText is one line per open ask: its key, headline, words on one line, the pull requests it covers and how to answer.
|
|
func askText(s Snapshot) [][]string {
|
|
var out [][]string
|
|
for _, a := range s.Asks {
|
|
how := a.How
|
|
switch {
|
|
case a.Chosen != "":
|
|
how = "answered " + a.Chosen + " in the view; the teller fills it"
|
|
case len(a.Options) > 0:
|
|
how += ": " + strings.Join(a.Options, " or ")
|
|
}
|
|
out = append(out, []string{a.Key, a.Headline, strings.Join(strings.Fields(strings.ReplaceAll(a.Words, "\n", " / ")), " "), strings.Join(a.Pulls, ", "), how})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// differenceText is one line per difference: the node, the module, what the assigner wants, what the controller sends, the rule
|
|
// that explains it (or unexplained) and the assigner's words.
|
|
func differenceText(s Snapshot) [][]string {
|
|
var out [][]string
|
|
for _, d := range s.Differences {
|
|
want, sent := d.Want, d.Sent
|
|
switch {
|
|
case d.WantRemoved:
|
|
want = "removed"
|
|
case want == "":
|
|
want = "none"
|
|
}
|
|
if sent == "" {
|
|
sent = "none"
|
|
if d.SentBuild != "" {
|
|
sent = "build " + d.SentBuild
|
|
}
|
|
}
|
|
why := "unexplained"
|
|
if !d.Unexplained {
|
|
why = fmt.Sprintf("rule %d", d.Rule)
|
|
}
|
|
out = append(out, []string{d.Node, d.Module, want, sent, why, d.Words})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// backlogText is one line per language and kind: its waiting asks, the asks held by a node, the oldest waiting ask's age.
|
|
func backlogText(s Snapshot) [][]string {
|
|
var out [][]string
|
|
for _, b := range s.Backlog {
|
|
age := ""
|
|
if !b.Oldest.IsZero() {
|
|
age = (time.Duration(b.OldestSecs) * time.Second).String() + " (since " + since(b.Oldest) + ")"
|
|
}
|
|
if b.Error != "" {
|
|
age = "unreadable: " + b.Error
|
|
}
|
|
out = append(out, []string{b.Language, b.Kind, fmt.Sprint(b.Waiting), fmt.Sprint(b.Held), age})
|
|
}
|
|
return out
|
|
}
|
|
|
|
var backlogHead = []string{"LANGUAGE", "KIND", "WAITING", "HELD", "OLDEST WAITING"}
|
|
|
|
var differenceHead = []string{"NODE", "MODULE", "WANT", "SENT", "RULE", "WORDS"}
|
|
|
|
var head = []string{"NODE", "MODULE", "ASSIGNED", "HAVE", "PHASE", "HEALTH", "SINCE", "DETAIL", "WAITS"}
|
|
|
|
// Table prints the snapshot as a table.
|
|
func Table(w io.Writer, s Snapshot) {
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
row := func(cells []string) {
|
|
for i, c := range cells {
|
|
if i > 0 {
|
|
fmt.Fprint(tw, "\t")
|
|
}
|
|
fmt.Fprint(tw, c)
|
|
}
|
|
fmt.Fprintln(tw)
|
|
}
|
|
row(head)
|
|
for _, r := range s.Rows {
|
|
row(rowText(r))
|
|
}
|
|
if len(s.Nodes) > 0 {
|
|
fmt.Fprintln(tw)
|
|
row([]string{"NODE", "WINDOWS", "BUSY", "STRAYS", "SINCE"})
|
|
for _, n := range s.Nodes {
|
|
row([]string{n.Node, fmt.Sprint(n.Windows), fmt.Sprint(n.Busy), fmt.Sprint(n.Strays), since(n.Since)})
|
|
}
|
|
}
|
|
if g := groupText(s); len(g) > 0 {
|
|
fmt.Fprintln(tw)
|
|
row([]string{"CANDIDATES", "PULL REQUEST", "HEAD", "MODULES"})
|
|
for _, cells := range g {
|
|
row(cells)
|
|
}
|
|
}
|
|
if a := askText(s); len(a) > 0 {
|
|
fmt.Fprintln(tw)
|
|
row([]string{"ASKS", "HEADLINE", "WORDS", "PULL REQUESTS", "ANSWER"})
|
|
for _, cells := range a {
|
|
row(cells)
|
|
}
|
|
}
|
|
if b := backlogText(s); len(b) > 0 {
|
|
fmt.Fprintln(tw)
|
|
row([]string{"BACKLOG OF BUILD ASKS"})
|
|
row(backlogHead)
|
|
for _, cells := range b {
|
|
row(cells)
|
|
}
|
|
}
|
|
if d := differenceText(s); len(d) > 0 {
|
|
fmt.Fprintln(tw)
|
|
row([]string{"WANTS AGAINST SENDS"})
|
|
row(differenceHead)
|
|
for _, cells := range d {
|
|
row(cells)
|
|
}
|
|
}
|
|
if f := fingerprintText(s); len(f) > 0 {
|
|
fmt.Fprintln(tw)
|
|
row([]string{"MODULE", "FINGERPRINT", "INDEXER AGAINST BUILD"})
|
|
for _, cells := range f {
|
|
row(cells)
|
|
}
|
|
}
|
|
if len(s.Offline) > 0 {
|
|
fmt.Fprintln(tw)
|
|
row([]string{"OFFLINE", "NAME", "STATE", "SINCE"})
|
|
for _, o := range s.Offline {
|
|
row([]string{o.Kind, o.name(), "offline", since(o.Since)})
|
|
}
|
|
}
|
|
if len(s.Stuck) > 0 {
|
|
fmt.Fprintln(tw)
|
|
row([]string{"STUCK", "KEY", "WAITED FOR", "SINCE"})
|
|
for _, k := range s.Stuck {
|
|
row([]string{k.Bucket, k.Key, k.Waited, since(k.Since)})
|
|
}
|
|
}
|
|
if len(s.Unreadable) > 0 {
|
|
fmt.Fprintln(tw)
|
|
row([]string{"UNREADABLE", "KEY", "WHY"})
|
|
for _, u := range s.Unreadable {
|
|
row([]string{u.Bucket, u.Key, u.Error})
|
|
}
|
|
}
|
|
tw.Flush()
|
|
}
|
|
|
|
func (o OfflineRow) name() string {
|
|
if o.Kind == "part" {
|
|
return o.Part + "." + o.Instance
|
|
}
|
|
return o.Node
|
|
}
|
|
|
|
var page = template.Must(template.New("p").Parse(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="5">
|
|
<title>Delivery</title><style>body{font:14px sans-serif;margin:1em}table{border-collapse:collapse}td,th{padding:2px 10px;text-align:left}</style></head>
|
|
<body><h1>What each node runs</h1><table><tr>{{range .Head}}<th>{{.}}</th>{{end}}</tr>
|
|
{{range .Rows}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>
|
|
{{if .Groups}}<h2>CANDIDATES</h2><table><tr><th>GROUP</th><th>PULL REQUEST</th><th>HEAD</th><th>MODULES</th></tr>{{range .Groups}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
|
{{if .Asks}}<h2>ASKS</h2><table><tr><th>ASK</th><th>HEADLINE</th><th>WORDS</th><th>PULL REQUESTS</th><th>ANSWER</th></tr>{{range .Asks}}<tr>{{range .Cells}}<td>{{.}}</td>{{end}}<td>{{if .Options}}<form method="post" action="/answer"><input type="hidden" name="key" value="{{.Key}}"><input name="words" placeholder="words (optional)">{{range .Options}} <button name="option" value="{{.}}">{{.}}</button>{{end}}</form>{{end}}</td></tr>{{end}}</table>{{end}}
|
|
{{if .Backlog}}<h2>BACKLOG OF BUILD ASKS</h2><table><tr>{{range .BacklogHead}}<th>{{.}}</th>{{end}}</tr>{{range .Backlog}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
|
{{if .Differences}}<h2>WANTS AGAINST SENDS</h2><table><tr>{{range .DifferenceHead}}<th>{{.}}</th>{{end}}</tr>{{range .Differences}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
|
{{if .Fingerprints}}<h2>FINGERPRINTS</h2><table><tr><th>MODULE</th><th>STATE</th><th>INDEXER AGAINST BUILD</th></tr>{{range .Fingerprints}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
|
{{if .Offline}}<h2>OFFLINE</h2><table>{{range .Offline}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
|
{{if .Stuck}}<h2>STUCK</h2><table><tr><th>BUCKET</th><th>KEY</th><th>WAITED FOR</th><th>SINCE</th></tr>{{range .Stuck}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
|
{{if .Unreadable}}<h2>UNREADABLE</h2><table>{{range .Unreadable}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}</body></html>`))
|
|
|
|
var wantsPage = template.Must(template.New("w").Parse(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="5">
|
|
<title>Wants against sends</title><style>body{font:14px sans-serif;margin:1em}table{border-collapse:collapse}td,th{padding:2px 10px;text-align:left}</style></head>
|
|
<body><p><a href="/">What each node runs</a></p><h1>WANTS AGAINST SENDS</h1>
|
|
{{if .Differences}}<table><tr>{{range .Head}}<th>{{.}}</th>{{end}}</tr>{{range .Differences}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>
|
|
{{else}}<p>no difference: every want is what the controller sends.</p>{{end}}</body></html>`))
|
|
|
|
// Web is the read-only web view of the same rows, refreshed by the browser.
|
|
func Web(m *Model) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/answer" && r.Method == http.MethodPost {
|
|
answerForm(m, w, r)
|
|
return
|
|
}
|
|
if r.URL.Path == "/answer" {
|
|
w.Header().Set("Allow", "POST")
|
|
http.Error(w, "an answer is posted from the form of the page", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
w.Header().Set("Allow", "GET, HEAD")
|
|
http.Error(w, "this view is read-only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if r.URL.Path == "/wants" {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
wantsPage.Execute(w, map[string]any{"Head": differenceHead, "Differences": differenceText(m.Snapshot())})
|
|
return
|
|
}
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
var rows [][]string
|
|
for _, row := range m.Snapshot().Rows {
|
|
rows = append(rows, rowText(row))
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
snap := m.Snapshot()
|
|
var off, stuck, bad [][]string
|
|
for _, o := range snap.Offline {
|
|
off = append(off, []string{o.Kind, o.name(), "offline", since(o.Since)})
|
|
}
|
|
for _, k := range snap.Stuck {
|
|
stuck = append(stuck, []string{k.Bucket, k.Key, k.Waited, since(k.Since)})
|
|
}
|
|
for _, u := range snap.Unreadable {
|
|
bad = append(bad, []string{u.Bucket, u.Key, u.Error})
|
|
}
|
|
page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Asks": askPage(snap), "Fingerprints": fingerprintText(snap), "Differences": differenceText(snap), "Backlog": backlogText(snap), "BacklogHead": backlogHead, "DifferenceHead": differenceHead, "Offline": off, "Stuck": stuck, "Unreadable": bad})
|
|
})
|
|
}
|
|
|
|
// askPageRow is an open ask on the page: its cells, and the options a form offers when the view may answer it.
|
|
type askPageRow struct {
|
|
Cells []string
|
|
Key string
|
|
Options []string
|
|
}
|
|
|
|
func askPage(s Snapshot) []askPageRow {
|
|
var out []askPageRow
|
|
cells := askText(s)
|
|
for i, a := range s.Asks {
|
|
row := askPageRow{Cells: cells[i], Key: a.Key}
|
|
if a.Chosen == "" {
|
|
row.Options = a.Options
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// loopback says whether a Host header names the machine itself, as the web view listens on 127.0.0.1 only: a page of another
|
|
// origin, or a name an attacker's DNS points here, writes nothing.
|
|
func loopback(host string) bool {
|
|
h, _, err := net.SplitHostPort(host)
|
|
if err != nil {
|
|
h = host
|
|
}
|
|
return h == "127.0.0.1" || h == "localhost" || h == "[::1]" || h == "::1"
|
|
}
|
|
|
|
// answerForm is the one write the web view makes: POST /answer with key, option and words, from a form of this page.
|
|
func answerForm(m *Model, w http.ResponseWriter, r *http.Request) {
|
|
if !loopback(r.Host) {
|
|
http.Error(w, "this view answers only on its own address", http.StatusForbidden)
|
|
return
|
|
}
|
|
if o := r.Header.Get("Origin"); o != "" && o != "http://"+r.Host {
|
|
http.Error(w, "this view answers only a form of its own page", http.StatusForbidden)
|
|
return
|
|
}
|
|
if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
|
http.Error(w, "this view answers only a form of its own page", http.StatusForbidden)
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, 8192)
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := m.Answer(r.PostFormValue("key"), r.PostFormValue("option"), r.PostFormValue("words")); err != nil {
|
|
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|