From fb8d874488f48f620c7efb4673991ee934646046 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Fri, 2 Oct 2026 14:25:19 -0700 Subject: [PATCH 1/2] fix status and use devplane as source of truth --- Makefile | 2 - go.mod | 4 +- go.sum | 8 +- pkg/cmd/deregister/deregister_test.go | 3 +- pkg/cmd/enablessh/enablessh_test.go | 3 +- pkg/cmd/gpucreate/gpucreate.go | 81 ++++-- pkg/cmd/gpucreate/gpucreate_test.go | 155 ++++++++++- pkg/cmd/grantssh/grantssh_test.go | 3 +- pkg/cmd/launch/launch_test.go | 5 + pkg/cmd/launch/secrets.go | 3 +- pkg/cmd/ls/ls.go | 49 +++- pkg/cmd/ls/ls_test.go | 36 +++ pkg/cmd/mintcert/mintcert.go | 7 +- pkg/cmd/ports/close.go | 6 +- pkg/cmd/ports/open.go | 14 +- pkg/cmd/ports/ports.go | 8 +- pkg/cmd/ports/ports_test.go | 6 + pkg/cmd/ports/update.go | 18 +- pkg/cmd/refresh/refresh.go | 5 +- pkg/cmd/refresh/refresh_test.go | 6 + pkg/cmd/refresh/sshaccess.go | 4 +- pkg/cmd/register/providers.go | 3 +- pkg/cmd/register/register.go | 73 +++++ pkg/cmd/register/register_test.go | 301 +++++++++++++++++++- pkg/cmd/register/rpcclient.go | 145 ---------- pkg/cmd/register/rpcclient_test.go | 387 -------------------------- pkg/cmd/revokessh/revokessh_test.go | 3 +- pkg/cmd/util/externalnode.go | 14 +- pkg/cmd/util/externalnode_test.go | 6 + pkg/config/config.go | 10 +- pkg/environment/client.go | 70 +++++ pkg/environment/client_test.go | 110 ++++++++ pkg/environment/status.go | 85 ++++++ pkg/environment/status_test.go | 72 +++++ pkg/store/devplane_client.go | 82 ++++++ pkg/store/devplane_client_test.go | 78 ++++++ pkg/store/environment_status.go | 23 ++ pkg/store/http.go | 10 +- pkg/store/instancetypes.go | 12 +- pkg/store/managedsecret.go | 7 +- pkg/store/organization.go | 28 +- 41 files changed, 1264 insertions(+), 681 deletions(-) delete mode 100644 pkg/cmd/register/rpcclient.go delete mode 100644 pkg/cmd/register/rpcclient_test.go create mode 100644 pkg/environment/client.go create mode 100644 pkg/environment/client_test.go create mode 100644 pkg/environment/status.go create mode 100644 pkg/environment/status_test.go create mode 100644 pkg/store/devplane_client.go create mode 100644 pkg/store/devplane_client_test.go create mode 100644 pkg/store/environment_status.go diff --git a/Makefile b/Makefile index 8cbf9ee81..ba454f037 100644 --- a/Makefile +++ b/Makefile @@ -42,11 +42,9 @@ ifdef env ifeq ($(env),local-stack) @echo 'export BREV_API_URL="http://localhost:8080"' >> brev @echo 'export BREV_PUBLIC_API_URL="http://localhost:8081"' >> brev - @echo 'export BREV_GRPC_URL="localhost:8081"' >> brev else @echo 'export BREV_API_URL="https://bd.$(env).brev.nvidia.com"' >> brev @echo 'export BREV_PUBLIC_API_URL="https://api.$(env).brev.nvidia.com"' >> brev - @echo 'export BREV_GRPC_URL="api.$(env).brev.nvidia.com:443"' >> brev endif @echo 'exec "$$(cd "$$(dirname "$$0")" && pwd)/brev-local" "$$@"' >> brev @chmod +x brev diff --git a/go.mod b/go.mod index 4d0829606..e826383b0 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/brevdev/brev-cli go 1.25.0 require ( - buf.build/gen/go/brevdev/devplane/connectrpc/go v1.21.0-20260915224432-6a5d0c858507.1 - buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260915224432-6a5d0c858507.2 + buf.build/gen/go/brevdev/devplane/connectrpc/go v1.21.0-20261002013339-6f4f902cdf2d.1 + buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20261002013339-6f4f902cdf2d.2 connectrpc.com/connect v1.21.0 github.com/NVIDIA/go-nvml v0.13.0-1 github.com/alessio/shellescape v1.4.1 diff --git a/go.sum b/go.sum index 294c9e6c4..f73d8a8d7 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -buf.build/gen/go/brevdev/devplane/connectrpc/go v1.21.0-20260915224432-6a5d0c858507.1 h1:CFXTDUzEoMYQzJXAG+hnMO5Cs6h3zVq734k/VnyWuCM= -buf.build/gen/go/brevdev/devplane/connectrpc/go v1.21.0-20260915224432-6a5d0c858507.1/go.mod h1:dS34xJi+QXqWDdKwUHTBfzkcbQFNK3u4UH3F3L6izx0= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260915224432-6a5d0c858507.2 h1:CjGsO8kcUR0YcKzjKy/L5m2jIhnNW6VW6ArYliA2p6o= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20260915224432-6a5d0c858507.2/go.mod h1:SBVLUYGc/4vgMlhOJL5SkwIrSh/FL9EEimlpI8zQSXo= +buf.build/gen/go/brevdev/devplane/connectrpc/go v1.21.0-20261002013339-6f4f902cdf2d.1 h1:6YxQ+ovFXTH0qhavCXrRAsa/+CgyJviIBBPvuar5fAU= +buf.build/gen/go/brevdev/devplane/connectrpc/go v1.21.0-20261002013339-6f4f902cdf2d.1/go.mod h1:0EoTlhUve4UaoJRDO8DXLA79x0HYGLnFchd8ITsIn/4= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20261002013339-6f4f902cdf2d.2 h1:FoyCt/oxRt8ZFE4JmtAclKTlGOT7zFbyyhY/xeEmyjQ= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.12-20261002013339-6f4f902cdf2d.2/go.mod h1:SBVLUYGc/4vgMlhOJL5SkwIrSh/FL9EEimlpI8zQSXo= buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.12-20220906235457-8b4922735da5.2 h1:W5SVs96P8ZgLElYzL93rJu+S+XmkdGQSaVWzPvR5lvg= buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.12-20220906235457-8b4922735da5.2/go.mod h1:SacJAYqnICCQAsBA46cSA/hxhqhxYkiYzseucf6/fhQ= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= diff --git a/pkg/cmd/deregister/deregister_test.go b/pkg/cmd/deregister/deregister_test.go index eb02932c0..0d0342e94 100644 --- a/pkg/cmd/deregister/deregister_test.go +++ b/pkg/cmd/deregister/deregister_test.go @@ -19,6 +19,7 @@ import ( "github.com/brevdev/brev-cli/pkg/entity" "github.com/brevdev/brev-cli/pkg/externalnode" "github.com/brevdev/brev-cli/pkg/sshcert" + "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/sudo" "github.com/brevdev/brev-cli/pkg/terminal" ) @@ -141,7 +142,7 @@ type mockNodeClientFactory struct { } func (m mockNodeClientFactory) NewNodeClient(provider externalnode.TokenProvider, _ string) nodev1connect.ExternalNodeServiceClient { - return register.NewNodeServiceClient(provider, m.serverURL) + return store.NewDevPlaneClient(provider, m.serverURL).ExternalNodes } type mockSSHKeyRemover struct { diff --git a/pkg/cmd/enablessh/enablessh_test.go b/pkg/cmd/enablessh/enablessh_test.go index c747a46d5..a4b6f7dac 100644 --- a/pkg/cmd/enablessh/enablessh_test.go +++ b/pkg/cmd/enablessh/enablessh_test.go @@ -17,6 +17,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/entity" "github.com/brevdev/brev-cli/pkg/externalnode" + "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" ) @@ -222,7 +223,7 @@ func Test_RemoveAuthorizedKey_DoesNotRemoveOtherBrevKeys(t *testing.T) { type mockNodeClientFactory struct{ serverURL string } func (m mockNodeClientFactory) NewNodeClient(provider externalnode.TokenProvider, _ string) nodev1connect.ExternalNodeServiceClient { - return register.NewNodeServiceClient(provider, m.serverURL) + return store.NewDevPlaneClient(provider, m.serverURL).ExternalNodes } type mockEnableSSHStore struct { diff --git a/pkg/cmd/gpucreate/gpucreate.go b/pkg/cmd/gpucreate/gpucreate.go index cb6b8f123..1693f5c0d 100644 --- a/pkg/cmd/gpucreate/gpucreate.go +++ b/pkg/cmd/gpucreate/gpucreate.go @@ -3,6 +3,7 @@ package gpucreate import ( "encoding/json" + "errors" "fmt" "io" "math/rand/v2" @@ -25,6 +26,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/util" "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" + "github.com/brevdev/brev-cli/pkg/environment" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/featureflag" "github.com/brevdev/brev-cli/pkg/names" @@ -109,7 +111,7 @@ type GPUCreateStore interface { GetActiveOrganizationOrDefault() (*entity.Organization, error) GetAuthTokens() (*entity.AuthTokens, error) GetCurrentUser() (*entity.User, error) - GetWorkspace(workspaceID string) (*entity.Workspace, error) + GetEnvironmentStatus(environmentID string) (environment.Status, error) CreateWorkspace(organizationID string, options *store.CreateWorkspacesOptions) (*entity.Workspace, error) DeleteWorkspace(workspaceID string) (*entity.Workspace, error) GetAllInstanceTypesWithCloudCreds(orgID string) (*gpusearch.AllInstanceTypesResponse, error) @@ -1130,25 +1132,49 @@ func (c *createContext) cleanupExtraInstances(workspaces []*entity.Workspace) [] return workspaces[:c.opts.Count] } -// waitForInstances waits for all instances to be ready -func (c *createContext) waitForInstances(workspaces []*entity.Workspace) { +// errReadyTimeout marks a pollUntilReady timeout. It is non-fatal: waiting is a +// convenience and the instance may still finish provisioning after the window. +var errReadyTimeout = errors.New("timeout waiting for instance to be ready") + +// workspaceFailedError reports a workspace that reached a terminal failure. +type workspaceFailedError struct{ message string } + +func (e *workspaceFailedError) Error() string { return e.message } + +// waitForInstances blocks until each workspace is ready. It returns an error +// when any instance reaches a terminal failure so the command exits non-zero; +// timeouts and transient polling errors are reported but remain non-fatal. +func (c *createContext) waitForInstances(workspaces []*entity.Workspace) error { if c.opts.Detached { - return + return nil } c.logf("\nWaiting for instance(s) to be ready...\n") c.logf("You can safely ctrl+c to exit\n") + var failed []string for _, ws := range workspaces { err := c.pollUntilReady(ws.ID) - if err != nil { - if strings.Contains(err.Error(), "timeout waiting") { - c.logf(" %s: Timeout waiting for ready state\n", ws.Name) - } else { - c.logf(" %s: %s\n", ws.Name, c.colorize(err.Error(), c.t.Red)) - } + var failedErr *workspaceFailedError + switch { + case err == nil: + case errors.Is(err, errReadyTimeout): + c.logf(" %s: Timeout waiting for ready state\n", ws.Name) + case errors.As(err, &failedErr): + c.logf(" %s: %s\n", ws.Name, c.colorize(err.Error(), c.t.Red)) + failed = append(failed, err.Error()) + default: + c.logf(" %s: %s\n", ws.Name, c.colorize(err.Error(), c.t.Red)) } } + + if len(failed) > 0 { + return breverrors.NewValidationError(fmt.Sprintf( + "%d/%d instance(s) failed to become ready: %s", + len(failed), len(workspaces), strings.Join(failed, "; "), + )) + } + return nil } // printSummary outputs the final creation summary @@ -1230,7 +1256,9 @@ func RunGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore, opts GPUC } successfulWorkspaces = ctx.cleanupExtraInstances(successfulWorkspaces) - ctx.waitForInstances(successfulWorkspaces) + if err := ctx.waitForInstances(successfulWorkspaces); err != nil { + return err + } ctx.printSummary(successfulWorkspaces) return nil @@ -1608,32 +1636,41 @@ func resolveWorkspaceUserOptions(options *store.CreateWorkspacesOptions, user *e return options } -// pollUntilReady waits for a workspace to reach the running state +// pollUntilReady waits for a workspace to reach the running state, using +// dev-plane's environment status as the single source of truth. func (c *createContext) pollUntilReady(wsID string) error { deadline := time.Now().Add(c.opts.Timeout) for time.Now().Before(deadline) { - ws, err := c.store.GetWorkspace(wsID) + status, err := c.store.GetEnvironmentStatus(wsID) if err != nil { return breverrors.WrapAndTrace(err) } - if ws.Status == entity.Running { - c.logf(" %s: %s\n", ws.Name, c.colorize("Ready", c.t.Green)) - return nil + name := status.Name + if name == "" { + name = wsID } - if ws.Status == entity.Failure { - if ws.StatusMessage != "" { - return breverrors.NewValidationError(fmt.Sprintf("instance %s failed: %s", ws.Name, ws.StatusMessage)) - } - return breverrors.NewValidationError(fmt.Sprintf("instance %s failed", ws.Name)) + switch { + case status.Ready: + c.logf(" %s: %s\n", name, c.colorize("Ready", c.t.Green)) + return nil + case status.Failed: + return &workspaceFailedError{message: instanceFailureMessage(name, status.Message)} } time.Sleep(5 * time.Second) } - return breverrors.NewValidationError("timeout waiting for instance to be ready") + return errReadyTimeout +} + +func instanceFailureMessage(name, statusMessage string) string { + if statusMessage != "" { + return fmt.Sprintf("instance %s failed: %s", name, statusMessage) + } + return fmt.Sprintf("instance %s failed", name) } // displayConnectBreadCrumb shows connection instructions diff --git a/pkg/cmd/gpucreate/gpucreate_test.go b/pkg/cmd/gpucreate/gpucreate_test.go index e259dc06e..47bcd9b60 100644 --- a/pkg/cmd/gpucreate/gpucreate_test.go +++ b/pkg/cmd/gpucreate/gpucreate_test.go @@ -10,6 +10,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/gpusearch" "github.com/brevdev/brev-cli/pkg/entity" + "github.com/brevdev/brev-cli/pkg/environment" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" @@ -24,6 +25,7 @@ type MockGPUCreateStore struct { Workspaces map[string]*entity.Workspace CreateError error CreateErrorTypes map[string]error // Errors for specific instance types + EnvironmentStatuses map[string]environment.Status DeleteError error CreatedOptions []*store.CreateWorkspacesOptions CreatedWorkspaces []*entity.Workspace @@ -43,6 +45,7 @@ func NewMockGPUCreateStore() *MockGPUCreateStore { }, Workspaces: make(map[string]*entity.Workspace), CreateErrorTypes: make(map[string]error), + EnvironmentStatuses: make(map[string]environment.Status), CreatedWorkspaces: []*entity.Workspace{}, DeletedWorkspaceIDs: []string{}, } @@ -60,14 +63,11 @@ func (m *MockGPUCreateStore) GetActiveOrganizationOrDefault() (*entity.Organizat return m.Org, nil } -func (m *MockGPUCreateStore) GetWorkspace(workspaceID string) (*entity.Workspace, error) { - if ws, ok := m.Workspaces[workspaceID]; ok { - return ws, nil +func (m *MockGPUCreateStore) GetEnvironmentStatus(environmentID string) (environment.Status, error) { + if status, ok := m.EnvironmentStatuses[environmentID]; ok { + return status, nil } - return &entity.Workspace{ - ID: workspaceID, - Status: entity.Running, - }, nil + return environment.Status{}, nil } func (m *MockGPUCreateStore) CreateWorkspace(organizationID string, options *store.CreateWorkspacesOptions) (*entity.Workspace, error) { @@ -108,7 +108,12 @@ func (m *MockGPUCreateStore) GetWorkspaceByNameOrID(orgID string, nameOrID strin } func (m *MockGPUCreateStore) GetAllInstanceTypesWithCloudCreds(orgID string) (*gpusearch.AllInstanceTypesResponse, error) { - return nil, nil + return &gpusearch.AllInstanceTypesResponse{ + AllInstanceTypes: []gpusearch.InstanceType{ + {Type: "test.fail.quota", CloudCredID: "testkube-brev-test"}, + {Type: "g5.xlarge", CloudCredID: "cc-shadeform"}, + }, + }, nil } func (m *MockGPUCreateStore) GetLaunchable(launchableID string) (*store.LaunchableResponse, error) { @@ -1057,11 +1062,11 @@ func TestFormatInstanceSpecs(t *testing.T) { func TestPollUntilReadyReportsWorkspaceFailureMessage(t *testing.T) { store := NewMockGPUCreateStore() - store.Workspaces["ws-failed"] = &entity.Workspace{ - ID: "ws-failed", - Name: "test", - Status: entity.Failure, - StatusMessage: "unexpected end of JSON input", + store.EnvironmentStatuses["ws-failed"] = environment.Status{ + Name: "test", + Display: entity.Failure, + Failed: true, + Message: "unexpected end of JSON input", } ctx := &createContext{ @@ -1074,6 +1079,130 @@ func TestPollUntilReadyReportsWorkspaceFailureMessage(t *testing.T) { assert.ErrorContains(t, err, "instance test failed: unexpected end of JSON input") } +func TestWaitForInstancesReturnsErrorOnTerminalFailure(t *testing.T) { + mock := NewMockGPUCreateStore() + failed := &entity.Workspace{ID: "ws-failed", Name: "failed-instance"} + mock.EnvironmentStatuses[failed.ID] = environment.Status{ + Name: failed.Name, + Display: entity.Failure, + Failed: true, + Message: "out of quota in the region fulfill the request, InsufficientQuota", + } + + ctx := &createContext{ + t: terminal.New(), + store: mock, + opts: GPUCreateOptions{Timeout: time.Second}, + logf: func(string, ...interface{}) {}, + } + + err := ctx.waitForInstances([]*entity.Workspace{failed}) + + require.Error(t, err) + assert.ErrorContains(t, err, "1/1 instance(s) failed to become ready") + assert.ErrorContains(t, err, "failed-instance") + assert.ErrorContains(t, err, "InsufficientQuota") +} + +func TestWaitForInstancesTreatsTimeoutAsWarning(t *testing.T) { + mock := NewMockGPUCreateStore() + pending := &entity.Workspace{ID: "ws-pending", Name: "pending"} + + ctx := &createContext{ + t: terminal.New(), + store: mock, + opts: GPUCreateOptions{Timeout: 0}, // deadline already elapsed: immediate timeout + logf: func(string, ...interface{}) {}, + } + + assert.NoError(t, ctx.waitForInstances([]*entity.Workspace{pending})) +} + +func TestRunGPUCreateReturnsErrorWhenProvisioningFails(t *testing.T) { + mock := NewMockGPUCreateStore() + mock.EnvironmentStatuses["ws-repro"] = environment.Status{ + Name: "repro", + Display: entity.Failure, + Failed: true, + Message: "out of quota in the region fulfill the request, InsufficientQuota", + } + + err := RunGPUCreate(terminal.New(), mock, GPUCreateOptions{ + Name: "repro", + Count: 1, + Parallel: 1, + InstanceTypes: []InstanceSpec{{Type: "test.fail.quota"}}, + Timeout: time.Second, + }) + + require.Error(t, err) + assert.ErrorContains(t, err, "failed to become ready") + assert.ErrorContains(t, err, "InsufficientQuota") +} + +func TestRunGPUCreateSucceedsWhenWorkspaceBecomesReady(t *testing.T) { + mock := NewMockGPUCreateStore() + mock.EnvironmentStatuses["ws-ok"] = environment.Status{ + Name: "ok", + Display: entity.Running, + Ready: true, + } + + err := RunGPUCreate(terminal.New(), mock, GPUCreateOptions{ + Name: "ok", + Count: 1, + Parallel: 1, + InstanceTypes: []InstanceSpec{{Type: "g5.xlarge"}}, + Timeout: time.Second, + }) + + require.NoError(t, err) + require.Len(t, mock.CreatedWorkspaces, 1) +} + +func TestPollUntilReadyUsesDevPlaneEnvironmentStatus(t *testing.T) { + mock := NewMockGPUCreateStore() + mock.EnvironmentStatuses["ws-x"] = environment.Status{ + Name: "x", + Display: entity.Failure, + Failed: true, + Message: "out of quota", + } + + ctx := &createContext{ + t: terminal.New(), + store: mock, + opts: GPUCreateOptions{Timeout: time.Second}, + logf: func(string, ...interface{}) {}, + } + + err := ctx.pollUntilReady("ws-x") + + require.Error(t, err) + assert.ErrorContains(t, err, "instance x failed: out of quota") +} + +func TestRunGPUCreateFailsWhenDevPlaneEnvironmentFails(t *testing.T) { + mock := NewMockGPUCreateStore() + mock.EnvironmentStatuses["ws-repro"] = environment.Status{ + Name: "repro", + Display: entity.Failure, + Failed: true, + Message: "out of quota in the region fulfill the request, InsufficientQuota", + } + + err := RunGPUCreate(terminal.New(), mock, GPUCreateOptions{ + Name: "repro", + Count: 1, + Parallel: 1, + InstanceTypes: []InstanceSpec{{Type: "test.fail.quota"}}, + Timeout: time.Second, + }) + + require.Error(t, err) + assert.ErrorContains(t, err, "InsufficientQuota") +} + func TestInlineLaunchableLifeCycleScript(t *testing.T) { t.Run("fetches and inlines lifecycle script body", func(t *testing.T) { mockStore := NewMockGPUCreateStore() diff --git a/pkg/cmd/grantssh/grantssh_test.go b/pkg/cmd/grantssh/grantssh_test.go index 1b2f6aa2e..c3b76e77b 100644 --- a/pkg/cmd/grantssh/grantssh_test.go +++ b/pkg/cmd/grantssh/grantssh_test.go @@ -15,6 +15,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/entity" "github.com/brevdev/brev-cli/pkg/externalnode" + "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" ) @@ -47,7 +48,7 @@ type mockNodeClientFactory struct { } func (m mockNodeClientFactory) NewNodeClient(provider externalnode.TokenProvider, _ string) nodev1connect.ExternalNodeServiceClient { - return register.NewNodeServiceClient(provider, m.serverURL) + return store.NewDevPlaneClient(provider, m.serverURL).ExternalNodes } // mockRegistrationStore satisfies register.RegistrationStore. diff --git a/pkg/cmd/launch/launch_test.go b/pkg/cmd/launch/launch_test.go index ac60c398c..9d23cea96 100644 --- a/pkg/cmd/launch/launch_test.go +++ b/pkg/cmd/launch/launch_test.go @@ -15,6 +15,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/gpusearch" "github.com/brevdev/brev-cli/pkg/entity" + "github.com/brevdev/brev-cli/pkg/environment" "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" "github.com/stretchr/testify/assert" @@ -119,6 +120,10 @@ type fakeLaunchStore struct { func (f *fakeLaunchStore) GetAccessToken() (string, error) { return "token", nil } +func (f *fakeLaunchStore) GetEnvironmentStatus(string) (environment.Status, error) { + return environment.Status{}, nil +} + func (f *fakeLaunchStore) GetCurrentUser() (*entity.User, error) { return &entity.User{ID: "user-1", GlobalUserType: "Standard"}, nil } diff --git a/pkg/cmd/launch/secrets.go b/pkg/cmd/launch/secrets.go index 2e23afdcf..883ba0ca7 100644 --- a/pkg/cmd/launch/secrets.go +++ b/pkg/cmd/launch/secrets.go @@ -3,7 +3,6 @@ package launch import ( "context" - "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/config" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/managedsecret" @@ -23,7 +22,7 @@ type devplaneManagedSecretResolver struct { func newDevplaneManagedSecretResolver(provider Store) managedSecretResolver { return devplaneManagedSecretResolver{ client: managedsecret.NewClient( - register.NewManagedSecretServiceClient(provider, config.GlobalConfig.GetBrevPublicAPIURL()), + store.NewDevPlaneClient(provider, config.GlobalConfig.GetBrevPublicAPIURL()).ManagedSecrets, ), } } diff --git a/pkg/cmd/ls/ls.go b/pkg/cmd/ls/ls.go index fcf09af0d..4e7687fb7 100644 --- a/pkg/cmd/ls/ls.go +++ b/pkg/cmd/ls/ls.go @@ -18,11 +18,11 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" "github.com/brevdev/brev-cli/pkg/cmd/gpusearch" "github.com/brevdev/brev-cli/pkg/cmd/hello" - "github.com/brevdev/brev-cli/pkg/cmd/register" cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" "github.com/brevdev/brev-cli/pkg/cmdcontext" "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" + "github.com/brevdev/brev-cli/pkg/environment" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/featureflag" "github.com/brevdev/brev-cli/pkg/store" @@ -41,6 +41,7 @@ type LsStore interface { GetWorkspace(workspaceID string) (*entity.Workspace, error) GetOrganizations(options *store.GetOrganizationsOptions) ([]entity.Organization, error) externalnode.TokenProvider + DevPlane() *store.DevPlaneClient GetAuthTokens() (*entity.AuthTokens, error) GetInstanceTypes(includeCPU bool) (*gpusearch.InstanceTypesResponse, error) hello.HelloStore @@ -130,6 +131,38 @@ func getOrgForRunLs(lsStore LsStore) (*entity.Organization, error) { return org, nil } +// It is a package var so tests can stub +var listDevPlaneEnvironments = func(lsStore LsStore, orgID string) (map[string]environment.Status, error) { + devPlane := lsStore.DevPlane() + client := environment.NewClient(devPlane.Environments, devPlane.Organizations) + return client.EnvironmentStatusByOrg(context.Background(), orgID) +} + +// overlayDevPlaneStatuses applies authoritative dev-plane status, returning an +// error instead of silently falling back to brev-deploy's derived status. +func (ls Ls) overlayDevPlaneStatuses(workspaces []entity.Workspace, statuses map[string]environment.Status, fetchErr error) error { + if fetchErr != nil { + return breverrors.WrapAndTrace(fetchErr) + } + applyDevPlaneStatuses(workspaces, statuses) + return nil +} + +func applyDevPlaneStatuses(workspaces []entity.Workspace, statuses map[string]environment.Status) { + for i := range workspaces { + status, ok := statuses[workspaces[i].ID] + if !ok { + continue + } + if status.Display != "" { + workspaces[i].Status = status.Display + } + if status.Message != "" { + workspaces[i].StatusMessage = status.Message + } + } +} + func RunLs(t *terminal.Terminal, cliAuth auth.CLIAuth, lsStore LsStore, args []string, showAll bool, jsonOutput bool) error { ls := NewLs(lsStore, t, jsonOutput) @@ -411,9 +444,11 @@ func (ls Ls) RunWorkspaces(cliAuth auth.CLIAuth, org *entity.Organization, showA var wsErr error var gpuLookup map[string]string var nodes []*nodev1.ExternalNode + var environmentStatuses map[string]environment.Status + var envErr error var wg sync.WaitGroup - wg.Add(2) + wg.Add(3) if showAll { wg.Add(1) } @@ -425,6 +460,10 @@ func (ls Ls) RunWorkspaces(cliAuth auth.CLIAuth, org *entity.Organization, showA defer wg.Done() gpuLookup = buildGPULookup(ls.lsStore) }() + go func() { + defer wg.Done() + environmentStatuses, envErr = listDevPlaneEnvironments(ls.lsStore, org.ID) + }() if showAll { go func() { defer wg.Done() @@ -443,6 +482,10 @@ func (ls Ls) RunWorkspaces(cliAuth auth.CLIAuth, org *entity.Organization, showA return breverrors.WrapAndTrace(wsErr) } + if err := ls.overlayDevPlaneStatuses(allWorkspaces, environmentStatuses, envErr); err != nil { + return err + } + // Determine which workspaces to show var workspacesToShow []entity.Workspace switch { @@ -727,7 +770,7 @@ type NodeInfo struct { } func (ls Ls) listNodes(org *entity.Organization) ([]*nodev1.ExternalNode, error) { - client := register.NewNodeServiceClient(ls.lsStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := ls.lsStore.DevPlane().ExternalNodes resp, err := client.ListNodes(context.Background(), connect.NewRequest(&nodev1.ListNodesRequest{ OrganizationId: org.ID, })) diff --git a/pkg/cmd/ls/ls_test.go b/pkg/cmd/ls/ls_test.go index b2322f58e..5fefd6c87 100644 --- a/pkg/cmd/ls/ls_test.go +++ b/pkg/cmd/ls/ls_test.go @@ -14,14 +14,46 @@ import ( authpkg "github.com/brevdev/brev-cli/pkg/auth" "github.com/brevdev/brev-cli/pkg/cmd/gpusearch" + "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" + "github.com/brevdev/brev-cli/pkg/environment" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" + "github.com/stretchr/testify/assert" ) const testAPIKey = authpkg.BrevAPIKeyPrefix + "test-key" +// TestMain stubs the dev-plane status source so unit tests never make network calls. +func TestMain(m *testing.M) { + listDevPlaneEnvironments = func(LsStore, string) (map[string]environment.Status, error) { + return nil, nil + } + os.Exit(m.Run()) +} + +func TestApplyDevPlaneStatusesOverridesDivergentStatus(t *testing.T) { + workspaces := []entity.Workspace{ + {ID: "env-failed", Name: "failed", Status: entity.Running}, + {ID: "env-running", Name: "running", Status: entity.Deploying}, + {ID: "env-unknown", Name: "unknown", Status: entity.Deploying}, + } + statuses := map[string]environment.Status{ + "env-failed": {Name: "failed", Display: entity.Failure, Failed: true, Message: "out of quota"}, + "env-running": {Name: "running", Display: entity.Running, Ready: true}, + } + + applyDevPlaneStatuses(workspaces, statuses) + + // Instance-derived status said RUNNING, but the environment failed. + assert.Equal(t, entity.Failure, workspaces[0].Status) + assert.Equal(t, "out of quota", workspaces[0].StatusMessage) + assert.Equal(t, entity.Running, workspaces[1].Status) + // Environments absent from the dev-plane response keep their existing status. + assert.Equal(t, entity.Deploying, workspaces[2].Status) +} + // mockLsStore implements LsStore (including the embedded hello.HelloStore) for // testing the ls command routing without real API calls. type mockLsStore struct { @@ -48,6 +80,10 @@ func (m *mockLsStore) GetAccessToken() (string, error) { return "tok", nil } +func (m *mockLsStore) DevPlane() *store.DevPlaneClient { + return store.NewDevPlaneClient(m, config.GlobalConfig.GetBrevPublicAPIURL()) +} + func (m *mockLsStore) GetWorkspace(_ string) (*entity.Workspace, error) { return nil, nil } diff --git a/pkg/cmd/mintcert/mintcert.go b/pkg/cmd/mintcert/mintcert.go index f2b7d4a6f..a0714aef4 100644 --- a/pkg/cmd/mintcert/mintcert.go +++ b/pkg/cmd/mintcert/mintcert.go @@ -15,11 +15,11 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" - "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/config" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/externalnode" "github.com/brevdev/brev-cli/pkg/sshcert" + "github.com/brevdev/brev-cli/pkg/store" ) const timeout = 15 * time.Second @@ -191,8 +191,9 @@ func runMintCertWith(store Store, fs afero.Fs, issuer CertIssuer, req mintCertRe } func newCertIssuer(provider externalnode.TokenProvider, baseURL string) CertIssuer { + devPlane := store.NewDevPlaneClient(provider, baseURL) return rpcCertIssuer{ - client: register.NewEnvironmentServiceClient(provider, baseURL), - nodeClient: register.NewNodeServiceClient(provider, baseURL), + client: devPlane.Environments, + nodeClient: devPlane.ExternalNodes, } } diff --git a/pkg/cmd/ports/close.go b/pkg/cmd/ports/close.go index b35b989f4..07fbf0e44 100644 --- a/pkg/cmd/ports/close.go +++ b/pkg/cmd/ports/close.go @@ -11,9 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" - "github.com/brevdev/brev-cli/pkg/cmd/register" cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" - "github.com/brevdev/brev-cli/pkg/config" breverrors "github.com/brevdev/brev-cli/pkg/errors" ) @@ -146,14 +144,14 @@ func closePort( portID string, ) error { if target.Workspace != nil { - client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().Environments _, err := client.ClosePort(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceClosePortRequest{ PortId: portID, })) return breverrors.WrapAndTrace(err) } if target.Node != nil { - client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().ExternalNodes _, err := client.ClosePort(ctx, connect.NewRequest(&devplanev1.ClosePortRequest{ PortId: portID, })) diff --git a/pkg/cmd/ports/open.go b/pkg/cmd/ports/open.go index 02edd0886..cea2dc85f 100644 --- a/pkg/cmd/ports/open.go +++ b/pkg/cmd/ports/open.go @@ -15,9 +15,7 @@ import ( "github.com/spf13/cobra" "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" - "github.com/brevdev/brev-cli/pkg/cmd/register" cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" - "github.com/brevdev/brev-cli/pkg/config" breverrors "github.com/brevdev/brev-cli/pkg/errors" ) @@ -190,7 +188,7 @@ func OpenHTTP( if err != nil { return err } - client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().Environments resp, err := client.OpenHTTPPort(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceOpenHTTPPortRequest{ EnvironmentId: target.Workspace.ID, PortNumber: portNumber, @@ -210,7 +208,7 @@ func OpenHTTP( if err != nil { return err } - client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().ExternalNodes resp, err := client.OpenHTTPPort(ctx, connect.NewRequest(&devplanev1.OpenHTTPPortRequest{ ExternalNodeId: target.Node.GetExternalNodeId(), PortNumber: portNumber, @@ -251,7 +249,7 @@ func Open( var openedPort *devplanev1.Port if target.Workspace != nil { - client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().Environments resp, err := client.OpenPort(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceOpenPortRequest{ EnvironmentId: target.Workspace.ID, Protocol: protocol, @@ -265,7 +263,7 @@ func Open( openedPort = resp.Msg.GetPort() } } else if target.Node != nil { - client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().ExternalNodes resp, err := client.OpenPort(ctx, connect.NewRequest(&devplanev1.OpenPortRequest{ ExternalNodeId: target.Node.GetExternalNodeId(), Protocol: protocol, @@ -306,7 +304,7 @@ func OpenSequential( var openedPorts []*devplanev1.Port if target.Workspace != nil { - client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().Environments resp, err := client.OpenSequentialPorts(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceOpenSequentialPortsRequest{ EnvironmentId: target.Workspace.ID, Protocol: protocol, @@ -321,7 +319,7 @@ func OpenSequential( openedPorts = resp.Msg.GetPorts() } } else if target.Node != nil { - client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().ExternalNodes resp, err := client.OpenSequentialPorts(ctx, connect.NewRequest(&devplanev1.OpenSequentialPortsRequest{ ExternalNodeId: target.Node.GetExternalNodeId(), Protocol: protocol, diff --git a/pkg/cmd/ports/ports.go b/pkg/cmd/ports/ports.go index 7339e3dc5..277d8a7cb 100644 --- a/pkg/cmd/ports/ports.go +++ b/pkg/cmd/ports/ports.go @@ -17,9 +17,7 @@ import ( "github.com/spf13/cobra" "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" - "github.com/brevdev/brev-cli/pkg/cmd/register" cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" - "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" ) @@ -121,7 +119,7 @@ func resolveTargetPorts( var apiPorts []*devplanev1.Port if target.Workspace != nil { - client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().Environments resp, err := client.GetNetworkInfo(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceGetNetworkInfoRequest{ EnvironmentId: target.Workspace.ID, })) @@ -167,7 +165,7 @@ func resolvePortID( //nolint:gocyclo // A port may belong to either supported ow if err != nil { return nil, nil, breverrors.WrapAndTrace(err) } - environmentClient := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + environmentClient := portStore.DevPlane().Environments var lookupErr error for i := range workspaces { workspace := &workspaces[i] @@ -192,7 +190,7 @@ func resolvePortID( //nolint:gocyclo // A port may belong to either supported ow if err != nil { return nil, nil, breverrors.WrapAndTrace(err) } - nodeClient := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + nodeClient := portStore.DevPlane().ExternalNodes nodesResp, err := nodeClient.ListNodes(ctx, connect.NewRequest(&devplanev1.ListNodesRequest{ OrganizationId: org.ID, Options: &devplanev1.ListNodesOptions{ diff --git a/pkg/cmd/ports/ports_test.go b/pkg/cmd/ports/ports_test.go index e47f83cb8..fe3f9626f 100644 --- a/pkg/cmd/ports/ports_test.go +++ b/pkg/cmd/ports/ports_test.go @@ -12,7 +12,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" + "github.com/brevdev/brev-cli/pkg/store" ) type fakeStore struct { @@ -45,6 +47,10 @@ func (s *fakeStore) GetAccessToken() (string, error) { return "test-token", nil } +func (s *fakeStore) DevPlane() *store.DevPlaneClient { + return store.NewDevPlaneClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) +} + type fakeEnvironmentService struct { devplanev1connect.UnimplementedEnvironmentServiceHandler t *testing.T diff --git a/pkg/cmd/ports/update.go b/pkg/cmd/ports/update.go index cc4a2183e..45074e69d 100644 --- a/pkg/cmd/ports/update.go +++ b/pkg/cmd/ports/update.go @@ -10,9 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" - "github.com/brevdev/brev-cli/pkg/cmd/register" cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" - "github.com/brevdev/brev-cli/pkg/config" breverrors "github.com/brevdev/brev-cli/pkg/errors" ) @@ -266,7 +264,7 @@ func setPortTarget( destinationPort int32, ) (*devplanev1.Port, error) { if target.Workspace != nil { - client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().Environments resp, err := client.SetPortTarget(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceSetPortTargetRequest{ PortId: portID, PortNumber: destinationPort, })) @@ -279,7 +277,7 @@ func setPortTarget( return resp.Msg.GetPort(), nil } if target.Node != nil { - client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().ExternalNodes resp, err := client.SetPortTarget(ctx, connect.NewRequest(&devplanev1.SetPortTargetRequest{ PortId: portID, PortNumber: destinationPort, })) @@ -302,7 +300,7 @@ func setPortAllowedSources( allowedSources []string, ) (*devplanev1.Port, error) { if target.Workspace != nil { - client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().Environments resp, err := client.SetPortAllowedSources(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceSetPortAllowedSourcesRequest{ PortId: portID, AllowedSources: &devplanev1.EnvironmentServiceSetPortAllowedSourcesRequestAllowedSources{ @@ -318,7 +316,7 @@ func setPortAllowedSources( return resp.Msg.GetPort(), nil } if target.Node != nil { - client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().ExternalNodes resp, err := client.SetPortAllowedSources(ctx, connect.NewRequest(&devplanev1.SetPortAllowedSourcesRequest{ PortId: portID, AllowedSources: allowedSources, })) @@ -342,7 +340,7 @@ func setHTTPPortProtocol( protocol devplanev1.HttpPortProtocol, ) (*devplanev1.Port, error) { if target.Workspace != nil { - client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().Environments resp, err := client.SetHTTPPortProtocol(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceSetHTTPPortProtocolRequest{ PortId: portID, HttpProtocol: protocol, })) @@ -355,7 +353,7 @@ func setHTTPPortProtocol( return resp.Msg.GetPort(), nil } if target.Node != nil { - client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().ExternalNodes resp, err := client.SetHTTPPortProtocol(ctx, connect.NewRequest(&devplanev1.SetHTTPPortProtocolRequest{ PortId: portID, HttpProtocol: protocol, })) @@ -379,7 +377,7 @@ func setHTTPPortAccess( public bool, ) (*devplanev1.Port, error) { if target.Workspace != nil { - client := register.NewEnvironmentServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().Environments resp, err := client.SetHTTPPortAccess(ctx, connect.NewRequest(&devplanev1.EnvironmentServiceSetHTTPPortAccessRequest{ PortId: portID, AuthorizedEmails: &devplanev1.EnvironmentServiceSetHTTPPortAccessRequestAuthorizedEmails{ @@ -396,7 +394,7 @@ func setHTTPPortAccess( return resp.Msg.GetPort(), nil } if target.Node != nil { - client := register.NewNodeServiceClient(portStore, config.GlobalConfig.GetBrevPublicAPIURL()) + client := portStore.DevPlane().ExternalNodes resp, err := client.SetHTTPPortAccess(ctx, connect.NewRequest(&devplanev1.SetHTTPPortAccessRequest{ PortId: portID, AuthorizedEmails: authorizedEmails, diff --git a/pkg/cmd/refresh/refresh.go b/pkg/cmd/refresh/refresh.go index 07f0d496e..073730eec 100644 --- a/pkg/cmd/refresh/refresh.go +++ b/pkg/cmd/refresh/refresh.go @@ -12,10 +12,8 @@ import ( nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" "connectrpc.com/connect" - "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/cmd/util" "github.com/brevdev/brev-cli/pkg/cmdcontext" - "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/ssh" @@ -37,6 +35,7 @@ type RefreshStore interface { MkdirAll(string, fs.FileMode) error GetBrevCloudflaredBinaryPath() (string, error) Create(string) (io.WriteCloser, error) + DevPlane() *store.DevPlaneClient } func NewCmdRefresh(t *terminal.Terminal, store RefreshStore) *cobra.Command { @@ -176,7 +175,7 @@ func getExternalNodeSSHEntries(store RefreshStore, identity refreshIdentity) []s } org, user := identity.org, identity.user - client := register.NewNodeServiceClient(store, config.GlobalConfig.GetBrevPublicAPIURL()) + client := store.DevPlane().ExternalNodes resp, err := client.ListNodes(context.Background(), connect.NewRequest(&nodev1.ListNodesRequest{ OrganizationId: org.ID, Options: &nodev1.ListNodesOptions{ diff --git a/pkg/cmd/refresh/refresh_test.go b/pkg/cmd/refresh/refresh_test.go index a3c8bce06..9ccdffbeb 100644 --- a/pkg/cmd/refresh/refresh_test.go +++ b/pkg/cmd/refresh/refresh_test.go @@ -8,7 +8,9 @@ import ( "github.com/stretchr/testify/require" "github.com/brevdev/brev-cli/pkg/cmd/util" + "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" + "github.com/brevdev/brev-cli/pkg/store" ) func strPtr(s string) *string { return &s } @@ -40,6 +42,10 @@ func (s *identityCountingStore) GetContextWorkspaces() ([]entity.Workspace, erro return s.workspaces, nil } +func (s *identityCountingStore) DevPlane() *store.DevPlaneClient { + return store.NewDevPlaneClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) +} + func (s *identityCountingStore) GetContextWorkspacesFor(orgID, userID string) ([]entity.Workspace, error) { s.forIdentity = append(s.forIdentity, orgID+"/"+userID) return s.workspaces, nil diff --git a/pkg/cmd/refresh/sshaccess.go b/pkg/cmd/refresh/sshaccess.go index 8ddb4e4c7..caee70e32 100644 --- a/pkg/cmd/refresh/sshaccess.go +++ b/pkg/cmd/refresh/sshaccess.go @@ -9,8 +9,6 @@ import ( devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" "connectrpc.com/connect" - "github.com/brevdev/brev-cli/pkg/cmd/register" - "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/sshcert" @@ -48,7 +46,7 @@ func (s workspaceSSHStore) GetContextWorkspaces() ([]entity.Workspace, error) { } } - client := register.NewEnvironmentServiceClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) + client := s.DevPlane().Environments ctx, cancel := context.WithTimeout(context.Background(), sshAccessRefreshBudget) defer cancel() diff --git a/pkg/cmd/register/providers.go b/pkg/cmd/register/providers.go index 6970b80e1..e2bdbc17f 100644 --- a/pkg/cmd/register/providers.go +++ b/pkg/cmd/register/providers.go @@ -11,6 +11,7 @@ import ( nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" "github.com/brevdev/brev-cli/pkg/externalnode" + "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" ) @@ -93,5 +94,5 @@ func (ShellSetupRunner) RunSetup(script string) error { return runSetupCommand(s type DefaultNodeClientFactory struct{} func (DefaultNodeClientFactory) NewNodeClient(provider externalnode.TokenProvider, baseURL string) nodev1connect.ExternalNodeServiceClient { - return NewNodeServiceClient(provider, baseURL) + return store.NewDevPlaneClient(provider, baseURL).ExternalNodes } diff --git a/pkg/cmd/register/register.go b/pkg/cmd/register/register.go index 9da4b170f..98c6e57ca 100644 --- a/pkg/cmd/register/register.go +++ b/pkg/cmd/register/register.go @@ -516,3 +516,76 @@ func resumeRegistration(ctx context.Context, t *terminal.Terminal, s RegisterSto org := &entity.Organization{ID: pending.OrgID, Name: pending.OrgName} return runRegisterSteps(ctx, t, s, pending.DisplayName, org, deps, pending.DeviceID, registrationToken) } + +// toProtoNodeSpec converts the local HardwareProfile (used for collection, display, +// persistence) to the generated proto NodeSpec for RPC calls. +func toProtoNodeSpec(hw *HardwareProfile) *nodev1.NodeSpec { + if hw == nil { + return nil + } + + proto := &nodev1.NodeSpec{ + RamBytes: hw.RAMBytes, + CpuCount: hw.CPUCount, + } + + for _, st := range hw.Storage { + storageSpec := &nodev1.StorageSpec{ + StorageBytes: st.StorageBytes, + StorageType: st.StorageType, + } + if st.Name != "" { + storageSpec.Device = &st.Name + } + proto.Storage = append(proto.Storage, storageSpec) + } + + if hw.Architecture != "" { + proto.Architecture = &hw.Architecture + } + if hw.OS != "" { + proto.Os = &hw.OS + } + if hw.OSVersion != "" { + proto.OsVersion = &hw.OSVersion + } + + if hw.ProductName != "" { + proto.ProductName = &hw.ProductName + } + + for _, g := range hw.GPUs { + pg := &nodev1.GPUSpec{ + Model: g.Model, + Count: g.Count, + MemoryBytes: g.MemoryBytes, + } + if g.Architecture != "" { + pg.GpuArchitecture = &g.Architecture + } + proto.Gpus = append(proto.Gpus, pg) + } + + for _, ic := range hw.Interconnects { + spec := &nodev1.InterconnectSpec{Device: ic.Device} + switch ic.Type { + case "NVLink": + spec.Details = &nodev1.InterconnectSpec_Nvlink{ + Nvlink: &nodev1.NVLinkDetails{ + ActiveLinks: int32(ic.ActiveLinks), + Version: ic.Version, + }, + } + case "PCIe": + spec.Details = &nodev1.InterconnectSpec_Pcie{ + Pcie: &nodev1.PCIeDetails{ + Generation: int32(ic.Generation), + Width: int32(ic.Width), + }, + } + } + proto.Interconnects = append(proto.Interconnects, spec) + } + + return proto +} diff --git a/pkg/cmd/register/register_test.go b/pkg/cmd/register/register_test.go index 68a7a95b8..df32729c6 100644 --- a/pkg/cmd/register/register_test.go +++ b/pkg/cmd/register/register_test.go @@ -15,6 +15,7 @@ import ( "github.com/brevdev/brev-cli/pkg/auth" "github.com/brevdev/brev-cli/pkg/entity" "github.com/brevdev/brev-cli/pkg/externalnode" + "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/sudo" "github.com/brevdev/brev-cli/pkg/terminal" ) @@ -154,7 +155,7 @@ type mockNodeClientFactory struct { } func (m mockNodeClientFactory) NewNodeClient(provider externalnode.TokenProvider, _ string) nodev1connect.ExternalNodeServiceClient { - return NewNodeServiceClient(provider, m.serverURL) + return store.NewDevPlaneClient(provider, m.serverURL).ExternalNodes } // testHardwareProfile returns a realistic HardwareProfile for use in tests. @@ -984,3 +985,301 @@ func Test_isAuthenticated(t *testing.T) { } }) } + +type staticTokenProvider struct{ token string } + +func (p staticTokenProvider) GetAccessToken() (string, error) { return p.token, nil } + +func Test_toProtoNodeSpec(t *testing.T) { + cpuCount := int32(12) + ramBytes := int64(137438953472) + memBytes := int64(137438953472) + + local := &HardwareProfile{ + GPUs: []GPU{ + {Model: "NVIDIA GB10", Architecture: "Blackwell", Count: 2, MemoryBytes: &memBytes}, + }, + RAMBytes: &ramBytes, + CPUCount: &cpuCount, + Architecture: "arm64", + Storage: []StorageDevice{ + {Name: "nvme0n1", StorageBytes: 500107862016, StorageType: "SSD"}, + }, + OS: "Ubuntu", + OSVersion: "24.04", + ProductName: "DGX Spark", + Interconnects: []Interconnect{ + {Type: "NVLink", Device: "GPU 0", ActiveLinks: 4, Version: 4}, + }, + } + + proto := toProtoNodeSpec(local) + + if proto.GetCpuCount() != 12 { + t.Errorf("expected CpuCount 12, got %d", proto.GetCpuCount()) + } + if proto.GetRamBytes() != 137438953472 { + t.Errorf("expected RamBytes, got %d", proto.GetRamBytes()) + } + if proto.GetArchitecture() != "arm64" { + t.Errorf("expected arm64, got %s", proto.GetArchitecture()) + } + if proto.GetOs() != "Ubuntu" { + t.Errorf("expected Ubuntu, got %s", proto.GetOs()) + } + if proto.GetOsVersion() != "24.04" { + t.Errorf("expected 24.04, got %s", proto.GetOsVersion()) + } + if len(proto.GetStorage()) != 1 { + t.Fatalf("expected 1 storage entry, got %d", len(proto.GetStorage())) + } + if proto.GetStorage()[0].GetStorageBytes() != 500107862016 { + t.Errorf("expected StorageBytes 500107862016, got %d", proto.GetStorage()[0].GetStorageBytes()) + } + if proto.GetStorage()[0].GetStorageType() != "SSD" { + t.Errorf("expected SSD, got %s", proto.GetStorage()[0].GetStorageType()) + } + if len(proto.GetGpus()) != 1 { + t.Fatalf("expected 1 GPU, got %d", len(proto.GetGpus())) + } + gpu := proto.GetGpus()[0] + if gpu.GetModel() != "NVIDIA GB10" { + t.Errorf("expected NVIDIA GB10, got %s", gpu.GetModel()) + } + if gpu.GetCount() != 2 { + t.Errorf("expected count 2, got %d", gpu.GetCount()) + } + if gpu.GetMemoryBytes() != 137438953472 { + t.Errorf("expected memory bytes, got %d", gpu.GetMemoryBytes()) + } +} + +func Test_toProtoNodeSpec_PCIeInterconnect(t *testing.T) { + local := &HardwareProfile{ + Architecture: "amd64", + Interconnects: []Interconnect{ + {Type: "PCIe", Device: "GPU 0", Generation: 4, Width: 16}, + }, + } + + proto := toProtoNodeSpec(local) + + if len(proto.GetInterconnects()) != 1 { + t.Fatalf("expected 1 interconnect, got %d", len(proto.GetInterconnects())) + } + ic := proto.GetInterconnects()[0] + if ic.GetDevice() != "GPU 0" { + t.Errorf("expected device 'GPU 0', got %s", ic.GetDevice()) + } + pcie := ic.GetPcie() + if pcie == nil { + t.Fatal("expected PCIe details, got nil") + } + if pcie.GetGeneration() != 4 { + t.Errorf("expected PCIe Gen 4, got %d", pcie.GetGeneration()) + } + if pcie.GetWidth() != 16 { + t.Errorf("expected PCIe Width 16, got %d", pcie.GetWidth()) + } +} + +func Test_toProtoNodeSpec_Nil(t *testing.T) { + if toProtoNodeSpec(nil) != nil { + t.Error("expected nil for nil input") + } +} + +func Test_toProtoNodeSpec_MinimalFields(t *testing.T) { + local := &HardwareProfile{ + Architecture: "amd64", + } + proto := toProtoNodeSpec(local) + if proto.GetArchitecture() != "amd64" { + t.Errorf("expected amd64, got %s", proto.GetArchitecture()) + } + if proto.RamBytes != nil { + t.Error("expected nil RamBytes") + } + if proto.CpuCount != nil { + t.Error("expected nil CpuCount") + } + if len(proto.GetGpus()) != 0 { + t.Error("expected no GPUs") + } +} + +// fakeNodeService implements the server side of ExternalNodeService for testing. +type fakeNodeService struct { + nodev1connect.UnimplementedExternalNodeServiceHandler + addNodeFn func(*nodev1.AddNodeRequest) (*nodev1.AddNodeResponse, error) + removeNodeFn func(*nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) + getNodeFn func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) + openPortFn func(*nodev1.OpenPortRequest) (*nodev1.OpenPortResponse, error) + grantNodeSSHAccessFn func(*nodev1.GrantNodeSSHAccessRequest) (*nodev1.GrantNodeSSHAccessResponse, error) +} + +func (f *fakeNodeService) AddNode(_ context.Context, req *connect.Request[nodev1.AddNodeRequest]) (*connect.Response[nodev1.AddNodeResponse], error) { + resp, err := f.addNodeFn(req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil +} + +func (f *fakeNodeService) RemoveNode(_ context.Context, req *connect.Request[nodev1.RemoveNodeRequest]) (*connect.Response[nodev1.RemoveNodeResponse], error) { + resp, err := f.removeNodeFn(req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil +} + +func (f *fakeNodeService) GetNode(_ context.Context, req *connect.Request[nodev1.GetNodeRequest]) (*connect.Response[nodev1.GetNodeResponse], error) { + if f.getNodeFn == nil { + return nil, connect.NewError(connect.CodeUnimplemented, nil) + } + resp, err := f.getNodeFn(req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil +} + +func (f *fakeNodeService) OpenPort(_ context.Context, req *connect.Request[nodev1.OpenPortRequest]) (*connect.Response[nodev1.OpenPortResponse], error) { + if f.openPortFn == nil { + return connect.NewResponse(&nodev1.OpenPortResponse{ + Port: &nodev1.Port{ + PortId: "port_ssh", + Protocol: req.Msg.GetProtocol(), + PortNumber: req.Msg.GetPortNumber(), + ServerPort: req.Msg.GetPortNumber(), + }, + }), nil + } + resp, err := f.openPortFn(req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil +} + +func (f *fakeNodeService) GrantNodeSSHAccess(_ context.Context, req *connect.Request[nodev1.GrantNodeSSHAccessRequest]) (*connect.Response[nodev1.GrantNodeSSHAccessResponse], error) { + if f.grantNodeSSHAccessFn == nil { + return nil, connect.NewError(connect.CodeUnimplemented, nil) + } + resp, err := f.grantNodeSSHAccessFn(req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil +} + +func Test_ExternalNodeServiceClient_AddNode(t *testing.T) { + svc := &fakeNodeService{ + addNodeFn: func(req *nodev1.AddNodeRequest) (*nodev1.AddNodeResponse, error) { + if req.GetOrganizationId() != "org_123" { + t.Errorf("unexpected org ID: %s", req.GetOrganizationId()) + } + if req.GetName() != "My Spark" { + t.Errorf("unexpected name: %s", req.GetName()) + } + return &nodev1.AddNodeResponse{ + ExternalNode: &nodev1.ExternalNode{ + ExternalNodeId: "unode_abc", + OrganizationId: "org_123", + Name: req.GetName(), + DeviceId: req.GetDeviceId(), + }, + }, nil + }, + } + + _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) + server := httptest.NewServer(handler) + defer server.Close() + + client := store.NewDevPlaneClient(&staticTokenProvider{token: "tok"}, server.URL).ExternalNodes + + resp, err := client.AddNode(context.Background(), connect.NewRequest(&nodev1.AddNodeRequest{ + OrganizationId: "org_123", + Name: "My Spark", + DeviceId: "dev-uuid", + NodeSpec: &nodev1.NodeSpec{Architecture: strPtr("arm64")}, + })) + if err != nil { + t.Fatalf("AddNode failed: %v", err) + } + if resp.Msg.GetExternalNode().GetExternalNodeId() != "unode_abc" { + t.Errorf("unexpected node ID: %s", resp.Msg.GetExternalNode().GetExternalNodeId()) + } +} + +func Test_ExternalNodeServiceClient_AddNode_ServerError(t *testing.T) { + svc := &fakeNodeService{ + addNodeFn: func(_ *nodev1.AddNodeRequest) (*nodev1.AddNodeResponse, error) { + return nil, connect.NewError(connect.CodeInternal, nil) + }, + } + + _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) + server := httptest.NewServer(handler) + defer server.Close() + + client := store.NewDevPlaneClient(&staticTokenProvider{token: "tok"}, server.URL).ExternalNodes + + _, err := client.AddNode(context.Background(), connect.NewRequest(&nodev1.AddNodeRequest{ + OrganizationId: "org_123", + Name: "Test", + DeviceId: "dev", + })) + if err == nil { + t.Fatal("expected error for server error response") + } +} + +func Test_ExternalNodeServiceClient_RemoveNode(t *testing.T) { + svc := &fakeNodeService{ + removeNodeFn: func(req *nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) { + if req.GetExternalNodeId() != "unode_abc" { + t.Errorf("unexpected node ID: %s", req.GetExternalNodeId()) + } + return &nodev1.RemoveNodeResponse{}, nil + }, + } + + _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) + server := httptest.NewServer(handler) + defer server.Close() + + client := store.NewDevPlaneClient(&staticTokenProvider{token: "tok"}, server.URL).ExternalNodes + + _, err := client.RemoveNode(context.Background(), connect.NewRequest(&nodev1.RemoveNodeRequest{ + ExternalNodeId: "unode_abc", + })) + if err != nil { + t.Fatalf("RemoveNode failed: %v", err) + } +} + +func Test_ExternalNodeServiceClient_RemoveNode_ServerError(t *testing.T) { + svc := &fakeNodeService{ + removeNodeFn: func(_ *nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) { + return nil, connect.NewError(connect.CodeNotFound, nil) + }, + } + + _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) + server := httptest.NewServer(handler) + defer server.Close() + + client := store.NewDevPlaneClient(&staticTokenProvider{token: "tok"}, server.URL).ExternalNodes + + _, err := client.RemoveNode(context.Background(), connect.NewRequest(&nodev1.RemoveNodeRequest{ + ExternalNodeId: "unode_missing", + })) + if err == nil { + t.Fatal("expected error for not found response") + } +} + +func strPtr(s string) *string { return &s } diff --git a/pkg/cmd/register/rpcclient.go b/pkg/cmd/register/rpcclient.go deleted file mode 100644 index 3689a199e..000000000 --- a/pkg/cmd/register/rpcclient.go +++ /dev/null @@ -1,145 +0,0 @@ -package register - -import ( - "net/http" - - nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" - nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" - - breverrors "github.com/brevdev/brev-cli/pkg/errors" - "github.com/brevdev/brev-cli/pkg/externalnode" - "github.com/brevdev/brev-cli/pkg/store" -) - -// bearerTokenTransport injects a Bearer token into every request. -// We use a custom RoundTripper instead of setting headers on individual -// client.Do() calls because ConnectRPC owns the HTTP requests internally — -// this is the only hook we have to add auth headers. -type bearerTokenTransport struct { - provider externalnode.TokenProvider - base http.RoundTripper -} - -func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { - token, err := t.provider.GetAccessToken() - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - req = req.Clone(req.Context()) - req.Header.Set("Authorization", "Bearer "+token) - store.AddCLIAttributionParams(req) - resp, err := t.base.RoundTrip(req) - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - return resp, nil -} - -// newAuthenticatedHTTPClient creates an http.Client that injects the bearer token -// from the given provider on every request. -func newAuthenticatedHTTPClient(provider externalnode.TokenProvider) *http.Client { - return &http.Client{ - Transport: &bearerTokenTransport{ - provider: provider, - base: http.DefaultTransport, - }, - } -} - -// NewNodeServiceClient creates a ConnectRPC ExternalNodeServiceClient using the -// given token provider for authentication. -func NewNodeServiceClient(provider externalnode.TokenProvider, baseURL string) nodev1connect.ExternalNodeServiceClient { - return nodev1connect.NewExternalNodeServiceClient( - newAuthenticatedHTTPClient(provider), - baseURL, - ) -} - -// NewEnvironmentServiceClient creates an authenticated ConnectRPC EnvironmentServiceClient. -func NewEnvironmentServiceClient(provider externalnode.TokenProvider, baseURL string) nodev1connect.EnvironmentServiceClient { - return nodev1connect.NewEnvironmentServiceClient( - newAuthenticatedHTTPClient(provider), - baseURL, - ) -} - -// NewManagedSecretServiceClient creates an authenticated ConnectRPC managed-secret client. -func NewManagedSecretServiceClient(provider externalnode.TokenProvider, baseURL string) nodev1connect.ManagedSecretServiceClient { - return nodev1connect.NewManagedSecretServiceClient( - newAuthenticatedHTTPClient(provider), - baseURL, - ) -} - -// toProtoNodeSpec converts the local HardwareProfile (used for collection, display, -// persistence) to the generated proto NodeSpec for RPC calls. -func toProtoNodeSpec(hw *HardwareProfile) *nodev1.NodeSpec { - if hw == nil { - return nil - } - - proto := &nodev1.NodeSpec{ - RamBytes: hw.RAMBytes, - CpuCount: hw.CPUCount, - } - - for _, st := range hw.Storage { - storageSpec := &nodev1.StorageSpec{ - StorageBytes: st.StorageBytes, - StorageType: st.StorageType, - } - if st.Name != "" { - storageSpec.Device = &st.Name - } - proto.Storage = append(proto.Storage, storageSpec) - } - - if hw.Architecture != "" { - proto.Architecture = &hw.Architecture - } - if hw.OS != "" { - proto.Os = &hw.OS - } - if hw.OSVersion != "" { - proto.OsVersion = &hw.OSVersion - } - - if hw.ProductName != "" { - proto.ProductName = &hw.ProductName - } - - for _, g := range hw.GPUs { - pg := &nodev1.GPUSpec{ - Model: g.Model, - Count: g.Count, - MemoryBytes: g.MemoryBytes, - } - if g.Architecture != "" { - pg.GpuArchitecture = &g.Architecture - } - proto.Gpus = append(proto.Gpus, pg) - } - - for _, ic := range hw.Interconnects { - spec := &nodev1.InterconnectSpec{Device: ic.Device} - switch ic.Type { - case "NVLink": - spec.Details = &nodev1.InterconnectSpec_Nvlink{ - Nvlink: &nodev1.NVLinkDetails{ - ActiveLinks: int32(ic.ActiveLinks), - Version: ic.Version, - }, - } - case "PCIe": - spec.Details = &nodev1.InterconnectSpec_Pcie{ - Pcie: &nodev1.PCIeDetails{ - Generation: int32(ic.Generation), - Width: int32(ic.Width), - }, - } - } - proto.Interconnects = append(proto.Interconnects, spec) - } - - return proto -} diff --git a/pkg/cmd/register/rpcclient_test.go b/pkg/cmd/register/rpcclient_test.go deleted file mode 100644 index 78ffdee42..000000000 --- a/pkg/cmd/register/rpcclient_test.go +++ /dev/null @@ -1,387 +0,0 @@ -package register - -import ( - "context" - "net/http" - "net/http/httptest" - "net/url" - "runtime" - "testing" - - nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" - nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" - "connectrpc.com/connect" - - "github.com/brevdev/brev-cli/pkg/cmd/version" -) - -type mockTokenProvider struct { - token string - err error -} - -func (m *mockTokenProvider) GetAccessToken() (string, error) { - return m.token, m.err -} - -func Test_bearerTokenTransport_InjectsHeader(t *testing.T) { - var gotAuth string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - provider := &mockTokenProvider{token: "test-token-123"} - client := newAuthenticatedHTTPClient(provider) - - req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) - resp, err := client.Do(req) - if err != nil { - t.Fatalf("request failed: %v", err) - } - defer resp.Body.Close() //nolint:errcheck // test - - if gotAuth != "Bearer test-token-123" { - t.Errorf("expected 'Bearer test-token-123', got %q", gotAuth) - } -} - -func Test_bearerTokenTransport_AddsCLIAttributionParams(t *testing.T) { - var gotQuery url.Values - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotQuery = r.URL.Query() - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - client := newAuthenticatedHTTPClient(&mockTokenProvider{token: "t"}) - - req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/devplaneapi.v1.ExternalNodeService/ListNodes?connect=v1", nil) - resp, err := client.Do(req) - if err != nil { - t.Fatalf("request failed: %v", err) - } - defer resp.Body.Close() //nolint:errcheck // test - - // Same attribution the REST client sends, so dev-plane sees one CLI identity. - if got := gotQuery.Get("utm_source"); got != "cli" { - t.Errorf("expected utm_source=cli, got %q", got) - } - if got := gotQuery.Get("cli_version"); got != version.Version { - t.Errorf("expected cli_version=%q, got %q", version.Version, got) - } - if got := gotQuery.Get("os"); got != runtime.GOOS { - t.Errorf("expected os=%q, got %q", runtime.GOOS, got) - } - // ConnectRPC's own protocol params must survive. - if got := gotQuery.Get("connect"); got != "v1" { - t.Errorf("expected existing query param to be preserved, got %q", got) - } -} - -func Test_bearerTokenTransport_PropagatesTokenError(t *testing.T) { - provider := &mockTokenProvider{err: http.ErrAbortHandler} - client := newAuthenticatedHTTPClient(provider) - - req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil) - resp, err := client.Do(req) - if err == nil { - resp.Body.Close() //nolint:errcheck // test - t.Fatal("expected error from token provider") - } -} - -func Test_toProtoNodeSpec(t *testing.T) { - cpuCount := int32(12) - ramBytes := int64(137438953472) - memBytes := int64(137438953472) - - local := &HardwareProfile{ - GPUs: []GPU{ - {Model: "NVIDIA GB10", Architecture: "Blackwell", Count: 2, MemoryBytes: &memBytes}, - }, - RAMBytes: &ramBytes, - CPUCount: &cpuCount, - Architecture: "arm64", - Storage: []StorageDevice{ - {Name: "nvme0n1", StorageBytes: 500107862016, StorageType: "SSD"}, - }, - OS: "Ubuntu", - OSVersion: "24.04", - ProductName: "DGX Spark", - Interconnects: []Interconnect{ - {Type: "NVLink", Device: "GPU 0", ActiveLinks: 4, Version: 4}, - }, - } - - proto := toProtoNodeSpec(local) - - if proto.GetCpuCount() != 12 { - t.Errorf("expected CpuCount 12, got %d", proto.GetCpuCount()) - } - if proto.GetRamBytes() != 137438953472 { - t.Errorf("expected RamBytes, got %d", proto.GetRamBytes()) - } - if proto.GetArchitecture() != "arm64" { - t.Errorf("expected arm64, got %s", proto.GetArchitecture()) - } - if proto.GetOs() != "Ubuntu" { - t.Errorf("expected Ubuntu, got %s", proto.GetOs()) - } - if proto.GetOsVersion() != "24.04" { - t.Errorf("expected 24.04, got %s", proto.GetOsVersion()) - } - if len(proto.GetStorage()) != 1 { - t.Fatalf("expected 1 storage entry, got %d", len(proto.GetStorage())) - } - if proto.GetStorage()[0].GetStorageBytes() != 500107862016 { - t.Errorf("expected StorageBytes 500107862016, got %d", proto.GetStorage()[0].GetStorageBytes()) - } - if proto.GetStorage()[0].GetStorageType() != "SSD" { - t.Errorf("expected SSD, got %s", proto.GetStorage()[0].GetStorageType()) - } - if len(proto.GetGpus()) != 1 { - t.Fatalf("expected 1 GPU, got %d", len(proto.GetGpus())) - } - gpu := proto.GetGpus()[0] - if gpu.GetModel() != "NVIDIA GB10" { - t.Errorf("expected NVIDIA GB10, got %s", gpu.GetModel()) - } - if gpu.GetCount() != 2 { - t.Errorf("expected count 2, got %d", gpu.GetCount()) - } - if gpu.GetMemoryBytes() != 137438953472 { - t.Errorf("expected memory bytes, got %d", gpu.GetMemoryBytes()) - } -} - -func Test_toProtoNodeSpec_PCIeInterconnect(t *testing.T) { - local := &HardwareProfile{ - Architecture: "amd64", - Interconnects: []Interconnect{ - {Type: "PCIe", Device: "GPU 0", Generation: 4, Width: 16}, - }, - } - - proto := toProtoNodeSpec(local) - - if len(proto.GetInterconnects()) != 1 { - t.Fatalf("expected 1 interconnect, got %d", len(proto.GetInterconnects())) - } - ic := proto.GetInterconnects()[0] - if ic.GetDevice() != "GPU 0" { - t.Errorf("expected device 'GPU 0', got %s", ic.GetDevice()) - } - pcie := ic.GetPcie() - if pcie == nil { - t.Fatal("expected PCIe details, got nil") - } - if pcie.GetGeneration() != 4 { - t.Errorf("expected PCIe Gen 4, got %d", pcie.GetGeneration()) - } - if pcie.GetWidth() != 16 { - t.Errorf("expected PCIe Width 16, got %d", pcie.GetWidth()) - } -} - -func Test_toProtoNodeSpec_Nil(t *testing.T) { - if toProtoNodeSpec(nil) != nil { - t.Error("expected nil for nil input") - } -} - -func Test_toProtoNodeSpec_MinimalFields(t *testing.T) { - local := &HardwareProfile{ - Architecture: "amd64", - } - proto := toProtoNodeSpec(local) - if proto.GetArchitecture() != "amd64" { - t.Errorf("expected amd64, got %s", proto.GetArchitecture()) - } - if proto.RamBytes != nil { - t.Error("expected nil RamBytes") - } - if proto.CpuCount != nil { - t.Error("expected nil CpuCount") - } - if len(proto.GetGpus()) != 0 { - t.Error("expected no GPUs") - } -} - -// fakeNodeService implements the server side of ExternalNodeService for testing. -type fakeNodeService struct { - nodev1connect.UnimplementedExternalNodeServiceHandler - addNodeFn func(*nodev1.AddNodeRequest) (*nodev1.AddNodeResponse, error) - removeNodeFn func(*nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) - getNodeFn func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) - openPortFn func(*nodev1.OpenPortRequest) (*nodev1.OpenPortResponse, error) - grantNodeSSHAccessFn func(*nodev1.GrantNodeSSHAccessRequest) (*nodev1.GrantNodeSSHAccessResponse, error) -} - -func (f *fakeNodeService) AddNode(_ context.Context, req *connect.Request[nodev1.AddNodeRequest]) (*connect.Response[nodev1.AddNodeResponse], error) { - resp, err := f.addNodeFn(req.Msg) - if err != nil { - return nil, err - } - return connect.NewResponse(resp), nil -} - -func (f *fakeNodeService) RemoveNode(_ context.Context, req *connect.Request[nodev1.RemoveNodeRequest]) (*connect.Response[nodev1.RemoveNodeResponse], error) { - resp, err := f.removeNodeFn(req.Msg) - if err != nil { - return nil, err - } - return connect.NewResponse(resp), nil -} - -func (f *fakeNodeService) GetNode(_ context.Context, req *connect.Request[nodev1.GetNodeRequest]) (*connect.Response[nodev1.GetNodeResponse], error) { - if f.getNodeFn == nil { - return nil, connect.NewError(connect.CodeUnimplemented, nil) - } - resp, err := f.getNodeFn(req.Msg) - if err != nil { - return nil, err - } - return connect.NewResponse(resp), nil -} - -func (f *fakeNodeService) OpenPort(_ context.Context, req *connect.Request[nodev1.OpenPortRequest]) (*connect.Response[nodev1.OpenPortResponse], error) { - if f.openPortFn == nil { - return connect.NewResponse(&nodev1.OpenPortResponse{ - Port: &nodev1.Port{ - PortId: "port_ssh", - Protocol: req.Msg.GetProtocol(), - PortNumber: req.Msg.GetPortNumber(), - ServerPort: req.Msg.GetPortNumber(), - }, - }), nil - } - resp, err := f.openPortFn(req.Msg) - if err != nil { - return nil, err - } - return connect.NewResponse(resp), nil -} - -func (f *fakeNodeService) GrantNodeSSHAccess(_ context.Context, req *connect.Request[nodev1.GrantNodeSSHAccessRequest]) (*connect.Response[nodev1.GrantNodeSSHAccessResponse], error) { - if f.grantNodeSSHAccessFn == nil { - return nil, connect.NewError(connect.CodeUnimplemented, nil) - } - resp, err := f.grantNodeSSHAccessFn(req.Msg) - if err != nil { - return nil, err - } - return connect.NewResponse(resp), nil -} - -func Test_NewNodeServiceClient_AddNode(t *testing.T) { - svc := &fakeNodeService{ - addNodeFn: func(req *nodev1.AddNodeRequest) (*nodev1.AddNodeResponse, error) { - if req.GetOrganizationId() != "org_123" { - t.Errorf("unexpected org ID: %s", req.GetOrganizationId()) - } - if req.GetName() != "My Spark" { - t.Errorf("unexpected name: %s", req.GetName()) - } - return &nodev1.AddNodeResponse{ - ExternalNode: &nodev1.ExternalNode{ - ExternalNodeId: "unode_abc", - OrganizationId: "org_123", - Name: req.GetName(), - DeviceId: req.GetDeviceId(), - }, - }, nil - }, - } - - _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) - server := httptest.NewServer(handler) - defer server.Close() - - client := NewNodeServiceClient(&mockTokenProvider{token: "tok"}, server.URL) - - resp, err := client.AddNode(context.Background(), connect.NewRequest(&nodev1.AddNodeRequest{ - OrganizationId: "org_123", - Name: "My Spark", - DeviceId: "dev-uuid", - NodeSpec: &nodev1.NodeSpec{Architecture: strPtr("arm64")}, - })) - if err != nil { - t.Fatalf("AddNode failed: %v", err) - } - if resp.Msg.GetExternalNode().GetExternalNodeId() != "unode_abc" { - t.Errorf("unexpected node ID: %s", resp.Msg.GetExternalNode().GetExternalNodeId()) - } -} - -func Test_NewNodeServiceClient_AddNode_ServerError(t *testing.T) { - svc := &fakeNodeService{ - addNodeFn: func(_ *nodev1.AddNodeRequest) (*nodev1.AddNodeResponse, error) { - return nil, connect.NewError(connect.CodeInternal, nil) - }, - } - - _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) - server := httptest.NewServer(handler) - defer server.Close() - - client := NewNodeServiceClient(&mockTokenProvider{token: "tok"}, server.URL) - - _, err := client.AddNode(context.Background(), connect.NewRequest(&nodev1.AddNodeRequest{ - OrganizationId: "org_123", - Name: "Test", - DeviceId: "dev", - })) - if err == nil { - t.Fatal("expected error for server error response") - } -} - -func Test_NewNodeServiceClient_RemoveNode(t *testing.T) { - svc := &fakeNodeService{ - removeNodeFn: func(req *nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) { - if req.GetExternalNodeId() != "unode_abc" { - t.Errorf("unexpected node ID: %s", req.GetExternalNodeId()) - } - return &nodev1.RemoveNodeResponse{}, nil - }, - } - - _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) - server := httptest.NewServer(handler) - defer server.Close() - - client := NewNodeServiceClient(&mockTokenProvider{token: "tok"}, server.URL) - - _, err := client.RemoveNode(context.Background(), connect.NewRequest(&nodev1.RemoveNodeRequest{ - ExternalNodeId: "unode_abc", - })) - if err != nil { - t.Fatalf("RemoveNode failed: %v", err) - } -} - -func Test_NewNodeServiceClient_RemoveNode_ServerError(t *testing.T) { - svc := &fakeNodeService{ - removeNodeFn: func(_ *nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) { - return nil, connect.NewError(connect.CodeNotFound, nil) - }, - } - - _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) - server := httptest.NewServer(handler) - defer server.Close() - - client := NewNodeServiceClient(&mockTokenProvider{token: "tok"}, server.URL) - - _, err := client.RemoveNode(context.Background(), connect.NewRequest(&nodev1.RemoveNodeRequest{ - ExternalNodeId: "unode_missing", - })) - if err == nil { - t.Fatal("expected error for not found response") - } -} - -func strPtr(s string) *string { return &s } diff --git a/pkg/cmd/revokessh/revokessh_test.go b/pkg/cmd/revokessh/revokessh_test.go index b0f971aa6..d0945f572 100644 --- a/pkg/cmd/revokessh/revokessh_test.go +++ b/pkg/cmd/revokessh/revokessh_test.go @@ -13,6 +13,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/entity" "github.com/brevdev/brev-cli/pkg/externalnode" + "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" ) @@ -31,7 +32,7 @@ type mockNodeClientFactory struct { } func (m mockNodeClientFactory) NewNodeClient(provider externalnode.TokenProvider, _ string) nodev1connect.ExternalNodeServiceClient { - return register.NewNodeServiceClient(provider, m.serverURL) + return store.NewDevPlaneClient(provider, m.serverURL).ExternalNodes } type mockRegistrationStore struct { diff --git a/pkg/cmd/util/externalnode.go b/pkg/cmd/util/externalnode.go index 7d37e43fc..f00ba3a6e 100644 --- a/pkg/cmd/util/externalnode.go +++ b/pkg/cmd/util/externalnode.go @@ -8,11 +8,10 @@ import ( nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" "connectrpc.com/connect" - "github.com/brevdev/brev-cli/pkg/cmd/register" - "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/ssh" + "github.com/brevdev/brev-cli/pkg/store" ) // ExternalNodeStore is the minimal interface needed for Brev Connect machine lookup and SSH resolution. @@ -20,6 +19,7 @@ type ExternalNodeStore interface { GetActiveOrganizationOrDefault() (*entity.Organization, error) GetAccessToken() (string, error) GetCurrentUser() (*entity.User, error) + DevPlane() *store.DevPlaneClient } type WorkspaceOrNodeResolver interface { @@ -129,8 +129,8 @@ func resolvePortForSSHAccess(node *nodev1.ExternalNode, access *nodev1.SSHAccess // OpenPort calls the OpenPort RPC to open a port on a Brev Connect machine via netbird. // This must be called before attempting to connect to a non-SSH port on a node. -func OpenPort(store ExternalNodeStore, nodeID string, portNumber int32, protocol nodev1.PortProtocol) (*nodev1.Port, error) { - client := register.NewNodeServiceClient(store, config.GlobalConfig.GetBrevPublicAPIURL()) +func OpenPort(nodeStore ExternalNodeStore, nodeID string, portNumber int32, protocol nodev1.PortProtocol) (*nodev1.Port, error) { + client := nodeStore.DevPlane().ExternalNodes resp, err := client.OpenPort(context.Background(), connect.NewRequest(&nodev1.OpenPortRequest{ ExternalNodeId: nodeID, Protocol: protocol, @@ -151,12 +151,12 @@ func FindExternalNode(store ExternalNodeStore, nameOrID string) (*nodev1.Externa // FindExternalNodeWithContext searches for a Brev Connect machine by name or ID in the user's active // organization. Exact IDs take precedence over case-insensitive names. // Returns (nil, nil) if no matching node is found. -func FindExternalNodeWithContext(ctx context.Context, store ExternalNodeStore, nameOrID string) (*nodev1.ExternalNode, error) { - org, err := store.GetActiveOrganizationOrDefault() +func FindExternalNodeWithContext(ctx context.Context, nodeStore ExternalNodeStore, nameOrID string) (*nodev1.ExternalNode, error) { + org, err := nodeStore.GetActiveOrganizationOrDefault() if err != nil { return nil, breverrors.WrapAndTrace(err) } - client := register.NewNodeServiceClient(store, config.GlobalConfig.GetBrevPublicAPIURL()) + client := nodeStore.DevPlane().ExternalNodes resp, err := client.ListNodes(ctx, connect.NewRequest(&nodev1.ListNodesRequest{ OrganizationId: org.ID, Options: &nodev1.ListNodesOptions{ diff --git a/pkg/cmd/util/externalnode_test.go b/pkg/cmd/util/externalnode_test.go index 2674b9ebd..0e3fea8b7 100644 --- a/pkg/cmd/util/externalnode_test.go +++ b/pkg/cmd/util/externalnode_test.go @@ -12,8 +12,10 @@ import ( nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" "connectrpc.com/connect" + "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/store" ) // mockExternalNodeStore satisfies the shared node and workspace lookup interfaces. @@ -31,6 +33,10 @@ func (m *mockExternalNodeStore) GetActiveOrganizationOrDefault() (*entity.Organi func (m *mockExternalNodeStore) GetAccessToken() (string, error) { return "tok", nil } +func (m *mockExternalNodeStore) DevPlane() *store.DevPlaneClient { + return store.NewDevPlaneClient(m, config.GlobalConfig.GetBrevPublicAPIURL()) +} + func (m *mockExternalNodeStore) GetAuthTokens() (*entity.AuthTokens, error) { return nil, nil } func (m *mockExternalNodeStore) GetWorkspaceByNameOrID(_, _ string) ([]entity.Workspace, error) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 7d492335d..74be86c0f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,7 +8,6 @@ type EnvVarName string // should be caps with underscore const ( brevAPIURL EnvVarName = "BREV_API_URL" - brevGRPCURL EnvVarName = "BREV_GRPC_URL" brevAuthURL EnvVarName = "BREV_AUTH_URL" brevAuthIssuerURL EnvVarName = "BREV_AUTH_ISSUER_URL" brevConsoleURL EnvVarName = "BREV_CONSOLE_URL" @@ -33,16 +32,13 @@ func (c ConstantsConfig) GetBrevAPIURl() string { return getEnvOrDefault(brevAPIURL, "https://brevapi.us-west-2-prod.control-plane.brev.dev") } -// GetBrevPublicAPIURL returns the public REST API URL (no auth required) +// GetBrevPublicAPIURL returns the base URL of dev-plane's public API server +// (BREV_PUBLIC_API_URL). That server serves both the public HTTP routes (/v1/...) +// and the ConnectRPC services the CLI calls (pkg/cmd/register, pkg/environment). func (c ConstantsConfig) GetBrevPublicAPIURL() string { return getEnvOrDefault(brevPublicAPIURL, "https://api.brev.dev") } -func (c ConstantsConfig) GetBrevGRPCURL() string { - // GRPC does not use https:// prefix - return getEnvOrDefault(brevGRPCURL, "api.brev.dev:443") -} - func (c ConstantsConfig) GetBrevAuthURL() string { return getEnvOrDefault(brevAuthURL, "https://api.ngc.nvidia.com") } diff --git a/pkg/environment/client.go b/pkg/environment/client.go new file mode 100644 index 000000000..81cb84bf3 --- /dev/null +++ b/pkg/environment/client.go @@ -0,0 +1,70 @@ +// Package environment provides shared operations for DevPlane environments. +package environment + +import ( + "context" + + devplanev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + + breverrors "github.com/brevdev/brev-cli/pkg/errors" +) + +// maxEnvironmentPageSize caps ListOrganizationComputeResources page size at 100. +const maxEnvironmentPageSize = 100 + +// Client exposes the DevPlane environment and organization APIs and common read operations. +type Client struct { + devplanev1connect.EnvironmentServiceClient + devplanev1connect.OrganizationServiceClient +} + +// NewClient adds environment helpers to DevPlane API clients. +func NewClient(environmentClient devplanev1connect.EnvironmentServiceClient, organizationClient devplanev1connect.OrganizationServiceClient) Client { + return Client{ + EnvironmentServiceClient: environmentClient, + OrganizationServiceClient: organizationClient, + } +} + +// EnvironmentStatus reads a single environment's authoritative lifecycle state. +func (c Client) EnvironmentStatus(ctx context.Context, environmentID string) (Status, error) { + response, err := c.EnvironmentServiceClient.GetEnvironment(ctx, connect.NewRequest(&devplanev1.GetEnvironmentRequest{ + EnvironmentId: environmentID, + AttachedDataOptions: &devplanev1.GetEnvironmentAttachedDataOptions{Instance: true}, + })) + if err != nil { + return Status{}, breverrors.WrapAndTrace(err) + } + return resolveStatus(response.Msg.GetEnvironment()), nil +} + +// EnvironmentStatusByOrg reads the authoritative lifecycle state of every environment in the +// organization, keyed by environment ID. +func (c Client) EnvironmentStatusByOrg(ctx context.Context, organizationID string) (map[string]Status, error) { + statuses := make(map[string]Status) + pageToken := "" + for { + response, err := c.OrganizationServiceClient.ListOrganizationComputeResources(ctx, connect.NewRequest(&devplanev1.ListOrganizationComputeResourcesRequest{ + OrganizationId: organizationID, + PageParams: &devplanev1.PageParams{PageSize: maxEnvironmentPageSize, PageToken: pageToken}, + Options: &devplanev1.ListOrganizationComputeResourcesOptions{ + Type: devplanev1.ComputeResourceType_COMPUTE_RESOURCE_TYPE_ENVIRONMENT, + EnvironmentOptions: &devplanev1.ListEnvironmentAttachedDataOptions{Instance: true}, + }, + })) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + for _, item := range response.Msg.GetItems() { + if env := item.GetEnvironment(); env != nil { + statuses[env.GetEnvironmentId()] = resolveStatus(env) + } + } + pageToken = response.Msg.GetNextPageToken() + if pageToken == "" { + return statuses, nil + } + } +} diff --git a/pkg/environment/client_test.go b/pkg/environment/client_test.go new file mode 100644 index 000000000..ed0d73ae8 --- /dev/null +++ b/pkg/environment/client_test.go @@ -0,0 +1,110 @@ +package environment + +import ( + "context" + "testing" + + devplanev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/brevdev/brev-cli/pkg/entity" +) + +type fakeEnvironmentClient struct { + devplanev1connect.EnvironmentServiceClient + environments map[string]*devplanev1.Environment + gotID string +} + +func (f *fakeEnvironmentClient) GetEnvironment( + _ context.Context, + request *connect.Request[devplanev1.GetEnvironmentRequest], +) (*connect.Response[devplanev1.GetEnvironmentResponse], error) { + f.gotID = request.Msg.GetEnvironmentId() + return connect.NewResponse(&devplanev1.GetEnvironmentResponse{ + Environment: f.environments[f.gotID], + }), nil +} + +type fakeOrganizationClient struct { + devplanev1connect.OrganizationServiceClient + pages []*devplanev1.ListOrganizationComputeResourcesResponse + calls int + gotOrgID string +} + +func (f *fakeOrganizationClient) ListOrganizationComputeResources( + _ context.Context, + request *connect.Request[devplanev1.ListOrganizationComputeResourcesRequest], +) (*connect.Response[devplanev1.ListOrganizationComputeResourcesResponse], error) { + f.gotOrgID = request.Msg.GetOrganizationId() + page := f.pages[f.calls] + f.calls++ + return connect.NewResponse(page), nil +} + +func item(env *devplanev1.Environment) *devplanev1.ComputeResourceListItem { + listItem := &devplanev1.ComputeResourceListItem{} + listItem.SetEnvironment(env) + return listItem +} + +func TestEnvironmentStatusReadsSingleEnvironment(t *testing.T) { + envClient := &fakeEnvironmentClient{environments: map[string]*devplanev1.Environment{ + "env-1": { + EnvironmentId: "env-1", + Name: "box", + Status: devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PROVISIONING_FAILED, + Instance: &devplanev1.Instance{ + ProvisionStatus: &devplanev1.CreateAttemptStatus{Message: "out of quota"}, + }, + }, + }} + client := NewClient(envClient, &fakeOrganizationClient{}) + + status, err := client.EnvironmentStatus(context.Background(), "env-1") + + require.NoError(t, err) + assert.Equal(t, "env-1", envClient.gotID) + assert.True(t, status.Failed) + assert.Equal(t, "box", status.Name) + assert.Equal(t, "out of quota", status.Message) +} + +func TestEnvironmentStatusByOrgPaginatesAndMaps(t *testing.T) { + orgClient := &fakeOrganizationClient{pages: []*devplanev1.ListOrganizationComputeResourcesResponse{ + { + Items: []*devplanev1.ComputeResourceListItem{ + item(&devplanev1.Environment{ + EnvironmentId: "env-1", + Name: "a", + Status: devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_RUNNING, + }), + }, + NextPageToken: "next", + }, + { + Items: []*devplanev1.ComputeResourceListItem{ + item(&devplanev1.Environment{ + EnvironmentId: "env-2", + Name: "b", + Status: devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_STOPPED, + }), + }, + }, + }} + client := NewClient(&fakeEnvironmentClient{}, orgClient) + + statuses, err := client.EnvironmentStatusByOrg(context.Background(), "org-1") + + require.NoError(t, err) + assert.Equal(t, "org-1", orgClient.gotOrgID) + assert.Equal(t, 2, orgClient.calls, "expected both pages to be fetched") + require.Len(t, statuses, 2) + assert.True(t, statuses["env-1"].Ready) + assert.Equal(t, entity.Running, statuses["env-1"].Display) + assert.Equal(t, entity.Stopped, statuses["env-2"].Display) +} diff --git a/pkg/environment/status.go b/pkg/environment/status.go new file mode 100644 index 000000000..14c181dc8 --- /dev/null +++ b/pkg/environment/status.go @@ -0,0 +1,85 @@ +package environment + +import ( + "strings" + + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + + "github.com/brevdev/brev-cli/pkg/entity" +) + +// Status is the brev CLI view of a DevPlane environment's lifecycle. +type Status struct { + Name string + Display string // entity workspace status constant; empty when unmapped + Message string + Ready bool + Failed bool + Deleted bool +} + +// resolveStatus maps a DevPlane environment onto the brev CLI status model. +func resolveStatus(env *devplanev1.Environment) Status { + if env == nil { + return Status{} + } + + status := Status{Name: env.GetName()} + if instance := env.GetInstance(); instance != nil { + status.Message = instance.GetProvisionStatus().GetMessage() + } + + switch env.GetStatus() { + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PENDING, + devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PREPARING, + devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PROVISIONING, + devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_BUILDING: + status.Display = entity.Deploying + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PREPARING_FAILED, + devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PROVISIONING_FAILED, + devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_BUILDING_FAILED, + devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_STARTING_FAILED, + devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_INSTANCE_LOST: + status.Display = entity.Failure + status.Failed = true + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_RUNNING: + status.Display = entity.Running + status.Ready = true + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_STOPPING, + devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_STOPPING_FAILED: + status.Display = entity.Stopping + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_STOPPED: + status.Display = entity.Stopped + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_STARTING: + status.Display = entity.Starting + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_TERMINATING, + devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_TERMINATING_FAILED: + status.Display = entity.Deleting + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_TERMINATED: + status.Display = entity.Deleting + status.Deleted = true + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_UNSPECIFIED: + // Leave unmapped; callers keep the status they already had. + } + + if status.Failed && status.Message == "" { + status.Message = failureText(env.GetStatus()) + } + return status +} + +func failureText(status devplanev1.EnvironmentStatus) string { + switch status { + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PREPARING_FAILED: + return "preparation failed" + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PROVISIONING_FAILED: + return "provisioning failed" + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_BUILDING_FAILED: + return "build failed" + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_STARTING_FAILED: + return "start failed" + case devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_INSTANCE_LOST: + return "instance lost" + } + return strings.ToLower(status.String()) +} diff --git a/pkg/environment/status_test.go b/pkg/environment/status_test.go new file mode 100644 index 000000000..c0c2969e3 --- /dev/null +++ b/pkg/environment/status_test.go @@ -0,0 +1,72 @@ +package environment + +import ( + "testing" + + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/brevdev/brev-cli/pkg/entity" +) + +func TestResolveStatusMapsEnvironmentStatuses(t *testing.T) { + cases := []struct { + name string + status devplanev1.EnvironmentStatus + display string + ready bool + failed bool + }{ + {"pending", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PENDING, entity.Deploying, false, false}, + {"provisioning", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PROVISIONING, entity.Deploying, false, false}, + {"building", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_BUILDING, entity.Deploying, false, false}, + {"running", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_RUNNING, entity.Running, true, false}, + {"stopping", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_STOPPING, entity.Stopping, false, false}, + {"stopped", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_STOPPED, entity.Stopped, false, false}, + {"starting", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_STARTING, entity.Starting, false, false}, + {"terminating", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_TERMINATING, entity.Deleting, false, false}, + {"provisioning_failed", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PROVISIONING_FAILED, entity.Failure, false, true}, + {"building_failed", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_BUILDING_FAILED, entity.Failure, false, true}, + {"instance_lost", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_INSTANCE_LOST, entity.Failure, false, true}, + {"unspecified", devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_UNSPECIFIED, "", false, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status := resolveStatus(&devplanev1.Environment{ + EnvironmentId: "env-1", + Name: "box", + Status: tc.status, + }) + assert.Equal(t, tc.display, status.Display) + assert.Equal(t, tc.ready, status.Ready) + assert.Equal(t, tc.failed, status.Failed) + }) + } +} + +func TestResolveStatusUsesProvisionStatusMessage(t *testing.T) { + status := resolveStatus(&devplanev1.Environment{ + EnvironmentId: "env-1", + Name: "box", + Status: devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_PROVISIONING_FAILED, + Instance: &devplanev1.Instance{ + ProvisionStatus: &devplanev1.CreateAttemptStatus{Message: "out of quota"}, + }, + }) + + require.True(t, status.Failed) + assert.Equal(t, "out of quota", status.Message) + assert.Equal(t, "box", status.Name) +} + +func TestResolveStatusFallsBackToFailureText(t *testing.T) { + status := resolveStatus(&devplanev1.Environment{ + EnvironmentId: "env-1", + Name: "box", + Status: devplanev1.EnvironmentStatus_ENVIRONMENT_STATUS_INSTANCE_LOST, + }) + + assert.Equal(t, "instance lost", status.Message) +} diff --git a/pkg/store/devplane_client.go b/pkg/store/devplane_client.go new file mode 100644 index 000000000..473e3c014 --- /dev/null +++ b/pkg/store/devplane_client.go @@ -0,0 +1,82 @@ +package store + +import ( + "net/http" + + devplanev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + + "github.com/brevdev/brev-cli/pkg/config" + breverrors "github.com/brevdev/brev-cli/pkg/errors" +) + +// DevPlaneClient bundles dev-plane's ConnectRPC service clients over a single HTTP client +type DevPlaneClient struct { + Instances devplanev1connect.InstanceServiceClient + Environments devplanev1connect.EnvironmentServiceClient + Organizations devplanev1connect.OrganizationServiceClient + ExternalNodes devplanev1connect.ExternalNodeServiceClient + ManagedSecrets devplanev1connect.ManagedSecretServiceClient +} + +// NewAuthenticatedHTTPClient builds the HTTP client shared by every authenticated +// dev-plane ConnectRPC service. It injects the bearer token from auth and the CLI +// attribution params on each request. +func NewAuthenticatedHTTPClient(auth Auth) *http.Client { + return &http.Client{Transport: &authenticatedTransport{provider: auth, base: http.DefaultTransport}} +} + +// NewPublicHTTPClient builds the HTTP client used by unauthenticated dev-plane +// public routes; it only adds the CLI attribution params. +func NewPublicHTTPClient() *http.Client { + return &http.Client{Transport: attributionTransport{base: http.DefaultTransport}} +} + +// NewDevPlaneClient builds an authenticated bundle over one shared HTTP client. +func NewDevPlaneClient(auth Auth, baseURL string) *DevPlaneClient { + return newDevPlaneClient(NewAuthenticatedHTTPClient(auth), baseURL) +} + +// NewPublicDevPlaneClient builds an unauthenticated bundle for dev-plane's public routes. +func NewPublicDevPlaneClient(baseURL string) *DevPlaneClient { + return newDevPlaneClient(NewPublicHTTPClient(), baseURL) +} + +func newDevPlaneClient(httpClient *http.Client, baseURL string) *DevPlaneClient { + return &DevPlaneClient{ + Instances: devplanev1connect.NewInstanceServiceClient(httpClient, baseURL), + Environments: devplanev1connect.NewEnvironmentServiceClient(httpClient, baseURL), + Organizations: devplanev1connect.NewOrganizationServiceClient(httpClient, baseURL), + ExternalNodes: devplanev1connect.NewExternalNodeServiceClient(httpClient, baseURL), + ManagedSecrets: devplanev1connect.NewManagedSecretServiceClient(httpClient, baseURL), + } +} + +// DevPlane returns the store's authenticated dev-plane client bundle. It is +// created with the store so commands and store methods share one HTTP client. +func (s *AuthHTTPStore) DevPlane() *DevPlaneClient { + if s.devPlaneClient != nil { + return s.devPlaneClient + } + // Stores constructed as literals (e.g. tests) fall back to a fresh bundle. + return NewDevPlaneClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) +} + +type authenticatedTransport struct { + provider Auth + base http.RoundTripper +} + +func (t *authenticatedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + token, err := t.provider.GetAccessToken() + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+token) + AddCLIAttributionParams(req) + resp, err := t.base.RoundTrip(req) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + return resp, nil +} diff --git a/pkg/store/devplane_client_test.go b/pkg/store/devplane_client_test.go new file mode 100644 index 000000000..521235939 --- /dev/null +++ b/pkg/store/devplane_client_test.go @@ -0,0 +1,78 @@ +package store + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/brevdev/brev-cli/pkg/cmd/version" +) + +type failingAuth struct{} + +func (failingAuth) GetAccessToken() (string, error) { + return "", errors.New("token unavailable") +} + +func TestAuthenticatedHTTPClientInjectsBearerToken(t *testing.T) { + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewAuthenticatedHTTPClient(MockAuth{}) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck // test + + assert.Equal(t, "Bearer mock-token", gotAuth) +} + +func TestAuthenticatedHTTPClientAddsCLIAttributionParams(t *testing.T) { + var gotQuery url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewAuthenticatedHTTPClient(MockAuth{}) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, + server.URL+"/devplaneapi.v1.ExternalNodeService/ListNodes?connect=v1", nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck // test + + // Same attribution the REST client sends, so dev-plane sees one CLI identity. + assert.Equal(t, "cli", gotQuery.Get("utm_source")) + assert.Equal(t, version.Version, gotQuery.Get("cli_version")) + assert.Equal(t, runtime.GOOS, gotQuery.Get("os")) + // ConnectRPC's own protocol params must survive. + assert.Equal(t, "v1", gotQuery.Get("connect")) +} + +func TestAuthenticatedHTTPClientPropagatesTokenError(t *testing.T) { + client := NewAuthenticatedHTTPClient(failingAuth{}) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil) + require.NoError(t, err) + resp, err := client.Do(req) + if err == nil { + resp.Body.Close() //nolint:errcheck // test + t.Fatal("expected error from token provider") + } +} diff --git a/pkg/store/environment_status.go b/pkg/store/environment_status.go new file mode 100644 index 000000000..89e5f1e82 --- /dev/null +++ b/pkg/store/environment_status.go @@ -0,0 +1,23 @@ +package store + +import ( + "context" + + "github.com/brevdev/brev-cli/pkg/environment" + breverrors "github.com/brevdev/brev-cli/pkg/errors" +) + +func (s *AuthHTTPStore) newEnvironmentClient() environment.Client { + devPlane := s.DevPlane() + return environment.NewClient(devPlane.Environments, devPlane.Organizations) +} + +// GetEnvironmentStatus reads live environment lifecycle state directly from dev-plane, +// which is authoritative over the instance-derived status exposed by brev-deploy. +func (s *AuthHTTPStore) GetEnvironmentStatus(environmentID string) (environment.Status, error) { + status, err := s.newEnvironmentClient().EnvironmentStatus(context.Background(), environmentID) + if err != nil { + return environment.Status{}, breverrors.WrapAndTrace(err) + } + return status, nil +} diff --git a/pkg/store/http.go b/pkg/store/http.go index ed77c1598..fa7b40b7d 100644 --- a/pkg/store/http.go +++ b/pkg/store/http.go @@ -13,6 +13,7 @@ import ( "github.com/brevdev/brev-cli/pkg/auth" "github.com/brevdev/brev-cli/pkg/cmd/version" + "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/featureflag" @@ -103,6 +104,7 @@ type AuthHTTPStore struct { isRefreshTokenHandlerSet bool organizationOverride *entity.Organization organizationOverrideName string + devPlaneClient *DevPlaneClient BasicStore } @@ -145,7 +147,9 @@ func (f *FileStore) WithAuthHTTPClient(c *AuthHTTPClient) *AuthHTTPStore { c.restyClient.SetQueryParam("local", "true") } na := f.WithNoAuthHTTPClient(NewNoAuthHTTPClient(c.restyClient.BaseURL)) - return &AuthHTTPStore{NoAuthHTTPStore: *na, authHTTPClient: c} + s := &AuthHTTPStore{NoAuthHTTPStore: *na, authHTTPClient: c} + s.devPlaneClient = NewDevPlaneClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) + return s } func (n *NoAuthHTTPStore) WithAuthHTTPClient(c *AuthHTTPClient) *AuthHTTPStore { @@ -154,7 +158,9 @@ func (n *NoAuthHTTPStore) WithAuthHTTPClient(c *AuthHTTPClient) *AuthHTTPStore { if id == "" { c.restyClient.SetQueryParam("local", "true") } - return &AuthHTTPStore{NoAuthHTTPStore: *n, authHTTPClient: c} + s := &AuthHTTPStore{NoAuthHTTPStore: *n, authHTTPClient: c} + s.devPlaneClient = NewDevPlaneClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) + return s } func (n *NoAuthHTTPStore) WithAuth(auth Auth, options ...Option) *AuthHTTPStore { diff --git a/pkg/store/instancetypes.go b/pkg/store/instancetypes.go index 81acefd60..6fccdf94f 100644 --- a/pkg/store/instancetypes.go +++ b/pkg/store/instancetypes.go @@ -2,9 +2,7 @@ package store import ( "context" - "net/http" - "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" devplaneapiv1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" "connectrpc.com/connect" "github.com/brevdev/brev-cli/pkg/cmd/gpusearch" @@ -29,10 +27,7 @@ func (s AuthHTTPStore) GetInstanceTypes(includeCPU bool) (*gpusearch.InstanceTyp // fetchInstanceTypes fetches instance types from dev-plane's public Connect API. func fetchInstanceTypes(includeCPU bool) (*gpusearch.InstanceTypesResponse, error) { - client := devplaneapiv1connect.NewInstanceServiceClient( - &http.Client{Transport: attributionTransport{base: http.DefaultTransport}}, - config.NewConstants().GetBrevPublicAPIURL(), - ) + client := NewPublicDevPlaneClient(config.GlobalConfig.GetBrevPublicAPIURL()).Instances skipAccessFilter := false res, err := client.ListPublicInstanceType(context.Background(), connect.NewRequest(&devplaneapiv1.ListPublicInstanceTypeRequest{ Options: &devplaneapiv1.ListInstanceTypeOptions{ @@ -50,10 +45,7 @@ func fetchInstanceTypes(includeCPU bool) (*gpusearch.InstanceTypesResponse, erro // GetAllInstanceTypesWithCloudCreds fetches org-scoped instance types from dev-plane's public Connect API. func (s AuthHTTPStore) GetAllInstanceTypesWithCloudCreds(orgID string) (*gpusearch.AllInstanceTypesResponse, error) { - client := devplaneapiv1connect.NewInstanceServiceClient( - &http.Client{Transport: &authHTTPStoreTransport{store: &s, base: http.DefaultTransport}}, - config.NewConstants().GetBrevPublicAPIURL(), - ) + client := s.DevPlane().Instances includeUnavailable := false includePreemptible := false includeCPU := true diff --git a/pkg/store/managedsecret.go b/pkg/store/managedsecret.go index 78422c7c6..48fc2edf3 100644 --- a/pkg/store/managedsecret.go +++ b/pkg/store/managedsecret.go @@ -4,21 +4,16 @@ import ( "context" "errors" "fmt" - "net/http" nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" "connectrpc.com/connect" - "github.com/brevdev/brev-cli/pkg/config" breverrors "github.com/brevdev/brev-cli/pkg/errors" ) func (s *AuthHTTPStore) newManagedSecretServiceClient() nodev1connect.ManagedSecretServiceClient { - return nodev1connect.NewManagedSecretServiceClient( - &http.Client{Transport: &authHTTPStoreTransport{store: s, base: http.DefaultTransport}}, - config.GlobalConfig.GetBrevPublicAPIURL(), - ) + return s.DevPlane().ManagedSecrets } func (s *AuthHTTPStore) CreateManagedSecret(organizationID, name, value string) (*nodev1.ManagedSecret, error) { diff --git a/pkg/store/organization.go b/pkg/store/organization.go index 14430383a..21b02eccf 100644 --- a/pkg/store/organization.go +++ b/pkg/store/organization.go @@ -3,15 +3,12 @@ package store import ( "context" "fmt" - "net/http" "strings" - nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" "connectrpc.com/connect" "github.com/brevdev/brev-cli/pkg/auth" - "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/files" @@ -311,31 +308,8 @@ func (s AuthHTTPStore) CreateInviteLink(organizationID string) (string, error) { return result, nil } -type authHTTPStoreTransport struct { - store *AuthHTTPStore - base http.RoundTripper -} - -func (t *authHTTPStoreTransport) RoundTrip(req *http.Request) (*http.Response, error) { - token, err := t.store.GetAccessToken() - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - req = req.Clone(req.Context()) - req.Header.Set("Authorization", "Bearer "+token) - AddCLIAttributionParams(req) - resp, err := t.base.RoundTrip(req) - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - return resp, nil -} - func (s *AuthHTTPStore) ListOrganizationMembers(ctx context.Context, orgID string) ([]*nodev1.OrganizationMember, error) { - client := nodev1connect.NewOrganizationServiceClient( - &http.Client{Transport: &authHTTPStoreTransport{store: s, base: http.DefaultTransport}}, - config.GlobalConfig.GetBrevPublicAPIURL(), - ) + client := s.DevPlane().Organizations var members []*nodev1.OrganizationMember var pageToken string From 39f22d0ed5cdaecb45515d401aaf1945b4f7eeca Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Fri, 9 Oct 2026 15:41:17 -0700 Subject: [PATCH 2/2] fix comments --- pkg/cmd/gpucreate/gpucreate.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/cmd/gpucreate/gpucreate.go b/pkg/cmd/gpucreate/gpucreate.go index 1693f5c0d..d4a630794 100644 --- a/pkg/cmd/gpucreate/gpucreate.go +++ b/pkg/cmd/gpucreate/gpucreate.go @@ -1136,7 +1136,6 @@ func (c *createContext) cleanupExtraInstances(workspaces []*entity.Workspace) [] // convenience and the instance may still finish provisioning after the window. var errReadyTimeout = errors.New("timeout waiting for instance to be ready") -// workspaceFailedError reports a workspace that reached a terminal failure. type workspaceFailedError struct{ message string } func (e *workspaceFailedError) Error() string { return e.message } @@ -1636,8 +1635,7 @@ func resolveWorkspaceUserOptions(options *store.CreateWorkspacesOptions, user *e return options } -// pollUntilReady waits for a workspace to reach the running state, using -// dev-plane's environment status as the single source of truth. +// pollUntilReady waits for a workspace to reach the running state func (c *createContext) pollUntilReady(wsID string) error { deadline := time.Now().Add(c.opts.Timeout)