Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
840 changes: 516 additions & 324 deletions api/proto/gen/v1/devnet.pb.go

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions api/proto/v1/devnet.proto
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,26 @@ message DevnetStatus {
string sdk_version = 5;
google.protobuf.Timestamp last_health_check = 6;
string message = 7;
repeated Condition conditions = 8; // Detailed status conditions
repeated Event events = 9; // Recent events (last 10)
}

// Condition represents a status condition of a resource.
message Condition {
string type = 1; // Type of condition (Ready, Progressing, etc.)
string status = 2; // True, False, Unknown
google.protobuf.Timestamp last_transition_time = 3;
string reason = 4; // CamelCase reason code
string message = 5; // Human-readable message
}

// Event represents a significant occurrence during resource lifecycle.
message Event {
google.protobuf.Timestamp timestamp = 1;
string type = 2; // Normal, Warning
string reason = 3; // CamelCase reason code
string message = 4; // Human-readable message
string component = 5; // Source component (controller, runtime, etc.)
}

// DevnetService request/response messages
Expand Down
42 changes: 42 additions & 0 deletions cmd/dvb/describe_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//go:build integration

package main

import (
"context"
"testing"
"time"

"github.com/altuslabsxyz/devnet-builder/internal/client"
)

func TestDescribeIntegration(t *testing.T) {
c, err := client.New()
if err != nil {
t.Skip("daemon not running, skipping integration test")
}
defer c.Close()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

devnets, err := c.ListDevnets(ctx)
if err != nil {
t.Fatalf("failed to list devnets: %v", err)
}

if len(devnets) == 0 {
t.Skip("no devnets found, skipping")
}

// Get first devnet
devnet, err := c.GetDevnet(ctx, devnets[0].Metadata.Name)
if err != nil {
t.Fatalf("failed to get devnet: %v", err)
}

// Verify we have conditions (after the controller changes)
if len(devnet.Status.Conditions) == 0 {
t.Log("warning: no conditions found on devnet")
}
}
73 changes: 73 additions & 0 deletions cmd/dvb/describe_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package main

import (
"bytes"
"testing"
"time"

v1 "github.com/altuslabsxyz/devnet-builder/api/proto/gen/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)

func TestFormatDescribeOutput(t *testing.T) {
devnet := &v1.Devnet{
Metadata: &v1.DevnetMetadata{
Name: "test-devnet",
CreatedAt: timestamppb.New(time.Now().Add(-1 * time.Hour)),
},
Spec: &v1.DevnetSpec{
Plugin: "stable",
Mode: "docker",
Validators: 4,
},
Status: &v1.DevnetStatus{
Phase: "Provisioning",
Nodes: 4,
ReadyNodes: 2,
Message: "Creating nodes",
Conditions: []*v1.Condition{
{
Type: "Ready",
Status: "False",
Reason: "NodesNotReady",
Message: "2/4 nodes ready",
LastTransitionTime: timestamppb.Now(),
},
{
Type: "Progressing",
Status: "True",
Reason: "CreatingNodes",
Message: "Creating validator nodes",
LastTransitionTime: timestamppb.Now(),
},
},
Events: []*v1.Event{
{
Timestamp: timestamppb.Now(),
Type: "Normal",
Reason: "Provisioning",
Message: "Started provisioning",
Component: "devnet-controller",
},
},
},
}

var buf bytes.Buffer
formatDescribeOutput(&buf, devnet, nil)
output := buf.String()

// Check key sections exist
if !bytes.Contains([]byte(output), []byte("Name:")) {
t.Error("missing Name field")
}
if !bytes.Contains([]byte(output), []byte("Conditions:")) {
t.Error("missing Conditions section")
}
if !bytes.Contains([]byte(output), []byte("Events:")) {
t.Error("missing Events section")
}
if !bytes.Contains([]byte(output), []byte("NodesNotReady")) {
t.Error("missing condition reason")
}
}
194 changes: 155 additions & 39 deletions cmd/dvb/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package main

import (
"fmt"
"io"
"os"
"text/tabwriter"
"time"

v1 "github.com/altuslabsxyz/devnet-builder/api/proto/gen/v1"
"github.com/altuslabsxyz/devnet-builder/internal/client"
"github.com/altuslabsxyz/devnet-builder/internal/version"
"github.com/fatih/color"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

var (
Expand Down Expand Up @@ -65,7 +68,7 @@ func main() {
newDiffCmd(),
newDeployCmd(), // deprecated
newListCmd(),
newStatusCmd(),
newDescribeCmd(),
newStartCmd(),
newStopCmd(),
newDestroyCmd(), // deprecated
Expand Down Expand Up @@ -241,29 +244,6 @@ func newListCmd() *cobra.Command {
}
}

func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status [devnet]",
Short: "Show devnet status",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]

if daemonClient == nil {
return fmt.Errorf("daemon not running - start with: devnetd")
}

devnet, err := daemonClient.GetDevnet(cmd.Context(), name)
if err != nil {
return err
}

printDevnetStatus(devnet)
return nil
},
}
}

func newStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start [devnet]",
Expand Down Expand Up @@ -353,37 +333,173 @@ func newDestroyCmd() *cobra.Command {
return cmd
}

func printDevnetStatus(d *v1.Devnet) {
func newDescribeCmd() *cobra.Command {
var outputFormat string

cmd := &cobra.Command{
Use: "describe <devnet>",
Short: "Show detailed devnet information",
Long: `Show detailed information about a devnet including status conditions,
recent events, and node details. Similar to kubectl describe.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]

if daemonClient == nil {
return fmt.Errorf("daemon not running - start with: devnetd")
}

devnet, err := daemonClient.GetDevnet(cmd.Context(), name)
if err != nil {
return err
}

nodes, err := daemonClient.ListNodes(cmd.Context(), name)
if err != nil {
// Don't fail if nodes can't be listed
nodes = nil
}

if outputFormat == "yaml" {
return printDescribeYAML(devnet, nodes)
}

formatDescribeOutput(os.Stdout, devnet, nodes)
return nil
},
}

cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (yaml)")
return cmd
}

func formatDescribeOutput(w io.Writer, d *v1.Devnet, nodes []*v1.Node) {
if d == nil {
fmt.Fprintf(w, "No devnet data available\n")
return
}
if d.Status == nil {
d.Status = &v1.DevnetStatus{}
}
if d.Metadata == nil {
d.Metadata = &v1.DevnetMetadata{}
}
if d.Spec == nil {
d.Spec = &v1.DevnetSpec{}
}

// Phase with color
phase := d.Status.Phase
switch phase {
case "Running":
color.Green("● %s", phase)
color.New(color.FgGreen).Fprintf(w, "● %s\n", phase)
case "Pending", "Provisioning":
color.Yellow("◐ %s", phase)
color.New(color.FgYellow).Fprintf(w, "◐ %s\n", phase)
case "Stopped":
color.White("○ %s", phase)
color.New(color.FgWhite).Fprintf(w, "○ %s\n", phase)
case "Degraded":
color.Red("◑ %s", phase)
color.New(color.FgRed).Fprintf(w, "◑ %s\n", phase)
default:
fmt.Printf("? %s", phase)
fmt.Fprintf(w, "? %s\n", phase)
}

fmt.Printf("\nName: %s\n", d.Metadata.Name)
fmt.Printf("Plugin: %s\n", d.Spec.Plugin)
fmt.Printf("Mode: %s\n", d.Spec.Mode)
fmt.Printf("Validators: %d\n", d.Spec.Validators)
// Basic info
fmt.Fprintf(w, "\nName: %s\n", d.Metadata.Name)
if d.Metadata.CreatedAt != nil {
age := time.Since(d.Metadata.CreatedAt.AsTime()).Round(time.Second)
fmt.Fprintf(w, "Age: %s\n", age)
}
fmt.Fprintf(w, "Plugin: %s\n", d.Spec.Plugin)
fmt.Fprintf(w, "Mode: %s\n", d.Spec.Mode)
fmt.Fprintf(w, "Validators: %d\n", d.Spec.Validators)
if d.Spec.FullNodes > 0 {
fmt.Printf("Full Nodes: %d\n", d.Spec.FullNodes)
fmt.Fprintf(w, "Full Nodes: %d\n", d.Spec.FullNodes)
}
fmt.Printf("Nodes: %d/%d ready\n", d.Status.ReadyNodes, d.Status.Nodes)

// Status section
fmt.Fprintf(w, "\nStatus:\n")
fmt.Fprintf(w, " Nodes: %d/%d ready\n", d.Status.ReadyNodes, d.Status.Nodes)
if d.Status.CurrentHeight > 0 {
fmt.Printf("Height: %d\n", d.Status.CurrentHeight)
fmt.Fprintf(w, " Height: %d\n", d.Status.CurrentHeight)
}
if d.Status.SdkVersion != "" {
fmt.Printf("SDK: %s\n", d.Status.SdkVersion)
fmt.Fprintf(w, " SDK Version: %s\n", d.Status.SdkVersion)
}
if d.Status.Message != "" {
fmt.Printf("Message: %s\n", d.Status.Message)
fmt.Fprintf(w, " Message: %s\n", d.Status.Message)
}

// Conditions section
if len(d.Status.Conditions) > 0 {
fmt.Fprintf(w, "\nConditions:\n")
fmt.Fprintf(w, " %-20s %-8s %-25s %s\n", "TYPE", "STATUS", "REASON", "MESSAGE")
for _, c := range d.Status.Conditions {
status := c.Status
if c.Status == "True" {
status = color.GreenString("True")
} else if c.Status == "False" {
status = color.RedString("False")
}
fmt.Fprintf(w, " %-20s %-8s %-25s %s\n", c.Type, status, c.Reason, c.Message)
}
}

// Nodes section
if len(nodes) > 0 {
fmt.Fprintf(w, "\nNodes:\n")
fmt.Fprintf(w, " %-6s %-10s %-10s %-10s %-8s %s\n", "INDEX", "ROLE", "PHASE", "HEIGHT", "RESTARTS", "MESSAGE")
for _, n := range nodes {
phase := n.Status.Phase
switch phase {
case "Running":
phase = color.GreenString(phase)
case "Pending", "Starting":
phase = color.YellowString(phase)
case "Crashed":
phase = color.RedString(phase)
}
msg := n.Status.Message
if len(msg) > 30 {
msg = msg[:27] + "..."
}
fmt.Fprintf(w, " %-6d %-10s %-10s %-10d %-8d %s\n",
n.Metadata.Index,
n.Spec.Role,
phase,
n.Status.BlockHeight,
n.Status.RestartCount,
msg,
)
}
}

// Events section
if len(d.Status.Events) > 0 {
fmt.Fprintf(w, "\nEvents:\n")
fmt.Fprintf(w, " %-8s %-20s %-20s %s\n", "TYPE", "REASON", "AGE", "MESSAGE")
for _, e := range d.Status.Events {
eventType := e.Type
if e.Type == "Warning" {
eventType = color.YellowString("Warning")
}
age := "Unknown"
if e.Timestamp != nil {
age = time.Since(e.Timestamp.AsTime()).Round(time.Second).String()
}
fmt.Fprintf(w, " %-8s %-20s %-20s %s\n", eventType, e.Reason, age, e.Message)
}
}
}

func printDescribeYAML(d *v1.Devnet, nodes []*v1.Node) error {
data := map[string]interface{}{
"devnet": d,
"nodes": nodes,
}
out, err := yaml.Marshal(data)
if err != nil {
return err
}
fmt.Println(string(out))
return nil
}
Loading
Loading