Skip to content

Commit f5d917a

Browse files
authored
feat(cli): add describe command for detailed devnet status (#25)
* feat(api): add Condition and Event types to DevnetStatus * feat(types): add condition and event type constants * feat(types): add Event type with ring buffer * feat(types): add condition helper functions * feat(types): add Events field to DevnetStatus * feat(server): convert Conditions and Events to proto * feat(controller): wire up conditions in devnet reconciliation * feat(cli): add describe command with detailed status output * refactor(cli): replace status command with describe * test(cli): add integration test for describe command * fix: gofmt formatting in convert.go and tx_e2e_test.go
1 parent 7f0fd8e commit f5d917a

17 files changed

Lines changed: 1692 additions & 371 deletions

api/proto/gen/v1/devnet.pb.go

Lines changed: 516 additions & 324 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/proto/v1/devnet.proto

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ message DevnetStatus {
5959
string sdk_version = 5;
6060
google.protobuf.Timestamp last_health_check = 6;
6161
string message = 7;
62+
repeated Condition conditions = 8; // Detailed status conditions
63+
repeated Event events = 9; // Recent events (last 10)
64+
}
65+
66+
// Condition represents a status condition of a resource.
67+
message Condition {
68+
string type = 1; // Type of condition (Ready, Progressing, etc.)
69+
string status = 2; // True, False, Unknown
70+
google.protobuf.Timestamp last_transition_time = 3;
71+
string reason = 4; // CamelCase reason code
72+
string message = 5; // Human-readable message
73+
}
74+
75+
// Event represents a significant occurrence during resource lifecycle.
76+
message Event {
77+
google.protobuf.Timestamp timestamp = 1;
78+
string type = 2; // Normal, Warning
79+
string reason = 3; // CamelCase reason code
80+
string message = 4; // Human-readable message
81+
string component = 5; // Source component (controller, runtime, etc.)
6282
}
6383

6484
// DevnetService request/response messages
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//go:build integration
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"testing"
8+
"time"
9+
10+
"github.com/altuslabsxyz/devnet-builder/internal/client"
11+
)
12+
13+
func TestDescribeIntegration(t *testing.T) {
14+
c, err := client.New()
15+
if err != nil {
16+
t.Skip("daemon not running, skipping integration test")
17+
}
18+
defer c.Close()
19+
20+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
21+
defer cancel()
22+
23+
devnets, err := c.ListDevnets(ctx)
24+
if err != nil {
25+
t.Fatalf("failed to list devnets: %v", err)
26+
}
27+
28+
if len(devnets) == 0 {
29+
t.Skip("no devnets found, skipping")
30+
}
31+
32+
// Get first devnet
33+
devnet, err := c.GetDevnet(ctx, devnets[0].Metadata.Name)
34+
if err != nil {
35+
t.Fatalf("failed to get devnet: %v", err)
36+
}
37+
38+
// Verify we have conditions (after the controller changes)
39+
if len(devnet.Status.Conditions) == 0 {
40+
t.Log("warning: no conditions found on devnet")
41+
}
42+
}

cmd/dvb/describe_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
"time"
7+
8+
v1 "github.com/altuslabsxyz/devnet-builder/api/proto/gen/v1"
9+
"google.golang.org/protobuf/types/known/timestamppb"
10+
)
11+
12+
func TestFormatDescribeOutput(t *testing.T) {
13+
devnet := &v1.Devnet{
14+
Metadata: &v1.DevnetMetadata{
15+
Name: "test-devnet",
16+
CreatedAt: timestamppb.New(time.Now().Add(-1 * time.Hour)),
17+
},
18+
Spec: &v1.DevnetSpec{
19+
Plugin: "stable",
20+
Mode: "docker",
21+
Validators: 4,
22+
},
23+
Status: &v1.DevnetStatus{
24+
Phase: "Provisioning",
25+
Nodes: 4,
26+
ReadyNodes: 2,
27+
Message: "Creating nodes",
28+
Conditions: []*v1.Condition{
29+
{
30+
Type: "Ready",
31+
Status: "False",
32+
Reason: "NodesNotReady",
33+
Message: "2/4 nodes ready",
34+
LastTransitionTime: timestamppb.Now(),
35+
},
36+
{
37+
Type: "Progressing",
38+
Status: "True",
39+
Reason: "CreatingNodes",
40+
Message: "Creating validator nodes",
41+
LastTransitionTime: timestamppb.Now(),
42+
},
43+
},
44+
Events: []*v1.Event{
45+
{
46+
Timestamp: timestamppb.Now(),
47+
Type: "Normal",
48+
Reason: "Provisioning",
49+
Message: "Started provisioning",
50+
Component: "devnet-controller",
51+
},
52+
},
53+
},
54+
}
55+
56+
var buf bytes.Buffer
57+
formatDescribeOutput(&buf, devnet, nil)
58+
output := buf.String()
59+
60+
// Check key sections exist
61+
if !bytes.Contains([]byte(output), []byte("Name:")) {
62+
t.Error("missing Name field")
63+
}
64+
if !bytes.Contains([]byte(output), []byte("Conditions:")) {
65+
t.Error("missing Conditions section")
66+
}
67+
if !bytes.Contains([]byte(output), []byte("Events:")) {
68+
t.Error("missing Events section")
69+
}
70+
if !bytes.Contains([]byte(output), []byte("NodesNotReady")) {
71+
t.Error("missing condition reason")
72+
}
73+
}

cmd/dvb/main.go

Lines changed: 155 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ package main
33

44
import (
55
"fmt"
6+
"io"
67
"os"
78
"text/tabwriter"
9+
"time"
810

911
v1 "github.com/altuslabsxyz/devnet-builder/api/proto/gen/v1"
1012
"github.com/altuslabsxyz/devnet-builder/internal/client"
1113
"github.com/altuslabsxyz/devnet-builder/internal/version"
1214
"github.com/fatih/color"
1315
"github.com/spf13/cobra"
16+
"gopkg.in/yaml.v3"
1417
)
1518

1619
var (
@@ -65,7 +68,7 @@ func main() {
6568
newDiffCmd(),
6669
newDeployCmd(), // deprecated
6770
newListCmd(),
68-
newStatusCmd(),
71+
newDescribeCmd(),
6972
newStartCmd(),
7073
newStopCmd(),
7174
newDestroyCmd(), // deprecated
@@ -241,29 +244,6 @@ func newListCmd() *cobra.Command {
241244
}
242245
}
243246

244-
func newStatusCmd() *cobra.Command {
245-
return &cobra.Command{
246-
Use: "status [devnet]",
247-
Short: "Show devnet status",
248-
Args: cobra.ExactArgs(1),
249-
RunE: func(cmd *cobra.Command, args []string) error {
250-
name := args[0]
251-
252-
if daemonClient == nil {
253-
return fmt.Errorf("daemon not running - start with: devnetd")
254-
}
255-
256-
devnet, err := daemonClient.GetDevnet(cmd.Context(), name)
257-
if err != nil {
258-
return err
259-
}
260-
261-
printDevnetStatus(devnet)
262-
return nil
263-
},
264-
}
265-
}
266-
267247
func newStartCmd() *cobra.Command {
268248
return &cobra.Command{
269249
Use: "start [devnet]",
@@ -353,37 +333,173 @@ func newDestroyCmd() *cobra.Command {
353333
return cmd
354334
}
355335

356-
func printDevnetStatus(d *v1.Devnet) {
336+
func newDescribeCmd() *cobra.Command {
337+
var outputFormat string
338+
339+
cmd := &cobra.Command{
340+
Use: "describe <devnet>",
341+
Short: "Show detailed devnet information",
342+
Long: `Show detailed information about a devnet including status conditions,
343+
recent events, and node details. Similar to kubectl describe.`,
344+
Args: cobra.ExactArgs(1),
345+
RunE: func(cmd *cobra.Command, args []string) error {
346+
name := args[0]
347+
348+
if daemonClient == nil {
349+
return fmt.Errorf("daemon not running - start with: devnetd")
350+
}
351+
352+
devnet, err := daemonClient.GetDevnet(cmd.Context(), name)
353+
if err != nil {
354+
return err
355+
}
356+
357+
nodes, err := daemonClient.ListNodes(cmd.Context(), name)
358+
if err != nil {
359+
// Don't fail if nodes can't be listed
360+
nodes = nil
361+
}
362+
363+
if outputFormat == "yaml" {
364+
return printDescribeYAML(devnet, nodes)
365+
}
366+
367+
formatDescribeOutput(os.Stdout, devnet, nodes)
368+
return nil
369+
},
370+
}
371+
372+
cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (yaml)")
373+
return cmd
374+
}
375+
376+
func formatDescribeOutput(w io.Writer, d *v1.Devnet, nodes []*v1.Node) {
377+
if d == nil {
378+
fmt.Fprintf(w, "No devnet data available\n")
379+
return
380+
}
381+
if d.Status == nil {
382+
d.Status = &v1.DevnetStatus{}
383+
}
384+
if d.Metadata == nil {
385+
d.Metadata = &v1.DevnetMetadata{}
386+
}
387+
if d.Spec == nil {
388+
d.Spec = &v1.DevnetSpec{}
389+
}
390+
357391
// Phase with color
358392
phase := d.Status.Phase
359393
switch phase {
360394
case "Running":
361-
color.Green("● %s", phase)
395+
color.New(color.FgGreen).Fprintf(w, "● %s\n", phase)
362396
case "Pending", "Provisioning":
363-
color.Yellow("◐ %s", phase)
397+
color.New(color.FgYellow).Fprintf(w, "◐ %s\n", phase)
364398
case "Stopped":
365-
color.White("○ %s", phase)
399+
color.New(color.FgWhite).Fprintf(w, "○ %s\n", phase)
366400
case "Degraded":
367-
color.Red("◑ %s", phase)
401+
color.New(color.FgRed).Fprintf(w, "◑ %s\n", phase)
368402
default:
369-
fmt.Printf("? %s", phase)
403+
fmt.Fprintf(w, "? %s\n", phase)
370404
}
371405

372-
fmt.Printf("\nName: %s\n", d.Metadata.Name)
373-
fmt.Printf("Plugin: %s\n", d.Spec.Plugin)
374-
fmt.Printf("Mode: %s\n", d.Spec.Mode)
375-
fmt.Printf("Validators: %d\n", d.Spec.Validators)
406+
// Basic info
407+
fmt.Fprintf(w, "\nName: %s\n", d.Metadata.Name)
408+
if d.Metadata.CreatedAt != nil {
409+
age := time.Since(d.Metadata.CreatedAt.AsTime()).Round(time.Second)
410+
fmt.Fprintf(w, "Age: %s\n", age)
411+
}
412+
fmt.Fprintf(w, "Plugin: %s\n", d.Spec.Plugin)
413+
fmt.Fprintf(w, "Mode: %s\n", d.Spec.Mode)
414+
fmt.Fprintf(w, "Validators: %d\n", d.Spec.Validators)
376415
if d.Spec.FullNodes > 0 {
377-
fmt.Printf("Full Nodes: %d\n", d.Spec.FullNodes)
416+
fmt.Fprintf(w, "Full Nodes: %d\n", d.Spec.FullNodes)
378417
}
379-
fmt.Printf("Nodes: %d/%d ready\n", d.Status.ReadyNodes, d.Status.Nodes)
418+
419+
// Status section
420+
fmt.Fprintf(w, "\nStatus:\n")
421+
fmt.Fprintf(w, " Nodes: %d/%d ready\n", d.Status.ReadyNodes, d.Status.Nodes)
380422
if d.Status.CurrentHeight > 0 {
381-
fmt.Printf("Height: %d\n", d.Status.CurrentHeight)
423+
fmt.Fprintf(w, " Height: %d\n", d.Status.CurrentHeight)
382424
}
383425
if d.Status.SdkVersion != "" {
384-
fmt.Printf("SDK: %s\n", d.Status.SdkVersion)
426+
fmt.Fprintf(w, " SDK Version: %s\n", d.Status.SdkVersion)
385427
}
386428
if d.Status.Message != "" {
387-
fmt.Printf("Message: %s\n", d.Status.Message)
429+
fmt.Fprintf(w, " Message: %s\n", d.Status.Message)
430+
}
431+
432+
// Conditions section
433+
if len(d.Status.Conditions) > 0 {
434+
fmt.Fprintf(w, "\nConditions:\n")
435+
fmt.Fprintf(w, " %-20s %-8s %-25s %s\n", "TYPE", "STATUS", "REASON", "MESSAGE")
436+
for _, c := range d.Status.Conditions {
437+
status := c.Status
438+
if c.Status == "True" {
439+
status = color.GreenString("True")
440+
} else if c.Status == "False" {
441+
status = color.RedString("False")
442+
}
443+
fmt.Fprintf(w, " %-20s %-8s %-25s %s\n", c.Type, status, c.Reason, c.Message)
444+
}
445+
}
446+
447+
// Nodes section
448+
if len(nodes) > 0 {
449+
fmt.Fprintf(w, "\nNodes:\n")
450+
fmt.Fprintf(w, " %-6s %-10s %-10s %-10s %-8s %s\n", "INDEX", "ROLE", "PHASE", "HEIGHT", "RESTARTS", "MESSAGE")
451+
for _, n := range nodes {
452+
phase := n.Status.Phase
453+
switch phase {
454+
case "Running":
455+
phase = color.GreenString(phase)
456+
case "Pending", "Starting":
457+
phase = color.YellowString(phase)
458+
case "Crashed":
459+
phase = color.RedString(phase)
460+
}
461+
msg := n.Status.Message
462+
if len(msg) > 30 {
463+
msg = msg[:27] + "..."
464+
}
465+
fmt.Fprintf(w, " %-6d %-10s %-10s %-10d %-8d %s\n",
466+
n.Metadata.Index,
467+
n.Spec.Role,
468+
phase,
469+
n.Status.BlockHeight,
470+
n.Status.RestartCount,
471+
msg,
472+
)
473+
}
474+
}
475+
476+
// Events section
477+
if len(d.Status.Events) > 0 {
478+
fmt.Fprintf(w, "\nEvents:\n")
479+
fmt.Fprintf(w, " %-8s %-20s %-20s %s\n", "TYPE", "REASON", "AGE", "MESSAGE")
480+
for _, e := range d.Status.Events {
481+
eventType := e.Type
482+
if e.Type == "Warning" {
483+
eventType = color.YellowString("Warning")
484+
}
485+
age := "Unknown"
486+
if e.Timestamp != nil {
487+
age = time.Since(e.Timestamp.AsTime()).Round(time.Second).String()
488+
}
489+
fmt.Fprintf(w, " %-8s %-20s %-20s %s\n", eventType, e.Reason, age, e.Message)
490+
}
491+
}
492+
}
493+
494+
func printDescribeYAML(d *v1.Devnet, nodes []*v1.Node) error {
495+
data := map[string]interface{}{
496+
"devnet": d,
497+
"nodes": nodes,
498+
}
499+
out, err := yaml.Marshal(data)
500+
if err != nil {
501+
return err
388502
}
503+
fmt.Println(string(out))
504+
return nil
389505
}

0 commit comments

Comments
 (0)