diff --git a/pkg/cmd/gpucreate/gpucreate.go b/pkg/cmd/gpucreate/gpucreate.go index 3576cce18..a029b53af 100644 --- a/pkg/cmd/gpucreate/gpucreate.go +++ b/pkg/cmd/gpucreate/gpucreate.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "io" + "math/rand/v2" + "net/url" "os" "strconv" "strings" @@ -19,6 +21,7 @@ import ( breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/featureflag" "github.com/brevdev/brev-cli/pkg/names" + "github.com/brevdev/brev-cli/pkg/ssh" "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" "github.com/spf13/cobra" @@ -99,6 +102,7 @@ type GPUCreateStore interface { CreateWorkspace(organizationID string, options *store.CreateWorkspacesOptions) (*entity.Workspace, error) DeleteWorkspace(workspaceID string) (*entity.Workspace, error) GetAllInstanceTypesWithWorkspaceGroups(orgID string) (*gpusearch.AllInstanceTypesResponse, error) + GetLaunchable(launchableID string) (*store.LaunchableResponse, error) } // Default filter values for automatic GPU selection @@ -140,7 +144,7 @@ func (f *searchFilterFlags) hasUserFilters() bool { } // NewCmdGPUCreate creates the gpu-create command -func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra.Command { +func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra.Command { //nolint:gocognit,gocyclo,funlen // easier to read as one function var name string var instanceTypes string var count int @@ -153,6 +157,7 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra var jupyter bool var containerImage string var composeFile string + var launchable string var filters searchFilterFlags cmd := &cobra.Command{ @@ -169,82 +174,100 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra name = args[0] } - if err := validateBuildMode(mode, containerImage, composeFile); err != nil { + launchableID, err := parseLaunchableID(launchable) + if err != nil { return err } - // Parse instance types from flag or stdin - types, err := parseInstanceTypes(instanceTypes) - if err != nil { - return breverrors.WrapAndTrace(err) + warnLaunchableFlagConflicts(cmd, t, launchableID) + + if launchableID == "" { + if err := validateBuildMode(mode, containerImage, composeFile); err != nil { + return err + } } - if dryRun { - return runDryRun(t, gpuCreateStore, types, &filters) + launchableInfo, err := fetchAndDisplayLaunchable(gpuCreateStore, t, launchableID) + if err != nil { + return err } - // If no types provided, use search filters (or defaults) to find suitable GPUs - if len(types) == 0 { - types, err = getFilteredInstanceTypes(gpuCreateStore, &filters) - if err != nil { - return breverrors.WrapAndTrace(err) - } + // Default workspace name from launchable with random suffix for uniqueness + if name == "" && launchableInfo != nil { + name = fmt.Sprintf("%s-%05d", ssh.SanitizeNodeName(launchableInfo.Name), rand.IntN(100000)) //nolint:gosec // not security-sensitive + } - if len(types) == 0 { - return breverrors.NewValidationError("no GPU instances match the specified filters. Try 'brev search' to see available options") - } + err = validateArgs(name, count) + if err != nil { + return err } - if err := names.ValidateNodeName(name); err != nil { + types, err := parseInstanceTypes(instanceTypes) + if err != nil { return breverrors.WrapAndTrace(err) } - if count < 1 { - return breverrors.NewValidationError("--count must be at least 1") + if dryRun { + if launchableID != "" { + types, err = resolveInstanceTypes(cmd, gpuCreateStore, launchableID, launchableInfo, types, &filters) + if err != nil { + return err + } + t.Vprintf("Resolved instance type: %s\n", formatInstanceSpecs(types)) + return nil + } + return runDryRun(t, gpuCreateStore, types, &filters) } - if parallel < 1 { - parallel = 1 + types, err = resolveInstanceTypes(cmd, gpuCreateStore, launchableID, launchableInfo, types, &filters) + if err != nil { + return err } - // Parse startup script (can be a string or @filepath) scriptContent, err := parseStartupScript(startupScript) if err != nil { return breverrors.WrapAndTrace(err) } - jupyterSet := cmd.Flags().Changed("jupyter") - opts := GPUCreateOptions{ Name: name, InstanceTypes: types, Count: count, - Parallel: parallel, + Parallel: max(1, parallel), Detached: detached, Timeout: time.Duration(timeout) * time.Second, StartupScript: scriptContent, Mode: mode, Jupyter: jupyter, - JupyterSet: jupyterSet, + JupyterSet: cmd.Flags().Changed("jupyter"), ContainerImage: containerImage, ComposeFile: composeFile, + LaunchableID: launchableID, + LaunchableInfo: launchableInfo, } - err = RunGPUCreate(t, gpuCreateStore, opts) - if err != nil { - return breverrors.WrapAndTrace(err) - } - return nil + return RunGPUCreate(t, gpuCreateStore, opts) }, } - registerCreateFlags(cmd, &name, &instanceTypes, &count, ¶llel, &detached, &timeout, &startupScript, &dryRun, &mode, &jupyter, &containerImage, &composeFile, &filters) + registerCreateFlags(cmd, &name, &instanceTypes, &count, ¶llel, &detached, &timeout, &startupScript, &dryRun, &mode, &jupyter, &containerImage, &composeFile, &launchable, &filters) return cmd } +func validateArgs(name string, count int) error { + if err := names.ValidateNodeName(name); err != nil { + return breverrors.WrapAndTrace(err) + } + + if count < 1 { + return breverrors.NewValidationError("--count must be at least 1") + } + return nil +} + // registerCreateFlags registers all flags for the create command -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) { +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) { cmd.Flags().StringVarP(name, "name", "n", "", "Base name for the instances (or pass as first argument)") cmd.Flags().StringVarP(instanceTypes, "type", "t", "", "Comma-separated list of instance types to try") cmd.Flags().IntVarP(count, "count", "c", 1, "Number of instances to create") @@ -259,6 +282,7 @@ func registerCreateFlags(cmd *cobra.Command, name, instanceTypes *string, count, cmd.Flags().BoolVar(jupyter, "jupyter", true, "Install Jupyter (default true for vm/k8s modes)") cmd.Flags().StringVar(containerImage, "container-image", "", "Container image URL (required for container mode)") cmd.Flags().StringVar(composeFile, "compose-file", "", "Docker compose file path or URL (required for compose mode)") + cmd.Flags().StringVarP(launchable, "launchable", "l", "", "Launchable ID or URL to deploy (e.g., env-XXX or console URL)") cmd.Flags().StringVarP(&filters.gpuName, "gpu-name", "g", "", "Filter by GPU name (e.g., A100, H100)") cmd.Flags().StringVar(&filters.provider, "provider", "", "Filter by provider/cloud (e.g., aws, gcp)") @@ -294,6 +318,133 @@ type GPUCreateOptions struct { JupyterSet bool // whether --jupyter was explicitly set ContainerImage string ComposeFile string + LaunchableID string + LaunchableInfo *store.LaunchableResponse // populated when LaunchableID is set +} + +// parseLaunchableID extracts a launchable ID from either a raw ID (env-XXX) or +// a console URL (https://console.brev.dev/launchable/deploy?launchableID=env-XXX) +func parseLaunchableID(input string) (string, error) { + if input == "" { + return "", nil + } + // Check if it looks like a URL + if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { + u, err := url.Parse(input) + if err != nil { + return "", fmt.Errorf("invalid launchable URL: %w", err) + } + if id := u.Query().Get("launchableID"); id != "" { + return id, nil + } + // Check path for launchable ID (e.g., /launchables/env-XXX) + parts := strings.Split(strings.TrimRight(u.Path, "/"), "/") + if len(parts) > 0 { + last := parts[len(parts)-1] + if strings.HasPrefix(last, "env-") { + return last, nil + } + } + return "", fmt.Errorf("could not extract launchable ID from URL %q — expected a launchableID query parameter or env-XXX path segment", input) + } + if err := validateLaunchableID(input); err != nil { + return "", err + } + return input, nil +} + +// validateLaunchableID checks that a launchable ID is safe to use in API paths +func validateLaunchableID(id string) error { + if strings.ContainsAny(id, "/?&#") { + return fmt.Errorf("invalid launchable ID %q — must not contain path or query characters", id) + } + return nil +} + +// warnLaunchableFlagConflicts warns about flags that conflict with --launchable +func warnLaunchableFlagConflicts(cmd *cobra.Command, t *terminal.Terminal, launchableID string) { + if launchableID == "" { + return + } + + buildFlagsSet := cmd.Flags().Changed("mode") || cmd.Flags().Changed("container-image") || + cmd.Flags().Changed("compose-file") || cmd.Flags().Changed("startup-script") || + cmd.Flags().Changed("jupyter") + if buildFlagsSet { + t.Vprintf("Warning: Build config flags (--mode, --container-image, --compose-file, --startup-script, --jupyter) are ignored when deploying a launchable.\n") + t.Vprintf("The launchable defines its own build configuration.\n\n") + } + + instanceFlagsSet := cmd.Flags().Changed("type") || cmd.Flags().Changed("gpu-name") || + cmd.Flags().Changed("provider") || cmd.Flags().Changed("min-vram") + if instanceFlagsSet { + t.Vprintf("Warning: Overriding the launchable's recommended instance configuration. This is not the recommended path and may cause issues.\n\n") + } +} + +// fetchAndDisplayLaunchable fetches launchable info and displays it to the user +func fetchAndDisplayLaunchable(gpuCreateStore GPUCreateStore, t *terminal.Terminal, launchableID string) (*store.LaunchableResponse, error) { + if launchableID == "" { + return nil, nil + } + + info, err := gpuCreateStore.GetLaunchable(launchableID) + if err != nil { + return nil, fmt.Errorf("failed to fetch launchable %q: %w", launchableID, err) + } + + t.Vprintf("Deploying launchable: %q\n", info.Name) + if info.Description != "" { + t.Vprintf("Description: %s\n", info.Description) + } + if info.CreateWorkspaceRequest.InstanceType != "" { + t.Vprintf("Instance type: %s\n", info.CreateWorkspaceRequest.InstanceType) + } + if info.CreateWorkspaceRequest.Storage != "" { + t.Vprintf("Storage: %s\n", info.CreateWorkspaceRequest.Storage) + } + buildMode := launchableBuildModeName(info) + t.Vprintf("Build mode: %s\n\n", buildMode) + + return info, nil +} + +func launchableBuildModeName(info *store.LaunchableResponse) string { + switch { + case info.BuildRequest.CustomContainer != nil: + return "Container" + case info.BuildRequest.DockerCompose != nil: + return "Docker Compose" + default: + return "VM" + } +} + +// resolveInstanceTypes determines instance types from launchable, flags, or filters +func resolveInstanceTypes(cmd *cobra.Command, gpuCreateStore GPUCreateStore, launchableID string, launchableInfo *store.LaunchableResponse, types []InstanceSpec, filters *searchFilterFlags) ([]InstanceSpec, error) { + if launchableID != "" && len(types) == 0 && !cmd.Flags().Changed("type") { + instanceType := "" + if launchableInfo != nil { + instanceType = launchableInfo.CreateWorkspaceRequest.InstanceType + } + if instanceType != "" { + return []InstanceSpec{{Type: instanceType}}, nil + } + return nil, breverrors.NewValidationError("launchable has no instance type configured and no --type was specified") + } + + if len(types) == 0 { + filtered, err := getFilteredInstanceTypes(gpuCreateStore, filters) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if len(filtered) == 0 { + return nil, breverrors.NewValidationError("no GPU instances match the specified filters. Try 'brev search' to see available options") + } + return filtered, nil + } + + return types, nil } // parseStartupScript parses the startup script from a string or file path @@ -827,10 +978,14 @@ func (c *createContext) createWorkspace(name string, spec InstanceSpec) (*entity } } - // Apply build mode - err := applyBuildMode(cwOptions, c.opts) - if err != nil { - return nil, breverrors.WrapAndTrace(err) + // Apply launchable config or build mode + if c.opts.LaunchableID != "" { + applyLaunchableConfig(cwOptions, c.opts.LaunchableID, c.opts.LaunchableInfo) + } else { + err := applyBuildMode(cwOptions, c.opts) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } } workspace, err := c.store.CreateWorkspace(c.org.ID, cwOptions) @@ -930,6 +1085,98 @@ func applyBuildMode(cwOptions *store.CreateWorkspacesOptions, opts GPUCreateOpti return nil } +// applyLaunchableConfig populates the workspace create request with all launchable +// configuration, mirroring what the web UI sends when deploying a launchable. +func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableID string, info *store.LaunchableResponse) { + cwOptions.LaunchableConfig = &store.LaunchableConfig{ID: launchableID} + + if info == nil { + return + } + + wsReq := info.CreateWorkspaceRequest + + // Use launchable's workspace group if not already resolved from instance types + if cwOptions.WorkspaceGroupID == "" && wsReq.WorkspaceGroupID != "" { + cwOptions.WorkspaceGroupID = wsReq.WorkspaceGroupID + } + + // Location + if wsReq.Location != "" { + cwOptions.Location = wsReq.Location + } + + // Disk storage — the API may return a bare number (e.g., "256") or with + // a unit suffix (e.g., "256Gi"). The server's ParseDiskStorage expects a + // Kubernetes quantity suffix, so append "Gi" only when the value is purely numeric. + if wsReq.Storage != "" { + cwOptions.DiskStorage = normalizeDiskStorage(wsReq.Storage) + } + + // Build configuration from launchable + build := info.BuildRequest + switch { + case build.VMBuild != nil: + cwOptions.VMBuild = build.VMBuild + case build.CustomContainer != nil: + cwOptions.VMBuild = nil + cwOptions.CustomContainer = build.CustomContainer + case build.DockerCompose != nil: + cwOptions.VMBuild = nil + cwOptions.DockerCompose = build.DockerCompose + } + + // Port mappings from build request ports + if len(build.Ports) > 0 { + portMappings := make(map[string]string) + for _, p := range build.Ports { + portMappings[p.Name] = p.Port + } + cwOptions.PortMappings = portMappings + } + + // Files from launchable + if info.File != nil { + cwOptions.Files = []map[string]string{ + {"url": info.File.URL, "path": info.File.Path}, + } + } + + // 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 { + labels = existing + } else { + labels = make(map[string]string) + } + labels["launchableId"] = launchableID + labels["launchableInstanceType"] = wsReq.InstanceType + labels["workspaceGroupId"] = cwOptions.WorkspaceGroupID + labels["launchableCreatedByUserId"] = info.CreatedByUserID + labels["launchableCreatedByOrgId"] = info.CreatedByOrgID + labels["launchableRawURL"] = "/launchable/deploy/now?launchableID=" + launchableID + cwOptions.Labels = labels +} + +// normalizeDiskStorage ensures a disk storage value has a Kubernetes quantity suffix. +// If the value is purely numeric (e.g., "256"), appends "Gi". Otherwise passes through +// as-is, trusting the server's ParseDiskStorage to handle formats like "256Gi", "100G", etc. +func normalizeDiskStorage(s string) string { + s = strings.TrimSpace(s) + // Check if the string is purely numeric (digits and optional decimal point) + allDigits := true + for _, c := range s { + if c != '.' && (c < '0' || c > '9') { + allDigits = false + break + } + } + if allDigits { + return s + "Gi" + } + return s +} + // resolveWorkspaceUserOptions sets workspace template and class based on user type func resolveWorkspaceUserOptions(options *store.CreateWorkspacesOptions, user *entity.User) *store.CreateWorkspacesOptions { isAdmin := featureflag.IsAdmin(user.GlobalUserType) diff --git a/pkg/cmd/gpucreate/gpucreate_test.go b/pkg/cmd/gpucreate/gpucreate_test.go index ea82269c6..1242a7765 100644 --- a/pkg/cmd/gpucreate/gpucreate_test.go +++ b/pkg/cmd/gpucreate/gpucreate_test.go @@ -98,6 +98,13 @@ func (m *MockGPUCreateStore) GetAllInstanceTypesWithWorkspaceGroups(orgID string return nil, nil } +func (m *MockGPUCreateStore) GetLaunchable(launchableID string) (*store.LaunchableResponse, error) { + return &store.LaunchableResponse{ + ID: launchableID, + Name: "test-launchable", + }, nil +} + func (m *MockGPUCreateStore) GetInstanceTypes(_ bool) (*gpusearch.InstanceTypesResponse, error) { // Return a default set of instance types for testing return &gpusearch.InstanceTypesResponse{ @@ -144,6 +151,196 @@ func TestIsValidInstanceType(t *testing.T) { } } +func TestParseLaunchableID(t *testing.T) { + tests := []struct { + name string + input string + expected string + expectErr bool + }{ + {"Empty string", "", "", false}, + {"Raw ID", "env-2jeVokEK44iJZzleTF8yKjt3hh7", "env-2jeVokEK44iJZzleTF8yKjt3hh7", false}, + {"Console URL with query param", "https://console.brev.dev/launchable/deploy?launchableID=env-abc123", "env-abc123", false}, + {"Console URL with extra params", "https://console.brev.dev/launchable/deploy?userID=u1&launchableID=env-abc123&name=test", "env-abc123", false}, + {"URL with env- in path", "https://console.brev.dev/launchables/env-abc123", "env-abc123", false}, + {"URL without launchableID param", "https://console.brev.dev/launchable/deploy", "", true}, + {"Non-env ID", "some-other-id", "some-other-id", false}, + {"ID with path chars", "env-abc/../../etc", "", true}, + {"ID with query chars", "env-abc?foo=bar", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseLaunchableID(tt.input) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +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{}, + } + info := &store.LaunchableResponse{ + ID: "env-abc123", + Name: "Test Launchable", + CreatedByUserID: "user-1", + CreatedByOrgID: "org-1", + CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ + WorkspaceGroupID: "GCP", + InstanceType: "n2-standard-4", + Storage: "256", + Location: "us-west1", + }, + BuildRequest: store.LaunchableBuildRequest{ + VMBuild: &store.VMBuild{ + ForceJupyterInstall: false, + LifeCycleScriptAttr: &store.LifeCycleScriptAttr{ + Script: "#!/bin/bash\necho hello", + ID: "ls-abc", + }, + }, + Ports: []store.LaunchablePort{ + {Name: "Code-Server", Port: "13337"}, + {Name: "OpenClaw", Port: "18789"}, + }, + }, + File: &store.LaunchableFile{ + URL: "https://github.com/NVIDIA/NemoClaw", + Path: "./", + }, + } + + applyLaunchableConfig(cwOptions, "env-abc123", info) + + // Workspace group from launchable + assert.Equal(t, "GCP", cwOptions.WorkspaceGroupID) + // Location + assert.Equal(t, "us-west1", cwOptions.Location) + // Storage with Gi suffix + assert.Equal(t, "256Gi", cwOptions.DiskStorage) + // Build config + assert.NotNil(t, cwOptions.VMBuild) + assert.False(t, cwOptions.VMBuild.ForceJupyterInstall) + assert.Equal(t, "#!/bin/bash\necho hello", cwOptions.VMBuild.LifeCycleScriptAttr.Script) + assert.Equal(t, "ls-abc", cwOptions.VMBuild.LifeCycleScriptAttr.ID) + // Port mappings + assert.Equal(t, map[string]string{"Code-Server": "13337", "OpenClaw": "18789"}, cwOptions.PortMappings) + // Files + assert.NotNil(t, cwOptions.Files) + // LaunchableConfig + assert.Equal(t, "env-abc123", cwOptions.LaunchableConfig.ID) + // Labels + labels, ok := cwOptions.Labels.(map[string]string) + 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, "user-1", labels["launchableCreatedByUserId"]) + assert.Equal(t, "org-1", labels["launchableCreatedByOrgId"]) + }) + + t.Run("preserves existing workspace group", func(t *testing.T) { + cwOptions := &store.CreateWorkspacesOptions{ + WorkspaceGroupID: "existing-wg", + } + info := &store.LaunchableResponse{ + CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ + WorkspaceGroupID: "GCP", + InstanceType: "n2-standard-4", + }, + } + + applyLaunchableConfig(cwOptions, "env-abc", info) + + assert.Equal(t, "existing-wg", cwOptions.WorkspaceGroupID) + }) + + t.Run("storage already has Gi suffix", func(t *testing.T) { + cwOptions := &store.CreateWorkspacesOptions{} + info := &store.LaunchableResponse{ + CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ + Storage: "256Gi", + }, + } + + applyLaunchableConfig(cwOptions, "env-abc", info) + + assert.Equal(t, "256Gi", cwOptions.DiskStorage) + }) + + t.Run("storage bare number gets Gi suffix", func(t *testing.T) { + cwOptions := &store.CreateWorkspacesOptions{} + info := &store.LaunchableResponse{ + CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ + Storage: "256", + }, + } + + applyLaunchableConfig(cwOptions, "env-abc", info) + + assert.Equal(t, "256Gi", cwOptions.DiskStorage) + }) + + t.Run("storage with other suffix passes through", func(t *testing.T) { + cwOptions := &store.CreateWorkspacesOptions{} + info := &store.LaunchableResponse{ + CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ + Storage: "100G", + }, + } + + applyLaunchableConfig(cwOptions, "env-abc", info) + + assert.Equal(t, "100G", cwOptions.DiskStorage) + }) + + t.Run("container build clears VMBuild", func(t *testing.T) { + cwOptions := &store.CreateWorkspacesOptions{ + VMBuild: &store.VMBuild{ForceJupyterInstall: true}, + } + info := &store.LaunchableResponse{ + BuildRequest: store.LaunchableBuildRequest{ + CustomContainer: &store.CustomContainer{ + ContainerURL: "nvcr.io/nvidia/test:latest", + }, + }, + } + + applyLaunchableConfig(cwOptions, "env-abc", info) + + assert.Nil(t, cwOptions.VMBuild) + assert.Equal(t, "nvcr.io/nvidia/test:latest", cwOptions.CustomContainer.ContainerURL) + }) + + t.Run("merges with existing labels", func(t *testing.T) { + cwOptions := &store.CreateWorkspacesOptions{ + Labels: map[string]string{"existingKey": "existingValue"}, + } + info := &store.LaunchableResponse{ + CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ + InstanceType: "n2-standard-4", + }, + CreatedByUserID: "user-1", + CreatedByOrgID: "org-1", + } + + applyLaunchableConfig(cwOptions, "env-abc", info) + + labels, ok := cwOptions.Labels.(map[string]string) + assert.True(t, ok) + assert.Equal(t, "existingValue", labels["existingKey"]) + assert.Equal(t, "env-abc", labels["launchableId"]) + }) +} + func TestParseInstanceTypesFromFlag(t *testing.T) { tests := []struct { name string diff --git a/pkg/store/workspace.go b/pkg/store/workspace.go index 64722540b..77d0fd433 100644 --- a/pkg/store/workspace.go +++ b/pkg/store/workspace.go @@ -37,6 +37,8 @@ type ModifyWorkspaceRequest struct { // LifeCycleScriptAttr holds the lifecycle script configuration type LifeCycleScriptAttr struct { Script string `json:"script,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` } // VMBuild holds VM-specific build configuration @@ -95,6 +97,7 @@ type CreateWorkspacesOptions struct { ReposV1 *entity.ReposV1 `json:"reposV1"` ExecsV1 *entity.ExecsV1 `json:"execsV1"` InstanceType string `json:"instanceType"` + Location string `json:"location,omitempty"` DiskStorage string `json:"diskStorage"` BaseImage string `json:"baseImage"` VMOnlyMode bool `json:"vmOnlyMode"` @@ -107,6 +110,48 @@ type CreateWorkspacesOptions struct { Labels interface{} `json:"labels"` WorkspaceVersion string `json:"workspaceVersion"` LaunchJupyterOnStart bool `json:"launchJupyterOnStart"` + LaunchableConfig *LaunchableConfig `json:"launchableConfig,omitempty"` +} + +type LaunchableConfig struct { + ID string `json:"id"` +} + +type LaunchableResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreateWorkspaceRequest LaunchableWorkspaceRequest `json:"createWorkspaceRequest"` + BuildRequest LaunchableBuildRequest `json:"buildRequest"` + CreatedByUserID string `json:"createdByUserId"` + CreatedByOrgID string `json:"createdByOrgId"` + File *LaunchableFile `json:"file,omitempty"` +} + +type LaunchableWorkspaceRequest struct { + WorkspaceGroupID string `json:"workspaceGroupId,omitempty"` + InstanceType string `json:"instanceType"` + Storage string `json:"storage,omitempty"` + Location string `json:"location,omitempty"` + ImageId string `json:"imageId,omitempty"` +} + +type LaunchableBuildRequest struct { + VMBuild *VMBuild `json:"vmBuild,omitempty"` + CustomContainer *CustomContainer `json:"containerBuild,omitempty"` + DockerCompose *DockerCompose `json:"dockerCompose,omitempty"` + Ports []LaunchablePort `json:"ports"` +} + +type LaunchablePort struct { + Port string `json:"port"` + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` +} + +type LaunchableFile struct { + URL string `json:"url"` + Path string `json:"path"` } var ( @@ -221,6 +266,21 @@ func (s AuthHTTPStore) CreateWorkspace(organizationID string, options *CreateWor return &result, nil } +func (s AuthHTTPStore) GetLaunchable(launchableID string) (*LaunchableResponse, error) { + var result LaunchableResponse + res, err := s.authHTTPClient.restyClient.R(). + SetHeader("Content-Type", "application/json"). + SetResult(&result). + Get(fmt.Sprintf("api/launchables/%s/now", launchableID)) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if res.IsError() { + return nil, NewHTTPResponseError(res) + } + return &result, nil +} + type GetWorkspacesOptions struct { UserID string Name string