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
47 changes: 39 additions & 8 deletions internal/daemon/server/devnet_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,10 @@ func (s *DevnetService) DeleteDevnet(ctx context.Context, req *v1.DeleteDevnetRe
return &v1.DeleteDevnetResponse{Deleted: true}, nil
}

// StartDevnet starts a stopped devnet.
// StartDevnet starts a stopped devnet by directly starting all its nodes.
// Unlike provisioning, this does not rebuild binaries, fork genesis, or reinitialize
// node directories — it simply sets existing nodes to desired=Running and lets the
// NodeController start the processes.
func (s *DevnetService) StartDevnet(ctx context.Context, req *v1.StartDevnetRequest) (*v1.StartDevnetResponse, error) {
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
Expand All @@ -235,20 +238,48 @@ func (s *DevnetService) StartDevnet(ctx context.Context, req *v1.StartDevnetRequ
return nil, status.Errorf(codes.Internal, "failed to get devnet: %v", err)
}

// Transition to Pending to trigger reconciliation
devnet.Status.Phase = types.PhasePending
devnet.Status.Message = "Starting devnet"
// List all nodes for this devnet
nodes, err := s.store.ListNodes(ctx, namespace, req.Name)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list nodes: %v", err)
}
if len(nodes) == 0 {
return nil, status.Errorf(codes.FailedPrecondition, "devnet %q has no nodes; use 'dvb provision' to create it first", req.Name)
}

// Set each non-running node to desired=Running, phase=Pending for NodeController.
// Note: this is not atomic across nodes+devnet. A partial failure may leave some
// nodes updated while the devnet phase is unchanged. This is consistent with how
// other multi-resource operations (e.g. StopDevnet) work in this codebase.
for _, node := range nodes {
if node.Status.Phase == types.NodePhaseRunning || node.Status.Phase == types.NodePhaseStarting {
continue
}
node.Spec.Desired = types.NodePhaseRunning
node.Status.Phase = types.NodePhasePending
node.Status.Message = "Starting node"
node.Metadata.UpdatedAt = time.Now()
if err := s.store.UpdateNode(ctx, node); err != nil {
s.logger.Error("failed to update node", "devnet", req.Name, "index", node.Spec.Index, "error", err)
return nil, status.Errorf(codes.Internal, "failed to update node %d: %v", node.Spec.Index, err)
}
}

// Transition devnet to Running
devnet.Status.Phase = types.PhaseRunning
devnet.Status.Message = "Starting nodes"
devnet.Metadata.UpdatedAt = time.Now()

err = s.store.UpdateDevnet(ctx, devnet)
if err != nil {
if err = s.store.UpdateDevnet(ctx, devnet); err != nil {
s.logger.Error("failed to update devnet", "name", req.Name, "error", err)
return nil, status.Errorf(codes.Internal, "failed to update devnet: %v", err)
}

// Enqueue for reconciliation with namespace/name key
// Enqueue each node for reconciliation by NodeController
if s.manager != nil {
s.manager.Enqueue("devnets", namespace+"/"+req.Name)
for _, node := range nodes {
s.manager.Enqueue("nodes", controller.NodeKeyWithNamespace(namespace, req.Name, node.Spec.Index))
}
}

return &v1.StartDevnetResponse{Devnet: DevnetToProto(devnet)}, nil
Expand Down
182 changes: 173 additions & 9 deletions internal/daemon/server/devnet_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"context"
"errors"
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -247,36 +248,199 @@ func TestDevnetService_DeleteNotFound(t *testing.T) {
}

func TestDevnetService_StartDevnet(t *testing.T) {
ctx := context.Background()
s := store.NewMemoryStore()
svc := NewDevnetService(s, nil, nil)

// Create and simulate it being stopped
// Create and simulate it being stopped with provisioned nodes
createReq := &v1.CreateDevnetRequest{
Name: "stopped-devnet",
Spec: &v1.DevnetSpec{
Plugin: "stable",
Validators: 2,
},
}
_, err := svc.CreateDevnet(ctx, createReq)
if err != nil {
t.Fatalf("CreateDevnet failed: %v", err)
}

// Manually set devnet to stopped
devnet, _ := s.GetDevnet(ctx, "", "stopped-devnet")
devnet.Status.Phase = "Stopped"
s.UpdateDevnet(ctx, devnet)

// Create stopped nodes (simulating previously provisioned nodes)
for i := 0; i < 2; i++ {
node := &types.Node{
Metadata: types.ResourceMeta{
Name: fmt.Sprintf("stopped-devnet-node-%d", i),
Namespace: types.DefaultNamespace,
},
Spec: types.NodeSpec{
DevnetRef: "stopped-devnet",
Index: i,
Role: "validator",
Desired: types.NodePhaseStopped,
},
Status: types.NodeStatus{
Phase: types.NodePhaseStopped,
Message: "Node stopped",
},
}
if err := s.CreateNode(ctx, node); err != nil {
t.Fatalf("CreateNode %d failed: %v", i, err)
}
}

// Start it
resp, err := svc.StartDevnet(ctx, &v1.StartDevnetRequest{Name: "stopped-devnet"})
if err != nil {
t.Fatalf("StartDevnet failed: %v", err)
}

// Devnet should transition to Running (not Pending which triggers provisioning)
if resp.Devnet.Status.Phase != "Running" {
t.Errorf("expected phase Running after start, got %s", resp.Devnet.Status.Phase)
}

// Verify each node has desired=Running and phase=Pending
nodes, err := s.ListNodes(ctx, "", "stopped-devnet")
if err != nil {
t.Fatalf("ListNodes failed: %v", err)
}
for _, node := range nodes {
if node.Spec.Desired != types.NodePhaseRunning {
t.Errorf("node %d: expected desired=Running, got %s", node.Spec.Index, node.Spec.Desired)
}
if node.Status.Phase != types.NodePhasePending {
t.Errorf("node %d: expected phase=Pending, got %s", node.Spec.Index, node.Status.Phase)
}
}
}

func TestDevnetService_StartDevnet_NoNodes(t *testing.T) {
ctx := context.Background()
s := store.NewMemoryStore()
svc := NewDevnetService(s, nil, nil)

// Create devnet without any nodes (not yet provisioned)
createReq := &v1.CreateDevnetRequest{
Name: "empty-devnet",
Spec: &v1.DevnetSpec{
Plugin: "stable",
Validators: 4,
},
}
_, err := svc.CreateDevnet(context.Background(), createReq)
_, err := svc.CreateDevnet(ctx, createReq)
if err != nil {
t.Fatalf("CreateDevnet failed: %v", err)
}

// Manually set to stopped
devnet, _ := s.GetDevnet(context.Background(), "", "stopped-devnet")
devnet, _ := s.GetDevnet(ctx, "", "empty-devnet")
devnet.Status.Phase = "Stopped"
s.UpdateDevnet(context.Background(), devnet)
s.UpdateDevnet(ctx, devnet)

// Start it
resp, err := svc.StartDevnet(context.Background(), &v1.StartDevnetRequest{Name: "stopped-devnet"})
// Start should fail because no nodes exist
_, err = svc.StartDevnet(ctx, &v1.StartDevnetRequest{Name: "empty-devnet"})
if err == nil {
t.Fatal("expected error when starting devnet with no nodes")
}

st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.FailedPrecondition {
t.Errorf("expected FailedPrecondition, got %v", st.Code())
}
}

func TestDevnetService_StartDevnet_SkipsRunningNodes(t *testing.T) {
ctx := context.Background()
s := store.NewMemoryStore()
svc := NewDevnetService(s, nil, nil)

// Create devnet
createReq := &v1.CreateDevnetRequest{
Name: "mixed-devnet",
Spec: &v1.DevnetSpec{
Plugin: "stable",
Validators: 2,
},
}
_, err := svc.CreateDevnet(ctx, createReq)
if err != nil {
t.Fatalf("CreateDevnet failed: %v", err)
}

devnet, _ := s.GetDevnet(ctx, "", "mixed-devnet")
devnet.Status.Phase = "Stopped"
s.UpdateDevnet(ctx, devnet)

// Create one running node and one stopped node
runningNode := &types.Node{
Metadata: types.ResourceMeta{
Name: "mixed-devnet-node-0",
Namespace: types.DefaultNamespace,
},
Spec: types.NodeSpec{
DevnetRef: "mixed-devnet",
Index: 0,
Role: "validator",
Desired: types.NodePhaseRunning,
},
Status: types.NodeStatus{
Phase: types.NodePhaseRunning,
Message: "Node is running",
},
}
stoppedNode := &types.Node{
Metadata: types.ResourceMeta{
Name: "mixed-devnet-node-1",
Namespace: types.DefaultNamespace,
},
Spec: types.NodeSpec{
DevnetRef: "mixed-devnet",
Index: 1,
Role: "validator",
Desired: types.NodePhaseStopped,
},
Status: types.NodeStatus{
Phase: types.NodePhaseStopped,
Message: "Node stopped",
},
}
if err := s.CreateNode(ctx, runningNode); err != nil {
t.Fatalf("CreateNode 0 failed: %v", err)
}
if err := s.CreateNode(ctx, stoppedNode); err != nil {
t.Fatalf("CreateNode 1 failed: %v", err)
}

// Start the devnet
_, err = svc.StartDevnet(ctx, &v1.StartDevnetRequest{Name: "mixed-devnet"})
if err != nil {
t.Fatalf("StartDevnet failed: %v", err)
}

// Should transition to Pending (to be reconciled)
if resp.Devnet.Status.Phase != "Pending" {
t.Errorf("expected phase Pending after start, got %s", resp.Devnet.Status.Phase)
// Running node should remain completely unchanged
node0, _ := s.GetNode(ctx, "", "mixed-devnet", 0)
if node0.Status.Phase != types.NodePhaseRunning {
t.Errorf("running node should stay Running, got %s", node0.Status.Phase)
}
if node0.Spec.Desired != types.NodePhaseRunning {
t.Errorf("running node desired should stay Running, got %s", node0.Spec.Desired)
}

// Stopped node should be set to Pending
node1, _ := s.GetNode(ctx, "", "mixed-devnet", 1)
if node1.Status.Phase != types.NodePhasePending {
t.Errorf("stopped node should become Pending, got %s", node1.Status.Phase)
}
if node1.Spec.Desired != types.NodePhaseRunning {
t.Errorf("stopped node desired should be Running, got %s", node1.Spec.Desired)
}
}

Expand Down
Loading