diff --git a/assets/test_workspace.json b/assets/test_workspace.json index c6467e49b..93f7a4928 100644 --- a/assets/test_workspace.json +++ b/assets/test_workspace.json @@ -1,4 +1,3 @@ { - "workspaceId": "test_workspace_id", - "workspaceGroupId": "test_workspace_group_id" -} \ No newline at end of file + "workspaceId": "test_workspace_id" +} diff --git a/go.mod b/go.mod index 172108c29..81aaaeb0a 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.20.0-20260626205643-49b0d20e08f1.1 - buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260626205643-49b0d20e08f1.1 + buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260708012811-ecba52f49600.1 + buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260708012811-ecba52f49600.1 connectrpc.com/connect v1.20.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 22ba4d7ea..98ffbc89e 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260626205643-49b0d20e08f1.1 h1:Qj4BTbhIF0KE5YHiJJ+SN2goGYF8dJC1l8cv69YU/Ms= -buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260626205643-49b0d20e08f1.1/go.mod h1:KW+lsYUmrF994Z/zj/wibrS7zhitXrYLicqR5BbVSp0= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260626205643-49b0d20e08f1.1 h1:+GNKe6qV3aRH+N/FBlH6NfqyKOxMecAtbHndj3NPZc4= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260626205643-49b0d20e08f1.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo= +buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260708012811-ecba52f49600.1 h1:xanul5g4JQ0OPAQ3tjN8bTznw+aA6B/oq3pzOy8kC8Q= +buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260708012811-ecba52f49600.1/go.mod h1:ZxWENaPM6882Wtl2z6rZYVpXoagSyF6DiY/6m4BjGMU= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260708012811-ecba52f49600.1 h1:KMs3AGf1zys1H8TnjBCorCd12zzWoUQae956KgsNfRM= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260708012811-ecba52f49600.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo= buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1 h1:6amhprQmCKJ4wgJ6ngkh32d9V+dQcOLUZ/SfHdOnYgo= buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1/go.mod h1:O+pnSHMru/naTMrm4tmpBoH3wz6PHa+R75HR7Mv8X2g= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index a5ebc6db2..439d1a1f0 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -133,15 +133,6 @@ func NewBrevCommand() *cobra.Command { //nolint:funlen,gocognit,gocyclo // defin analytics.SetUserStore(noLoginCmdStore) - workspaceGroupID, err := fsStore.GetCurrentWorkspaceGroupID() - if err != nil { - fmt.Printf("%v\n", err) - } - if workspaceGroupID != "" { - loginCmdStore.WithStaticHeader("X-Workspace-Group-ID", workspaceGroupID) - noLoginCmdStore.WithStaticHeader("X-Workspace-Group-ID", workspaceGroupID) - } - cmds := &cobra.Command{ SilenceErrors: true, SilenceUsage: true, diff --git a/pkg/cmd/create/create.go b/pkg/cmd/create/create.go index f67807a04..ac1fab865 100644 --- a/pkg/cmd/create/create.go +++ b/pkg/cmd/create/create.go @@ -132,7 +132,6 @@ func createEmptyWorkspace(user *entity.User, t *terminal.Terminal, options Creat } else { t.Vprintf("\tCPU instance %s\n", t.Green(cwOptions.WorkspaceClassID)) } - t.Vprintf("\tCloud %s\n\n", t.Green(cwOptions.WorkspaceGroupID)) s := t.NewSpinner() s.Suffix = " Creating your instance. Hang tight 🤙" diff --git a/pkg/cmd/gpucreate/gpucreate.go b/pkg/cmd/gpucreate/gpucreate.go index 7b379dbd7..05194a571 100644 --- a/pkg/cmd/gpucreate/gpucreate.go +++ b/pkg/cmd/gpucreate/gpucreate.go @@ -105,7 +105,7 @@ type GPUCreateStore interface { GetWorkspace(workspaceID string) (*entity.Workspace, error) CreateWorkspace(organizationID string, options *store.CreateWorkspacesOptions) (*entity.Workspace, error) DeleteWorkspace(workspaceID string) (*entity.Workspace, error) - GetAllInstanceTypesWithWorkspaceGroups(orgID string) (*gpusearch.AllInstanceTypesResponse, error) + GetAllInstanceTypesWithCloudCreds(orgID string) (*gpusearch.AllInstanceTypesResponse, error) GetLaunchable(launchableID string) (*store.LaunchableResponse, error) GetLaunchableLifeCycleScript(launchableID, scriptID string) (*store.LifeCycleScriptResponse, error) RedeemCouponCode(organizationID string, code string) (*store.RedeemCouponCodeResponse, error) @@ -768,11 +768,11 @@ func newCreateContext(t *terminal.Terminal, store GPUCreateStore, opts GPUCreate } ctx.org = org - // Fetch instance types with workspace groups - allInstanceTypes, err := store.GetAllInstanceTypesWithWorkspaceGroups(org.ID) + // Fetch instance types with cloud credentials. + allInstanceTypes, err := store.GetAllInstanceTypesWithCloudCreds(org.ID) if err != nil { - ctx.logf("Warning: could not fetch instance types with workspace groups: %s\n", err.Error()) - ctx.logf("Falling back to default workspace group\n") + ctx.logf("Warning: could not fetch instance types with cloud credentials: %s\n", err.Error()) + ctx.logf("Falling back to default cloud credential\n") } ctx.allInstanceTypes = allInstanceTypes @@ -791,7 +791,7 @@ func (c *createContext) validateInstanceTypeAvailability(instanceType string) er if c.allInstanceTypes == nil { return nil } - if c.allInstanceTypes.GetWorkspaceGroupID(instanceType) != "" { + if c.allInstanceTypes.GetCloudCredID(instanceType) != "" { return nil } if !c.allInstanceTypes.HasInstanceType(instanceType) { @@ -1042,8 +1042,8 @@ func (c *createContext) createWorkspace(name string, spec InstanceSpec) (*entity } if c.allInstanceTypes != nil { - if wgID := c.allInstanceTypes.GetWorkspaceGroupID(spec.Type); wgID != "" { - cwOptions.WorkspaceGroupID = wgID + if cloudCredID := c.allInstanceTypes.GetCloudCredID(spec.Type); cloudCredID != "" { + cwOptions.WithCloudCredID(cloudCredID) } } @@ -1057,10 +1057,10 @@ func (c *createContext) createWorkspace(name string, spec InstanceSpec) (*entity } } - if cwOptions.WorkspaceGroupID == "" { + if cwOptions.CloudCredID == "" { if c.allInstanceTypes == nil { return nil, breverrors.NewValidationError(fmt.Sprintf( - "could not resolve workspace group for %q (instance-type listing was unavailable); please retry", + "could not resolve cloud credential for %q (instance-type listing was unavailable); please retry", spec.Type, )) } @@ -1176,13 +1176,17 @@ func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableI return } - wsReq := info.CreateWorkspaceRequest + applyLaunchableWorkspaceRequest(cwOptions, info.CreateWorkspaceRequest) + applyLaunchableBuildRequest(cwOptions, info.BuildRequest) + applyLaunchableFile(cwOptions, info.File) + applyLaunchableLabels(cwOptions, launchableID, info) +} - // Use launchable's workspace group if not already resolved from instance types - if cwOptions.WorkspaceGroupID == "" && wsReq.WorkspaceGroupID != "" { - cwOptions.WorkspaceGroupID = wsReq.WorkspaceGroupID +func applyLaunchableWorkspaceRequest(cwOptions *store.CreateWorkspacesOptions, wsReq store.LaunchableWorkspaceRequest) { + // Use launchable's cloud credential if not already resolved from instance types. + if cwOptions.CloudCredID == "" && wsReq.CloudCredID != "" { + cwOptions.WithCloudCredID(wsReq.CloudCredID) } - // Location / sub-location if wsReq.Location != "" { cwOptions.Location = wsReq.Location @@ -1198,8 +1202,13 @@ func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableI cwOptions.DiskStorage = normalizeDiskStorage(wsReq.Storage) } + if len(wsReq.FirewallRules) > 0 { + cwOptions.FirewallRules = resolveFirewallRulesClientIP(wsReq.FirewallRules, publicIPLookup) + } +} + +func applyLaunchableBuildRequest(cwOptions *store.CreateWorkspacesOptions, build store.LaunchableBuildRequest) { // Build configuration from launchable - build := info.BuildRequest switch { case build.VMBuild != nil: cwOptions.VMBuild = build.VMBuild @@ -1208,7 +1217,7 @@ func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableI cwOptions.CustomContainer = build.CustomContainer case build.DockerCompose != nil: cwOptions.VMBuild = nil - cwOptions.DockerCompose = build.DockerCompose + cwOptions.DockerCompose = normalizeLaunchableDockerCompose(build.DockerCompose) } // Port mappings from build request ports @@ -1219,18 +1228,26 @@ func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableI } cwOptions.PortMappings = portMappings } +} - if len(wsReq.FirewallRules) > 0 { - cwOptions.FirewallRules = resolveFirewallRulesClientIP(wsReq.FirewallRules, publicIPLookup) +func normalizeLaunchableDockerCompose(dockerCompose *store.DockerCompose) *store.DockerCompose { + normalized := *dockerCompose + if normalized.FileURL != "" { + normalized.YamlString = "" } + return &normalized +} +func applyLaunchableFile(cwOptions *store.CreateWorkspacesOptions, file *store.LaunchableFile) { // Files from launchable - if info.File != nil { + if file != nil { cwOptions.Files = []map[string]string{ - {"url": info.File.URL, "path": info.File.Path}, + {"url": file.URL, "path": file.Path}, } } +} +func applyLaunchableLabels(cwOptions *store.CreateWorkspacesOptions, launchableID string, info *store.LaunchableResponse) { // Labels for tracking and UI rendering — merge with any existing labels var labels map[string]string if existing, ok := cwOptions.Labels.(map[string]string); ok && existing != nil { @@ -1239,8 +1256,8 @@ func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableI labels = make(map[string]string) } labels["launchableId"] = launchableID - labels["launchableInstanceType"] = wsReq.InstanceType - labels["workspaceGroupId"] = cwOptions.WorkspaceGroupID + labels["launchableInstanceType"] = info.CreateWorkspaceRequest.InstanceType + labels["cloudCredId"] = cwOptions.CloudCredID labels["launchableCreatedByUserId"] = info.CreatedByUserID labels["launchableCreatedByOrgId"] = info.CreatedByOrgID labels["launchableRawURL"] = "/launchable/deploy/now?launchableID=" + launchableID diff --git a/pkg/cmd/gpucreate/gpucreate_test.go b/pkg/cmd/gpucreate/gpucreate_test.go index 5f71cdaec..2c076a1d1 100644 --- a/pkg/cmd/gpucreate/gpucreate_test.go +++ b/pkg/cmd/gpucreate/gpucreate_test.go @@ -14,6 +14,7 @@ import ( "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // MockGPUCreateStore is a mock implementation of GPUCreateStore for testing @@ -24,6 +25,7 @@ type MockGPUCreateStore struct { CreateError error CreateErrorTypes map[string]error // Errors for specific instance types DeleteError error + CreatedOptions []*store.CreateWorkspacesOptions CreatedWorkspaces []*entity.Workspace DeletedWorkspaceIDs []string FetchedLifeCycleScriptIDs []string @@ -85,6 +87,7 @@ func (m *MockGPUCreateStore) CreateWorkspace(organizationID string, options *sto Status: entity.Running, } m.Workspaces[ws.ID] = ws + m.CreatedOptions = append(m.CreatedOptions, options) m.CreatedWorkspaces = append(m.CreatedWorkspaces, ws) return ws, nil } @@ -104,7 +107,7 @@ func (m *MockGPUCreateStore) GetWorkspaceByNameOrID(orgID string, nameOrID strin return []entity.Workspace{}, nil } -func (m *MockGPUCreateStore) GetAllInstanceTypesWithWorkspaceGroups(orgID string) (*gpusearch.AllInstanceTypesResponse, error) { +func (m *MockGPUCreateStore) GetAllInstanceTypesWithCloudCreds(orgID string) (*gpusearch.AllInstanceTypesResponse, error) { return nil, nil } @@ -207,8 +210,7 @@ func TestParseLaunchableID(t *testing.T) { func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test t.Run("populates all fields from launchable", func(t *testing.T) { cwOptions := &store.CreateWorkspacesOptions{ - WorkspaceGroupID: "", - PortMappings: map[string]string{}, + PortMappings: map[string]string{}, } info := &store.LaunchableResponse{ ID: "env-abc123", @@ -216,11 +218,11 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test CreatedByUserID: "user-1", CreatedByOrgID: "org-1", CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ - WorkspaceGroupID: "GCP", - InstanceType: "n2-standard-4", - Storage: "256", - Location: "us-west1", - SubLocation: "us-west1-b", + CloudCredID: "GCP", + InstanceType: "n2-standard-4", + Storage: "256", + Location: "us-west1", + SubLocation: "us-west1-b", FirewallRules: []store.CreateFirewallRule{ {Port: "8080", AllowedIPs: "all"}, {Port: "9000-9100", AllowedIPs: "all"}, @@ -247,8 +249,8 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test applyLaunchableConfig(cwOptions, "env-abc123", info) - // Workspace group from launchable - assert.Equal(t, "GCP", cwOptions.WorkspaceGroupID) + // Cloud credential from launchable input. + assert.Equal(t, "GCP", cwOptions.CloudCredID) // Location / sub-location assert.Equal(t, "us-west1", cwOptions.Location) assert.Equal(t, "us-west1-b", cwOptions.SubLocation) @@ -274,25 +276,26 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test assert.True(t, ok) assert.Equal(t, "env-abc123", labels["launchableId"]) assert.Equal(t, "n2-standard-4", labels["launchableInstanceType"]) - assert.Equal(t, "GCP", labels["workspaceGroupId"]) + assert.Equal(t, "GCP", labels["cloudCredId"]) + assert.NotContains(t, labels, "workspaceGroupId") assert.Equal(t, "user-1", labels["launchableCreatedByUserId"]) assert.Equal(t, "org-1", labels["launchableCreatedByOrgId"]) }) - t.Run("preserves existing workspace group", func(t *testing.T) { + t.Run("preserves existing cloud credential", func(t *testing.T) { cwOptions := &store.CreateWorkspacesOptions{ - WorkspaceGroupID: "existing-wg", + CloudCredID: "existing-cloud-cred", } info := &store.LaunchableResponse{ CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ - WorkspaceGroupID: "GCP", - InstanceType: "n2-standard-4", + CloudCredID: "GCP", + InstanceType: "n2-standard-4", }, } applyLaunchableConfig(cwOptions, "env-abc", info) - assert.Equal(t, "existing-wg", cwOptions.WorkspaceGroupID) + assert.Equal(t, "existing-cloud-cred", cwOptions.CloudCredID) }) t.Run("storage already has Gi suffix", func(t *testing.T) { @@ -352,6 +355,28 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test assert.Equal(t, "nvcr.io/nvidia/test:latest", cwOptions.CustomContainer.ContainerURL) }) + t.Run("docker compose file URL takes precedence over hydrated YAML", func(t *testing.T) { + cwOptions := &store.CreateWorkspacesOptions{ + VMBuild: &store.VMBuild{ForceJupyterInstall: true}, + } + dockerCompose := &store.DockerCompose{ + FileURL: "https://example.com/compose.yaml", + YamlString: "services:\n app:\n image: example/app", + } + info := &store.LaunchableResponse{ + BuildRequest: store.LaunchableBuildRequest{ + DockerCompose: dockerCompose, + }, + } + + applyLaunchableConfig(cwOptions, "env-abc", info) + + assert.Nil(t, cwOptions.VMBuild) + assert.Equal(t, dockerCompose.FileURL, cwOptions.DockerCompose.FileURL) + assert.Empty(t, cwOptions.DockerCompose.YamlString) + assert.NotEmpty(t, dockerCompose.YamlString, "launchable response should not be mutated") + }) + t.Run("substitutes public IP for user-ip firewall rules", func(t *testing.T) { orig := publicIPLookup publicIPLookup = func() (string, error) { return "203.0.113.7", nil } @@ -1002,15 +1027,13 @@ func TestValidateInstanceTypeAvailability(t *testing.T) { assert.NoError(t, ctx.validateInstanceTypeAvailability("hyperstack_H100x8_one")) }) - t.Run("returns nil when type has a workspace group", func(t *testing.T) { + t.Run("returns nil when type has a cloud credential", func(t *testing.T) { ctx := &createContext{ allInstanceTypes: &gpusearch.AllInstanceTypesResponse{ AllInstanceTypes: []gpusearch.InstanceType{ { - Type: "hyperstack_H100_sxm5x8", - WorkspaceGroups: []gpusearch.WorkspaceGroup{ - {ID: "wg-1", Name: "Shadeform", PlatformType: "shadeform"}, - }, + Type: "hyperstack_H100_sxm5x8", + CloudCredID: "cc-1", }, }, }, @@ -1022,7 +1045,7 @@ func TestValidateInstanceTypeAvailability(t *testing.T) { ctx := &createContext{ allInstanceTypes: &gpusearch.AllInstanceTypesResponse{ AllInstanceTypes: []gpusearch.InstanceType{ - {Type: "hyperstack_H100_sxm5x8", WorkspaceGroups: []gpusearch.WorkspaceGroup{{ID: "wg-1"}}}, + {Type: "hyperstack_H100_sxm5x8", CloudCredID: "cc-1"}, }, }, } @@ -1033,11 +1056,11 @@ func TestValidateInstanceTypeAvailability(t *testing.T) { assert.Contains(t, err.Error(), "brev search") }) - t.Run("returns unavailable error for known type without workspace groups", func(t *testing.T) { + t.Run("returns unavailable error for known type without a cloud credential", func(t *testing.T) { ctx := &createContext{ allInstanceTypes: &gpusearch.AllInstanceTypesResponse{ AllInstanceTypes: []gpusearch.InstanceType{ - {Type: "hyperstack_H100x8_NVLINK", WorkspaceGroups: nil}, + {Type: "hyperstack_H100x8_NVLINK"}, }, }, } @@ -1074,10 +1097,8 @@ func TestCreateInstancesWithTypeSkipsInvalidType(t *testing.T) { allInstanceTypes: &gpusearch.AllInstanceTypesResponse{ AllInstanceTypes: []gpusearch.InstanceType{ { - Type: "hyperstack_H100_sxm5x8", - WorkspaceGroups: []gpusearch.WorkspaceGroup{ - {ID: "wg-shadeform", Name: "Shadeform", PlatformType: "shadeform"}, - }, + Type: "hyperstack_H100_sxm5x8", + CloudCredID: "cc-shadeform", }, }, }, @@ -1103,7 +1124,7 @@ func TestCreateInstancesWithTypeSkipsUnavailableType(t *testing.T) { piped: true, allInstanceTypes: &gpusearch.AllInstanceTypesResponse{ AllInstanceTypes: []gpusearch.InstanceType{ - {Type: "hyperstack_H100x8_NVLINK", WorkspaceGroups: nil}, + {Type: "hyperstack_H100x8_NVLINK"}, }, }, } @@ -1113,7 +1134,34 @@ func TestCreateInstancesWithTypeSkipsUnavailableType(t *testing.T) { assert.True(t, result.hadFailure, "expected hadFailure for an unavailable instance type") assert.Empty(t, result.successes) - assert.Empty(t, mock.CreatedWorkspaces, "CreateWorkspace must not be called when no workspace group is available") + assert.Empty(t, mock.CreatedWorkspaces, "CreateWorkspace must not be called when no cloud credential is available") +} + +func TestCreateInstancesWithTypeSetsCloudCredIDFromCatalog(t *testing.T) { + mock := NewMockGPUCreateStore() + ctx := &createContext{ + t: terminal.New(), + store: mock, + opts: GPUCreateOptions{Count: 1, Parallel: 1, Name: "jt-4"}, + org: mock.Org, + user: mock.User, + piped: true, + allInstanceTypes: &gpusearch.AllInstanceTypesResponse{ + AllInstanceTypes: []gpusearch.InstanceType{ + { + Type: "hyperstack_H100_sxm5x8", + CloudCredID: "cc-shadeform", + }, + }, + }, + } + ctx.logf = func(_ string, _ ...interface{}) {} + + result := ctx.createInstancesWithType(InstanceSpec{Type: "hyperstack_H100_sxm5x8"}, 0, 1) + + assert.False(t, result.hadFailure) + require.Len(t, mock.CreatedOptions, 1) + assert.Equal(t, "cc-shadeform", mock.CreatedOptions[0].CloudCredID) } func TestCreateInstancesWithTypeBypassesValidationForLaunchable(t *testing.T) { @@ -1130,8 +1178,8 @@ func TestCreateInstancesWithTypeBypassesValidationForLaunchable(t *testing.T) { ID: "env-abc", Name: "test-launchable", CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ - WorkspaceGroupID: "wg-from-launchable", - InstanceType: "n2-standard-4", + CloudCredID: "cc-from-launchable", + InstanceType: "n2-standard-4", }, }, }, diff --git a/pkg/cmd/gpusearch/gpusearch.go b/pkg/cmd/gpusearch/gpusearch.go index 059431418..a00aa3ce4 100644 --- a/pkg/cmd/gpusearch/gpusearch.go +++ b/pkg/cmd/gpusearch/gpusearch.go @@ -48,32 +48,34 @@ type Storage struct { PricePerGBHr BasePrice `json:"price_per_gb_hr"` // Uses BasePrice since API returns {currency, amount} } -// WorkspaceGroup represents a workspace group that can run an instance type -type WorkspaceGroup struct { +// CloudCred represents a cloud credential that can run an instance type. +type CloudCred struct { ID string `json:"id"` Name string `json:"name"` PlatformType string `json:"platformType"` + TenantType string `json:"tenantType"` } // InstanceType represents an instance type from the API type InstanceType struct { - Type string `json:"type"` - SupportedGPUs []GPU `json:"supported_gpus"` - SupportedStorage []Storage `json:"supported_storage"` - SupportedArchitectures []string `json:"supported_architectures"` - Memory string `json:"memory"` - InstanceMemoryBytes MemoryBytes `json:"memory_bytes"` - VCPU int `json:"vcpu"` - BasePrice BasePrice `json:"base_price"` - Location string `json:"location"` - SubLocation string `json:"sub_location"` - AvailableLocations []string `json:"available_locations"` - Provider string `json:"provider"` - WorkspaceGroups []WorkspaceGroup `json:"workspace_groups"` - EstimatedDeployTime string `json:"estimated_deploy_time"` - Stoppable bool `json:"stoppable"` - Rebootable bool `json:"rebootable"` - CanModifyFirewallRules bool `json:"can_modify_firewall_rules"` + Type string `json:"type"` + SupportedGPUs []GPU `json:"supported_gpus"` + SupportedStorage []Storage `json:"supported_storage"` + SupportedArchitectures []string `json:"supported_architectures"` + Memory string `json:"memory"` + InstanceMemoryBytes MemoryBytes `json:"memory_bytes"` + VCPU int `json:"vcpu"` + BasePrice BasePrice `json:"base_price"` + Location string `json:"location"` + SubLocation string `json:"sub_location"` + AvailableLocations []string `json:"available_locations"` + Provider string `json:"provider"` + CloudCredID string `json:"cloud_cred_id"` + CloudCreds []CloudCred `json:"cloud_creds"` + EstimatedDeployTime string `json:"estimated_deploy_time"` + Stoppable bool `json:"stoppable"` + Rebootable bool `json:"rebootable"` + CanModifyFirewallRules bool `json:"can_modify_firewall_rules"` } // InstanceTypesResponse represents the API response @@ -81,17 +83,20 @@ type InstanceTypesResponse struct { Items []InstanceType `json:"items"` } -// AllInstanceTypesResponse represents the authenticated API response with workspace groups +// AllInstanceTypesResponse represents the authenticated API response with cloud credentials. type AllInstanceTypesResponse struct { AllInstanceTypes []InstanceType `json:"allInstanceTypes"` } -// GetWorkspaceGroupID returns the workspace group ID for an instance type, or empty string if not found -func (r *AllInstanceTypesResponse) GetWorkspaceGroupID(instanceType string) string { +// GetCloudCredID returns the cloud credential ID for an instance type, or empty string if not found. +func (r *AllInstanceTypesResponse) GetCloudCredID(instanceType string) string { for _, it := range r.AllInstanceTypes { if it.Type == instanceType { - if len(it.WorkspaceGroups) > 0 { - return it.WorkspaceGroups[0].ID + if it.CloudCredID != "" { + return it.CloudCredID + } + if len(it.CloudCreds) > 0 { + return it.CloudCreds[0].ID } } } diff --git a/pkg/cmd/gpusearch/gpusearch_test.go b/pkg/cmd/gpusearch/gpusearch_test.go index cf633465d..cbc40f1da 100644 --- a/pkg/cmd/gpusearch/gpusearch_test.go +++ b/pkg/cmd/gpusearch/gpusearch_test.go @@ -590,33 +590,28 @@ func TestAllInstanceTypesResponseLookup(t *testing.T) { resp := &AllInstanceTypesResponse{ AllInstanceTypes: []InstanceType{ { - Type: "hyperstack_H100_sxm5x8", - WorkspaceGroups: []WorkspaceGroup{ - {ID: "wg-shadeform", Name: "Shadeform", PlatformType: "shadeform"}, - }, + Type: "hyperstack_H100_sxm5x8", + CloudCredID: "cc-shadeform", }, { - Type: "hyperstack_H100x8_NVLINK", - WorkspaceGroups: nil, + Type: "hyperstack_H100x8_NVLINK", }, { - Type: "verda-b300-8x", - WorkspaceGroups: []WorkspaceGroup{}, + Type: "verda-b300-8x", }, }, } - t.Run("GetWorkspaceGroupID returns id when type has groups", func(t *testing.T) { - assert.Equal(t, "wg-shadeform", resp.GetWorkspaceGroupID("hyperstack_H100_sxm5x8")) + t.Run("GetCloudCredID returns the cloud credential instead of the workspace group", func(t *testing.T) { + assert.Equal(t, "cc-shadeform", resp.GetCloudCredID("hyperstack_H100_sxm5x8")) }) - t.Run("GetWorkspaceGroupID returns empty for type without groups", func(t *testing.T) { - assert.Equal(t, "", resp.GetWorkspaceGroupID("hyperstack_H100x8_NVLINK")) - assert.Equal(t, "", resp.GetWorkspaceGroupID("verda-b300-8x")) + t.Run("GetCloudCredID returns empty when no cloud credential is available", func(t *testing.T) { + assert.Equal(t, "", resp.GetCloudCredID("verda-b300-8x")) }) - t.Run("GetWorkspaceGroupID returns empty for unknown type", func(t *testing.T) { - assert.Equal(t, "", resp.GetWorkspaceGroupID("hyperstack_H100x8_one")) + t.Run("GetCloudCredID returns empty for unknown type", func(t *testing.T) { + assert.Equal(t, "", resp.GetCloudCredID("hyperstack_H100x8_one")) }) t.Run("HasInstanceType is true even when groups are empty", func(t *testing.T) { diff --git a/pkg/cmd/scale/scale.go b/pkg/cmd/scale/scale.go index 6e14c4fe2..70e9d03c0 100644 --- a/pkg/cmd/scale/scale.go +++ b/pkg/cmd/scale/scale.go @@ -88,7 +88,6 @@ func Runscale(t *terminal.Terminal, args []string, gpu string, cpu string, sstor // fmt.Printf("template %s %s\n", result.WorkspaceTemplate.ID, result.WorkspaceTemplate.Name) // fmt.Printf("resource class %s\n", result.WorkspaceClassID) // fmt.Printf("instance %s\n", result.InstanceType) - // fmt.Printf("workspace group %s\n", result.WorkspaceGroupID) if gpu != "" { t.Vprintf("\n\nInstance %s scaled to %s 🤙\n", t.Green(ws.Name), t.Green(ws.InstanceType)) diff --git a/pkg/cmd/start/start.go b/pkg/cmd/start/start.go index 1110a160d..1fb07986d 100644 --- a/pkg/cmd/start/start.go +++ b/pkg/cmd/start/start.go @@ -360,7 +360,6 @@ func createEmptyWorkspace(user *entity.User, apiKeyAuth bool, t *terminal.Termin } else { t.Vprintf("\tCPU instance %s\n", t.Green(cwOptions.WorkspaceClassID)) } - t.Vprintf("\tCloud %s\n\n", t.Green(cwOptions.WorkspaceGroupID)) s := t.NewSpinner() s.Suffix = " Creating your instance. Hang tight 🤙" @@ -467,7 +466,6 @@ func joinProjectWithNewWorkspace(t *terminal.Terminal, templateWorkspace entity. } else { t.Vprintf("\tCPU instance %s\n", cwOptions.WorkspaceClassID) } - t.Vprintf("\tCloud %s\n", cwOptions.WorkspaceGroupID) s := t.NewSpinner() s.Suffix = " Creating your instance. Hang tight 🤙" @@ -635,7 +633,6 @@ func createWorkspace(user *entity.User, apiKeyAuth bool, t *terminal.Terminal, w } else { t.Vprintf("\tCPU instance %s\n", options.WorkspaceClassID) } - t.Vprintf("\tCloud %s\n", options.WorkspaceGroupID) s := t.NewSpinner() s.Suffix = " Creating your instance. Hang tight 🤙" diff --git a/pkg/cmd/start/start_test.go b/pkg/cmd/start/start_test.go index 18abaeec0..3b8d606d8 100644 --- a/pkg/cmd/start/start_test.go +++ b/pkg/cmd/start/start_test.go @@ -48,7 +48,6 @@ func Test_DisplayBC(t *testing.T) { displayConnectBreadCrumb(term, &entity.Workspace{ ID: "123456789", Name: "my-name", - WorkspaceGroupID: "", OrganizationID: "", WorkspaceClassID: "", CreatedByUserID: "", diff --git a/pkg/entity/entity.go b/pkg/entity/entity.go index 4a9795676..ee0911290 100644 --- a/pkg/entity/entity.go +++ b/pkg/entity/entity.go @@ -17,13 +17,6 @@ const ( CredientialProviderUnspecified CredentialProvider = "" ) -const WorkspaceGroupDevPlane = "devplane-brev-1" - -var LegacyWorkspaceGroups = map[string]bool{ - "k8s.brevstack.com": true, - "brev-test-brevtenant-cluster": true, -} - type AuthTokens struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` @@ -232,7 +225,6 @@ type Application struct { type RequestCreateWorkspace struct { Name string `json:"name"` - WorkspaceGroupID string `json:"workspaceGroupId"` WorkspaceClassID string `json:"workspaceClassId"` GitRepo string `json:"gitRepo"` IsStoppable bool `json:"isStoppable"` @@ -267,18 +259,6 @@ const ( Ready = "READY" ) -type WorkspaceGroup struct { - ID string `json:"id"` - Name string `json:"name"` - BaseDNS string `json:"baseDns"` - Status string `json:"status"` - Platform string `json:"platform"` - PlatformID string `json:"platformId"` - PlatformRegion string `json:"platformRegion"` - Version string `json:"version"` - TenantType string `json:"tenantType"` -} // @Name WorkspaceGroup - type InstanceTypeInfo struct { Stoppable bool `json:"stoppable"` } @@ -286,7 +266,6 @@ type InstanceTypeInfo struct { type Workspace struct { ID string `json:"id"` Name string `json:"name"` - WorkspaceGroupID string `json:"workspaceGroupId"` OrganizationID string `json:"organizationId"` WorkspaceClassID string `json:"workspaceClassId"` // WorkspaceClassID is resources, like "2x8" InstanceType string `json:"instanceType,omitempty"` @@ -425,17 +404,6 @@ func (w Workspace) GetHostSSHPort() int { return port } -func (w Workspace) IsLegacy() bool { - return MapContainsKey(LegacyWorkspaceGroups, w.WorkspaceGroupID) -} - -func (w Workspace) GetUsername() string { - if w.IsLegacy() { - return "brev" - } - return DefaultUser -} - type WorkspaceTemplate struct { ID string `json:"id"` Type string `json:"type"` @@ -446,21 +414,11 @@ type WorkspaceTemplate struct { Port int `json:"port"` } -func MapContainsKey[K comparable, V any](m map[K]V, key K) bool { - _, ok := m[key] - return ok -} - func (w Workspace) GetProjectFolderPath() (string, error) { - var prefix string - if MapContainsKey(LegacyWorkspaceGroups, w.WorkspaceGroupID) { - prefix = "/home/brev/workspace" - } else { - if w.SSHUser == "" { - return "", fmt.Errorf("workspace %s has empty SSH user (workspace may not be running)", w.ID) - } - prefix = "/home/" + w.SSHUser + if w.SSHUser == "" { + return "", fmt.Errorf("workspace %s has empty SSH user (workspace may not be running)", w.ID) } + prefix := "/home/" + w.SSHUser var folderName string if w.IDEConfig.DefaultWorkingDir != "" { //nolint:gocritic // i like if else if path.IsAbs(w.IDEConfig.DefaultWorkingDir) { diff --git a/pkg/entity/entity_test.go b/pkg/entity/entity_test.go new file mode 100644 index 000000000..465d5aa62 --- /dev/null +++ b/pkg/entity/entity_test.go @@ -0,0 +1,20 @@ +package entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetProjectFolderPathUsesSSHUser(t *testing.T) { + workspace := Workspace{ + ID: "workspace-1", + SSHUser: "ubuntu", + GitRepo: "https://github.com/brevdev/example.git", + } + + projectFolderPath, err := workspace.GetProjectFolderPath() + require.NoError(t, err) + assert.Equal(t, "/home/ubuntu/example", projectFolderPath) +} diff --git a/pkg/huproxyclient/huproxyclient.go b/pkg/huproxyclient/huproxyclient.go index eabfb5c6c..e1de9b12a 100644 --- a/pkg/huproxyclient/huproxyclient.go +++ b/pkg/huproxyclient/huproxyclient.go @@ -5,7 +5,6 @@ package huproxyclient import ( "context" "crypto/tls" - "fmt" "io" "io/ioutil" "net/http" @@ -24,7 +23,6 @@ var writeTimeout = 10 * time.Second type HubProxyStore interface { GetAuthTokens() (*entity.AuthTokens, error) - GetCurrentWorkspaceGroupID() (string, error) } func dialError(url string, resp *http.Response, err error) { @@ -55,14 +53,6 @@ func Run(url string, store HubProxyStore) error { return errors.WrapAndTrace(err) } - workspaceGroupID, err := store.GetCurrentWorkspaceGroupID() - if err != nil { - fmt.Printf("%v\n", err) - } - if workspaceGroupID != "" { - head["X-Workspace-Group-ID"] = []string{workspaceGroupID} - } - head["Authorization"] = []string{ "Bearer " + token.AccessToken, } diff --git a/pkg/ssh/ssh_test.go b/pkg/ssh/ssh_test.go index f8763ad27..0190ab065 100644 --- a/pkg/ssh/ssh_test.go +++ b/pkg/ssh/ssh_test.go @@ -28,7 +28,6 @@ var someWorkspaces = []entity.WorkspaceWithMeta{ Workspace: entity.Workspace{ ID: "test-id", Name: "testName", - WorkspaceGroupID: "wgi", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", diff --git a/pkg/ssh/sshconfigurer.go b/pkg/ssh/sshconfigurer.go index cb6822fea..9f3340015 100644 --- a/pkg/ssh/sshconfigurer.go +++ b/pkg/ssh/sshconfigurer.go @@ -356,30 +356,6 @@ func tmplAndValToString(tmpl *template.Template, val interface{}) (string, error func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string, cloudflaredBinaryPath string) (string, error) { //nolint:funlen,gocyclo // ok alias := string(workspace.GetLocalIdentifier()) privateKeyPath = "\"" + privateKeyPath + "\"" - if workspace.IsLegacy() { - proxyCommand := makeProxyCommand(workspace.ID) - projPath, err := workspace.GetProjectFolderPath() - if err != nil { - return "", breverrors.WrapAndTrace(err) - } - entry := SSHConfigEntryV2{ - Alias: alias, - IdentityFile: privateKeyPath, - User: "brev", - ProxyCommand: proxyCommand, - Dir: projPath, - } - tmpl, err := template.New(alias).Parse(SSHConfigEntryTemplateV2) - if err != nil { - return "", breverrors.WrapAndTrace(err) - } - val, err := tmplAndValToString(tmpl, entry) - if err != nil { - return "", breverrors.WrapAndTrace(err) - } - return val, nil - } - var sshVal string user := workspace.GetSSHUser() hostname := workspace.GetHostname() @@ -480,70 +456,10 @@ func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string, clo return val, nil } -// func makeSSHConfigEntryV2(workspace entity.Workspace, privateKeyPath string) (string, error) { -// alias := string(workspace.GetLocalIdentifier()) -// privateKeyPath = "\"" + privateKeyPath + "\"" -// if workspace.IsLegacy() { -// proxyCommand := makeProxyCommand(workspace.ID) -// entry := SSHConfigEntryV2{ -// Alias: alias, -// IdentityFile: privateKeyPath, -// User: "brev", // todo param-user -// ProxyCommand: proxyCommand, -// Dir: workspace.GetProjectFolderPath(), -// } -// tmpl, err := template.New(alias).Parse(SSHConfigEntryTemplateV2) -// if err != nil { -// return "", breverrors.WrapAndTrace(err) -// } - -// buf := &bytes.Buffer{} -// err = tmpl.Execute(buf, entry) -// if err != nil { -// return "", breverrors.WrapAndTrace(err) -// } - -// return buf.String(), nil -// } else { -// hostname := workspace.GetHostname() -// var userName string -// port := workspace.GetSSHPort() -// if port == 22 { -// userName = "ubuntu" -// } else { -// userName = "root" -// } -// entry := SSHConfigEntryV2{ -// Alias: alias, -// IdentityFile: privateKeyPath, -// User: userName, // todo param-user -// Dir: workspace.GetProjectFolderPath(), -// HostName: hostname, -// Port: port, -// } -// tmpl, err := template.New(alias).Parse(SSHConfigEntryTemplateV3) -// if err != nil { -// return "", breverrors.WrapAndTrace(err) -// } -// buf := &bytes.Buffer{} -// err = tmpl.Execute(buf, entry) -// if err != nil { -// return "", breverrors.WrapAndTrace(err) -// } - -// return buf.String(), nil -// } -// } - func makeCloudflareSSHProxyCommand(cloudflaredBinaryPath string, hostname string) string { return fmt.Sprintf("%s access ssh --hostname %s", cloudflaredBinaryPath, hostname) } -func makeProxyCommand(workspaceID string) string { - huproxyExec := "brev proxy" - return fmt.Sprintf("%s %s", huproxyExec, workspaceID) -} - func (s SSHConfigurerV2) EnsureWSLConfigHasInclude() error { // openssh-7.3 @@ -724,7 +640,7 @@ func makeJetbrainsConfigEntry(workspace entity.Workspace, privateKeyPath string) Host: hostname, Port: fmt.Sprint(port), KeyPath: privateKeyPath, - Username: workspace.GetUsername(), + Username: workspace.GetSSHUser(), // CustomName: name, NameFormat: "DESCRIPTIVE", ConnectionConfig: `{"hostKeyVerifier":{"stringHostKeyChecking":"NO"},"serverAliveInterval":30}`, diff --git a/pkg/ssh/sshconfigurer_test.go b/pkg/ssh/sshconfigurer_test.go index 6f4975296..b1f655245 100644 --- a/pkg/ssh/sshconfigurer_test.go +++ b/pkg/ssh/sshconfigurer_test.go @@ -15,7 +15,6 @@ var somePlainWorkspaces = []entity.Workspace{ { ID: "test-id-1", Name: "testName1", - WorkspaceGroupID: "wgi", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -28,7 +27,6 @@ var somePlainWorkspaces = []entity.Workspace{ { ID: "test-id-2", Name: "testName2", - WorkspaceGroupID: "wgi", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -294,7 +292,6 @@ func Test_makeSSHConfigEntryV2(t *testing.T) { //nolint:funlen // test workspace: entity.Workspace{ ID: "test-id-2", Name: "testName2", - WorkspaceGroupID: entity.WorkspaceGroupDevPlane, OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -352,7 +349,6 @@ Host testName2-host workspace: entity.Workspace{ ID: "test-id-2", Name: "testName2", - WorkspaceGroupID: "test-id-2", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -407,7 +403,6 @@ Host testName2-host workspace: entity.Workspace{ ID: "test-id-2", Name: "testName2", - WorkspaceGroupID: "test-id-2", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -455,78 +450,6 @@ Host testName2-host ControlPersist 10m Port 22 -`, - }, - { - name: "test legacy workspace uses brev user 1", - args: args{ - workspace: entity.Workspace{ - ID: "test-id-2", - Name: "testName2", - WorkspaceGroupID: "k8s.brevstack.com", // a legacy wsg - OrganizationID: "oi", - WorkspaceClassID: "wci", - CreatedByUserID: "cui", - DNS: "test2-dns-org.brev.sh", - Status: entity.Running, - Password: "sdfal", - GitRepo: "gitrepo", - }, - privateKeyPath: "/my/priv/key.pem", - runRemoteCMD: true, - }, - want: `Host testName2 - IdentityFile "/my/priv/key.pem" - User brev - ProxyCommand brev proxy test-id-2 - ServerAliveInterval 30 - UserKnownHostsFile /dev/null - IdentitiesOnly yes - StrictHostKeyChecking no - PasswordAuthentication no - AddKeysToAgent yes - ForwardAgent yes - RequestTTY yes - ControlMaster auto - ControlPath ~/.ssh/brev-control-%C - ControlPersist 10m - -`, - }, - { - name: "test legacy workspace uses brev user 1", - args: args{ - workspace: entity.Workspace{ - ID: "test-id-2", - Name: "testName2", - WorkspaceGroupID: "brev-test-brevtenant-cluster", // a legacy wsg - OrganizationID: "oi", - WorkspaceClassID: "wci", - CreatedByUserID: "cui", - DNS: "test2-dns-org.brev.sh", - Status: entity.Running, - Password: "sdfal", - GitRepo: "gitrepo", - }, - privateKeyPath: "/my/priv/key.pem", - runRemoteCMD: true, - }, - want: `Host testName2 - IdentityFile "/my/priv/key.pem" - User brev - ProxyCommand brev proxy test-id-2 - ServerAliveInterval 30 - UserKnownHostsFile /dev/null - IdentitiesOnly yes - StrictHostKeyChecking no - PasswordAuthentication no - AddKeysToAgent yes - ForwardAgent yes - RequestTTY yes - ControlMaster auto - ControlPath ~/.ssh/brev-control-%C - ControlPersist 10m - `, }, { @@ -535,7 +458,6 @@ Host testName2-host workspace: entity.Workspace{ ID: "test-id-2", Name: "testName2", - WorkspaceGroupID: "test-id-2", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -780,7 +702,6 @@ func TestSSHConfigurerV2_Update(t *testing.T) { //nolint // this is a test { ID: "test-id-1", Name: "testName1", - WorkspaceGroupID: "test-id-1", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -846,7 +767,6 @@ Host testName1-host { ID: "test-id-1", Name: "testName1", - WorkspaceGroupID: "test-id-1", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", diff --git a/pkg/store/file.go b/pkg/store/file.go index 55ea44ff2..3c9dd0cc7 100644 --- a/pkg/store/file.go +++ b/pkg/store/file.go @@ -168,10 +168,9 @@ func (f FileStore) GetDependenciesForImport(path string) (*Dependencies, error) } type WorkspaceMeta struct { - WorkspaceID string `json:"workspaceId"` - WorkspaceGroupID string `json:"workspaceGroupId"` - UserID string `json:"userId"` - OrganizationID string `json:"organizationId"` + WorkspaceID string `json:"workspaceId"` + UserID string `json:"userId"` + OrganizationID string `json:"organizationId"` } // GetCurrentWorkspaceID will return an empty string when @@ -192,14 +191,6 @@ func (f FileStore) IsWorkspace() (bool, error) { return id != "", nil } -func (f FileStore) GetCurrentWorkspaceGroupID() (string, error) { - meta, err := f.GetCurrentWorkspaceMeta() - if err != nil { - return "", nil - } - return meta.WorkspaceGroupID, nil -} - func (f FileStore) GetCurrentWorkspaceMeta() (*WorkspaceMeta, error) { file, err := f.fs.Open("/etc/meta/workspace.json") if err != nil { @@ -224,7 +215,6 @@ func (f FileStore) GetCurrentWorkspaceMeta() (*WorkspaceMeta, error) { if wm.WorkspaceID != "" { breverrors.GetDefaultErrorReporter().AddTag("workspaceId", wm.WorkspaceID) breverrors.GetDefaultErrorReporter().AddTag("organizationId", wm.OrganizationID) - breverrors.GetDefaultErrorReporter().AddTag("workspaceGroupId", wm.WorkspaceGroupID) } return &wm, nil diff --git a/pkg/store/instancetypes.go b/pkg/store/instancetypes.go index 416b11bac..b873345b5 100644 --- a/pkg/store/instancetypes.go +++ b/pkg/store/instancetypes.go @@ -1,19 +1,20 @@ package store import ( - "encoding/json" - "fmt" - "runtime" + "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" "github.com/brevdev/brev-cli/pkg/config" breverrors "github.com/brevdev/brev-cli/pkg/errors" ) const ( - instanceTypesAPIPath = "v1/instance/types" - // Authenticated API for instance types with workspace groups - allInstanceTypesPathPattern = "api/instances/alltypesavailable/%s" + tenantTypeShared = "shared" + tenantTypeIsolated = "isolated" ) // GetInstanceTypes fetches all available instance types from the public API @@ -26,50 +27,141 @@ func (s AuthHTTPStore) GetInstanceTypes(includeCPU bool) (*gpusearch.InstanceTyp return fetchInstanceTypes(includeCPU) } -// fetchInstanceTypes fetches instance types from the public Brev API +// fetchInstanceTypes fetches instance types from dev-plane's public Connect API. func fetchInstanceTypes(includeCPU bool) (*gpusearch.InstanceTypesResponse, error) { - cfg := config.NewConstants() - client := NewRestyClient(cfg.GetBrevPublicAPIURL()) - - req := client.R(). - SetHeader("Accept", "application/json") - if includeCPU { - req.SetQueryParam("include_cpu", "true") - } - res, err := req.Get(instanceTypesAPIPath) + client := devplaneapiv1connect.NewInstanceServiceClient( + http.DefaultClient, + config.NewConstants().GetBrevPublicAPIURL(), + ) + skipAccessFilter := false + res, err := client.ListPublicInstanceType(context.Background(), connect.NewRequest(&devplaneapiv1.ListPublicInstanceTypeRequest{ + Options: &devplaneapiv1.ListInstanceTypeOptions{ + IncludeCpu: &includeCPU, + SkipAccessFilter: &skipAccessFilter, + }, + })) if err != nil { return nil, breverrors.WrapAndTrace(err) } - if res.IsError() { - return nil, NewHTTPResponseError(res) - } - var result gpusearch.InstanceTypesResponse - err = json.Unmarshal(res.Body(), &result) + mapped := mapProtoInstanceTypesToGPUSearchResponse(res.Msg.GetItems()) + return &gpusearch.InstanceTypesResponse{Items: mapped.AllInstanceTypes}, nil +} + +// 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(), + ) + includeUnavailable := false + includePreemptible := false + includeCPU := true + uniqueInstanceType := true + skipAccessFilter := false + res, err := client.ListOrganizationAvailableInstanceTypes(context.Background(), connect.NewRequest(&devplaneapiv1.ListOrganizationAvailableInstanceTypesRequest{ + OrganizationId: orgID, + Options: &devplaneapiv1.ListInstanceTypeOptions{ + IncludeUnavailable: &includeUnavailable, + IncludePreemptible: &includePreemptible, + IncludeCpu: &includeCPU, + UniqueInstanceType: &uniqueInstanceType, + SkipAccessFilter: &skipAccessFilter, + }, + })) if err != nil { return nil, breverrors.WrapAndTrace(err) } - return &result, nil + return mapProtoInstanceTypesToGPUSearchResponse(res.Msg.GetItems()), nil } -// GetAllInstanceTypesWithWorkspaceGroups fetches instance types with workspace groups from the authenticated API -func (s AuthHTTPStore) GetAllInstanceTypesWithWorkspaceGroups(orgID string) (*gpusearch.AllInstanceTypesResponse, error) { - path := fmt.Sprintf(allInstanceTypesPathPattern, orgID) +func mapProtoInstanceTypesToGPUSearchResponse(instanceTypes []*devplaneapiv1.InstanceType) *gpusearch.AllInstanceTypesResponse { + items := make([]gpusearch.InstanceType, 0, len(instanceTypes)) + for _, instanceType := range instanceTypes { + if instanceType == nil { + continue + } + item := gpusearch.InstanceType{ + Type: instanceType.GetType(), + SupportedArchitectures: instanceType.GetSupportedArchitectures(), + Memory: instanceType.GetMemory(), + InstanceMemoryBytes: mapProtoBytes(instanceType.GetMemoryBytes()), + VCPU: int(instanceType.GetVcpu()), + Location: instanceType.GetLocation(), + SubLocation: instanceType.GetSubLocation(), + AvailableLocations: instanceType.GetAvailableLocations(), + Provider: instanceType.GetProvider(), + CloudCredID: instanceType.GetCloudCredId(), + EstimatedDeployTime: instanceType.GetEstimatedDeployTime(), + Stoppable: instanceType.GetStoppable(), + Rebootable: instanceType.GetRebootable(), + CanModifyFirewallRules: instanceType.GetCanModifyFirewallRules(), + } + if basePrice := instanceType.GetBasePrice(); basePrice != nil { + item.BasePrice = mapProtoCurrencyAmount(basePrice) + } + for _, gpu := range instanceType.GetSupportedGpus() { + if gpu == nil { + continue + } + item.SupportedGPUs = append(item.SupportedGPUs, gpusearch.GPU{ + Count: int(gpu.GetCount()), + Name: gpu.GetName(), + Manufacturer: gpu.GetManufacturer(), + Memory: gpu.GetMemory(), + MemoryBytes: mapProtoBytes(gpu.GetMemoryBytes()), + }) + } + for _, storage := range instanceType.GetSupportedStorage() { + if storage == nil { + continue + } + item.SupportedStorage = append(item.SupportedStorage, gpusearch.Storage{ + Count: int(storage.GetCount()), + Size: storage.GetSize(), + Type: storage.GetType(), + MinSize: storage.GetMinSize(), + MaxSize: storage.GetMaxSize(), + SizeBytes: mapProtoBytes(storage.GetSizeBytes()), + PricePerGBHr: mapProtoCurrencyAmount(storage.GetPricePerGbHr()), + }) + } + if cloudCred := instanceType.GetCloudCred(); cloudCred != nil { + mappedCloudCred := gpusearch.CloudCred{ + ID: cloudCred.GetCloudCredId(), + Name: cloudCred.GetName(), + PlatformType: cloudCred.GetProviderId(), + TenantType: mapTenantType(cloudCred.GetTenantType()), + } + item.CloudCreds = []gpusearch.CloudCred{mappedCloudCred} + } + items = append(items, item) + } + return &gpusearch.AllInstanceTypesResponse{AllInstanceTypes: items} +} - var result gpusearch.AllInstanceTypesResponse - res, err := s.authHTTPClient.restyClient.R(). - SetHeader("Content-Type", "application/json"). - SetQueryParam("utm_source", "cli"). - SetQueryParam("os", runtime.GOOS). - SetResult(&result). - Get(path) - if err != nil { - return nil, breverrors.WrapAndTrace(err) +func mapProtoBytes(value *devplaneapiv1.Bytes) gpusearch.MemoryBytes { + if value == nil { + return gpusearch.MemoryBytes{} } - if res.IsError() { - return nil, NewHTTPResponseError(res) + return gpusearch.MemoryBytes{Value: value.GetValue(), Unit: value.GetUnit()} +} + +func mapProtoCurrencyAmount(value *devplaneapiv1.CurrencyAmount) gpusearch.BasePrice { + if value == nil { + return gpusearch.BasePrice{} } + return gpusearch.BasePrice{Currency: value.GetCurrency(), Amount: value.GetAmount()} +} - return &result, nil +func mapTenantType(tenantType devplaneapiv1.TenantType) string { + switch tenantType { + case devplaneapiv1.TenantType_TENANT_TYPE_ISOLATED: + return tenantTypeIsolated + case devplaneapiv1.TenantType_TENANT_TYPE_SHARED: + return tenantTypeShared + default: + return "" + } } diff --git a/pkg/store/instancetypes_test.go b/pkg/store/instancetypes_test.go new file mode 100644 index 000000000..085822afb --- /dev/null +++ b/pkg/store/instancetypes_test.go @@ -0,0 +1,175 @@ +package store + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "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" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type instanceCatalogTestHandler struct { + devplaneapiv1connect.UnimplementedInstanceServiceHandler + gotAuth string + gotOrgID string + gotConnectProtocolVersion string + gotIncludeCPU bool +} + +func (h *instanceCatalogTestHandler) ListPublicInstanceType( + _ context.Context, + req *connect.Request[devplaneapiv1.ListPublicInstanceTypeRequest], +) (*connect.Response[devplaneapiv1.ListPublicInstanceTypeResponse], error) { + h.gotAuth = req.Header().Get("Authorization") + h.gotConnectProtocolVersion = req.Header().Get("Connect-Protocol-Version") + h.gotIncludeCPU = req.Msg.GetOptions().GetIncludeCpu() + return connect.NewResponse(&devplaneapiv1.ListPublicInstanceTypeResponse{ + Items: []*devplaneapiv1.InstanceType{{ + Type: "h100-1x", + SupportedArchitectures: []string{"amd64"}, + Memory: "128 GB", + MemoryBytes: &devplaneapiv1.Bytes{Value: 128, Unit: "GB"}, + Vcpu: 16, + SupportedGpus: []*devplaneapiv1.Gpu{{ + Count: 1, + Name: "H100", + Manufacturer: "NVIDIA", + Memory: "80 GB", + MemoryBytes: &devplaneapiv1.Bytes{Value: 80, Unit: "GB"}, + }}, + SupportedStorage: []*devplaneapiv1.Storage{{ + Count: 1, + Size: "100 GB", + Type: "SSD", + MinSize: "50 GB", + MaxSize: "2 TB", + SizeBytes: &devplaneapiv1.Bytes{Value: 100, Unit: "GB"}, + PricePerGbHr: &devplaneapiv1.CurrencyAmount{ + Currency: "USD", + Amount: "0.0001", + }, + }}, + BasePrice: &devplaneapiv1.CurrencyAmount{ + Currency: "USD", + Amount: "2.50", + }, + Location: "us-east-1", + SubLocation: "use1-az1", + AvailableLocations: []string{"us-east-1", "us-west-2"}, + Provider: "aws", + CloudCredId: "cc-public-1", + EstimatedDeployTime: "5m", + Stoppable: true, + Rebootable: true, + CanModifyFirewallRules: true, + }}, + }), nil +} + +func (h *instanceCatalogTestHandler) ListOrganizationAvailableInstanceTypes( + _ context.Context, + req *connect.Request[devplaneapiv1.ListOrganizationAvailableInstanceTypesRequest], +) (*connect.Response[devplaneapiv1.ListOrganizationAvailableInstanceTypesResponse], error) { + h.gotAuth = req.Header().Get("Authorization") + h.gotOrgID = req.Msg.GetOrganizationId() + h.gotConnectProtocolVersion = req.Header().Get("Connect-Protocol-Version") + return connect.NewResponse(&devplaneapiv1.ListOrganizationAvailableInstanceTypesResponse{ + Items: []*devplaneapiv1.InstanceType{{ + Type: "h100-1x", + CloudCredId: "cc-org-1", + CloudCred: &devplaneapiv1.CloudCredMetadata{ + CloudCredId: "cc-org-1", + ProviderId: "aws", + Name: "Org AWS", + TenantType: devplaneapiv1.TenantType_TENANT_TYPE_ISOLATED, + }, + AvailableLocations: []string{"us-east-1"}, + IsAvailable: true, + }}, + }), nil +} + +func TestGetAllInstanceTypesWithCloudCredsUsesDevPlanePublicAPI(t *testing.T) { + catalogHandler := &instanceCatalogTestHandler{} + _, connectHandler := devplaneapiv1connect.NewInstanceServiceHandler(catalogHandler) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/devplaneapi.v1.InstanceService/ListOrganizationAvailableInstanceTypes", r.URL.Path) + connectHandler.ServeHTTP(w, r) + })) + defer server.Close() + + t.Setenv("BREV_PUBLIC_API_URL", server.URL) + legacy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("legacy brev-deploy API should not be called: %s", r.URL.Path) + })) + defer legacy.Close() + + token := "tok" + s := MakeMockNoHTTPStore().WithAuthHTTPClient(NewAuthHTTPClient(MockAuth{token: &token}, legacy.URL)) + + resp, err := s.GetAllInstanceTypesWithCloudCreds("org-1") + require.NoError(t, err) + + assert.Equal(t, "Bearer tok", catalogHandler.gotAuth) + assert.Equal(t, "1", catalogHandler.gotConnectProtocolVersion) + assert.Equal(t, "org-1", catalogHandler.gotOrgID) + if assert.Len(t, resp.AllInstanceTypes, 1) { + assert.Equal(t, "h100-1x", resp.AllInstanceTypes[0].Type) + assert.Equal(t, "cc-org-1", resp.GetCloudCredID("h100-1x")) + } +} + +func TestGetInstanceTypesUsesDevPlanePublicRPC(t *testing.T) { + catalogHandler := &instanceCatalogTestHandler{} + _, connectHandler := devplaneapiv1connect.NewInstanceServiceHandler(catalogHandler) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/devplaneapi.v1.InstanceService/ListPublicInstanceType", r.URL.Path) + connectHandler.ServeHTTP(w, r) + })) + defer server.Close() + + t.Setenv("BREV_PUBLIC_API_URL", server.URL) + + resp, err := (NoAuthHTTPStore{}).GetInstanceTypes(true) + require.NoError(t, err) + + assert.Empty(t, catalogHandler.gotAuth) + assert.Equal(t, "1", catalogHandler.gotConnectProtocolVersion) + assert.True(t, catalogHandler.gotIncludeCPU) + if assert.Len(t, resp.Items, 1) { + item := resp.Items[0] + assert.Equal(t, "h100-1x", item.Type) + assert.Equal(t, []string{"amd64"}, item.SupportedArchitectures) + assert.Equal(t, "128 GB", item.Memory) + assert.Equal(t, int64(128), item.InstanceMemoryBytes.Value) + assert.Equal(t, "GB", item.InstanceMemoryBytes.Unit) + assert.Equal(t, 16, item.VCPU) + assert.Equal(t, gpusearch.BasePrice{Currency: "USD", Amount: "2.50"}, item.BasePrice) + assert.Equal(t, "us-east-1", item.Location) + assert.Equal(t, "use1-az1", item.SubLocation) + assert.Equal(t, []string{"us-east-1", "us-west-2"}, item.AvailableLocations) + assert.Equal(t, "aws", item.Provider) + assert.Equal(t, "cc-public-1", item.CloudCredID) + assert.Equal(t, "5m", item.EstimatedDeployTime) + assert.True(t, item.Stoppable) + assert.True(t, item.Rebootable) + assert.True(t, item.CanModifyFirewallRules) + if assert.Len(t, item.SupportedGPUs, 1) { + assert.Equal(t, gpusearch.MemoryBytes{Value: 80, Unit: "GB"}, item.SupportedGPUs[0].MemoryBytes) + } + if assert.Len(t, item.SupportedStorage, 1) { + storage := item.SupportedStorage[0] + assert.Equal(t, "100 GB", storage.Size) + assert.Equal(t, "50 GB", storage.MinSize) + assert.Equal(t, "2 TB", storage.MaxSize) + assert.Equal(t, gpusearch.MemoryBytes{Value: 100, Unit: "GB"}, storage.SizeBytes) + assert.Equal(t, gpusearch.BasePrice{Currency: "USD", Amount: "0.0001"}, storage.PricePerGBHr) + } + } +} diff --git a/pkg/store/workspace.go b/pkg/store/workspace.go index 40a5a6105..0b7f44b13 100644 --- a/pkg/store/workspace.go +++ b/pkg/store/workspace.go @@ -80,7 +80,7 @@ type Registry struct { type CreateWorkspacesOptions struct { Name string `json:"name"` - WorkspaceGroupID string `json:"workspaceGroupId"` + CloudCredID string `json:"cloudCredId,omitempty"` WorkspaceClassID string `json:"workspaceClassId"` WorkspaceTemplateID string `json:"workspaceTemplateId"` Description string `json:"description"` @@ -141,13 +141,13 @@ type LaunchableResponse struct { } type LaunchableWorkspaceRequest struct { - WorkspaceGroupID string `json:"workspaceGroupId,omitempty"` - InstanceType string `json:"instanceType"` - Storage string `json:"storage,omitempty"` - Location string `json:"location,omitempty"` - SubLocation string `json:"subLocation,omitempty"` - ImageID string `json:"imageId,omitempty"` - FirewallRules []CreateFirewallRule `json:"firewallRules,omitempty"` + CloudCredID string `json:"cloudCredId,omitempty"` + InstanceType string `json:"instanceType"` + Storage string `json:"storage,omitempty"` + Location string `json:"location,omitempty"` + SubLocation string `json:"subLocation,omitempty"` + ImageID string `json:"imageId,omitempty"` + FirewallRules []CreateFirewallRule `json:"firewallRules,omitempty"` } type LaunchableBuildRequest struct { @@ -207,7 +207,6 @@ func NewCreateWorkspacesOptions(clusterID, name string) *CreateWorkspacesOptions PortMappings: map[string]string{}, ReposV1: &entity.ReposV1{}, VMBuild: &VMBuild{ForceJupyterInstall: true}, - WorkspaceGroupID: "", // resolved dynamically from instance type WorkspaceTemplateID: DefaultWorkspaceTemplateID, WorkspaceVersion: "v1", } @@ -253,8 +252,8 @@ func (c *CreateWorkspacesOptions) WithVMBuild(vmBuild *VMBuild) *CreateWorkspace return c } -func (c *CreateWorkspacesOptions) WithWorkspaceGroupID(workspaceGroupID string) *CreateWorkspacesOptions { - c.WorkspaceGroupID = workspaceGroupID +func (c *CreateWorkspacesOptions) WithCloudCredID(cloudCredID string) *CreateWorkspacesOptions { + c.CloudCredID = cloudCredID return c } @@ -498,7 +497,6 @@ func (s AuthHTTPStore) ModifyWorkspace(workspaceID string, options *ModifyWorksp // fmt.Printf("template %s %s\n", result.WorkspaceTemplate.ID, result.WorkspaceTemplate.Name) // fmt.Printf("resource class %s\n", result.WorkspaceClassID) // fmt.Printf("instance %s\n", result.InstanceType) - // fmt.Printf("workspace group %s\n", result.WorkspaceGroupID) return &result, nil } diff --git a/pkg/store/workspace_test.go b/pkg/store/workspace_test.go index f0a454049..009a2eb95 100644 --- a/pkg/store/workspace_test.go +++ b/pkg/store/workspace_test.go @@ -1,6 +1,7 @@ package store import ( + "encoding/json" "fmt" "testing" @@ -9,6 +10,19 @@ import ( "github.com/stretchr/testify/assert" ) +func TestCreateWorkspaceOptionsUsesOnlyCloudCredID(t *testing.T) { + options := NewCreateWorkspacesOptions("cluster-1", "workspace-1").WithCloudCredID("cloud-cred-1") + + payload, err := json.Marshal(options) + assert.NoError(t, err) + + var request map[string]any + err = json.Unmarshal(payload, &request) + assert.NoError(t, err) + assert.Equal(t, "cloud-cred-1", request["cloudCredId"]) + assert.NotContains(t, request, "workspaceGroupId") +} + func TestGetWorkspaces(t *testing.T) { s := MakeMockAuthHTTPStore() httpmock.ActivateNonDefault(s.authHTTPClient.restyClient.GetClient()) @@ -17,7 +31,6 @@ func TestGetWorkspaces(t *testing.T) { expected := []entity.Workspace{{ ID: "1", Name: "name", - WorkspaceGroupID: "wgi", OrganizationID: orgID, WorkspaceClassID: "wci", CreatedByUserID: "cbuid", @@ -68,7 +81,6 @@ func TestCreateWorkspace(t *testing.T) { expected := &entity.Workspace{ ID: "1", Name: "name", - WorkspaceGroupID: "wgi", OrganizationID: orgID, WorkspaceClassID: "wci", CreatedByUserID: "cbuid", @@ -105,7 +117,6 @@ func TestGetWorkspacesWithName(t *testing.T) { //nolint:dupl // To refactor late expected := []entity.Workspace{{ ID: "1", Name: "name", - WorkspaceGroupID: "wgi", OrganizationID: orgID, WorkspaceClassID: "wci", CreatedByUserID: "blas", @@ -119,7 +130,6 @@ func TestGetWorkspacesWithName(t *testing.T) { //nolint:dupl // To refactor late { ID: "2", Name: "n2", - WorkspaceGroupID: "wgi", OrganizationID: orgID, WorkspaceClassID: "wci", CreatedByUserID: "other", @@ -161,7 +171,6 @@ func TestGetWorkspacesWithUser(t *testing.T) { //nolint:dupl // To refactor late expected := []entity.Workspace{{ ID: "1", Name: "name", - WorkspaceGroupID: "wgi", OrganizationID: orgID, WorkspaceClassID: "wci", CreatedByUserID: "me", @@ -175,7 +184,6 @@ func TestGetWorkspacesWithUser(t *testing.T) { //nolint:dupl // To refactor late { ID: "2", Name: "n2", - WorkspaceGroupID: "wgi", OrganizationID: orgID, WorkspaceClassID: "wci", CreatedByUserID: "other", @@ -246,7 +254,6 @@ func TestStopWorkspace(t *testing.T) { //nolint:dupl // ok to have this be dupli expected := &entity.Workspace{ ID: workspaceID, Name: "name", - WorkspaceGroupID: "wgi", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -283,7 +290,6 @@ func TestStartWorkspace(t *testing.T) { //nolint:dupl // ok to have this be dupl expected := &entity.Workspace{ ID: workspaceID, Name: "name", - WorkspaceGroupID: "wgi", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -320,7 +326,6 @@ func TestResetWorkspace(t *testing.T) { //nolint:dupl // ok to have this be dupl expected := &entity.Workspace{ ID: workspaceID, Name: "name", - WorkspaceGroupID: "wgi", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -357,7 +362,6 @@ func TestGetWorkspace(t *testing.T) { //nolint:dupl // ok to have this be duplic expected := &entity.Workspace{ ID: workspaceID, Name: "name", - WorkspaceGroupID: "wgi", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", @@ -394,7 +398,6 @@ func TestDeleteWorkspace(t *testing.T) { //nolint:dupl // ok to have this be dup expected := &entity.Workspace{ ID: workspaceID, Name: "name", - WorkspaceGroupID: "wgi", OrganizationID: "oi", WorkspaceClassID: "wci", CreatedByUserID: "cui", diff --git a/pkg/workspacemanagerv2/workspacemanagerv2_test.go b/pkg/workspacemanagerv2/workspacemanagerv2_test.go index dcc4a175c..8e7680e5e 100644 --- a/pkg/workspacemanagerv2/workspacemanagerv2_test.go +++ b/pkg/workspacemanagerv2/workspacemanagerv2_test.go @@ -53,10 +53,9 @@ func (t TestStore) GetWorkspaceSecretsConfig(_ string) (string, error) { func (t TestStore) GetWorkspaceMeta(id string) (*store.WorkspaceMeta, error) { return &store.WorkspaceMeta{ - WorkspaceID: id, - WorkspaceGroupID: "", - UserID: "", - OrganizationID: "", + WorkspaceID: id, + UserID: "", + OrganizationID: "", }, nil }