diff --git a/internal/daemon/server/devnet_service.go b/internal/daemon/server/devnet_service.go index db54fb1e..0f46eb87 100644 --- a/internal/daemon/server/devnet_service.go +++ b/internal/daemon/server/devnet_service.go @@ -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") @@ -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 diff --git a/internal/daemon/server/devnet_service_test.go b/internal/daemon/server/devnet_service_test.go index ce3b6171..6ad7e8b8 100644 --- a/internal/daemon/server/devnet_service_test.go +++ b/internal/daemon/server/devnet_service_test.go @@ -3,6 +3,7 @@ package server import ( "context" "errors" + "fmt" "testing" "time" @@ -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) } }