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, `#`, 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(` Delivery

What each node runs

{{range .Head}}{{end}} {{range .Rows}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{if .Groups}}

CANDIDATES

{{range .Groups}}{{range .}}{{end}}{{end}}
GROUPPULL REQUESTHEADMODULES
{{.}}
{{end}} {{if .Asks}}

ASKS

{{range .Asks}}{{range .Cells}}{{end}}{{end}}
ASKHEADLINEWORDSPULL REQUESTSANSWER
{{.}}{{if .Options}}
{{range .Options}} {{end}}
{{end}}
{{end}} {{if .Backlog}}

BACKLOG OF BUILD ASKS

{{range .BacklogHead}}{{end}}{{range .Backlog}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{end}} {{if .Differences}}

WANTS AGAINST SENDS

{{range .DifferenceHead}}{{end}}{{range .Differences}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{end}} {{if .Fingerprints}}

FINGERPRINTS

{{range .Fingerprints}}{{range .}}{{end}}{{end}}
MODULESTATEINDEXER AGAINST BUILD
{{.}}
{{end}} {{if .Offline}}

OFFLINE

{{range .Offline}}{{range .}}{{end}}{{end}}
{{.}}
{{end}} {{if .Stuck}}

STUCK

{{range .Stuck}}{{range .}}{{end}}{{end}}
BUCKETKEYWAITED FORSINCE
{{.}}
{{end}} {{if .Unreadable}}

UNREADABLE

{{range .Unreadable}}{{range .}}{{end}}{{end}}
{{.}}
{{end}}`)) var wantsPage = template.Must(template.New("w").Parse(` Wants against sends

What each node runs

WANTS AGAINST SENDS

{{if .Differences}}{{range .Head}}{{end}}{{range .Differences}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{else}}

no difference: every want is what the controller sends.

{{end}}`)) // 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) }