Skip to content

Commit 67ec7bd

Browse files
committed
feat(create): add build mode flags and fix dynamic workspace group resolution
- Remove hardcoded "GCP" WorkspaceGroupID, resolve dynamically from instance type - Populate VMBuild by default so SSH setup works correctly - Add --mode flag (vm, k8s, container, compose) to select build type - Add --jupyter, --startup-script, --container-image, --compose-file flags - Match UI behavior: server determines mode from build field presence (vmBuild, customContainer, dockerCompose) Tested: vm, container, and compose modes create successfully with SSH access. K8s mode sends correct config but requires a dev-plane fix for dashboard install.
1 parent 6aece4f commit 67ec7bd

3 files changed

Lines changed: 320 additions & 27 deletions

File tree

pkg/cmd/create/create.go

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package create
22

33
import (
44
"fmt"
5+
"os"
56
"strings"
67
"time"
78

9+
"github.com/brevdev/brev-cli/pkg/cmd/gpusearch"
810
"github.com/brevdev/brev-cli/pkg/cmd/util"
911
"github.com/brevdev/brev-cli/pkg/config"
1012
"github.com/brevdev/brev-cli/pkg/entity"
@@ -30,12 +32,18 @@ type CreateStore interface {
3032
GetCurrentUser() (*entity.User, error)
3133
GetWorkspace(workspaceID string) (*entity.Workspace, error)
3234
CreateWorkspace(organizationID string, options *store.CreateWorkspacesOptions) (*entity.Workspace, error)
35+
GetAllInstanceTypesWithWorkspaceGroups(orgID string) (*gpusearch.AllInstanceTypesResponse, error)
3336
}
3437

3538
func NewCmdCreate(t *terminal.Terminal, createStore CreateStore) *cobra.Command {
3639
var detached bool
3740
var gpu string
3841
var cpu string
42+
var mode string
43+
var jupyter bool
44+
var startupScript string
45+
var containerImage string
46+
var composeFile string
3947

4048
cmd := &cobra.Command{
4149
Annotations: map[string]string{"workspace": ""},
@@ -50,11 +58,20 @@ func NewCmdCreate(t *terminal.Terminal, createStore CreateStore) *cobra.Command
5058
name = args[0]
5159
}
5260

61+
// Default jupyter to true unless explicitly set to false
62+
jupyterSet := cmd.Flags().Changed("jupyter")
63+
5364
err := runCreateWorkspace(t, CreateOptions{
5465
Name: name,
5566
WorkspaceClass: cpu,
5667
Detached: detached,
5768
InstanceType: gpu,
69+
Mode: mode,
70+
Jupyter: jupyter,
71+
JupyterSet: jupyterSet,
72+
StartupScript: startupScript,
73+
ContainerImage: containerImage,
74+
ComposeFile: composeFile,
5875
}, createStore)
5976
if err != nil {
6077
if strings.Contains(err.Error(), "duplicate instance with name") {
@@ -70,6 +87,11 @@ func NewCmdCreate(t *terminal.Terminal, createStore CreateStore) *cobra.Command
7087
cmd.Flags().BoolVarP(&detached, "detached", "d", false, "run the command in the background instead of blocking the shell")
7188
cmd.Flags().StringVarP(&cpu, "cpu", "c", "", "CPU instance type. Defaults to 2x8 [2x8, 4x16, 8x32, 16x32]. See docs.brev.dev/cpu for details")
7289
cmd.Flags().StringVarP(&gpu, "gpu", "g", "n1-highmem-4:nvidia-tesla-t4:1", "GPU instance type. See https://brev.dev/docs/reference/gpu for details")
90+
cmd.Flags().StringVarP(&mode, "mode", "m", "vm", "Build mode: vm (default), k8s, container, compose")
91+
cmd.Flags().BoolVar(&jupyter, "jupyter", true, "Install Jupyter (default true for vm/k8s modes)")
92+
cmd.Flags().StringVar(&startupScript, "startup-script", "", "Lifecycle script to run on boot")
93+
cmd.Flags().StringVar(&containerImage, "container-image", "", "Container image URL (required for container mode)")
94+
cmd.Flags().StringVar(&composeFile, "compose-file", "", "Docker compose file path or URL (required for compose mode)")
7395
return cmd
7496
}
7597

@@ -78,6 +100,12 @@ type CreateOptions struct {
78100
WorkspaceClass string
79101
Detached bool
80102
InstanceType string
103+
Mode string
104+
Jupyter bool
105+
JupyterSet bool // whether --jupyter was explicitly set
106+
StartupScript string
107+
ContainerImage string
108+
ComposeFile string
81109
}
82110

83111
func runCreateWorkspace(t *terminal.Terminal, options CreateOptions, createStore CreateStore) error {
@@ -98,6 +126,22 @@ func createEmptyWorkspace(user *entity.User, t *terminal.Terminal, options Creat
98126
return breverrors.NewValidationError("A name field is required to create a workspace!")
99127
}
100128

129+
// validate mode
130+
switch options.Mode {
131+
case "vm", "k8s", "container", "compose":
132+
// valid
133+
default:
134+
return breverrors.NewValidationError(fmt.Sprintf("invalid mode %q: must be one of vm, k8s, container, compose", options.Mode))
135+
}
136+
137+
// validate mode-specific required flags
138+
if options.Mode == "container" && options.ContainerImage == "" {
139+
return breverrors.NewValidationError("--container-image is required for container mode")
140+
}
141+
if options.Mode == "compose" && options.ComposeFile == "" {
142+
return breverrors.NewValidationError("--compose-file is required for compose mode")
143+
}
144+
101145
// ensure org
102146
var orgID string
103147
activeorg, err := createStore.GetActiveOrganizationOrDefault()
@@ -122,14 +166,28 @@ func createEmptyWorkspace(user *entity.User, t *terminal.Terminal, options Creat
122166
cwOptions.WithInstanceType(options.InstanceType)
123167
}
124168

125-
t.Vprintf("Creating instane %s in org %s\n", t.Green(cwOptions.Name), t.Green(orgID))
169+
// Resolve workspace group dynamically from instance type
170+
resolveWorkspaceGroupID(t, createStore, cwOptions, orgID)
171+
172+
// Apply build mode
173+
err = applyBuildMode(cwOptions, options)
174+
if err != nil {
175+
return breverrors.WrapAndTrace(err)
176+
}
177+
178+
t.Vprintf("Creating instance %s in org %s\n", t.Green(cwOptions.Name), t.Green(orgID))
126179
t.Vprintf("\tname %s\n", t.Green(cwOptions.Name))
127180
if options.InstanceType != "" {
128181
t.Vprintf("\tGPU instance %s\n", t.Green(options.InstanceType))
129182
} else {
130183
t.Vprintf("\tCPU instance %s\n", t.Green(cwOptions.WorkspaceClassID))
131184
}
132-
t.Vprintf("\tCloud %s\n\n", t.Green(cwOptions.WorkspaceGroupID))
185+
t.Vprintf("\tmode %s\n", t.Green(options.Mode))
186+
if cwOptions.WorkspaceGroupID != "" {
187+
t.Vprintf("\tCloud %s\n\n", t.Green(cwOptions.WorkspaceGroupID))
188+
} else {
189+
t.Vprintf("\tCloud %s\n\n", t.Green("(auto)"))
190+
}
133191

134192
s := t.NewSpinner()
135193
s.Suffix = " Creating your instance. Hang tight 🤙"
@@ -156,6 +214,90 @@ func createEmptyWorkspace(user *entity.User, t *terminal.Terminal, options Creat
156214
}
157215
}
158216

217+
// resolveWorkspaceGroupID fetches workspace groups for the instance type and sets the workspace group ID
218+
func resolveWorkspaceGroupID(t *terminal.Terminal, createStore CreateStore, cwOptions *store.CreateWorkspacesOptions, orgID string) {
219+
allInstanceTypes, err := createStore.GetAllInstanceTypesWithWorkspaceGroups(orgID)
220+
if err != nil {
221+
t.Vprintf("Warning: could not fetch workspace groups: %s\n", err.Error())
222+
t.Vprintf("Falling back to server default\n")
223+
return
224+
}
225+
if allInstanceTypes != nil && cwOptions.InstanceType != "" {
226+
if wgID := allInstanceTypes.GetWorkspaceGroupID(cwOptions.InstanceType); wgID != "" {
227+
cwOptions.WorkspaceGroupID = wgID
228+
}
229+
}
230+
}
231+
232+
// applyBuildMode configures the workspace options based on the selected build mode.
233+
// The server determines the mode from which build field is present (vmBuild, customContainer,
234+
// dockerCompose) — we do NOT send vmOnlyMode or onContainer, matching the UI behavior.
235+
func applyBuildMode(cwOptions *store.CreateWorkspacesOptions, options CreateOptions) error {
236+
switch options.Mode {
237+
case "vm":
238+
jupyter := true
239+
if options.JupyterSet {
240+
jupyter = options.Jupyter
241+
}
242+
cwOptions.VMBuild = &store.VMBuild{
243+
ForceJupyterInstall: jupyter,
244+
}
245+
if options.StartupScript != "" {
246+
cwOptions.VMBuild.LifeCycleScriptAttr = &store.LifeCycleScriptAttr{
247+
Script: options.StartupScript,
248+
}
249+
}
250+
251+
case "k8s":
252+
jupyter := false // UI defaults to false for k8s
253+
if options.JupyterSet {
254+
jupyter = options.Jupyter
255+
}
256+
cwOptions.VMBuild = &store.VMBuild{
257+
ForceJupyterInstall: jupyter,
258+
K8s: &store.K8sConfig{
259+
IsDisabled: false,
260+
IsDashboardEnabled: true,
261+
},
262+
}
263+
if options.StartupScript != "" {
264+
cwOptions.VMBuild.LifeCycleScriptAttr = &store.LifeCycleScriptAttr{
265+
Script: options.StartupScript,
266+
}
267+
}
268+
269+
case "container":
270+
cwOptions.VMBuild = nil
271+
cwOptions.CustomContainer = &store.CustomContainer{
272+
ContainerURL: options.ContainerImage,
273+
}
274+
275+
case "compose":
276+
cwOptions.VMBuild = nil
277+
composeConfig := &store.DockerCompose{}
278+
279+
// Check if it's a file path or URL
280+
if strings.HasPrefix(options.ComposeFile, "http://") || strings.HasPrefix(options.ComposeFile, "https://") {
281+
composeConfig.FileURL = options.ComposeFile
282+
} else {
283+
content, err := os.ReadFile(options.ComposeFile)
284+
if err != nil {
285+
return breverrors.WrapAndTrace(fmt.Errorf("could not read compose file %s: %w", options.ComposeFile, err))
286+
}
287+
composeConfig.YamlString = string(content)
288+
}
289+
290+
jupyter := false
291+
if options.JupyterSet {
292+
jupyter = options.Jupyter
293+
}
294+
composeConfig.JupyterInstall = jupyter
295+
cwOptions.DockerCompose = composeConfig
296+
}
297+
298+
return nil
299+
}
300+
159301
func resolveWorkspaceUserOptions(options *store.CreateWorkspacesOptions, user *entity.User) *store.CreateWorkspacesOptions {
160302
if options.WorkspaceTemplateID == "" {
161303
if featureflag.IsAdmin(user.GlobalUserType) {

0 commit comments

Comments
 (0)