diff --git a/cli/root_test.go b/cli/root_test.go index b0da45df78..6361836802 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -107,6 +107,10 @@ func TestCommandHelp(t *testing.T) { Name: "coder exp sync timeline --help", Cmd: []string{"exp", "sync", "timeline", "--help"}, }, + clitest.CommandHelpCase{ + Name: "coder exp sync graph --help", + Cmd: []string{"exp", "sync", "graph", "--help"}, + }, )) } diff --git a/cli/sync.go b/cli/sync.go index 08e68a599f..b3a6ecb236 100644 --- a/cli/sync.go +++ b/cli/sync.go @@ -22,6 +22,7 @@ func (r *RootCmd) syncCommand() *serpent.Command { r.syncStatus(&socketPath), r.syncList(&socketPath), r.syncTimeline(&socketPath), + r.syncGraph(&socketPath), }, Options: serpent.OptionSet{ { diff --git a/cli/sync_graph.go b/cli/sync_graph.go new file mode 100644 index 0000000000..5bfd881d50 --- /dev/null +++ b/cli/sync_graph.go @@ -0,0 +1,112 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/agent/agentsocket" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/serpent" +) + +func (*RootCmd) syncGraph(socketPath *string) *serpent.Command { + formatter := cliui.NewOutputFormatter( + // text/ascii is the default: a colorized summary of units and edges. + cliui.ChangeFormatterData( + cliui.TextFormat(), + func(data any) (any, error) { + g, err := syncGraphFromData(data) + if err != nil { + return nil, err + } + return renderSyncGraphASCII(g), nil + }), + cliui.JSONFormat(), + cliui.ChangeFormatterData( + cliui.TableFormat([]syncGraphTableRow{}, []string{"unit", "status", "ready", "depends on"}), + func(data any) (any, error) { + g, err := syncGraphFromData(data) + if err != nil { + return nil, err + } + return syncGraphTableRows(g), nil + }), + dotFormat{}, + ) + + cmd := &serpent.Command{ + Use: "graph", + Short: "Show the unit dependency graph", + Long: "Render the dependency graph of all units. Vertices are units and edges are declared dependencies. The default output is a colorized ASCII summary; use --output json for the raw graph, --output table for a flat listing, or --output dot for Graphviz DOT that can be piped to `dot`.", + Handler: func(i *serpent.Invocation) error { + ctx := i.Context() + + opts := []agentsocket.Option{} + if *socketPath != "" { + opts = append(opts, agentsocket.WithPath(*socketPath)) + } + + client, err := agentsocket.NewClient(ctx, opts...) + if err != nil { + return xerrors.Errorf("connect to agent socket: %w", err) + } + defer client.Close() + + events, err := client.SyncTimeline(ctx) + if err != nil { + // Older agents do not implement the SyncTimeline RPC. + if strings.Contains(err.Error(), "unknown rpc") { + return xerrors.New("agent does not support graph") + } + return xerrors.Errorf("get graph failed: %w", err) + } + + graph := buildSyncGraph(unitEventsFromSocket(events)) + + out, err := formatter.Format(ctx, graph) + if err != nil { + return xerrors.Errorf("format graph: %w", err) + } + + _, _ = fmt.Fprintln(i.Stdout, out) + + return nil + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + +// syncGraphFromData recovers a syncGraph from the value passed through the +// output formatter. +func syncGraphFromData(data any) (syncGraph, error) { + g, ok := data.(syncGraph) + if !ok { + return syncGraph{}, xerrors.Errorf("expected syncGraph, got %T", data) + } + return g, nil +} + +// dotFormat is a custom output format that renders the graph in Graphviz +// DOT. It cannot reuse the text formatter via ChangeFormatterData because +// DataChangeFormat delegates ID() to the wrapped format, which would +// collide with the "text" format ID. +type dotFormat struct{} + +var _ cliui.OutputFormat = dotFormat{} + +func (dotFormat) ID() string { return "dot" } + +func (dotFormat) AttachOptions(_ *serpent.OptionSet) {} + +func (dotFormat) Format(_ context.Context, data any) (string, error) { + g, err := syncGraphFromData(data) + if err != nil { + return "", err + } + return renderSyncGraphDOT(g), nil +} diff --git a/cli/sync_graph_internal_test.go b/cli/sync_graph_internal_test.go new file mode 100644 index 0000000000..f0831b22cd --- /dev/null +++ b/cli/sync_graph_internal_test.go @@ -0,0 +1,185 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/agent/unit" +) + +// statusChange builds a status_change event. +func statusChange(seq uint64, u unit.ID, from, to unit.Status) unit.Event { + return unit.Event{ + Seq: seq, + Kind: unit.EventStatusChange, + Unit: u, + From: from, + To: to, + } +} + +// dependencyAdded builds a dependency_added event. +func dependencyAdded(seq uint64, u, dependsOn unit.ID, required unit.Status) unit.Event { + return unit.Event{ + Seq: seq, + Kind: unit.EventDependencyAdded, + Unit: u, + DependsOn: dependsOn, + RequiredStatus: required, + } +} + +func TestBuildSyncGraph(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + t.Parallel() + g := buildSyncGraph(nil) + require.Empty(t, g.Nodes) + require.Empty(t, g.Edges) + }) + + t.Run("nodes_ordered_by_first_appearance", func(t *testing.T) { + t.Parallel() + events := []unit.Event{ + statusChange(1, "b", unit.StatusNotRegistered, unit.StatusPending), + statusChange(2, "a", unit.StatusNotRegistered, unit.StatusPending), + statusChange(3, "b", unit.StatusPending, unit.StatusStarted), + } + g := buildSyncGraph(events) + require.Len(t, g.Nodes, 2) + require.Equal(t, "b", g.Nodes[0].Unit) + require.Equal(t, "a", g.Nodes[1].Unit) + require.Equal(t, string(unit.StatusStarted), g.Nodes[0].Status) + }) + + t.Run("dependency_added_creates_both_nodes", func(t *testing.T) { + t.Parallel() + events := []unit.Event{ + dependencyAdded(1, "app", "db", unit.StatusComplete), + } + g := buildSyncGraph(events) + require.Len(t, g.Nodes, 2) + require.Equal(t, "app", g.Nodes[0].Unit) + require.Equal(t, "db", g.Nodes[1].Unit) + require.Len(t, g.Edges, 1) + require.Equal(t, "app", g.Edges[0].From) + require.Equal(t, "db", g.Edges[0].To) + require.False(t, g.Edges[0].Satisfied) + }) + + t.Run("edge_satisfied_when_status_matches", func(t *testing.T) { + t.Parallel() + events := []unit.Event{ + statusChange(1, "app", unit.StatusNotRegistered, unit.StatusPending), + dependencyAdded(2, "app", "db", unit.StatusComplete), + statusChange(3, "db", unit.StatusNotRegistered, unit.StatusPending), + statusChange(4, "db", unit.StatusPending, unit.StatusStarted), + statusChange(5, "db", unit.StatusStarted, unit.StatusComplete), + } + g := buildSyncGraph(events) + require.Len(t, g.Edges, 1) + require.True(t, g.Edges[0].Satisfied) + require.Equal(t, string(unit.StatusComplete), g.Edges[0].CurrentStatus) + + // app is pending with a satisfied dependency, so it is ready. + app := nodeByUnit(t, g, "app") + require.True(t, app.Ready) + }) + + t.Run("node_not_ready_with_unsatisfied_edge", func(t *testing.T) { + t.Parallel() + events := []unit.Event{ + statusChange(1, "app", unit.StatusNotRegistered, unit.StatusPending), + dependencyAdded(2, "app", "db", unit.StatusComplete), + statusChange(3, "db", unit.StatusNotRegistered, unit.StatusPending), + } + g := buildSyncGraph(events) + app := nodeByUnit(t, g, "app") + require.False(t, app.Ready) + }) + + t.Run("started_node_not_ready", func(t *testing.T) { + t.Parallel() + events := []unit.Event{ + statusChange(1, "solo", unit.StatusNotRegistered, unit.StatusPending), + statusChange(2, "solo", unit.StatusPending, unit.StatusStarted), + } + g := buildSyncGraph(events) + solo := nodeByUnit(t, g, "solo") + require.False(t, solo.Ready) + }) +} + +func nodeByUnit(t *testing.T, g syncGraph, name string) syncGraphNode { + t.Helper() + for _, n := range g.Nodes { + if n.Unit == name { + return n + } + } + t.Fatalf("node %q not found", name) + return syncGraphNode{} +} + +func TestRenderSyncGraphASCII(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + t.Parallel() + require.Equal(t, "No units found", renderSyncGraphASCII(syncGraph{})) + }) + + t.Run("units_and_edges", func(t *testing.T) { + t.Parallel() + events := []unit.Event{ + statusChange(1, "app", unit.StatusNotRegistered, unit.StatusPending), + dependencyAdded(2, "app", "db", unit.StatusComplete), + statusChange(3, "db", unit.StatusNotRegistered, unit.StatusPending), + statusChange(4, "db", unit.StatusPending, unit.StatusComplete), + } + out := renderSyncGraphASCII(buildSyncGraph(events)) + require.Contains(t, out, "Units:") + require.Contains(t, out, "Dependencies:") + require.Contains(t, out, "app → db") + require.Contains(t, out, "requires completed") + // app is pending with a satisfied dependency. + require.Contains(t, out, "(ready)") + // Satisfied edge is marked with a check. + require.Contains(t, out, "✓") + }) +} + +func TestRenderSyncGraphDOT(t *testing.T) { + t.Parallel() + + events := []unit.Event{ + statusChange(1, "app", unit.StatusNotRegistered, unit.StatusPending), + dependencyAdded(2, "app", "db", unit.StatusComplete), + statusChange(3, "db", unit.StatusNotRegistered, unit.StatusPending), + statusChange(4, "db", unit.StatusPending, unit.StatusComplete), + } + out := renderSyncGraphDOT(buildSyncGraph(events)) + require.True(t, strings.HasPrefix(out, "digraph sync {")) + require.Contains(t, out, `"app" -> "db"`) + require.Contains(t, out, "fillcolor=\"palegreen\"") // db completed + require.Contains(t, out, "style=solid") // satisfied edge + require.True(t, strings.HasSuffix(out, "}")) +} + +func TestSyncGraphTableRows(t *testing.T) { + t.Parallel() + + events := []unit.Event{ + statusChange(1, "app", unit.StatusNotRegistered, unit.StatusPending), + dependencyAdded(2, "app", "db", unit.StatusComplete), + statusChange(3, "db", unit.StatusNotRegistered, unit.StatusPending), + } + rows := syncGraphTableRows(buildSyncGraph(events)) + require.Len(t, rows, 2) + require.Equal(t, "app", rows[0].Unit) + require.Contains(t, rows[0].DependsOn, "db (completed)") + require.Empty(t, rows[1].DependsOn) +} diff --git a/cli/sync_graph_render.go b/cli/sync_graph_render.go new file mode 100644 index 0000000000..53aac1355a --- /dev/null +++ b/cli/sync_graph_render.go @@ -0,0 +1,245 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/coder/coder/v2/agent/unit" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/pretty" +) + +// syncGraphNode is a single unit (vertex) in the dependency graph. +type syncGraphNode struct { + Unit string `json:"unit"` + Status string `json:"status"` + Ready bool `json:"ready"` +} + +// syncGraphEdge is a declared dependency (edge) from one unit to another. +// Satisfied reports whether the dependency's current status already equals +// the required status. +type syncGraphEdge struct { + From string `json:"from"` + To string `json:"to"` + RequiredStatus string `json:"required_status"` + CurrentStatus string `json:"current_status"` + Satisfied bool `json:"satisfied"` +} + +// syncGraph is the whole dependency graph: units and the edges between +// them. +type syncGraph struct { + Nodes []syncGraphNode `json:"nodes"` + Edges []syncGraphEdge `json:"edges"` +} + +// syncGraphTableRow is one row of the table output: a unit with its +// dependencies collapsed into a single column. +type syncGraphTableRow struct { + Unit string `table:"unit,default_sort"` + Status string `table:"status"` + Ready bool `table:"ready"` + DependsOn string `table:"depends on"` +} + +// buildSyncGraph folds the unit event log into a dependency graph. Nodes +// are ordered by first appearance. An edge is satisfied when the current +// status of its target equals the required status. A node is ready when it +// is pending and all of its edges are satisfied, mirroring the Manager's +// readiness semantics. +func buildSyncGraph(events []unit.Event) syncGraph { + statuses := make(map[unit.ID]unit.Status) + order := make([]unit.ID, 0) + seen := make(map[unit.ID]bool) + ensure := func(u unit.ID) { + if !seen[u] { + seen[u] = true + order = append(order, u) + } + } + + type rawEdge struct { + from unit.ID + to unit.ID + required unit.Status + } + var rawEdges []rawEdge + + for _, ev := range events { + switch ev.Kind { + case unit.EventStatusChange: + ensure(ev.Unit) + statuses[ev.Unit] = ev.To + case unit.EventDependencyAdded: + ensure(ev.Unit) + ensure(ev.DependsOn) + rawEdges = append(rawEdges, rawEdge{ + from: ev.Unit, + to: ev.DependsOn, + required: ev.RequiredStatus, + }) + } + } + + // A node starts ready only if it is pending; any unsatisfied edge then + // makes it not ready. + ready := make(map[unit.ID]bool, len(order)) + for _, u := range order { + ready[u] = statuses[u] == unit.StatusPending + } + + edges := make([]syncGraphEdge, 0, len(rawEdges)) + for _, re := range rawEdges { + current := statuses[re.to] + satisfied := current == re.required + if !satisfied { + ready[re.from] = false + } + edges = append(edges, syncGraphEdge{ + From: string(re.from), + To: string(re.to), + RequiredStatus: string(re.required), + CurrentStatus: string(current), + Satisfied: satisfied, + }) + } + + nodes := make([]syncGraphNode, 0, len(order)) + for _, u := range order { + nodes = append(nodes, syncGraphNode{ + Unit: string(u), + Status: string(statuses[u]), + Ready: ready[u], + }) + } + + return syncGraph{Nodes: nodes, Edges: edges} +} + +// syncGraphTableRows flattens the graph into table rows, one per unit, +// with dependencies joined into the "depends on" column. +func syncGraphTableRows(g syncGraph) []syncGraphTableRow { + deps := make(map[string][]string, len(g.Nodes)) + for _, e := range g.Edges { + mark := "✗" + if e.Satisfied { + mark = "✓" + } + deps[e.From] = append(deps[e.From], fmt.Sprintf("%s (%s) %s", e.To, e.RequiredStatus, mark)) + } + + rows := make([]syncGraphTableRow, 0, len(g.Nodes)) + for _, n := range g.Nodes { + rows = append(rows, syncGraphTableRow{ + Unit: n.Unit, + Status: n.Status, + Ready: n.Ready, + DependsOn: strings.Join(deps[n.Unit], ", "), + }) + } + return rows +} + +// statusStyle returns the color style for a unit status. Completed units +// are green, running (started) units are yellow, and everything else +// (pending, unregistered) is dim gray. cliui.Color yields a colorless +// profile in tests and on non-TTY output, so this is safe for goldens. +func statusStyle(status string) pretty.Style { + switch unit.Status(status) { + case unit.StatusComplete: + return pretty.Style{pretty.FgColor(cliui.Color("2"))} + case unit.StatusStarted: + return pretty.Style{pretty.FgColor(cliui.Color("3"))} + default: + return pretty.Style{pretty.FgColor(cliui.Color("8"))} + } +} + +// renderSyncGraphASCII renders the dependency graph as a colorized text +// summary: a units section followed by a dependencies section. The status +// marker (●) and status label are colored by state. +func renderSyncGraphASCII(g syncGraph) string { + if len(g.Nodes) == 0 { + return "No units found" + } + + var sb strings.Builder + + unitWidth := 0 + for _, n := range g.Nodes { + if len(n.Unit) > unitWidth { + unitWidth = len(n.Unit) + } + } + + _, _ = sb.WriteString("Units:\n") + for _, n := range g.Nodes { + style := statusStyle(n.Status) + marker := pretty.Sprint(style, "●") + status := n.Status + if status == "" { + status = "unregistered" + } + status = pretty.Sprint(style, status) + readiness := "" + if n.Ready { + readiness = " (ready)" + } + _, _ = sb.WriteString(fmt.Sprintf(" %s %-*s %s%s\n", marker, unitWidth, n.Unit, status, readiness)) + } + + if len(g.Edges) > 0 { + _, _ = sb.WriteString("\nDependencies:\n") + for _, e := range g.Edges { + mark := pretty.Sprint(statusStyle(""), "✗") + if e.Satisfied { + mark = pretty.Sprint(statusStyle(string(unit.StatusComplete)), "✓") + } + _, _ = sb.WriteString(fmt.Sprintf(" %s → %s (requires %s) %s\n", e.From, e.To, e.RequiredStatus, mark)) + } + } + + return strings.TrimRight(sb.String(), "\n") +} + +// renderSyncGraphDOT renders the dependency graph in Graphviz DOT format. +// Nodes are colored by status using named Graphviz fill colors so the +// output can be piped to `dot` for visualization. +func renderSyncGraphDOT(g syncGraph) string { + var sb strings.Builder + _, _ = sb.WriteString("digraph sync {\n") + _, _ = sb.WriteString(" rankdir=LR;\n") + _, _ = sb.WriteString(" node [shape=box style=filled];\n") + + for _, n := range g.Nodes { + label := n.Unit + if n.Ready { + label += " (ready)" + } + _, _ = sb.WriteString(fmt.Sprintf(" %q [label=%q fillcolor=%q];\n", n.Unit, label, dotFillColor(n.Status))) + } + + for _, e := range g.Edges { + style := "dashed" + if e.Satisfied { + style = "solid" + } + _, _ = sb.WriteString(fmt.Sprintf(" %q -> %q [label=%q style=%s];\n", e.From, e.To, e.RequiredStatus, style)) + } + + _, _ = sb.WriteString("}") + return sb.String() +} + +// dotFillColor maps a unit status to a Graphviz named color. +func dotFillColor(status string) string { + switch unit.Status(status) { + case unit.StatusComplete: + return "palegreen" + case unit.StatusStarted: + return "lightyellow" + default: + return "lightgray" + } +} diff --git a/cli/sync_test.go b/cli/sync_test.go index dcca986301..97eb7e65bb 100644 --- a/cli/sync_test.go +++ b/cli/sync_test.go @@ -619,4 +619,122 @@ func TestSyncCommands_Golden(t *testing.T) { clitest.TestGoldenFile(t, "TestSyncCommands_Golden/list_json_format", outBuf.Bytes(), nil) }) + + t.Run("graph_no_units", func(t *testing.T) { + t.Parallel() + path, cleanup := setupSocketServer(t) + defer cleanup() + + ctx := testutil.Context(t, testutil.WaitShort) + + var outBuf bytes.Buffer + inv, _ := clitest.New(t, "exp", "sync", "graph", "--socket-path", path) + inv.Stdout = &outBuf + inv.Stderr = &outBuf + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, "TestSyncCommands_Golden/graph_no_units", outBuf.Bytes(), nil) + }) + + t.Run("graph_ascii", func(t *testing.T) { + t.Parallel() + path, cleanup := setupSocketServer(t) + defer cleanup() + + ctx := testutil.Context(t, testutil.WaitShort) + setupGraphScenario(ctx, t, path) + + var outBuf bytes.Buffer + inv, _ := clitest.New(t, "exp", "sync", "graph", "--socket-path", path) + inv.Stdout = &outBuf + inv.Stderr = &outBuf + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, "TestSyncCommands_Golden/graph_ascii", outBuf.Bytes(), nil) + }) + + t.Run("graph_json_format", func(t *testing.T) { + t.Parallel() + path, cleanup := setupSocketServer(t) + defer cleanup() + + ctx := testutil.Context(t, testutil.WaitShort) + setupGraphScenario(ctx, t, path) + + var outBuf bytes.Buffer + inv, _ := clitest.New(t, "exp", "sync", "graph", "--output", "json", "--socket-path", path) + inv.Stdout = &outBuf + inv.Stderr = &outBuf + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, "TestSyncCommands_Golden/graph_json_format", outBuf.Bytes(), nil) + }) + + t.Run("graph_table_format", func(t *testing.T) { + t.Parallel() + path, cleanup := setupSocketServer(t) + defer cleanup() + + ctx := testutil.Context(t, testutil.WaitShort) + setupGraphScenario(ctx, t, path) + + var outBuf bytes.Buffer + inv, _ := clitest.New(t, "exp", "sync", "graph", "--output", "table", "--socket-path", path) + inv.Stdout = &outBuf + inv.Stderr = &outBuf + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, "TestSyncCommands_Golden/graph_table_format", outBuf.Bytes(), nil) + }) + + t.Run("graph_dot_format", func(t *testing.T) { + t.Parallel() + path, cleanup := setupSocketServer(t) + defer cleanup() + + ctx := testutil.Context(t, testutil.WaitShort) + setupGraphScenario(ctx, t, path) + + var outBuf bytes.Buffer + inv, _ := clitest.New(t, "exp", "sync", "graph", "--output", "dot", "--socket-path", path) + inv.Stdout = &outBuf + inv.Stderr = &outBuf + + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, "TestSyncCommands_Golden/graph_dot_format", outBuf.Bytes(), nil) + }) +} + +// setupGraphScenario drives a deterministic dependency graph into the +// agent for graph golden tests. It produces units in every state +// (completed, started, pending) with both satisfied and unsatisfied +// edges, and a pending unit that is ready: +// +// - db: completed +// - migrate: started +// - app: pending, depends on db (satisfied) and migrate (unsatisfied) +// - cache: pending, depends on db (satisfied) so it is ready +func setupGraphScenario(ctx context.Context, t *testing.T, path string) { + t.Helper() + + client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(path)) + require.NoError(t, err) + defer client.Close() + + require.NoError(t, client.SyncWant(ctx, "app", "db")) + require.NoError(t, client.SyncWant(ctx, "app", "migrate")) + require.NoError(t, client.SyncWant(ctx, "cache", "db")) + require.NoError(t, client.SyncStart(ctx, "db")) + require.NoError(t, client.SyncComplete(ctx, "db")) + require.NoError(t, client.SyncStart(ctx, "migrate")) } diff --git a/cli/testdata/TestSyncCommands_Golden/graph_ascii.golden b/cli/testdata/TestSyncCommands_Golden/graph_ascii.golden new file mode 100644 index 0000000000..556e00895b --- /dev/null +++ b/cli/testdata/TestSyncCommands_Golden/graph_ascii.golden @@ -0,0 +1,10 @@ +Units: + ● app pending + ● db completed + ● migrate started + ● cache pending (ready) + +Dependencies: + app → db (requires completed) ✓ + app → migrate (requires completed) ✗ + cache → db (requires completed) ✓ diff --git a/cli/testdata/TestSyncCommands_Golden/graph_dot_format.golden b/cli/testdata/TestSyncCommands_Golden/graph_dot_format.golden new file mode 100644 index 0000000000..a7642b1cf5 --- /dev/null +++ b/cli/testdata/TestSyncCommands_Golden/graph_dot_format.golden @@ -0,0 +1,11 @@ +digraph sync { + rankdir=LR; + node [shape=box style=filled]; + "app" [label="app" fillcolor="lightgray"]; + "db" [label="db" fillcolor="palegreen"]; + "migrate" [label="migrate" fillcolor="lightyellow"]; + "cache" [label="cache (ready)" fillcolor="lightgray"]; + "app" -> "db" [label="completed" style=solid]; + "app" -> "migrate" [label="completed" style=dashed]; + "cache" -> "db" [label="completed" style=solid]; +} diff --git a/cli/testdata/TestSyncCommands_Golden/graph_json_format.golden b/cli/testdata/TestSyncCommands_Golden/graph_json_format.golden new file mode 100644 index 0000000000..bab5981e45 --- /dev/null +++ b/cli/testdata/TestSyncCommands_Golden/graph_json_format.golden @@ -0,0 +1,47 @@ +{ + "nodes": [ + { + "unit": "app", + "status": "pending", + "ready": false + }, + { + "unit": "db", + "status": "completed", + "ready": false + }, + { + "unit": "migrate", + "status": "started", + "ready": false + }, + { + "unit": "cache", + "status": "pending", + "ready": true + } + ], + "edges": [ + { + "from": "app", + "to": "db", + "required_status": "completed", + "current_status": "completed", + "satisfied": true + }, + { + "from": "app", + "to": "migrate", + "required_status": "completed", + "current_status": "started", + "satisfied": false + }, + { + "from": "cache", + "to": "db", + "required_status": "completed", + "current_status": "completed", + "satisfied": true + } + ] +} diff --git a/cli/testdata/TestSyncCommands_Golden/graph_no_units.golden b/cli/testdata/TestSyncCommands_Golden/graph_no_units.golden new file mode 100644 index 0000000000..fedf3c76a1 --- /dev/null +++ b/cli/testdata/TestSyncCommands_Golden/graph_no_units.golden @@ -0,0 +1 @@ +No units found diff --git a/cli/testdata/TestSyncCommands_Golden/graph_table_format.golden b/cli/testdata/TestSyncCommands_Golden/graph_table_format.golden new file mode 100644 index 0000000000..ebd6e22c67 --- /dev/null +++ b/cli/testdata/TestSyncCommands_Golden/graph_table_format.golden @@ -0,0 +1,5 @@ +UNIT STATUS READY DEPENDS ON +app pending false db (completed) ✓, migrate (completed) ✗ +cache pending true db (completed) ✓ +db completed false +migrate started false diff --git a/cli/testdata/coder_exp_sync_--help.golden b/cli/testdata/coder_exp_sync_--help.golden index dec70b5298..8c88d3289b 100644 --- a/cli/testdata/coder_exp_sync_--help.golden +++ b/cli/testdata/coder_exp_sync_--help.golden @@ -13,6 +13,7 @@ USAGE: SUBCOMMANDS: complete Mark a unit as complete + graph Show the unit dependency graph list List all registered units and their statuses ping Test agent socket connectivity and health start Wait until all unit dependencies are satisfied diff --git a/cli/testdata/coder_exp_sync_graph_--help.golden b/cli/testdata/coder_exp_sync_graph_--help.golden new file mode 100644 index 0000000000..61300e827a --- /dev/null +++ b/cli/testdata/coder_exp_sync_graph_--help.golden @@ -0,0 +1,21 @@ +coder v0.0.0-devel + +USAGE: + coder exp sync graph [flags] + + Show the unit dependency graph + + Render the dependency graph of all units. Vertices are units and edges are + declared dependencies. The default output is a colorized ASCII summary; use + --output json for the raw graph, --output table for a flat listing, or + --output dot for Graphviz DOT that can be piped to `dot`. + +OPTIONS: + -c, --column [unit|status|ready|depends on] (default: unit,status,ready,depends on) + Columns to display in table output. + + -o, --output text|json|table|dot (default: text) + Output format. + +——— +Run `coder --help` for a list of global options.