diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main.go new file mode 100644 index 0000000..ad45654 --- /dev/null +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main.go @@ -0,0 +1,52 @@ +// Command mesh-delivery-cli is the operator's command for the delivery view (novox/hq ADR 0429 ยง3). It asks the +// view's local socket for the rows and prints them; it holds no credential and changes nothing. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "mesh-delivery-view/internal/view" +) + +const defaultSocket = "/run/mesh-delivery-view/cli.sock" + +func main() { + socket := os.Getenv("MESH_DELIVERY_SOCKET") + if socket == "" { + socket = defaultSocket + } + os.Exit(run(os.Args[1:], socket, os.Stdout, os.Stderr)) +} + +func run(args []string, socket string, out, errOut io.Writer) int { + fs := flag.NewFlagSet("mesh-delivery-cli", flag.ContinueOnError) + fs.SetOutput(errOut) + asJSON := fs.Bool("json", false, "print the rows as JSON") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() > 0 { + fmt.Fprintln(errOut, "mesh-delivery-cli takes no argument: it only shows") + return 2 + } + s, err := view.Ask(socket) + if err != nil { + fmt.Fprintln(errOut, "mesh-delivery-cli:", err) + return 1 + } + if *asJSON { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + if err := enc.Encode(s); err != nil { + fmt.Fprintln(errOut, "mesh-delivery-cli:", err) + return 1 + } + return 0 + } + view.Table(out, s) + return 0 +} diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go new file mode 100644 index 0000000..bb3ba04 --- /dev/null +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "mesh-delivery-view/internal/view" +) + +func serve(t *testing.T) string { + t.Helper() + m := view.New() + m.Apply(view.Entry{Bucket: view.AssignmentsBucket, Key: "a.x", Value: []byte(`{"node":"a","module":"x","build":"b1"}`)}) + dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + l, err := view.ListenSocket(filepath.Join(dir, "cli.sock")) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + go view.ServeSocket(ctx, l, m) + t.Cleanup(cancel) + return filepath.Join(dir, "cli.sock") +} + +func TestPrintsATable(t *testing.T) { // 1134/AC6 + var out, errOut bytes.Buffer + if code := run(nil, serve(t), &out, &errOut); code != 0 { + t.Fatalf("exit %d: %s", code, errOut.String()) + } + if !strings.Contains(out.String(), "MODULE") || !strings.Contains(out.String(), "no have yet") { + t.Fatalf("table = %q", out.String()) + } +} + +func TestJSONFlag(t *testing.T) { // 1134/AC6 + var out, errOut bytes.Buffer + if code := run([]string{"--json"}, serve(t), &out, &errOut); code != 0 { + t.Fatalf("exit %d: %s", code, errOut.String()) + } + var s view.Snapshot + if err := json.Unmarshal(out.Bytes(), &s); err != nil || len(s.Rows) != 1 || s.Rows[0].Module != "x" { + t.Fatalf("json = %q, %v", out.String(), err) + } +} + +func TestFailsLoudWhenTheViewIsNotThere(t *testing.T) { // 1134/AC6 + var out, errOut bytes.Buffer + if code := run(nil, filepath.Join(os.Getenv("TMPDIR"), "none.sock"), &out, &errOut); code == 0 || errOut.Len() == 0 { + t.Fatalf("exit %d, stderr %q", code, errOut.String()) + } + if code := run([]string{"--start"}, "", &out, &errOut); code != 2 { + t.Fatalf("an unknown flag must exit 2, got %d", code) + } +} diff --git a/modules/mesh-delivery-view/internal/view/dial_test.go b/modules/mesh-delivery-view/internal/view/dial_test.go new file mode 100644 index 0000000..40dfcec --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/dial_test.go @@ -0,0 +1,5 @@ +package view + +import "net" + +func dial(path string) (net.Conn, error) { return net.Dial("unix", path) } diff --git a/modules/mesh-delivery-view/internal/view/serve.go b/modules/mesh-delivery-view/internal/view/serve.go new file mode 100644 index 0000000..97d584b --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/serve.go @@ -0,0 +1,173 @@ +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(`
+| {{.}} | {{end}}
|---|
| {{.}} | {{end}}