diff --git a/pkg/cmd/gpucreate/gpucreate.go b/pkg/cmd/gpucreate/gpucreate.go index 05194a57..54a07b68 100644 --- a/pkg/cmd/gpucreate/gpucreate.go +++ b/pkg/cmd/gpucreate/gpucreate.go @@ -10,6 +10,8 @@ import ( "net/http" "net/url" "os" + "slices" + "sort" "strconv" "strings" "sync" @@ -92,6 +94,9 @@ You can attach a startup script that runs when the instance boots using the # Use search filters directly and attach a startup script brev create my-instance -g a100 --startup-script @setup.sh + + # Deploy a launchable with setup values + brev create --launchable env-abc --param MODEL=llama --param PORT=8080 ` ) @@ -164,6 +169,7 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra var containerImage string var composeFile string var launchable string + var launchableParams []string var filters searchFilterFlags cmd := &cobra.Command{ @@ -198,6 +204,18 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra return err } + parameterValues, err := parseLaunchableParameterValues(launchableParams) + if err != nil { + return err + } + if launchableID == "" && len(parameterValues) > 0 { + return breverrors.NewValidationError("--param can only be used with --launchable") + } + parameterBindings, err := resolveLaunchableParameterBindings(launchableInfo, parameterValues) + if err != nil { + return 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 @@ -219,20 +237,21 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra } opts := GPUCreateOptions{ - Name: name, - InstanceTypes: types, - Count: count, - Parallel: max(1, parallel), - Detached: detached, - Timeout: time.Duration(timeout) * time.Second, - StartupScript: scriptContent, - Mode: mode, - Jupyter: jupyter, - JupyterSet: cmd.Flags().Changed("jupyter"), - ContainerImage: containerImage, - ComposeFile: composeFile, - LaunchableID: launchableID, - LaunchableInfo: launchableInfo, + Name: name, + InstanceTypes: types, + Count: count, + Parallel: max(1, parallel), + Detached: detached, + Timeout: time.Duration(timeout) * time.Second, + StartupScript: scriptContent, + Mode: mode, + Jupyter: jupyter, + JupyterSet: cmd.Flags().Changed("jupyter"), + ContainerImage: containerImage, + ComposeFile: composeFile, + LaunchableID: launchableID, + LaunchableInfo: launchableInfo, + ParameterBindings: parameterBindings, } opts.InstanceTypes, err = resolveInstanceTypes(cmd, gpuCreateStore, opts, types, &filters) @@ -248,7 +267,7 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra }, } - registerCreateFlags(cmd, &name, &instanceTypes, &count, ¶llel, &detached, &timeout, &startupScript, &dryRun, &mode, &jupyter, &containerImage, &composeFile, &launchable, &filters) + registerCreateFlags(cmd, &name, &instanceTypes, &count, ¶llel, &detached, &timeout, &startupScript, &dryRun, &mode, &jupyter, &containerImage, &composeFile, &launchable, &launchableParams, &filters) return cmd } @@ -265,7 +284,7 @@ func validateArgs(name string, count int) error { } // 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, launchable *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, launchableParams *[]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") @@ -281,6 +300,7 @@ func registerCreateFlags(cmd *cobra.Command, name, instanceTypes *string, count, 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().StringArrayVar(launchableParams, "param", nil, "Launchable setup value NAME=VALUE (repeatable)") 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)") @@ -304,20 +324,21 @@ type InstanceSpec struct { // GPUCreateOptions holds the options for GPU instance creation type GPUCreateOptions struct { - Name string - InstanceTypes []InstanceSpec - Count int - Parallel int - Detached bool - Timeout time.Duration - StartupScript string - Mode string - Jupyter bool - JupyterSet bool // whether --jupyter was explicitly set - ContainerImage string - ComposeFile string - LaunchableID string - LaunchableInfo *store.LaunchableResponse // populated when LaunchableID is set + Name string + InstanceTypes []InstanceSpec + Count int + Parallel int + Detached bool + Timeout time.Duration + StartupScript string + Mode string + Jupyter bool + JupyterSet bool // whether --jupyter was explicitly set + ContainerImage string + ComposeFile string + LaunchableID string + LaunchableInfo *store.LaunchableResponse // populated when LaunchableID is set + ParameterBindings []store.ParameterBinding } // parseLaunchableID extracts a launchable ID from either a raw ID (env-XXX) or @@ -365,6 +386,86 @@ func validateLaunchableID(id string) error { return nil } +func parseLaunchableParameterValues(values []string) (map[string]string, error) { + parsed := make(map[string]string, len(values)) + for _, value := range values { + name, parameterValue, ok := strings.Cut(value, "=") + name = strings.TrimSpace(name) + if !ok || name == "" { + return nil, breverrors.NewValidationError(fmt.Sprintf( + "invalid --param %q: expected NAME=VALUE", value, + )) + } + if _, exists := parsed[name]; exists { + return nil, breverrors.NewValidationError(fmt.Sprintf( + "launchable parameter %q was provided more than once", name, + )) + } + parsed[name] = parameterValue + } + return parsed, nil +} + +func resolveLaunchableParameterBindings(info *store.LaunchableResponse, values map[string]string) ([]store.ParameterBinding, error) { + if info == nil { + return nil, nil + } + + parameters := info.BuildRequest.Parameters + defined := make(map[string]struct{}, len(parameters)) + for _, parameter := range parameters { + defined[parameter.Name] = struct{}{} + } + + var problems []string + for name := range values { + if _, ok := defined[name]; !ok { + problems = append(problems, fmt.Sprintf("unknown parameter %q", name)) + } + } + + bindings := make([]store.ParameterBinding, 0, len(parameters)) + for _, parameter := range parameters { + value := values[parameter.Name] + if value == "" { + value = launchableParameterDefault(parameter) + } + + if parameter.Required && value == "" { + problems = append(problems, fmt.Sprintf( + "missing required parameter %q; provide --param %s=VALUE", + parameter.Name, parameter.Name, + )) + continue + } + if parameter.Choice != nil && value != "" && !slices.Contains(parameter.Choice.Choices, value) { + problems = append(problems, fmt.Sprintf( + "invalid value %q for %q (allowed: %s)", + value, parameter.Name, strings.Join(parameter.Choice.Choices, ", "), + )) + continue + } + if value != "" { + bindings = append(bindings, store.ParameterBinding{Name: parameter.Name, Value: value}) + } + } + + if len(problems) > 0 { + return nil, breverrors.NewValidationError("invalid launchable parameters:\n - " + strings.Join(problems, "\n - ")) + } + return bindings, nil +} + +func launchableParameterDefault(parameter store.Parameter) string { + if parameter.Text != nil { + return parameter.Text.DefaultValue + } + if parameter.Choice != nil { + return parameter.Choice.DefaultValue + } + return "" +} + // warnLaunchableFlagConflicts warns about flags that conflict with --launchable func warnLaunchableFlagConflicts(cmd *cobra.Command, t *terminal.Terminal, launchableID string) { if launchableID == "" { @@ -413,11 +514,87 @@ func fetchAndDisplayLaunchable(gpuCreateStore GPUCreateStore, t *terminal.Termin t.Vprintf("Storage: %s\n", info.CreateWorkspaceRequest.Storage) } buildMode := launchableBuildModeName(info) - t.Vprintf("Build mode: %s\n\n", buildMode) + t.Vprintf("Build mode: %s\n", buildMode) + for _, line := range launchableParameterDisplayLines(info.BuildRequest.Parameters) { + t.Vprintf("%s\n", line) + } + t.Vprintf("\n") return info, nil } +func launchableParameterDisplayLines(parameters []store.Parameter) []string { + if len(parameters) == 0 { + return nil + } + + required, optional := partitionLaunchableParameters(parameters) + nameWidth := maxParameterNameWidth(parameters) + + var lines []string + if len(required) > 0 { + lines = append(lines, "Required Parameters:") + lines = append(lines, formatLaunchableParameterLines(required, nameWidth)...) + } + if len(optional) > 0 { + lines = append(lines, "Optional Parameters:") + lines = append(lines, formatLaunchableParameterLines(optional, nameWidth)...) + } + return lines +} + +func partitionLaunchableParameters(parameters []store.Parameter) (required, optional []store.Parameter) { + for _, parameter := range parameters { + if parameter.Required { + required = append(required, parameter) + } else { + optional = append(optional, parameter) + } + } + sort.Slice(required, func(i, j int) bool { return required[i].Name < required[j].Name }) + sort.Slice(optional, func(i, j int) bool { return optional[i].Name < optional[j].Name }) + return required, optional +} + +func maxParameterNameWidth(parameters []store.Parameter) int { + width := 0 + for _, parameter := range parameters { + width = max(width, len(parameter.Name)) + } + return width +} + +func formatLaunchableParameterLines(parameters []store.Parameter, nameWidth int) []string { + lines := make([]string, 0, len(parameters)) + for _, parameter := range parameters { + line := fmt.Sprintf(" %-*s", nameWidth, parameter.Name) + if details := launchableParameterDetails(parameter); details != "" { + line += " " + details + } + lines = append(lines, line) + } + return lines +} + +func launchableParameterDetails(parameter store.Parameter) string { + var parts []string + if parameter.Description != "" { + parts = append(parts, parameter.Description) + } + + var meta []string + if parameter.Choice != nil && len(parameter.Choice.Choices) > 0 { + meta = append(meta, "allowed: "+strings.Join(parameter.Choice.Choices, ", ")) + } + if defaultValue := launchableParameterDefault(parameter); defaultValue != "" { + meta = append(meta, "default: "+defaultValue) + } + if len(meta) > 0 { + parts = append(parts, "("+strings.Join(meta, "; ")+")") + } + return strings.Join(parts, " ") +} + func inlineLaunchableLifeCycleScript(gpuCreateStore GPUCreateStore, launchableID string, info *store.LaunchableResponse) error { if info == nil || info.BuildRequest.VMBuild == nil { return nil @@ -1049,7 +1226,7 @@ func (c *createContext) createWorkspace(name string, spec InstanceSpec) (*entity // Apply launchable config or build mode if c.opts.LaunchableID != "" { - applyLaunchableConfig(cwOptions, c.opts.LaunchableID, c.opts.LaunchableInfo) + applyLaunchableConfig(cwOptions, c.opts.LaunchableID, c.opts.LaunchableInfo, c.opts.ParameterBindings) } else { err := applyBuildMode(cwOptions, c.opts) if err != nil { @@ -1169,8 +1346,11 @@ func applyBuildMode(cwOptions *store.CreateWorkspacesOptions, opts GPUCreateOpti // 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} +func applyLaunchableConfig(cwOptions *store.CreateWorkspacesOptions, launchableID string, info *store.LaunchableResponse, parameterBindings []store.ParameterBinding) { + cwOptions.LaunchableConfig = &store.LaunchableConfig{ + ID: launchableID, + ParameterBindings: parameterBindings, + } if info == nil { return diff --git a/pkg/cmd/gpucreate/gpucreate_test.go b/pkg/cmd/gpucreate/gpucreate_test.go index 2c076a1d..93b2c1a0 100644 --- a/pkg/cmd/gpucreate/gpucreate_test.go +++ b/pkg/cmd/gpucreate/gpucreate_test.go @@ -207,6 +207,92 @@ func TestParseLaunchableID(t *testing.T) { } } +func TestParseLaunchableParameterValues(t *testing.T) { + values, err := parseLaunchableParameterValues([]string{"MODEL=llama=3", "PORT=8080"}) + require.NoError(t, err) + assert.Equal(t, map[string]string{"MODEL": "llama=3", "PORT": "8080"}, values) + + _, err = parseLaunchableParameterValues([]string{"MISSING_SEPARATOR"}) + assert.ErrorContains(t, err, "expected NAME=VALUE") + + _, err = parseLaunchableParameterValues([]string{"MODEL=a", "MODEL=b"}) + assert.ErrorContains(t, err, "provided more than once") +} + +func TestResolveLaunchableParameterBindings(t *testing.T) { + info := &store.LaunchableResponse{ + BuildRequest: store.LaunchableBuildRequest{ + Parameters: []store.Parameter{ + {Name: "MODEL", Required: true, Choice: &store.ChoiceParameter{ + Choices: []string{"llama", "nemotron"}, + }}, + {Name: "REGION", Required: true, Choice: &store.ChoiceParameter{ + Choices: []string{"us", "eu"}, + }}, + {Name: "TAG", Text: &store.TextParameter{DefaultValue: "latest"}}, + {Name: "OPTIONAL", Text: &store.TextParameter{}}, + }, + }, + } + + bindings, err := resolveLaunchableParameterBindings(info, map[string]string{"MODEL": "nemotron", "REGION": "us"}) + require.NoError(t, err) + assert.Equal(t, []store.ParameterBinding{ + {Name: "MODEL", Value: "nemotron"}, + {Name: "REGION", Value: "us"}, + {Name: "TAG", Value: "latest"}, + }, bindings) + + _, err = resolveLaunchableParameterBindings(info, map[string]string{ + "MODEL": "unknown", + "REGION": "asia", + "TYPO": "value", + }) + require.Error(t, err) + assert.ErrorContains(t, err, `unknown parameter "TYPO"`) + assert.ErrorContains(t, err, `invalid value "unknown" for "MODEL"`) + assert.ErrorContains(t, err, `invalid value "asia" for "REGION"`) + + _, err = resolveLaunchableParameterBindings(info, nil) + require.Error(t, err) + assert.ErrorContains(t, err, `missing required parameter "MODEL"; provide --param MODEL=VALUE`) + assert.ErrorContains(t, err, `missing required parameter "REGION"; provide --param REGION=VALUE`) +} + +func TestLaunchableParameterDisplayLines(t *testing.T) { + parameters := []store.Parameter{ + { + Name: "B", + Description: "Optional parameter", + Text: &store.TextParameter{}, + }, + { + Name: "A", + Description: "Text parameter", + Required: true, + Text: &store.TextParameter{DefaultValue: "default-a"}, + }, + { + Name: "C", + Description: "Choice parameter", + Required: true, + Choice: &store.ChoiceParameter{ + Choices: []string{"one", "two"}, + DefaultValue: "one", + }, + }, + } + + assert.Equal(t, []string{ + "Required Parameters:", + " A Text parameter (default: default-a)", + " C Choice parameter (allowed: one, two; default: one)", + "Optional Parameters:", + " B Optional parameter", + }, launchableParameterDisplayLines(parameters)) + assert.Nil(t, launchableParameterDisplayLines(nil)) +} + func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test t.Run("populates all fields from launchable", func(t *testing.T) { cwOptions := &store.CreateWorkspacesOptions{ @@ -247,7 +333,7 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test }, } - applyLaunchableConfig(cwOptions, "env-abc123", info) + applyLaunchableConfig(cwOptions, "env-abc123", info, nil) // Cloud credential from launchable input. assert.Equal(t, "GCP", cwOptions.CloudCredID) @@ -293,7 +379,7 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test }, } - applyLaunchableConfig(cwOptions, "env-abc", info) + applyLaunchableConfig(cwOptions, "env-abc", info, nil) assert.Equal(t, "existing-cloud-cred", cwOptions.CloudCredID) }) @@ -306,7 +392,7 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test }, } - applyLaunchableConfig(cwOptions, "env-abc", info) + applyLaunchableConfig(cwOptions, "env-abc", info, nil) assert.Equal(t, "256Gi", cwOptions.DiskStorage) }) @@ -319,7 +405,7 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test }, } - applyLaunchableConfig(cwOptions, "env-abc", info) + applyLaunchableConfig(cwOptions, "env-abc", info, nil) assert.Equal(t, "256Gi", cwOptions.DiskStorage) }) @@ -332,7 +418,7 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test }, } - applyLaunchableConfig(cwOptions, "env-abc", info) + applyLaunchableConfig(cwOptions, "env-abc", info, nil) assert.Equal(t, "100G", cwOptions.DiskStorage) }) @@ -349,7 +435,7 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test }, } - applyLaunchableConfig(cwOptions, "env-abc", info) + applyLaunchableConfig(cwOptions, "env-abc", info, nil) assert.Nil(t, cwOptions.VMBuild) assert.Equal(t, "nvcr.io/nvidia/test:latest", cwOptions.CustomContainer.ContainerURL) @@ -369,7 +455,7 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test }, } - applyLaunchableConfig(cwOptions, "env-abc", info) + applyLaunchableConfig(cwOptions, "env-abc", info, nil) assert.Nil(t, cwOptions.VMBuild) assert.Equal(t, dockerCompose.FileURL, cwOptions.DockerCompose.FileURL) @@ -393,7 +479,7 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test }, } - applyLaunchableConfig(cwOptions, "env-abc", info) + applyLaunchableConfig(cwOptions, "env-abc", info, nil) assert.Equal(t, []store.CreateFirewallRule{ {Port: "22", AllowedIPs: "user-ip", ClientIPs: []string{"203.0.113.7/32"}}, @@ -479,7 +565,7 @@ func TestApplyLaunchableConfig(t *testing.T) { //nolint:funlen // test CreatedByOrgID: "org-1", } - applyLaunchableConfig(cwOptions, "env-abc", info) + applyLaunchableConfig(cwOptions, "env-abc", info, nil) labels, ok := cwOptions.Labels.(map[string]string) assert.True(t, ok) @@ -532,7 +618,9 @@ func TestLaunchableJSONWireFormat(t *testing.T) { }, } - applyLaunchableConfig(cwOptions, "env-abc", info) + applyLaunchableConfig(cwOptions, "env-abc", info, []store.ParameterBinding{ + {Name: "MODEL", Value: "llama"}, + }) body, err := json.Marshal(cwOptions) assert.NoError(t, err) @@ -545,7 +633,7 @@ func TestLaunchableJSONWireFormat(t *testing.T) { assert.Contains(t, s, `"allowedIPs":"all"`) assert.Contains(t, s, `"allowedIPs":"user-ip"`) assert.Contains(t, s, `"clientIPs":["203.0.113.7/32"]`) - assert.Contains(t, s, `"launchableConfig":{"id":"env-abc"}`) + assert.Contains(t, s, `"launchableConfig":{"id":"env-abc","parameterBindings":[{"name":"MODEL","value":"llama"}]}`) } func TestFetchPublicIP(t *testing.T) { diff --git a/pkg/store/workspace.go b/pkg/store/workspace.go index 0b7f44b1..5c06ac21 100644 --- a/pkg/store/workspace.go +++ b/pkg/store/workspace.go @@ -125,7 +125,14 @@ type CreateFirewallRule struct { } type LaunchableConfig struct { - ID string `json:"id"` + ID string `json:"id"` + ParameterBindings []ParameterBinding `json:"parameterBindings,omitempty"` +} + +type ParameterBinding struct { + Name string `json:"name"` + Value string `json:"value,omitempty"` + BrevSecretID string `json:"brevSecretId,omitempty"` } type LaunchableResponse struct { @@ -155,6 +162,24 @@ type LaunchableBuildRequest struct { CustomContainer *CustomContainer `json:"containerBuild,omitempty"` DockerCompose *DockerCompose `json:"dockerCompose,omitempty"` Ports []LaunchablePort `json:"ports"` + Parameters []Parameter `json:"parameters,omitempty"` +} + +type Parameter struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Required bool `json:"required"` + Text *TextParameter `json:"text,omitempty"` + Choice *ChoiceParameter `json:"choice,omitempty"` +} + +type TextParameter struct { + DefaultValue string `json:"defaultValue,omitempty"` +} + +type ChoiceParameter struct { + Choices []string `json:"choices"` + DefaultValue string `json:"defaultValue,omitempty"` } type LaunchablePort struct {