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
9341108func resolveWorkspaceUserOptions (options * store.CreateWorkspacesOptions , user * entity.User ) * store.CreateWorkspacesOptions {
9351109 isAdmin := featureflag .IsAdmin (user .GlobalUserType )
0 commit comments