The command holds no credential: it asks the local socket, which answers only the rows ask.
174 lines
4.6 KiB
Go
174 lines
4.6 KiB
Go
package view
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"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"`
|
|
}
|
|
|
|
// ServeSocket answers {"ask":"rows"} on l, line-delimited JSON, until ctx ends. 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" {
|
|
json.NewEncoder(c).Encode(map[string]string{"error": `refused: this socket answers {"ask":"rows"} and nothing else`})
|
|
return
|
|
}
|
|
json.NewEncoder(c).Encode(m.Snapshot())
|
|
}
|
|
|
|
// 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 && what == "" {
|
|
what = r.Status
|
|
} else if r.Status == StatusLeftBehind {
|
|
what = r.Status + "; " + what
|
|
}
|
|
return []string{r.Node, r.Module, r.Assigned, r.Have, r.Phase, r.Health, since(r.Since), r.Detail, what}
|
|
}
|
|
|
|
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)})
|
|
}
|
|
}
|
|
tw.Flush()
|
|
}
|
|
|
|
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></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.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 != "/" {
|
|
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")
|
|
page.Execute(w, map[string]any{"Head": head, "Rows": rows})
|
|
})
|
|
}
|