Skip to content

Commit a1c8c4c

Browse files
committed
add launchables support to create
1 parent 1a12fd5 commit a1c8c4c

3 files changed

Lines changed: 274 additions & 10 deletions

File tree

pkg/cmd/gpucreate/gpucreate.go

Lines changed: 184 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"io"
8+
"net/url"
89
"os"
910
"strconv"
1011
"strings"
@@ -99,6 +100,7 @@ type GPUCreateStore interface {
99100
CreateWorkspace(organizationID string, options *store.CreateWorkspacesOptions) (*entity.Workspace, error)
100101
DeleteWorkspace(workspaceID string) (*entity.Workspace, error)
101102
GetAllInstanceTypesWithWorkspaceGroups(orgID string) (*gpusearch.AllInstanceTypesResponse, error)
103+
GetLaunchable(launchableID string) (*store.LaunchableResponse, error)
102104
}
103105

104106
// Default filter values for automatic GPU selection
@@ -153,6 +155,7 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra
153155
var jupyter bool
154156
var containerImage string
155157
var composeFile string
158+
var launchable string
156159
var filters searchFilterFlags
157160

158161
cmd := &cobra.Command{
@@ -169,8 +172,21 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra
169172
name = args[0]
170173
}
171174

172-
if err := validateBuildMode(mode, containerImage, composeFile); err != nil {
173-
return err
175+
launchableID := parseLaunchableID(launchable)
176+
177+
if launchableID != "" {
178+
// Warn about build mode flags that are ignored with launchable
179+
buildFlagsSet := cmd.Flags().Changed("mode") || cmd.Flags().Changed("container-image") ||
180+
cmd.Flags().Changed("compose-file") || cmd.Flags().Changed("startup-script") ||
181+
cmd.Flags().Changed("jupyter")
182+
if buildFlagsSet {
183+
t.Vprintf("Warning: Build config flags (--mode, --container-image, --compose-file, --startup-script) are ignored when deploying a launchable.\n")
184+
t.Vprintf("The launchable defines its own build configuration.\n\n")
185+
}
186+
} else {
187+
if err := validateBuildMode(mode, containerImage, composeFile); err != nil {
188+
return err
189+
}
174190
}
175191

176192
// Parse instance types from flag or stdin
@@ -179,12 +195,54 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra
179195
return breverrors.WrapAndTrace(err)
180196
}
181197

198+
// Fetch and display launchable info before dry-run or creation
199+
var launchableInfo *store.LaunchableResponse
200+
if launchableID != "" {
201+
var launchErr error
202+
launchableInfo, launchErr = gpuCreateStore.GetLaunchable(launchableID)
203+
if launchErr != nil {
204+
return fmt.Errorf("failed to fetch launchable %q: %w", launchableID, launchErr)
205+
}
206+
t.Vprintf("Deploying launchable: %q\n", launchableInfo.Name)
207+
if launchableInfo.Description != "" {
208+
t.Vprintf("Description: %s\n", launchableInfo.Description)
209+
}
210+
if launchableInfo.CreateWorkspaceRequest.InstanceType != "" {
211+
t.Vprintf("Instance type: %s\n", launchableInfo.CreateWorkspaceRequest.InstanceType)
212+
}
213+
if launchableInfo.CreateWorkspaceRequest.Storage != "" {
214+
t.Vprintf("Storage: %s\n", launchableInfo.CreateWorkspaceRequest.Storage)
215+
}
216+
buildMode := "VM"
217+
if launchableInfo.BuildRequest.CustomContainer != nil {
218+
buildMode = "Container"
219+
} else if launchableInfo.BuildRequest.DockerCompose != nil {
220+
buildMode = "Docker Compose"
221+
}
222+
t.Vprintf("Build mode: %s\n\n", buildMode)
223+
}
224+
182225
if dryRun {
226+
if launchableID != "" {
227+
return nil // launchable info already displayed above
228+
}
183229
return runDryRun(t, gpuCreateStore, types, &filters)
184230
}
185231

186-
// If no types provided, use search filters (or defaults) to find suitable GPUs
187-
if len(types) == 0 {
232+
// If deploying a launchable and no types provided, use the launchable's
233+
// instance type so we can resolve the correct workspace group
234+
if launchableID != "" && len(types) == 0 && !cmd.Flags().Changed("type") {
235+
launchableInstanceType := ""
236+
if launchableInfo != nil {
237+
launchableInstanceType = launchableInfo.CreateWorkspaceRequest.InstanceType
238+
}
239+
if launchableInstanceType != "" {
240+
types = []InstanceSpec{{Type: launchableInstanceType}}
241+
} else {
242+
return breverrors.NewValidationError("launchable has no instance type configured and no --type was specified")
243+
}
244+
} else if len(types) == 0 {
245+
// If no types provided, use search filters (or defaults) to find suitable GPUs
188246
types, err = getFilteredInstanceTypes(gpuCreateStore, &filters)
189247
if err != nil {
190248
return breverrors.WrapAndTrace(err)
@@ -195,6 +253,12 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra
195253
}
196254
}
197255

256+
// Warn if overriding launchable instance config
257+
if launchableID != "" && (cmd.Flags().Changed("type") || cmd.Flags().Changed("gpu-name") ||
258+
cmd.Flags().Changed("provider") || cmd.Flags().Changed("min-vram")) {
259+
t.Vprintf("Warning: Overriding the launchable's recommended instance configuration. This is not the recommended path and may cause issues.\n\n")
260+
}
261+
198262
if err := names.ValidateNodeName(name); err != nil {
199263
return breverrors.WrapAndTrace(err)
200264
}
@@ -228,6 +292,8 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra
228292
JupyterSet: jupyterSet,
229293
ContainerImage: containerImage,
230294
ComposeFile: composeFile,
295+
LaunchableID: launchableID,
296+
LaunchableInfo: launchableInfo,
231297
}
232298

233299
err = RunGPUCreate(t, gpuCreateStore, opts)
@@ -238,13 +304,13 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra
238304
},
239305
}
240306

241-
registerCreateFlags(cmd, &name, &instanceTypes, &count, &parallel, &detached, &timeout, &startupScript, &dryRun, &mode, &jupyter, &containerImage, &composeFile, &filters)
307+
registerCreateFlags(cmd, &name, &instanceTypes, &count, &parallel, &detached, &timeout, &startupScript, &dryRun, &mode, &jupyter, &containerImage, &composeFile, &launchable, &filters)
242308

243309
return cmd
244310
}
245311

246312
// registerCreateFlags registers all flags for the create command
247-
func registerCreateFlags(cmd *cobra.Command, name, instanceTypes *string, count, parallel *int, detached *bool, timeout *int, startupScript *string, dryRun *bool, mode *string, jupyter *bool, containerImage, composeFile *string, filters *searchFilterFlags) {
313+
func registerCreateFlags(cmd *cobra.Command, name, instanceTypes *string, count, parallel *int, detached *bool, timeout *int, startupScript *string, dryRun *bool, mode *string, jupyter *bool, containerImage, composeFile, launchable *string, filters *searchFilterFlags) {
248314
cmd.Flags().StringVarP(name, "name", "n", "", "Base name for the instances (or pass as first argument)")
249315
cmd.Flags().StringVarP(instanceTypes, "type", "t", "", "Comma-separated list of instance types to try")
250316
cmd.Flags().IntVarP(count, "count", "c", 1, "Number of instances to create")
@@ -259,6 +325,7 @@ func registerCreateFlags(cmd *cobra.Command, name, instanceTypes *string, count,
259325
cmd.Flags().BoolVar(jupyter, "jupyter", true, "Install Jupyter (default true for vm/k8s modes)")
260326
cmd.Flags().StringVar(containerImage, "container-image", "", "Container image URL (required for container mode)")
261327
cmd.Flags().StringVar(composeFile, "compose-file", "", "Docker compose file path or URL (required for compose mode)")
328+
cmd.Flags().StringVarP(launchable, "launchable", "l", "", "Launchable ID or URL to deploy (e.g., env-XXX or console URL)")
262329

263330
cmd.Flags().StringVarP(&filters.gpuName, "gpu-name", "g", "", "Filter by GPU name (e.g., A100, H100)")
264331
cmd.Flags().StringVar(&filters.provider, "provider", "", "Filter by provider/cloud (e.g., aws, gcp)")
@@ -294,6 +361,36 @@ type GPUCreateOptions struct {
294361
JupyterSet bool // whether --jupyter was explicitly set
295362
ContainerImage string
296363
ComposeFile string
364+
LaunchableID string
365+
LaunchableInfo *store.LaunchableResponse // populated when LaunchableID is set
366+
}
367+
368+
// parseLaunchableID extracts a launchable ID from either a raw ID (env-XXX) or
369+
// a console URL (https://console.brev.dev/launchable/deploy?launchableID=env-XXX)
370+
func parseLaunchableID(input string) string {
371+
if input == "" {
372+
return ""
373+
}
374+
// Check if it looks like a URL
375+
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
376+
u, err := url.Parse(input)
377+
if err != nil {
378+
return input
379+
}
380+
if id := u.Query().Get("launchableID"); id != "" {
381+
return id
382+
}
383+
// Check path for launchable ID (e.g., /launchables/env-XXX)
384+
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
385+
if len(parts) > 0 {
386+
last := parts[len(parts)-1]
387+
if strings.HasPrefix(last, "env-") {
388+
return last
389+
}
390+
}
391+
return input
392+
}
393+
return input
297394
}
298395

299396
// parseStartupScript parses the startup script from a string or file path
@@ -827,12 +924,19 @@ func (c *createContext) createWorkspace(name string, spec InstanceSpec) (*entity
827924
}
828925
}
829926

830-
// Apply build mode
831-
err := applyBuildMode(cwOptions, c.opts)
832-
if err != nil {
833-
return nil, breverrors.WrapAndTrace(err)
927+
// Apply launchable config or build mode
928+
if c.opts.LaunchableID != "" {
929+
applyLaunchableConfig(cwOptions, c.opts.LaunchableID, c.opts.LaunchableInfo)
930+
} else {
931+
err := applyBuildMode(cwOptions, c.opts)
932+
if err != nil {
933+
return nil, breverrors.WrapAndTrace(err)
934+
}
834935
}
835936

937+
c.logf(" Creating workspace: instanceType=%s workspaceGroupID=%s launchable=%v\n",
938+
cwOptions.InstanceType, cwOptions.WorkspaceGroupID, cwOptions.LaunchableConfig != nil)
939+
836940
workspace, err := c.store.CreateWorkspace(c.org.ID, cwOptions)
837941
if err != nil {
838942
return nil, breverrors.WrapAndTrace(err)
@@ -930,6 +1034,76 @@ func applyBuildMode(cwOptions *store.CreateWorkspacesOptions, opts GPUCreateOpti
9301034
return nil
9311035
}
9321036

1037+
// applyLaunchableConfig populates the workspace create request with all launchable
1038+
// configuration, mirroring what the web UI sends when deploying a launchable.
1039+
func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableID string, info *store.LaunchableResponse) {
1040+
cwOptions.LaunchableConfig = &store.LaunchableConfig{ID: launchableID}
1041+
1042+
if info == nil {
1043+
return
1044+
}
1045+
1046+
wsReq := info.CreateWorkspaceRequest
1047+
1048+
// Use launchable's workspace group if not already resolved from instance types
1049+
if cwOptions.WorkspaceGroupID == "" && wsReq.WorkspaceGroupID != "" {
1050+
cwOptions.WorkspaceGroupID = wsReq.WorkspaceGroupID
1051+
}
1052+
1053+
// Location
1054+
if wsReq.Location != "" {
1055+
cwOptions.Location = wsReq.Location
1056+
}
1057+
1058+
// Disk storage — ensure Gi suffix
1059+
if wsReq.Storage != "" {
1060+
storage := wsReq.Storage
1061+
if !strings.HasSuffix(storage, "Gi") {
1062+
storage += "Gi"
1063+
}
1064+
cwOptions.DiskStorage = storage
1065+
}
1066+
1067+
// Build configuration from launchable
1068+
build := info.BuildRequest
1069+
if build.VMBuild != nil {
1070+
cwOptions.VMBuild = build.VMBuild
1071+
} else if build.CustomContainer != nil {
1072+
cwOptions.VMBuild = nil
1073+
cwOptions.CustomContainer = build.CustomContainer
1074+
} else if build.DockerCompose != nil {
1075+
cwOptions.VMBuild = nil
1076+
cwOptions.DockerCompose = build.DockerCompose
1077+
}
1078+
1079+
// Port mappings from build request ports
1080+
if len(build.Ports) > 0 {
1081+
portMappings := make(map[string]string)
1082+
for _, p := range build.Ports {
1083+
portMappings[p.Name] = p.Port
1084+
}
1085+
cwOptions.PortMappings = portMappings
1086+
}
1087+
1088+
// Files from launchable
1089+
if info.File != nil {
1090+
cwOptions.Files = []map[string]string{
1091+
{"url": info.File.URL, "path": info.File.Path},
1092+
}
1093+
}
1094+
1095+
// Labels for tracking and UI rendering
1096+
labels := map[string]string{
1097+
"launchableId": launchableID,
1098+
"launchableInstanceType": wsReq.InstanceType,
1099+
"workspaceGroupId": cwOptions.WorkspaceGroupID,
1100+
"launchableCreatedByUserId": info.CreatedByUserID,
1101+
"launchableCreatedByOrgId": info.CreatedByOrgID,
1102+
"launchableRawURL": "/launchable/deploy/now?launchableID=" + launchableID,
1103+
}
1104+
cwOptions.Labels = labels
1105+
}
1106+
9331107
// resolveWorkspaceUserOptions sets workspace template and class based on user type
9341108
func resolveWorkspaceUserOptions(options *store.CreateWorkspacesOptions, user *entity.User) *store.CreateWorkspacesOptions {
9351109
isAdmin := featureflag.IsAdmin(user.GlobalUserType)

pkg/cmd/gpucreate/gpucreate_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ func (m *MockGPUCreateStore) GetAllInstanceTypesWithWorkspaceGroups(orgID string
9898
return nil, nil
9999
}
100100

101+
func (m *MockGPUCreateStore) GetLaunchable(launchableID string) (*store.LaunchableResponse, error) {
102+
return &store.LaunchableResponse{
103+
ID: launchableID,
104+
Name: "test-launchable",
105+
}, nil
106+
}
107+
101108
func (m *MockGPUCreateStore) GetInstanceTypes(_ bool) (*gpusearch.InstanceTypesResponse, error) {
102109
// Return a default set of instance types for testing
103110
return &gpusearch.InstanceTypesResponse{
@@ -144,6 +151,29 @@ func TestIsValidInstanceType(t *testing.T) {
144151
}
145152
}
146153

154+
func TestParseLaunchableID(t *testing.T) {
155+
tests := []struct {
156+
name string
157+
input string
158+
expected string
159+
}{
160+
{"Empty string", "", ""},
161+
{"Raw ID", "env-2jeVokEK44iJZzleTF8yKjt3hh7", "env-2jeVokEK44iJZzleTF8yKjt3hh7"},
162+
{"Console URL with query param", "https://console.brev.dev/launchable/deploy?launchableID=env-abc123", "env-abc123"},
163+
{"Console URL with extra params", "https://console.brev.dev/launchable/deploy?userID=u1&launchableID=env-abc123&name=test", "env-abc123"},
164+
{"URL with env- in path", "https://console.brev.dev/launchables/env-abc123", "env-abc123"},
165+
{"URL without launchableID param", "https://console.brev.dev/launchable/deploy", "https://console.brev.dev/launchable/deploy"},
166+
{"Non-env ID", "some-other-id", "some-other-id"},
167+
}
168+
169+
for _, tt := range tests {
170+
t.Run(tt.name, func(t *testing.T) {
171+
result := parseLaunchableID(tt.input)
172+
assert.Equal(t, tt.expected, result)
173+
})
174+
}
175+
}
176+
147177
func TestParseInstanceTypesFromFlag(t *testing.T) {
148178
tests := []struct {
149179
name string

0 commit comments

Comments
 (0)