From 5f7c204e16812affc2abd3a92f4ecd2dc06ea7c5 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 27 Aug 2026 20:21:33 -0700 Subject: [PATCH 01/11] first pass --- pkg/cmd/cmd.go | 2 + pkg/cmd/launch/launch.go | 384 +++++++++++++++++++ pkg/cmd/launch/launch_test.go | 528 ++++++++++++++++++++++++++ pkg/cmd/launch/local.go | 242 ++++++++++++ pkg/cmd/launch/local_docker.go | 118 ++++++ pkg/cmd/launch/local_dockercompose.go | 127 +++++++ pkg/cmd/launch/local_vm.go | 72 ++++ pkg/cmd/launch/parameters.go | 204 ++++++++++ pkg/cmd/launch/secrets.go | 53 +++ pkg/cmd/register/rpcclient.go | 8 + pkg/store/workspace.go | 18 +- 11 files changed, 1753 insertions(+), 3 deletions(-) create mode 100644 pkg/cmd/launch/launch.go create mode 100644 pkg/cmd/launch/launch_test.go create mode 100644 pkg/cmd/launch/local.go create mode 100644 pkg/cmd/launch/local_docker.go create mode 100644 pkg/cmd/launch/local_dockercompose.go create mode 100644 pkg/cmd/launch/local_vm.go create mode 100644 pkg/cmd/launch/parameters.go create mode 100644 pkg/cmd/launch/secrets.go diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 8aa4c561f..7e439c73f 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -27,6 +27,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/hello" "github.com/brevdev/brev-cli/pkg/cmd/importideconfig" "github.com/brevdev/brev-cli/pkg/cmd/invite" + "github.com/brevdev/brev-cli/pkg/cmd/launch" "github.com/brevdev/brev-cli/pkg/cmd/login" "github.com/brevdev/brev-cli/pkg/cmd/logout" "github.com/brevdev/brev-cli/pkg/cmd/ls" @@ -301,6 +302,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor cmd.AddCommand(scale.NewCmdScale(t, noLoginCmdStore)) cmd.AddCommand(gpusearch.NewCmdGPUSearch(t, noLoginCmdStore)) cmd.AddCommand(gpucreate.NewCmdGPUCreate(t, loginCmdStore)) + cmd.AddCommand(launch.NewCmdLaunch(t, loginCmdStore)) cmd.AddCommand(configureenvvars.NewCmdConfigureEnvVars(t, loginCmdStore)) cmd.AddCommand(importideconfig.NewCmdImportIDEConfig(t, noLoginCmdStore)) cmd.AddCommand(shell.NewCmdShell(t, loginCmdStore, noLoginCmdStore)) diff --git a/pkg/cmd/launch/launch.go b/pkg/cmd/launch/launch.go new file mode 100644 index 000000000..d401eef7b --- /dev/null +++ b/pkg/cmd/launch/launch.go @@ -0,0 +1,384 @@ +// Package launch provides local and remote launchable execution. +package launch + +import ( + "context" + "fmt" + "io" + "math/rand/v2" + "net/url" + "strings" + "time" + + "github.com/brevdev/brev-cli/pkg/cmd/gpucreate" + "github.com/brevdev/brev-cli/pkg/config" + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "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" +) + +const defaultLaunchTimeoutSeconds = 300 + +// Store contains the authenticated APIs used by remote and local launches. +type Store interface { + gpucreate.GPUCreateStore + GetAccessToken() (string, error) +} + +type commandOptions struct { + name string + instanceType string + parameters []string + secrets []string + local bool + detached bool + approve bool + explain bool + timeout int +} + +type launchCommandArgs struct { + cmd *cobra.Command + terminal *terminal.Terminal + store Store + launchable string + options commandOptions + deps launchDeps +} + +type remoteLaunchArgs struct { + terminal *terminal.Terminal + store Store + launchableID string + info *store.LaunchableResponse + bindings []store.ParameterBinding + name string + options commandOptions +} + +// NewCmdLaunch creates the launch command. +func NewCmdLaunch(t *terminal.Terminal, launchStore Store) *cobra.Command { + return newCmdLaunch(t, launchStore, defaultLaunchDeps(launchStore)) +} + +func newCmdLaunch(t *terminal.Terminal, launchStore Store, deps launchDeps) *cobra.Command { + opts := commandOptions{} + cmd := &cobra.Command{ + Annotations: map[string]string{"workspace": ""}, + Use: "launch ", + Short: "Launch a launchable locally or on a Brev instance", + Long: `Launch a launchable on its recommended remote Brev instance, or use +--local to run its build on this machine. Local mode supports VM startup +scripts, custom containers, and Docker Compose builds.`, + DisableFlagsInUseLine: true, + Args: cobra.ExactArgs(1), + Example: ` # Provision the launchable on a remote Brev instance + brev launch env-abc + + # Inspect its definition without launching + brev launch env-abc --explain + + # Launch on this machine + brev launch env-abc --local + + # Supply direct parameter values + brev launch env-abc --param MODEL=llama --param PORT=8080 + + # Bind a text parameter to the latest or a specific managed-secret version + brev launch env-abc --param-secret API_TOKEN=msec-abc + brev launch env-abc --param-secret API_TOKEN=msec-abc:msecv-123`, + RunE: func(cmd *cobra.Command, args []string) error { + return runLaunchCommand(cmd.Context(), launchCommandArgs{ + cmd: cmd, + terminal: t, + store: launchStore, + launchable: args[0], + options: opts, + deps: deps, + }) + }, + } + cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Instance, container, or Compose project name") + cmd.Flags().StringVarP(&opts.instanceType, "type", "t", "", "Comma-separated remote instance types to try") + cmd.Flags().StringArrayVar(&opts.parameters, "param", nil, "Launchable parameter NAME=VALUE (repeatable)") + cmd.Flags().StringArrayVar(&opts.secrets, "param-secret", nil, "Text parameter NAME=SECRET_ID[:VERSION_ID] (repeatable)") + cmd.Flags().BoolVar(&opts.local, "local", false, "Run on this machine without provisioning an instance") + cmd.Flags().BoolVarP(&opts.detached, "detached", "d", false, "Do not wait for the remote instance or local Docker workload") + cmd.Flags().BoolVar(&opts.approve, "approve", false, "Run a local startup script without prompting") + cmd.Flags().BoolVar(&opts.explain, "explain", false, "Show launchable details without launching") + cmd.Flags().IntVar(&opts.timeout, "timeout", defaultLaunchTimeoutSeconds, "Remote instance readiness timeout in seconds") + return cmd +} + +func runLaunchCommand(ctx context.Context, args launchCommandArgs) error { + if err := validateCommandOptions(args.cmd, args.options); err != nil { + return err + } + + launchableID, err := parseLaunchableID(args.launchable) + if err != nil { + return err + } + if args.options.explain { + return explainLaunchable(args.cmd.OutOrStdout(), args.store, launchableID) + } + info, err := fetchLaunchable(args.store, launchableID) + if err != nil { + return err + } + displayLaunchable(args.terminal, info, args.options.local) + + values, err := parseParameterValues(args.options.parameters) + if err != nil { + return err + } + secretRefs, err := parseParameterSecrets(args.options.secrets) + if err != nil { + return err + } + bindings, err := resolveParameterBindings(ctx, parameterBindingArgs{ + parameters: info.BuildRequest.Parameters, + values: values, + secrets: secretRefs, + resolver: args.deps.secrets, + }) + if err != nil { + return err + } + + name, err := launchName(info.Name, args.options.name, args.options.local) + if err != nil { + return err + } + if args.options.local { + return runLocalLaunchable(ctx, localLaunchArgs{ + terminal: args.terminal, + launchableID: launchableID, + info: info, + bindings: bindings, + options: localOptions{ + name: name, + detached: args.options.detached, + approve: args.options.approve, + stdin: args.cmd.InOrStdin(), + stdout: args.cmd.OutOrStdout(), + stderr: args.cmd.ErrOrStderr(), + }, + deps: args.deps, + }) + } + return runRemoteLaunch(remoteLaunchArgs{ + terminal: args.terminal, + store: args.store, + launchableID: launchableID, + info: info, + bindings: bindings, + name: name, + options: args.options, + }) +} + +func validateCommandOptions(cmd *cobra.Command, opts commandOptions) error { + if opts.local && cmd.Flags().Changed("type") { + return breverrors.NewValidationError("--type cannot be used with --local") + } + if opts.local && cmd.Flags().Changed("timeout") { + return breverrors.NewValidationError("--timeout cannot be used with --local") + } + if !opts.local && opts.approve { + return breverrors.NewValidationError("--approve can only be used with --local") + } + if opts.timeout < 1 { + return breverrors.NewValidationError("--timeout must be at least 1 second") + } + return nil +} + +func parseLaunchableID(input string) (string, error) { + if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { + parsed, err := url.Parse(input) + if err != nil { + return "", fmt.Errorf("invalid launchable URL: %w", err) + } + if id := parsed.Query().Get("launchableID"); id != "" { + return validateLaunchableID(id) + } + parts := strings.Split(strings.TrimRight(parsed.Path, "/"), "/") + if len(parts) > 0 && strings.HasPrefix(parts[len(parts)-1], "env-") { + return validateLaunchableID(parts[len(parts)-1]) + } + return "", fmt.Errorf("could not extract a launchable ID from URL %q", input) + } + return validateLaunchableID(input) +} + +func explainLaunchable(out io.Writer, launchStore Store, launchableID string) error { + info, err := fetchLaunchableMetadata(launchStore, launchableID) + if err != nil { + return err + } + lines := []string{"Name: " + info.Name} + if info.Description != "" { + lines = append(lines, "Description: "+info.Description) + } + definitionURL, err := launchableDefinitionURL(config.GlobalConfig.GetConsoleURL(), launchableID) + if err != nil { + return err + } + lines = append(lines, "URL: "+definitionURL, "Build mode: "+buildModeName(info.BuildRequest)) + if parameterLines := parameterDisplayLines(info.BuildRequest.Parameters); len(parameterLines) > 0 { + lines = append(lines, parameterLines...) + } else { + lines = append(lines, "Parameters: none") + } + for _, line := range lines { + if _, err := fmt.Fprintln(out, line); err != nil { + return fmt.Errorf("write launchable explanation: %w", err) + } + } + return nil +} + +func launchableDefinitionURL(consoleURL string, launchableID string) (string, error) { + parsed, err := url.Parse(consoleURL) + if err != nil { + return "", fmt.Errorf("parse BREV_CONSOLE_URL: %w", err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("BREV_CONSOLE_URL must include a scheme and host") + } + parsed.Path = "/launchable/deploy/now" + parsed.RawPath = "" + parsed.RawQuery = url.Values{"launchableID": {launchableID}}.Encode() + parsed.Fragment = "" + return parsed.String(), nil +} + +func launchName(launchableName string, requestedName string, local bool) (string, error) { + name := requestedName + if name == "" { + name = ssh.SanitizeNodeName(launchableName) + if !local { + name = fmt.Sprintf("%s-%05d", name, rand.IntN(100000)) //nolint:gosec // uniqueness only + } + } + if err := names.ValidateNodeName(name); err != nil { + return "", breverrors.WrapAndTrace(err) + } + return name, nil +} + +func runRemoteLaunch(args remoteLaunchArgs) error { + instanceTypes, err := remoteInstanceTypes(args.options.instanceType, args.info.CreateWorkspaceRequest.InstanceType) + if err != nil { + return err + } + err = gpucreate.RunGPUCreate(args.terminal, args.store, gpucreate.GPUCreateOptions{ + Name: args.name, + InstanceTypes: instanceTypes, + Count: 1, + Parallel: 1, + Detached: args.options.detached, + Timeout: time.Duration(args.options.timeout) * time.Second, + LaunchableID: args.launchableID, + LaunchableInfo: args.info, + ParameterBindings: args.bindings, + }) + if err != nil { + return fmt.Errorf("launch on remote Brev instance: %w", err) + } + return nil +} + +func remoteInstanceTypes(flagValue string, recommended string) ([]gpucreate.InstanceSpec, error) { + if strings.TrimSpace(flagValue) == "" { + if strings.TrimSpace(recommended) == "" { + return nil, breverrors.NewValidationError("launchable has no instance type configured; provide --type") + } + return []gpucreate.InstanceSpec{{Type: recommended}}, nil + } + parts := strings.Split(flagValue, ",") + result := make([]gpucreate.InstanceSpec, 0, len(parts)) + for _, part := range parts { + instanceType := strings.TrimSpace(part) + if instanceType == "" { + return nil, breverrors.NewValidationError("--type contains an empty instance type") + } + result = append(result, gpucreate.InstanceSpec{Type: instanceType}) + } + return result, nil +} + +func fetchLaunchable(launchStore Store, launchableID string) (*store.LaunchableResponse, error) { + info, err := fetchLaunchableMetadata(launchStore, launchableID) + if err != nil { + return nil, err + } + if info.BuildRequest.VMBuild == nil || info.BuildRequest.VMBuild.LifeCycleScriptAttr == nil { + return info, nil + } + attr := info.BuildRequest.VMBuild.LifeCycleScriptAttr + if attr.ID == "" { + return info, nil + } + script, err := launchStore.GetLaunchableLifeCycleScript(launchableID, attr.ID) + if err != nil { + return nil, fmt.Errorf("fetch startup script %q for launchable %q: %w", attr.ID, launchableID, err) + } + if script != nil && script.Attrs != nil { + attr.Script = script.Attrs.Script + } + return info, nil +} + +func fetchLaunchableMetadata(launchStore Store, launchableID string) (*store.LaunchableResponse, error) { + info, err := launchStore.GetLaunchable(launchableID) + if err != nil { + return nil, fmt.Errorf("fetch launchable %q: %w", launchableID, err) + } + if info == nil { + return nil, fmt.Errorf("fetch launchable %q: API returned no configuration", launchableID) + } + return info, nil +} + +func displayLaunchable(t *terminal.Terminal, info *store.LaunchableResponse, local bool) { + target := "a remote Brev instance" + if local { + target = "this machine" + } + t.Vprintf("Launching %q on %s.\n", info.Name, target) + if info.Description != "" { + t.Vprintf("Description: %s\n", info.Description) + } + t.Vprintf("Build mode: %s\n", buildModeName(info.BuildRequest)) + for _, line := range parameterDisplayLines(info.BuildRequest.Parameters) { + t.Vprintf("%s\n", line) + } + t.Vprint("") +} + +func validateLaunchableID(id string) (string, error) { + if strings.TrimSpace(id) == "" || strings.ContainsAny(id, "/?&#") { + return "", fmt.Errorf("invalid launchable ID %q", id) + } + return id, nil +} + +func buildModeName(build store.LaunchableBuildRequest) string { + switch { + case build.VerbBuild != nil: + return "Verb" + case build.CustomContainer != nil: + return "Container" + case build.DockerCompose != nil: + return "Docker Compose" + case build.VMBuild != nil: + return "VM" + default: + return "Unknown" + } +} diff --git a/pkg/cmd/launch/launch_test.go b/pkg/cmd/launch/launch_test.go new file mode 100644 index 000000000..82172750e --- /dev/null +++ b/pkg/cmd/launch/launch_test.go @@ -0,0 +1,528 @@ +package launch + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "slices" + "strings" + "testing" + + "github.com/brevdev/brev-cli/pkg/cmd/gpusearch" + "github.com/brevdev/brev-cli/pkg/entity" + "github.com/brevdev/brev-cli/pkg/store" + "github.com/brevdev/brev-cli/pkg/terminal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeSecretResolver struct { + latest map[string]string + values map[string]string + calls []store.ManagedSecretReference +} + +func (f *fakeSecretResolver) LatestVersion(_ context.Context, secretID string) (string, error) { + version, ok := f.latest[secretID] + if !ok { + return "", errors.New("secret not found") + } + return version, nil +} + +func (f *fakeSecretResolver) Value(_ context.Context, ref store.ManagedSecretReference) (string, error) { + f.calls = append(f.calls, ref) + value, ok := f.values[ref.SecretID+"@"+ref.VersionID] + if !ok { + return "", errors.New("secret value not found") + } + return value, nil +} + +type fakeConfirmer struct { + result bool + calls int +} + +func (f *fakeConfirmer) ConfirmYesNo(_ string) bool { + f.calls++ + return f.result +} + +type recordedCommand struct { + spec commandSpec + stdin string + composeYAML string +} + +type fakeCommandRunner struct { + paths map[string]string + commands []recordedCommand + err error +} + +func (f *fakeCommandRunner) LookPath(file string) (string, error) { + path, ok := f.paths[file] + if !ok { + return "", errors.New("not found") + } + return path, nil +} + +func (f *fakeCommandRunner) Run(_ context.Context, spec commandSpec) error { + recorded := recordedCommand{spec: spec} + if spec.stdin != nil { + contents, err := io.ReadAll(spec.stdin) + if err != nil { + return fmt.Errorf("read command stdin: %w", err) + } + recorded.stdin = string(contents) + } + if index := slices.Index(spec.args, "--file"); index >= 0 && index+1 < len(spec.args) { + contents, err := os.ReadFile(spec.args[index+1]) + if err != nil { + return fmt.Errorf("read temporary compose file: %w", err) + } + recorded.composeYAML = string(contents) + } + f.commands = append(f.commands, recorded) + return f.err +} + +type fakeComposeFetcher struct { + contents []byte + url string + err error +} + +type fakeLaunchStore struct { + created []*store.CreateWorkspacesOptions + launchable *store.LaunchableResponse + getCalls []string +} + +func (f *fakeLaunchStore) GetAccessToken() (string, error) { return "token", nil } + +func (f *fakeLaunchStore) GetCurrentUser() (*entity.User, error) { + return &entity.User{ID: "user-1", GlobalUserType: "Standard"}, nil +} + +func (f *fakeLaunchStore) GetAuthTokens() (*entity.AuthTokens, error) { return nil, nil } + +func (f *fakeLaunchStore) GetActiveOrganizationOrDefault() (*entity.Organization, error) { + return &entity.Organization{ID: "org-1", Name: "Test Org"}, nil +} + +func (f *fakeLaunchStore) GetWorkspace(workspaceID string) (*entity.Workspace, error) { + return &entity.Workspace{ID: workspaceID, Status: entity.Running}, nil +} + +func (f *fakeLaunchStore) CreateWorkspace(_ string, options *store.CreateWorkspacesOptions) (*entity.Workspace, error) { + f.created = append(f.created, options) + return &entity.Workspace{ + ID: "ws-1", + Name: options.Name, + InstanceType: options.InstanceType, + Status: entity.Running, + }, nil +} + +func (f *fakeLaunchStore) DeleteWorkspace(string) (*entity.Workspace, error) { + return &entity.Workspace{}, nil +} + +func (f *fakeLaunchStore) GetWorkspaceByNameOrID(string, string) ([]entity.Workspace, error) { + return nil, nil +} + +func (f *fakeLaunchStore) GetAllInstanceTypesWithCloudCreds(string) (*gpusearch.AllInstanceTypesResponse, error) { + return &gpusearch.AllInstanceTypesResponse{}, nil +} + +func (f *fakeLaunchStore) GetLaunchable(id string) (*store.LaunchableResponse, error) { + f.getCalls = append(f.getCalls, id) + if f.launchable == nil { + return nil, errors.New("not used") + } + return f.launchable, nil +} + +func (f *fakeLaunchStore) GetLaunchableLifeCycleScript(string, string) (*store.LifeCycleScriptResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeLaunchStore) RedeemCouponCode(string, string) (*store.RedeemCouponCodeResponse, error) { + return &store.RedeemCouponCodeResponse{}, nil +} + +func (f *fakeLaunchStore) GetInstanceTypes(bool) (*gpusearch.InstanceTypesResponse, error) { + return &gpusearch.InstanceTypesResponse{}, nil +} + +func (f *fakeComposeFetcher) Fetch(_ context.Context, url string) ([]byte, error) { + f.url = url + return f.contents, f.err +} + +func TestLaunchableExplainDisplaysBuildModeAndParameters(t *testing.T) { + t.Setenv("BREV_CONSOLE_URL", "https://dev.brev.nvidia.com/cli-login") + launchStore := &fakeLaunchStore{launchable: &store.LaunchableResponse{ + Name: "vLLM Inference Server", + Description: "Run an OpenAI-compatible inference server.", + BuildRequest: store.LaunchableBuildRequest{ + DockerCompose: &store.DockerCompose{YamlString: "services: {}"}, + Parameters: []store.Parameter{ + {Name: "HF_TOKEN", Text: &store.TextParameter{}}, + {Name: "VLLM_ARGS", Required: true, Text: &store.TextParameter{DefaultValue: "--max-model-len 16384 --gpu-memory-utilization 0.9"}}, + {Name: "MODEL", Required: true, Text: &store.TextParameter{DefaultValue: "Qwen/Qwen3-8B"}}, + }, + }, + }} + var out bytes.Buffer + cmd := newCmdLaunch(terminal.New(), launchStore, launchDeps{}) + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"env-3IEl5O5SlUAYJ9X1GKjAIZxoSnm", "--explain"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Equal(t, `Name: vLLM Inference Server +Description: Run an OpenAI-compatible inference server. +URL: https://dev.brev.nvidia.com/launchable/deploy/now?launchableID=env-3IEl5O5SlUAYJ9X1GKjAIZxoSnm +Build mode: Docker Compose +Parameters: + MODEL (required, default: Qwen/Qwen3-8B) + VLLM_ARGS (required, default: --max-model-len 16384 --gpu-memory-utilization 0.9) + HF_TOKEN (optional) +`, out.String()) + assert.Equal(t, []string{"env-3IEl5O5SlUAYJ9X1GKjAIZxoSnm"}, launchStore.getCalls) + assert.Empty(t, launchStore.created) +} + +func TestLaunchableExplainOmitsMissingDescription(t *testing.T) { + t.Setenv("BREV_CONSOLE_URL", "https://brev.nvidia.com/cli-login") + launchStore := &fakeLaunchStore{launchable: &store.LaunchableResponse{ + Name: "Local setup", + BuildRequest: store.LaunchableBuildRequest{VMBuild: &store.VMBuild{}}, + }} + var out bytes.Buffer + cmd := newCmdLaunch(terminal.New(), launchStore, launchDeps{}) + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"env-abc", "--explain"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Equal(t, `Name: Local setup +URL: https://brev.nvidia.com/launchable/deploy/now?launchableID=env-abc +Build mode: VM +Parameters: none +`, out.String()) +} + +func TestLaunchableDefinitionURLRejectsInvalidConsoleURL(t *testing.T) { + _, err := launchableDefinitionURL("localhost:3000", "env-abc") + assert.ErrorContains(t, err, "scheme and host") +} + +func TestLaunchHelpWithIDRemainsCommandHelp(t *testing.T) { + launchStore := &fakeLaunchStore{} + var out bytes.Buffer + cmd := newCmdLaunch(terminal.New(), launchStore, launchDeps{}) + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"env-abc", "-h"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Contains(t, out.String(), "Usage:") + assert.Contains(t, out.String(), "launch ") + assert.Empty(t, launchStore.getCalls) +} + +func TestParseParameterInputs(t *testing.T) { + values, err := parseParameterValues([]string{"MODEL=llama=3", "PORT=8080"}) + require.NoError(t, err) + assert.Equal(t, map[string]string{"MODEL": "llama=3", "PORT": "8080"}, values) + + secrets, err := parseParameterSecrets([]string{ + "TOKEN=secret-1", + "PINNED=secret-2:version-3", + }) + require.NoError(t, err) + assert.Equal(t, store.ManagedSecretReference{SecretID: "secret-1"}, secrets["TOKEN"]) + assert.Equal(t, store.ManagedSecretReference{SecretID: "secret-2", VersionID: "version-3"}, secrets["PINNED"]) + + _, err = parseParameterSecrets([]string{"TOKEN=secret-1:"}) + assert.ErrorContains(t, err, "invalid --param-secret") + + _, err = parseParameterSecrets([]string{"TOKEN=secret-1@version-3"}) + assert.ErrorContains(t, err, "use ':'") +} + +func TestResolveParameterBindingsSupportsValuesDefaultsAndSecrets(t *testing.T) { + resolver := &fakeSecretResolver{latest: map[string]string{"secret-1": "version-9"}} + parameters := []store.Parameter{ + {Name: "TOKEN", Required: true, Text: &store.TextParameter{}}, + {Name: "MODEL", Choice: &store.ChoiceParameter{Choices: []string{"small", "large"}, DefaultValue: "small"}}, + {Name: "PORT", Text: &store.TextParameter{DefaultValue: "8080"}}, + } + + bindings, err := resolveParameterBindings( + t.Context(), + parameterBindingArgs{ + parameters: parameters, + values: map[string]string{"MODEL": "large"}, + secrets: map[string]store.ManagedSecretReference{"TOKEN": {SecretID: "secret-1"}}, + resolver: resolver, + }, + ) + + require.NoError(t, err) + require.Len(t, bindings, 3) + assert.Equal(t, &store.ManagedSecretReference{SecretID: "secret-1", VersionID: "version-9"}, bindings[0].ManagedSecret) + assert.Equal(t, store.ParameterBinding{Name: "MODEL", Value: "large"}, bindings[1]) + assert.Equal(t, store.ParameterBinding{Name: "PORT", Value: "8080"}, bindings[2]) +} + +func TestResolveParameterBindingsRejectsInvalidSecretBindings(t *testing.T) { + parameters := []store.Parameter{ + {Name: "TOKEN", Required: true, Text: &store.TextParameter{}}, + {Name: "MODEL", Required: true, Choice: &store.ChoiceParameter{Choices: []string{"small"}}}, + } + + _, err := resolveParameterBindings( + t.Context(), + parameterBindingArgs{ + parameters: parameters, + values: map[string]string{"TOKEN": "direct"}, + secrets: map[string]store.ManagedSecretReference{ + "TOKEN": {SecretID: "secret-1", VersionID: "version-1"}, + "MODEL": {SecretID: "secret-2", VersionID: "version-1"}, + }, + resolver: &fakeSecretResolver{}, + }, + ) + + require.Error(t, err) + assert.ErrorContains(t, err, `parameter "TOKEN" cannot use both`) + assert.ErrorContains(t, err, `choice parameter "MODEL" cannot be bound to a secret`) +} + +func TestManagedSecretBindingUsesUICompatibleJSON(t *testing.T) { + payload := store.LaunchableConfig{ + ID: "env-abc", + ParameterBindings: []store.ParameterBinding{{ + Name: "TOKEN", + ManagedSecret: &store.ManagedSecretReference{ + SecretID: "secret-1", + VersionID: "version-2", + }, + }}, + } + + contents, err := json.Marshal(payload) + require.NoError(t, err) + assert.JSONEq(t, `{ + "id":"env-abc", + "parameterBindings":[{ + "name":"TOKEN", + "managedSecret":{"secretId":"secret-1","versionId":"version-2"} + }] + }`, string(contents)) + assert.NotContains(t, string(contents), `"value"`) +} + +func TestRemoteLaunchPassesManagedSecretBindingToCreate(t *testing.T) { + launchStore := &fakeLaunchStore{} + info := &store.LaunchableResponse{ + Name: "remote-launch", + CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ + InstanceType: "gpu.test", + CloudCredID: "cloud-cred-1", + }, + BuildRequest: store.LaunchableBuildRequest{VMBuild: &store.VMBuild{}}, + } + bindings := []store.ParameterBinding{{ + Name: "TOKEN", + ManagedSecret: &store.ManagedSecretReference{SecretID: "secret-1", VersionID: "version-2"}, + }} + + err := runRemoteLaunch(remoteLaunchArgs{ + terminal: terminal.New(), + store: launchStore, + launchableID: "env-abc", + info: info, + bindings: bindings, + name: "remote-launch", + options: commandOptions{ + detached: true, + timeout: defaultLaunchTimeoutSeconds, + }, + }) + + require.NoError(t, err) + require.Len(t, launchStore.created, 1) + require.NotNil(t, launchStore.created[0].LaunchableConfig) + assert.Equal(t, "env-abc", launchStore.created[0].LaunchableConfig.ID) + assert.Equal(t, bindings, launchStore.created[0].LaunchableConfig.ParameterBindings) +} + +func TestRunLocalComposeFetchesYAMLAndKeepsSecretsOutOfArguments(t *testing.T) { //nolint:funlen // end-to-end local compose behavior + runner := &fakeCommandRunner{paths: map[string]string{"docker": "/usr/bin/docker"}} + fetcher := &fakeComposeFetcher{contents: []byte("services:\n app:\n image: example/app\n")} + resolver := &fakeSecretResolver{values: map[string]string{"secret-1@version-2": "super-secret"}} + info := &store.LaunchableResponse{ + BuildRequest: store.LaunchableBuildRequest{ + DockerCompose: &store.DockerCompose{ + FileURL: "https://example.com/docker-compose.yaml", + YamlString: "ignored: true", + EnvironmentVariables: map[string]string{"PUBLIC_SETTING": "enabled"}, + Registries: []*store.Registry{{ + Username: "registry-user", + Password: "registry-password", + Url: "registry.example.com", + }}, + }, + }, + } + bindings := []store.ParameterBinding{{ + Name: "API_TOKEN", + ManagedSecret: &store.ManagedSecretReference{SecretID: "secret-1", VersionID: "version-2"}, + }} + + err := runLocalLaunchable(t.Context(), localLaunchArgs{ + terminal: terminal.New(), + launchableID: "env-abc", + info: info, + bindings: bindings, + options: localOptions{ + name: "My Launchable", + detached: true, + stdin: bytes.NewReader(nil), + stdout: io.Discard, + stderr: io.Discard, + }, + deps: launchDeps{runner: runner, fetchCompose: fetcher.Fetch, secrets: resolver}, + }) + + require.NoError(t, err) + assert.Equal(t, "https://example.com/docker-compose.yaml", fetcher.url) + require.Len(t, runner.commands, 2) + assert.Equal(t, "registry-password\n", runner.commands[0].stdin) + assert.NotContains(t, strings.Join(runner.commands[0].spec.args, " "), "registry-password") + + compose := runner.commands[1] + assert.Equal(t, fetcher.contents, []byte(compose.composeYAML)) + assert.Contains(t, compose.spec.args, "--detach") + assert.Contains(t, compose.spec.args, "my-launchable") + assert.NotContains(t, strings.Join(compose.spec.args, " "), "super-secret") + assert.Contains(t, compose.spec.env, "API_TOKEN=super-secret") + assert.Contains(t, compose.spec.env, "PUBLIC_SETTING=enabled") +} + +func TestRunLocalContainerPassesParameterNamesThroughDockerEnvironment(t *testing.T) { + runner := &fakeCommandRunner{paths: map[string]string{"docker": "/usr/bin/docker"}} + info := &store.LaunchableResponse{BuildRequest: store.LaunchableBuildRequest{ + CustomContainer: &store.CustomContainer{ContainerURL: "example/app:latest", EntryPoint: "/start"}, + Ports: []store.LaunchablePort{{Port: "8080"}}, + }} + bindings := []store.ParameterBinding{{Name: "TOKEN", Value: "secret-ish-direct-value"}} + + err := runLocalLaunchable(t.Context(), localLaunchArgs{ + terminal: terminal.New(), + launchableID: "env-abc", + info: info, + bindings: bindings, + options: localOptions{ + name: "container-test", + stdout: io.Discard, + stderr: io.Discard, + }, + deps: launchDeps{runner: runner, secrets: &fakeSecretResolver{}}, + }) + + require.NoError(t, err) + require.Len(t, runner.commands, 1) + command := runner.commands[0].spec + assert.Contains(t, command.args, "TOKEN") + assert.Contains(t, command.args, "8080:8080") + assert.Contains(t, command.args, "/start") + assert.NotContains(t, strings.Join(command.args, " "), "secret-ish-direct-value") + assert.Contains(t, command.env, "TOKEN=secret-ish-direct-value") +} + +func TestRunLocalVMRequiresConfirmation(t *testing.T) { + info := &store.LaunchableResponse{BuildRequest: store.LaunchableBuildRequest{ + VMBuild: &store.VMBuild{LifeCycleScriptAttr: &store.LifeCycleScriptAttr{Script: "echo hello"}}, + }} + + t.Run("canceled", func(t *testing.T) { + runner := &fakeCommandRunner{paths: map[string]string{"bash": "/bin/bash"}} + confirmer := &fakeConfirmer{result: false} + err := runLocalLaunchable(t.Context(), localLaunchArgs{ + terminal: terminal.New(), + launchableID: "env-abc", + info: info, + options: localOptions{ + name: "vm-test", stdout: io.Discard, stderr: io.Discard, + }, + deps: launchDeps{runner: runner, confirm: confirmer.ConfirmYesNo, secrets: &fakeSecretResolver{}}, + }) + require.NoError(t, err) + assert.Equal(t, 1, confirmer.calls) + assert.Empty(t, runner.commands) + }) + + t.Run("approved by flag", func(t *testing.T) { + runner := &fakeCommandRunner{paths: map[string]string{"bash": "/bin/bash"}} + confirmer := &fakeConfirmer{result: false} + err := runLocalLaunchable(t.Context(), localLaunchArgs{ + terminal: terminal.New(), + launchableID: "env-abc", + info: info, + options: localOptions{ + name: "vm-test", approve: true, stdout: io.Discard, stderr: io.Discard, + }, + deps: launchDeps{runner: runner, confirm: confirmer.ConfirmYesNo, secrets: &fakeSecretResolver{}}, + }) + require.NoError(t, err) + assert.Zero(t, confirmer.calls) + require.Len(t, runner.commands, 1) + assert.Equal(t, []string{"-c", "echo hello"}, runner.commands[0].spec.args) + }) +} + +func TestDetectBuildModeRejectsAmbiguousLaunchable(t *testing.T) { + _, err := detectBuildMode(&store.LaunchableResponse{BuildRequest: store.LaunchableBuildRequest{ + VMBuild: &store.VMBuild{}, + DockerCompose: &store.DockerCompose{YamlString: "services: {}"}, + }}) + assert.ErrorContains(t, err, "multiple build modes") +} + +func TestParseLaunchableID(t *testing.T) { + id, err := parseLaunchableID("https://console.brev.dev/launchable/deploy?launchableID=env-abc") + require.NoError(t, err) + assert.Equal(t, "env-abc", id) + + _, err = parseLaunchableID("https://console.brev.dev/launchable/deploy?launchableID=env-abc/../../other") + assert.Error(t, err) +} + +func TestRemoteInstanceTypes(t *testing.T) { + types, err := remoteInstanceTypes("a100.large, h100.large", "ignored") + require.NoError(t, err) + assert.Equal(t, "a100.large", types[0].Type) + assert.Equal(t, "h100.large", types[1].Type) + + _, err = remoteInstanceTypes("", "") + assert.ErrorContains(t, err, "provide --type") +} diff --git a/pkg/cmd/launch/local.go b/pkg/cmd/launch/local.go new file mode 100644 index 000000000..6767214ca --- /dev/null +++ b/pkg/cmd/launch/local.go @@ -0,0 +1,242 @@ +package launch + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strings" + + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/store" + "github.com/brevdev/brev-cli/pkg/terminal" +) + +type localOptions struct { + name string + detached bool + approve bool + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +type localLaunchArgs struct { + terminal *terminal.Terminal + launchableID string + info *store.LaunchableResponse + bindings []store.ParameterBinding + options localOptions + deps launchDeps +} + +type launchDeps struct { + runner commandRunner + fetchCompose composeFileFetcher + confirm confirmFunc + secrets managedSecretResolver +} + +type commandSpec struct { + name string + args []string + dir string + env []string + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +type commandRunner interface { + LookPath(file string) (string, error) + Run(ctx context.Context, spec commandSpec) error +} + +type execCommandRunner struct{} + +type localBuildMode string + +const ( + buildModeVM localBuildMode = "VM" + buildModeContainer localBuildMode = "container" + buildModeCompose localBuildMode = "Docker Compose" + buildModeVerb localBuildMode = "Verb" +) + +func defaultLaunchDeps(launchStore Store) launchDeps { + return launchDeps{ + runner: execCommandRunner{}, + fetchCompose: fetchComposeFile, + confirm: confirmStartupScript, + secrets: newDevplaneManagedSecretResolver(launchStore), + } +} + +func runLocalLaunchable(ctx context.Context, args localLaunchArgs) error { + mode, err := detectBuildMode(args.info) + if err != nil { + return err + } + if mode == buildModeVerb { + return breverrors.NewValidationError("Verb launchables cannot yet run locally") + } + if args.info.File != nil { + args.terminal.Vprint("Warning: local mode does not copy the launchable's file attachment.") + } + parameterValues, err := localParameterValues(ctx, args.bindings, args.deps.secrets) + if err != nil { + return err + } + + switch mode { + case buildModeVM: + return runVM(ctx, vmLaunchArgs{ + terminal: args.terminal, + build: args.info.BuildRequest.VMBuild, + env: mergeEnvironment(os.Environ(), parameterValues), + options: args.options, + deps: args.deps, + }) + case buildModeContainer: + return runContainer(ctx, containerLaunchArgs{ + terminal: args.terminal, + build: args.info.BuildRequest.CustomContainer, + ports: args.info.BuildRequest.Ports, + parameterNames: sortedKeys(parameterValues), + env: mergeEnvironment(os.Environ(), parameterValues), + options: args.options, + deps: args.deps, + }) + case buildModeCompose: + return runCompose(ctx, composeLaunchArgs{ + terminal: args.terminal, + build: args.info.BuildRequest.DockerCompose, + launchableID: args.launchableID, + parameterValues: parameterValues, + options: args.options, + deps: args.deps, + }) + default: + return breverrors.NewValidationError(fmt.Sprintf("unsupported local build mode %q", mode)) + } +} + +func detectBuildMode(info *store.LaunchableResponse) (localBuildMode, error) { + if info == nil { + return "", breverrors.NewValidationError("launchable configuration is missing") + } + var modes []localBuildMode + if info.BuildRequest.VMBuild != nil { + modes = append(modes, buildModeVM) + } + if info.BuildRequest.CustomContainer != nil { + modes = append(modes, buildModeContainer) + } + if info.BuildRequest.DockerCompose != nil { + modes = append(modes, buildModeCompose) + } + if info.BuildRequest.VerbBuild != nil { + modes = append(modes, buildModeVerb) + } + if len(modes) == 0 { + return "", breverrors.NewValidationError("launchable does not define a supported build mode") + } + if len(modes) > 1 { + return "", breverrors.NewValidationError("launchable defines multiple build modes; cannot choose a safe local build") + } + return modes[0], nil +} + +func localParameterValues( + ctx context.Context, + bindings []store.ParameterBinding, + resolver managedSecretResolver, +) (map[string]string, error) { + values := make(map[string]string, len(bindings)) + for _, binding := range bindings { + if binding.ManagedSecret == nil { + values[binding.Name] = binding.Value + continue + } + if resolver == nil { + return nil, fmt.Errorf("managed-secret resolver is not configured") + } + value, err := resolver.Value(ctx, *binding.ManagedSecret) + if err != nil { + return nil, fmt.Errorf("resolve managed secret for parameter %q: %w", binding.Name, err) + } + values[binding.Name] = value + } + return values, nil +} + +func mergeEnvironment(base []string, overrides ...map[string]string) []string { + values := make(map[string]string, len(base)) + for _, item := range base { + name, value, ok := strings.Cut(item, "=") + if ok { + values[name] = value + } + } + for _, items := range overrides { + for name, value := range items { + values[name] = value + } + } + names := sortedKeys(values) + env := make([]string, 0, len(names)) + for _, name := range names { + env = append(env, name+"="+values[name]) + } + return env +} + +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func safeLocalName(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + var result strings.Builder + for _, char := range name { + switch { + case char >= 'a' && char <= 'z', char >= '0' && char <= '9', char == '-', char == '_': + result.WriteRune(char) + default: + result.WriteByte('-') + } + } + value := strings.Trim(result.String(), "-_") + if value == "" { + return "brev-launchable" + } + return value +} + +func (execCommandRunner) LookPath(file string) (string, error) { + path, err := exec.LookPath(file) + if err != nil { + return "", fmt.Errorf("find %s executable: %w", file, err) + } + return path, nil +} + +func (execCommandRunner) Run(ctx context.Context, spec commandSpec) error { + cmd := exec.CommandContext(ctx, spec.name, spec.args...) //nolint:gosec // commands use fixed executables and flags + cmd.Dir = spec.dir + cmd.Env = spec.env + cmd.Stdin = spec.stdin + cmd.Stdout = spec.stdout + cmd.Stderr = spec.stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("run %s: %w", spec.name, err) + } + return nil +} diff --git a/pkg/cmd/launch/local_docker.go b/pkg/cmd/launch/local_docker.go new file mode 100644 index 000000000..ee371ea38 --- /dev/null +++ b/pkg/cmd/launch/local_docker.go @@ -0,0 +1,118 @@ +package launch + +import ( + "context" + "fmt" + "sort" + "strings" + + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/store" + "github.com/brevdev/brev-cli/pkg/terminal" +) + +type containerLaunchArgs struct { + terminal *terminal.Terminal + build *store.CustomContainer + ports []store.LaunchablePort + parameterNames []string + env []string + options localOptions + deps launchDeps +} + +type dockerLoginArgs struct { + docker string + registry *store.Registry + options localOptions + runner commandRunner +} + +func runContainer(ctx context.Context, args containerLaunchArgs) error { + if strings.TrimSpace(args.build.ContainerURL) == "" { + return breverrors.NewValidationError("container launchable has no image configured") + } + docker, err := localDocker(args.deps.runner) + if err != nil { + return err + } + if err := dockerLogin(ctx, dockerLoginArgs{ + docker: docker, + registry: args.build.Registry, + options: args.options, + runner: args.deps.runner, + }); err != nil { + return err + } + dockerArgs := []string{"run", "--name", safeLocalName(args.options.name)} + if args.options.detached { + dockerArgs = append(dockerArgs, "--detach") + } else { + dockerArgs = append(dockerArgs, "--rm") + } + for _, name := range args.parameterNames { + dockerArgs = append(dockerArgs, "--env", name) + } + for _, port := range portMappings(args.ports) { + dockerArgs = append(dockerArgs, "--publish", port) + } + if entrypoint := strings.TrimSpace(args.build.EntryPoint); entrypoint != "" { + dockerArgs = append(dockerArgs, "--entrypoint", entrypoint) + } + dockerArgs = append(dockerArgs, args.build.ContainerURL) + + args.terminal.Vprintf("Starting %q with Docker.\n", args.options.name) + if err := args.deps.runner.Run(ctx, commandSpec{ + name: docker, + args: dockerArgs, + env: args.env, + stdin: args.options.stdin, + stdout: args.options.stdout, + stderr: args.options.stderr, + }); err != nil { + return fmt.Errorf("run launchable container: %w", err) + } + args.terminal.Vprintf("Local launchable %q started successfully.\n", args.options.name) + return nil +} + +func dockerLogin(ctx context.Context, args dockerLoginArgs) error { + if args.registry == nil || args.registry.Username == "" || args.registry.Password == "" { + return nil + } + dockerArgs := []string{"login"} + if args.registry.Url != "" { + dockerArgs = append(dockerArgs, args.registry.Url) + } + dockerArgs = append(dockerArgs, "--username", args.registry.Username, "--password-stdin") + if err := args.runner.Run(ctx, commandSpec{ + name: args.docker, + args: dockerArgs, + stdin: strings.NewReader(args.registry.Password + "\n"), + stdout: args.options.stdout, + stderr: args.options.stderr, + }); err != nil { + return fmt.Errorf("log in to Docker registry %q: %w", args.registry.Url, err) + } + return nil +} + +func localDocker(runner commandRunner) (string, error) { + docker, err := runner.LookPath("docker") + if err != nil { + return "", breverrors.NewValidationError("local container and Docker Compose launchables require Docker on PATH") + } + return docker, nil +} + +func portMappings(ports []store.LaunchablePort) []string { + result := make([]string, 0, len(ports)) + for _, port := range ports { + value := strings.TrimSpace(port.Port) + if value != "" { + result = append(result, value+":"+value) + } + } + sort.Strings(result) + return result +} diff --git a/pkg/cmd/launch/local_dockercompose.go b/pkg/cmd/launch/local_dockercompose.go new file mode 100644 index 000000000..79940a300 --- /dev/null +++ b/pkg/cmd/launch/local_dockercompose.go @@ -0,0 +1,127 @@ +package launch + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/store" + "github.com/brevdev/brev-cli/pkg/terminal" +) + +const maxComposeFileSize = 10 << 20 + +type composeLaunchArgs struct { + terminal *terminal.Terminal + build *store.DockerCompose + launchableID string + parameterValues map[string]string + options localOptions + deps launchDeps +} + +type composeFileFetcher func(ctx context.Context, url string) ([]byte, error) + +func runCompose(ctx context.Context, args composeLaunchArgs) error { + docker, err := localDocker(args.deps.runner) + if err != nil { + return err + } + contents, err := composeContents(ctx, args.build, args.deps.fetchCompose) + if err != nil { + return err + } + workingDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("get local working directory: %w", err) + } + tempDir, err := os.MkdirTemp("", "brev-launchable-"+safeLocalName(args.launchableID)+"-") + if err != nil { + return fmt.Errorf("create temporary launchable directory: %w", err) + } + defer os.RemoveAll(tempDir) //nolint:errcheck // best-effort cleanup + composePath := filepath.Join(tempDir, "docker-compose.yaml") + if err := os.WriteFile(composePath, contents, 0o600); err != nil { + return fmt.Errorf("write local compose file: %w", err) + } + for _, registry := range args.build.Registries { + if err := dockerLogin(ctx, dockerLoginArgs{ + docker: docker, + registry: registry, + options: args.options, + runner: args.deps.runner, + }); err != nil { + return err + } + } + env := mergeEnvironment(os.Environ(), args.build.EnvironmentVariables, args.parameterValues) + dockerArgs := []string{ + "compose", + "--project-name", safeLocalName(args.options.name), + "--project-directory", workingDir, + "--file", composePath, + "up", + } + if args.options.detached { + dockerArgs = append(dockerArgs, "--detach") + } + + args.terminal.Vprintf("Starting %q with Docker Compose.\n", args.options.name) + if err := args.deps.runner.Run(ctx, commandSpec{ + name: docker, + args: dockerArgs, + dir: workingDir, + env: env, + stdin: args.options.stdin, + stdout: args.options.stdout, + stderr: args.options.stderr, + }); err != nil { + return fmt.Errorf("run Docker Compose launchable: %w", err) + } + args.terminal.Vprintf("Local launchable %q started successfully.\n", args.options.name) + return nil +} + +func composeContents(ctx context.Context, build *store.DockerCompose, fetch composeFileFetcher) ([]byte, error) { + if strings.TrimSpace(build.FileURL) != "" { + contents, err := fetch(ctx, build.FileURL) + if err != nil { + return nil, fmt.Errorf("fetch launchable Docker Compose file: %w", err) + } + return contents, nil + } + if strings.TrimSpace(build.YamlString) == "" { + return nil, breverrors.NewValidationError("Docker Compose launchable has no file URL or YAML content") + } + return []byte(build.YamlString), nil +} + +func fetchComposeFile(ctx context.Context, sourceURL string) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return nil, fmt.Errorf("create compose request: %w", err) + } + client := http.Client{Timeout: 30 * time.Second} + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("download compose file: %w", err) + } + defer response.Body.Close() //nolint:errcheck // body is read before returning + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("download compose file: server returned %s", response.Status) + } + contents, err := io.ReadAll(io.LimitReader(response.Body, maxComposeFileSize+1)) + if err != nil { + return nil, fmt.Errorf("read compose file: %w", err) + } + if len(contents) > maxComposeFileSize { + return nil, fmt.Errorf("compose file exceeds %d MiB", maxComposeFileSize>>20) + } + return contents, nil +} diff --git a/pkg/cmd/launch/local_vm.go b/pkg/cmd/launch/local_vm.go new file mode 100644 index 000000000..2828c67f5 --- /dev/null +++ b/pkg/cmd/launch/local_vm.go @@ -0,0 +1,72 @@ +package launch + +import ( + "context" + "fmt" + "strings" + + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/store" + "github.com/brevdev/brev-cli/pkg/terminal" +) + +type vmLaunchArgs struct { + terminal *terminal.Terminal + build *store.VMBuild + env []string + options localOptions + deps launchDeps +} + +type confirmFunc func(label string) bool + +func runVM(ctx context.Context, args vmLaunchArgs) error { + script := args.build.LifeCycleScriptAttr + if script == nil || strings.TrimSpace(script.Script) == "" { + args.terminal.Vprint("This VM launchable has no startup script; there is nothing to run locally.") + return nil + } + name := strings.TrimSpace(script.Name) + if name == "" { + name = "The launchable startup script" + } + args.terminal.Vprintf("Warning: %s will run directly on this machine and may modify local files, packages, and services.\n", name) + if !args.options.approve && !args.deps.confirm("Run this startup script locally?") { + args.terminal.Vprint("Local launch canceled.") + return nil + } + shell, err := localShell(args.deps.runner) + if err != nil { + return err + } + spec := commandSpec{ + name: shell, + args: []string{"-c", script.Script}, + env: args.env, + stdin: args.options.stdin, + stdout: args.options.stdout, + stderr: args.options.stderr, + } + if err := args.deps.runner.Run(ctx, spec); err != nil { + return fmt.Errorf("run launchable startup script locally: %w", err) + } + args.terminal.Vprint("Local startup script completed successfully.") + return nil +} + +func localShell(runner commandRunner) (string, error) { + if shell, err := runner.LookPath("bash"); err == nil { + return shell, nil + } + if shell, err := runner.LookPath("sh"); err == nil { + return shell, nil + } + return "", breverrors.NewValidationError("local VM launchables require bash or sh") +} + +func confirmStartupScript(label string) bool { + return terminal.PromptSelectInput(terminal.PromptSelectContent{ + Label: label, + Items: []string{"Yes, proceed", "No, cancel"}, + }) == "Yes, proceed" +} diff --git a/pkg/cmd/launch/parameters.go b/pkg/cmd/launch/parameters.go new file mode 100644 index 000000000..ab2998fd2 --- /dev/null +++ b/pkg/cmd/launch/parameters.go @@ -0,0 +1,204 @@ +package launch + +import ( + "context" + "fmt" + "slices" + "sort" + "strings" + + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/store" +) + +type parameterBindingArgs struct { + parameters []store.Parameter + values map[string]string + secrets map[string]store.ManagedSecretReference + resolver managedSecretResolver +} + +func parseParameterValues(values []string) (map[string]string, error) { + parsed := make(map[string]string, len(values)) + for _, item := range values { + name, value, ok := strings.Cut(item, "=") + name = strings.TrimSpace(name) + if !ok || name == "" { + return nil, breverrors.NewValidationError(fmt.Sprintf("invalid --param %q: expected NAME=VALUE", item)) + } + if _, exists := parsed[name]; exists { + return nil, breverrors.NewValidationError(fmt.Sprintf("parameter %q was provided more than once", name)) + } + parsed[name] = value + } + return parsed, nil +} + +func parseParameterSecrets(values []string) (map[string]store.ManagedSecretReference, error) { + parsed := make(map[string]store.ManagedSecretReference, len(values)) + for _, item := range values { + name, selector, ok := strings.Cut(item, "=") + name = strings.TrimSpace(name) + selector = strings.TrimSpace(selector) + if !ok || name == "" || selector == "" { + return nil, breverrors.NewValidationError(fmt.Sprintf( + "invalid --param-secret %q: expected NAME=SECRET_ID[:VERSION_ID]", item, + )) + } + if strings.Contains(selector, "@") { + return nil, breverrors.NewValidationError(fmt.Sprintf( + "invalid --param-secret %q: use ':' between SECRET_ID and VERSION_ID", item, + )) + } + if _, exists := parsed[name]; exists { + return nil, breverrors.NewValidationError(fmt.Sprintf("secret parameter %q was provided more than once", name)) + } + secretID, versionID, hasVersion := strings.Cut(selector, ":") + secretID = strings.TrimSpace(secretID) + versionID = strings.TrimSpace(versionID) + if secretID == "" || (hasVersion && versionID == "") { + return nil, breverrors.NewValidationError(fmt.Sprintf("invalid --param-secret %q", item)) + } + parsed[name] = store.ManagedSecretReference{SecretID: secretID, VersionID: versionID} + } + return parsed, nil +} + +func resolveParameterBindings( + ctx context.Context, + args parameterBindingArgs, +) ([]store.ParameterBinding, error) { + problems := validateParameterSelections(args.parameters, args.values, args.secrets) + bindings, bindingProblems := buildParameterBindings(args.parameters, args.values, args.secrets) + problems = append(problems, bindingProblems...) + if len(problems) > 0 { + sort.Strings(problems) + return nil, breverrors.NewValidationError("invalid launchable parameters:\n - " + strings.Join(problems, "\n - ")) + } + if err := resolveSecretVersions(ctx, bindings, args.resolver); err != nil { + return nil, err + } + return bindings, nil +} + +func validateParameterSelections( + parameters []store.Parameter, + values map[string]string, + secrets map[string]store.ManagedSecretReference, +) []string { + defined := make(map[string]store.Parameter, len(parameters)) + for _, parameter := range parameters { + defined[parameter.Name] = parameter + } + + var problems []string + for name := range values { + if _, ok := defined[name]; !ok { + problems = append(problems, fmt.Sprintf("unknown parameter %q", name)) + } + } + for name := range secrets { + parameter, ok := defined[name] + if !ok { + problems = append(problems, fmt.Sprintf("unknown secret parameter %q", name)) + continue + } + if _, hasValue := values[name]; hasValue { + problems = append(problems, fmt.Sprintf("parameter %q cannot use both --param and --param-secret", name)) + } + if parameter.Choice != nil { + problems = append(problems, fmt.Sprintf("choice parameter %q cannot be bound to a secret", name)) + } + } + return problems +} + +func buildParameterBindings( + parameters []store.Parameter, + values map[string]string, + secrets map[string]store.ManagedSecretReference, +) ([]store.ParameterBinding, []string) { + var problems []string + bindings := make([]store.ParameterBinding, 0, len(parameters)) + for _, parameter := range parameters { + if ref, ok := secrets[parameter.Name]; ok { + bindings = append(bindings, store.ParameterBinding{Name: parameter.Name, ManagedSecret: &ref}) + continue + } + value := values[parameter.Name] + if value == "" { + value = parameterDefault(parameter) + } + if parameter.Required && value == "" { + problems = append(problems, fmt.Sprintf("missing required parameter %q", 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}) + } + } + return bindings, problems +} + +func resolveSecretVersions(ctx context.Context, bindings []store.ParameterBinding, resolver managedSecretResolver) error { + for i := range bindings { + ref := bindings[i].ManagedSecret + if ref == nil || ref.VersionID != "" { + continue + } + if resolver == nil { + return fmt.Errorf("managed-secret resolver is not configured") + } + versionID, err := resolver.LatestVersion(ctx, ref.SecretID) + if err != nil { + return fmt.Errorf("resolve latest version for managed secret %q: %w", ref.SecretID, err) + } + ref.VersionID = versionID + } + return nil +} + +func parameterDefault(parameter store.Parameter) string { + if parameter.Text != nil { + return parameter.Text.DefaultValue + } + if parameter.Choice != nil { + return parameter.Choice.DefaultValue + } + return "" +} + +func parameterDisplayLines(parameters []store.Parameter) []string { + if len(parameters) == 0 { + return nil + } + ordered := append([]store.Parameter(nil), parameters...) + sort.Slice(ordered, func(i int, j int) bool { + if ordered[i].Required != ordered[j].Required { + return ordered[i].Required + } + return ordered[i].Name < ordered[j].Name + }) + lines := []string{"Parameters:"} + for _, parameter := range ordered { + requirement := "optional" + if parameter.Required { + requirement = "required" + } + details := requirement + if value := parameterDefault(parameter); value != "" { + details += ", default: " + value + } + if parameter.Choice != nil { + details += ", choices: " + strings.Join(parameter.Choice.Choices, ", ") + } + lines = append(lines, fmt.Sprintf(" %s (%s)", parameter.Name, details)) + } + return lines +} diff --git a/pkg/cmd/launch/secrets.go b/pkg/cmd/launch/secrets.go new file mode 100644 index 000000000..8262dba5a --- /dev/null +++ b/pkg/cmd/launch/secrets.go @@ -0,0 +1,53 @@ +package launch + +import ( + "context" + "fmt" + + devplanev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + + "github.com/brevdev/brev-cli/pkg/cmd/register" + "github.com/brevdev/brev-cli/pkg/config" + "github.com/brevdev/brev-cli/pkg/store" +) + +type managedSecretResolver interface { + LatestVersion(ctx context.Context, secretID string) (string, error) + Value(ctx context.Context, ref store.ManagedSecretReference) (string, error) +} + +type devplaneManagedSecretResolver struct { + client devplanev1connect.ManagedSecretServiceClient +} + +func newDevplaneManagedSecretResolver(provider Store) managedSecretResolver { + return devplaneManagedSecretResolver{ + client: register.NewManagedSecretServiceClient(provider, config.GlobalConfig.GetBrevPublicAPIURL()), + } +} + +func (r devplaneManagedSecretResolver) LatestVersion(ctx context.Context, secretID string) (string, error) { + response, err := r.client.GetSecret(ctx, connect.NewRequest(&devplanev1.ManagedSecretServiceGetSecretRequest{ + SecretId: secretID, + })) + if err != nil { + return "", fmt.Errorf("get managed secret metadata: %w", err) + } + if response.Msg.GetSecret() == nil || response.Msg.GetSecret().GetLatestVersionId() == "" { + return "", fmt.Errorf("managed secret has no latest version") + } + return response.Msg.GetSecret().GetLatestVersionId(), nil +} + +func (r devplaneManagedSecretResolver) Value(ctx context.Context, ref store.ManagedSecretReference) (string, error) { + response, err := r.client.GetSecretValue(ctx, connect.NewRequest(&devplanev1.ManagedSecretServiceGetSecretValueRequest{ + SecretId: ref.SecretID, + VersionId: ref.VersionID, + })) + if err != nil { + return "", fmt.Errorf("get managed secret value: %w", err) + } + return response.Msg.GetValue(), nil +} diff --git a/pkg/cmd/register/rpcclient.go b/pkg/cmd/register/rpcclient.go index 20ff27bdf..155e8385f 100644 --- a/pkg/cmd/register/rpcclient.go +++ b/pkg/cmd/register/rpcclient.go @@ -61,6 +61,14 @@ func NewEnvironmentServiceClient(provider externalnode.TokenProvider, baseURL st ) } +// NewManagedSecretServiceClient creates an authenticated ConnectRPC managed-secret client. +func NewManagedSecretServiceClient(provider externalnode.TokenProvider, baseURL string) nodev1connect.ManagedSecretServiceClient { + return nodev1connect.NewManagedSecretServiceClient( + newAuthenticatedHTTPClient(provider), + baseURL, + ) +} + // toProtoNodeSpec converts the local HardwareProfile (used for collection, display, // persistence) to the generated proto NodeSpec for RPC calls. func toProtoNodeSpec(hw *HardwareProfile) *nodev1.NodeSpec { diff --git a/pkg/store/workspace.go b/pkg/store/workspace.go index 5c06ac215..6df2d3a0c 100644 --- a/pkg/store/workspace.go +++ b/pkg/store/workspace.go @@ -130,9 +130,16 @@ type LaunchableConfig struct { } type ParameterBinding struct { - Name string `json:"name"` - Value string `json:"value,omitempty"` - BrevSecretID string `json:"brevSecretId,omitempty"` + Name string `json:"name"` + Value string `json:"value,omitempty"` + BrevSecretID string `json:"brevSecretId,omitempty"` // Deprecated: retained for API compatibility. + ManagedSecret *ManagedSecretReference `json:"managedSecret,omitempty"` +} + +// ManagedSecretReference selects an immutable DevPlane managed-secret version. +type ManagedSecretReference struct { + SecretID string `json:"secretId"` + VersionID string `json:"versionId,omitempty"` } type LaunchableResponse struct { @@ -158,6 +165,7 @@ type LaunchableWorkspaceRequest struct { } type LaunchableBuildRequest struct { + VerbBuild *VerbBuild `json:"verbBuild,omitempty"` VMBuild *VMBuild `json:"vmBuild,omitempty"` CustomContainer *CustomContainer `json:"containerBuild,omitempty"` DockerCompose *DockerCompose `json:"dockerCompose,omitempty"` @@ -165,6 +173,10 @@ type LaunchableBuildRequest struct { Parameters []Parameter `json:"parameters,omitempty"` } +type VerbBuild struct { + VerbYAML string `json:"verbYaml"` +} + type Parameter struct { Name string `json:"name"` Description string `json:"description,omitempty"` From 7ef77ed2a95d5f03e024c84630085a885900ac05 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 27 Aug 2026 20:50:55 -0700 Subject: [PATCH 02/11] workspace --- pkg/cmd/launch/launch.go | 15 +++++--- pkg/cmd/launch/launch_test.go | 54 +++++++++++++++++++++++---- pkg/cmd/launch/local.go | 27 +++++++++----- pkg/cmd/launch/local_docker.go | 4 +- pkg/cmd/launch/local_dockercompose.go | 18 ++------- pkg/cmd/launch/local_vm.go | 12 +++--- pkg/cmd/launch/parameters.go | 6 ++- 7 files changed, 93 insertions(+), 43 deletions(-) diff --git a/pkg/cmd/launch/launch.go b/pkg/cmd/launch/launch.go index d401eef7b..1d4e73951 100644 --- a/pkg/cmd/launch/launch.go +++ b/pkg/cmd/launch/launch.go @@ -8,6 +8,7 @@ import ( "math/rand/v2" "net/url" "strings" + "text/tabwriter" "time" "github.com/brevdev/brev-cli/pkg/cmd/gpucreate" @@ -220,25 +221,29 @@ func explainLaunchable(out io.Writer, launchStore Store, launchableID string) er if err != nil { return err } - lines := []string{"Name: " + info.Name} - if info.Description != "" { - lines = append(lines, "Description: "+info.Description) + lines := []string{info.Name} + if description := strings.TrimSpace(info.Description); description != "" { + lines = append(lines, "", description) } definitionURL, err := launchableDefinitionURL(config.GlobalConfig.GetConsoleURL(), launchableID) if err != nil { return err } - lines = append(lines, "URL: "+definitionURL, "Build mode: "+buildModeName(info.BuildRequest)) + lines = append(lines, "", "URL: "+definitionURL, "Build mode: "+buildModeName(info.BuildRequest), "") if parameterLines := parameterDisplayLines(info.BuildRequest.Parameters); len(parameterLines) > 0 { lines = append(lines, parameterLines...) } else { lines = append(lines, "Parameters: none") } + writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) for _, line := range lines { - if _, err := fmt.Fprintln(out, line); err != nil { + if _, err := fmt.Fprintln(writer, line); err != nil { return fmt.Errorf("write launchable explanation: %w", err) } } + if err := writer.Flush(); err != nil { + return fmt.Errorf("write launchable explanation: %w", err) + } return nil } diff --git a/pkg/cmd/launch/launch_test.go b/pkg/cmd/launch/launch_test.go index 82172750e..389346709 100644 --- a/pkg/cmd/launch/launch_test.go +++ b/pkg/cmd/launch/launch_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "path/filepath" "slices" "strings" "testing" @@ -176,8 +177,8 @@ func TestLaunchableExplainDisplaysBuildModeAndParameters(t *testing.T) { BuildRequest: store.LaunchableBuildRequest{ DockerCompose: &store.DockerCompose{YamlString: "services: {}"}, Parameters: []store.Parameter{ - {Name: "HF_TOKEN", Text: &store.TextParameter{}}, - {Name: "VLLM_ARGS", Required: true, Text: &store.TextParameter{DefaultValue: "--max-model-len 16384 --gpu-memory-utilization 0.9"}}, + {Name: "HF_TOKEN", Description: "Hugging Face access token.", Text: &store.TextParameter{}}, + {Name: "VLLM_ARGS", Description: "Additional arguments passed to vLLM.", Required: true, Text: &store.TextParameter{DefaultValue: "--max-model-len 16384 --gpu-memory-utilization 0.9"}}, {Name: "MODEL", Required: true, Text: &store.TextParameter{DefaultValue: "Qwen/Qwen3-8B"}}, }, }, @@ -191,14 +192,17 @@ func TestLaunchableExplainDisplaysBuildModeAndParameters(t *testing.T) { err := cmd.Execute() require.NoError(t, err) - assert.Equal(t, `Name: vLLM Inference Server -Description: Run an OpenAI-compatible inference server. + assert.Equal(t, `vLLM Inference Server + +Run an OpenAI-compatible inference server. + URL: https://dev.brev.nvidia.com/launchable/deploy/now?launchableID=env-3IEl5O5SlUAYJ9X1GKjAIZxoSnm Build mode: Docker Compose + Parameters: - MODEL (required, default: Qwen/Qwen3-8B) - VLLM_ARGS (required, default: --max-model-len 16384 --gpu-memory-utilization 0.9) - HF_TOKEN (optional) + MODEL (required, default: Qwen/Qwen3-8B) + VLLM_ARGS (required, default: --max-model-len 16384 --gpu-memory-utilization 0.9): Additional arguments passed to vLLM. + HF_TOKEN (optional): Hugging Face access token. `, out.String()) assert.Equal(t, []string{"env-3IEl5O5SlUAYJ9X1GKjAIZxoSnm"}, launchStore.getCalls) assert.Empty(t, launchStore.created) @@ -219,9 +223,11 @@ func TestLaunchableExplainOmitsMissingDescription(t *testing.T) { err := cmd.Execute() require.NoError(t, err) - assert.Equal(t, `Name: Local setup + assert.Equal(t, `Local setup + URL: https://brev.nvidia.com/launchable/deploy/now?launchableID=env-abc Build mode: VM + Parameters: none `, out.String()) } @@ -420,9 +426,12 @@ func TestRunLocalComposeFetchesYAMLAndKeepsSecretsOutOfArguments(t *testing.T) { assert.NotContains(t, strings.Join(runner.commands[0].spec.args, " "), "registry-password") compose := runner.commands[1] + t.Cleanup(func() { _ = os.RemoveAll(compose.spec.dir) }) assert.Equal(t, fetcher.contents, []byte(compose.composeYAML)) assert.Contains(t, compose.spec.args, "--detach") assert.Contains(t, compose.spec.args, "my-launchable") + assert.Contains(t, compose.spec.args, compose.spec.dir) + assert.Contains(t, compose.spec.args, filepath.Join(compose.spec.dir, "docker-compose.yaml")) assert.NotContains(t, strings.Join(compose.spec.args, " "), "super-secret") assert.Contains(t, compose.spec.env, "API_TOKEN=super-secret") assert.Contains(t, compose.spec.env, "PUBLIC_SETTING=enabled") @@ -452,9 +461,12 @@ func TestRunLocalContainerPassesParameterNamesThroughDockerEnvironment(t *testin require.NoError(t, err) require.Len(t, runner.commands, 1) command := runner.commands[0].spec + t.Cleanup(func() { _ = os.RemoveAll(command.dir) }) assert.Contains(t, command.args, "TOKEN") assert.Contains(t, command.args, "8080:8080") assert.Contains(t, command.args, "/start") + assert.Contains(t, command.args, command.dir+":/workspace") + assert.Contains(t, command.args, "/workspace") assert.NotContains(t, strings.Join(command.args, " "), "secret-ish-direct-value") assert.Contains(t, command.env, "TOKEN=secret-ish-direct-value") } @@ -496,10 +508,36 @@ func TestRunLocalVMRequiresConfirmation(t *testing.T) { require.NoError(t, err) assert.Zero(t, confirmer.calls) require.Len(t, runner.commands, 1) + t.Cleanup(func() { _ = os.RemoveAll(runner.commands[0].spec.dir) }) assert.Equal(t, []string{"-c", "echo hello"}, runner.commands[0].spec.args) }) } +func TestPrepareLocalWorkspaceClonesRepositoryIntoRequestedDirectory(t *testing.T) { + runner := &fakeCommandRunner{paths: map[string]string{"git": "/usr/bin/git"}} + workspace, err := prepareLocalWorkspace(t.Context(), localWorkspaceArgs{ + terminal: terminal.New(), + launchableID: "env-abc", + repository: &store.LaunchableFile{ + URL: "https://github.com/example/project.git", + Path: "./source", + }, + options: localOptions{stdout: io.Discard, stderr: io.Discard}, + runner: runner, + }) + + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(workspace) }) + require.Len(t, runner.commands, 1) + assert.Equal(t, "/usr/bin/git", runner.commands[0].spec.name) + assert.Equal(t, filepath.Join(workspace, "source"), runner.commands[0].spec.dir) + assert.Equal(t, []string{ + "clone", + "https://github.com/example/project.git", + filepath.Join(workspace, "source", "project"), + }, runner.commands[0].spec.args) +} + func TestDetectBuildModeRejectsAmbiguousLaunchable(t *testing.T) { _, err := detectBuildMode(&store.LaunchableResponse{BuildRequest: store.LaunchableBuildRequest{ VMBuild: &store.VMBuild{}, diff --git a/pkg/cmd/launch/local.go b/pkg/cmd/launch/local.go index 6767214ca..8619b35d6 100644 --- a/pkg/cmd/launch/local.go +++ b/pkg/cmd/launch/local.go @@ -82,22 +82,30 @@ func runLocalLaunchable(ctx context.Context, args localLaunchArgs) error { if mode == buildModeVerb { return breverrors.NewValidationError("Verb launchables cannot yet run locally") } - if args.info.File != nil { - args.terminal.Vprint("Warning: local mode does not copy the launchable's file attachment.") - } parameterValues, err := localParameterValues(ctx, args.bindings, args.deps.secrets) if err != nil { return err } + workspace, err := prepareLocalWorkspace(ctx, localWorkspaceArgs{ + terminal: args.terminal, + launchableID: args.launchableID, + repository: args.info.File, + options: args.options, + runner: args.deps.runner, + }) + if err != nil { + return err + } switch mode { case buildModeVM: return runVM(ctx, vmLaunchArgs{ - terminal: args.terminal, - build: args.info.BuildRequest.VMBuild, - env: mergeEnvironment(os.Environ(), parameterValues), - options: args.options, - deps: args.deps, + terminal: args.terminal, + build: args.info.BuildRequest.VMBuild, + env: mergeEnvironment(os.Environ(), parameterValues), + workspace: workspace, + options: args.options, + deps: args.deps, }) case buildModeContainer: return runContainer(ctx, containerLaunchArgs{ @@ -106,6 +114,7 @@ func runLocalLaunchable(ctx context.Context, args localLaunchArgs) error { ports: args.info.BuildRequest.Ports, parameterNames: sortedKeys(parameterValues), env: mergeEnvironment(os.Environ(), parameterValues), + workspace: workspace, options: args.options, deps: args.deps, }) @@ -113,8 +122,8 @@ func runLocalLaunchable(ctx context.Context, args localLaunchArgs) error { return runCompose(ctx, composeLaunchArgs{ terminal: args.terminal, build: args.info.BuildRequest.DockerCompose, - launchableID: args.launchableID, parameterValues: parameterValues, + workspace: workspace, options: args.options, deps: args.deps, }) diff --git a/pkg/cmd/launch/local_docker.go b/pkg/cmd/launch/local_docker.go index ee371ea38..6b63b554c 100644 --- a/pkg/cmd/launch/local_docker.go +++ b/pkg/cmd/launch/local_docker.go @@ -17,6 +17,7 @@ type containerLaunchArgs struct { ports []store.LaunchablePort parameterNames []string env []string + workspace string options localOptions deps launchDeps } @@ -56,6 +57,7 @@ func runContainer(ctx context.Context, args containerLaunchArgs) error { for _, port := range portMappings(args.ports) { dockerArgs = append(dockerArgs, "--publish", port) } + dockerArgs = append(dockerArgs, "--volume", args.workspace+":/workspace", "--workdir", "/workspace") if entrypoint := strings.TrimSpace(args.build.EntryPoint); entrypoint != "" { dockerArgs = append(dockerArgs, "--entrypoint", entrypoint) } @@ -65,6 +67,7 @@ func runContainer(ctx context.Context, args containerLaunchArgs) error { if err := args.deps.runner.Run(ctx, commandSpec{ name: docker, args: dockerArgs, + dir: args.workspace, env: args.env, stdin: args.options.stdin, stdout: args.options.stdout, @@ -72,7 +75,6 @@ func runContainer(ctx context.Context, args containerLaunchArgs) error { }); err != nil { return fmt.Errorf("run launchable container: %w", err) } - args.terminal.Vprintf("Local launchable %q started successfully.\n", args.options.name) return nil } diff --git a/pkg/cmd/launch/local_dockercompose.go b/pkg/cmd/launch/local_dockercompose.go index 79940a300..9e0c34767 100644 --- a/pkg/cmd/launch/local_dockercompose.go +++ b/pkg/cmd/launch/local_dockercompose.go @@ -20,8 +20,8 @@ const maxComposeFileSize = 10 << 20 type composeLaunchArgs struct { terminal *terminal.Terminal build *store.DockerCompose - launchableID string parameterValues map[string]string + workspace string options localOptions deps launchDeps } @@ -37,16 +37,7 @@ func runCompose(ctx context.Context, args composeLaunchArgs) error { if err != nil { return err } - workingDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("get local working directory: %w", err) - } - tempDir, err := os.MkdirTemp("", "brev-launchable-"+safeLocalName(args.launchableID)+"-") - if err != nil { - return fmt.Errorf("create temporary launchable directory: %w", err) - } - defer os.RemoveAll(tempDir) //nolint:errcheck // best-effort cleanup - composePath := filepath.Join(tempDir, "docker-compose.yaml") + composePath := filepath.Join(args.workspace, "docker-compose.yaml") if err := os.WriteFile(composePath, contents, 0o600); err != nil { return fmt.Errorf("write local compose file: %w", err) } @@ -64,7 +55,7 @@ func runCompose(ctx context.Context, args composeLaunchArgs) error { dockerArgs := []string{ "compose", "--project-name", safeLocalName(args.options.name), - "--project-directory", workingDir, + "--project-directory", args.workspace, "--file", composePath, "up", } @@ -76,7 +67,7 @@ func runCompose(ctx context.Context, args composeLaunchArgs) error { if err := args.deps.runner.Run(ctx, commandSpec{ name: docker, args: dockerArgs, - dir: workingDir, + dir: args.workspace, env: env, stdin: args.options.stdin, stdout: args.options.stdout, @@ -84,7 +75,6 @@ func runCompose(ctx context.Context, args composeLaunchArgs) error { }); err != nil { return fmt.Errorf("run Docker Compose launchable: %w", err) } - args.terminal.Vprintf("Local launchable %q started successfully.\n", args.options.name) return nil } diff --git a/pkg/cmd/launch/local_vm.go b/pkg/cmd/launch/local_vm.go index 2828c67f5..046f0e32e 100644 --- a/pkg/cmd/launch/local_vm.go +++ b/pkg/cmd/launch/local_vm.go @@ -11,11 +11,12 @@ import ( ) type vmLaunchArgs struct { - terminal *terminal.Terminal - build *store.VMBuild - env []string - options localOptions - deps launchDeps + terminal *terminal.Terminal + build *store.VMBuild + env []string + workspace string + options localOptions + deps launchDeps } type confirmFunc func(label string) bool @@ -42,6 +43,7 @@ func runVM(ctx context.Context, args vmLaunchArgs) error { spec := commandSpec{ name: shell, args: []string{"-c", script.Script}, + dir: args.workspace, env: args.env, stdin: args.options.stdin, stdout: args.options.stdout, diff --git a/pkg/cmd/launch/parameters.go b/pkg/cmd/launch/parameters.go index ab2998fd2..ac1cb363d 100644 --- a/pkg/cmd/launch/parameters.go +++ b/pkg/cmd/launch/parameters.go @@ -198,7 +198,11 @@ func parameterDisplayLines(parameters []store.Parameter) []string { if parameter.Choice != nil { details += ", choices: " + strings.Join(parameter.Choice.Choices, ", ") } - lines = append(lines, fmt.Sprintf(" %s (%s)", parameter.Name, details)) + line := fmt.Sprintf(" %s\t(%s)", parameter.Name, details) + if description := strings.TrimSpace(parameter.Description); description != "" { + line += ": " + description + } + lines = append(lines, line) } return lines } From 3a425709631b2e2650ed78b8ca7ff69beaecd304 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 27 Aug 2026 20:51:02 -0700 Subject: [PATCH 03/11] workspace --- pkg/cmd/launch/local_workspace.go | 77 +++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 pkg/cmd/launch/local_workspace.go diff --git a/pkg/cmd/launch/local_workspace.go b/pkg/cmd/launch/local_workspace.go new file mode 100644 index 000000000..51b30151f --- /dev/null +++ b/pkg/cmd/launch/local_workspace.go @@ -0,0 +1,77 @@ +package launch + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/store" + "github.com/brevdev/brev-cli/pkg/terminal" +) + +type localWorkspaceArgs struct { + terminal *terminal.Terminal + launchableID string + repository *store.LaunchableFile + options localOptions + runner commandRunner +} + +func prepareLocalWorkspace(ctx context.Context, args localWorkspaceArgs) (string, error) { + workspace, err := os.MkdirTemp("", "brev-launch-"+safeLocalName(args.launchableID)+"-") + if err != nil { + return "", fmt.Errorf("create local launchable workspace: %w", err) + } + args.terminal.Vprintf("Local workspace: %s\n", workspace) + + if args.repository == nil || strings.TrimSpace(args.repository.URL) == "" { + return workspace, nil + } + directory := filepath.Join(workspace, args.repository.Path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return "", fmt.Errorf("create launchable repository directory: %w", err) + } + if err := cloneRepository(ctx, cloneRepositoryArgs{ + sourceURL: args.repository.URL, + directory: directory, + options: args.options, + runner: args.runner, + }); err != nil { + return "", err + } + return workspace, nil +} + +type cloneRepositoryArgs struct { + sourceURL string + directory string + options localOptions + runner commandRunner +} + +func cloneRepository(ctx context.Context, args cloneRepositoryArgs) error { + git, err := args.runner.LookPath("git") + if err != nil { + return breverrors.NewValidationError("repository-backed launchables require git on PATH") + } + destination := filepath.Join(args.directory, repositoryName(args.sourceURL)) + if err := args.runner.Run(ctx, commandSpec{ + name: git, + args: []string{"clone", args.sourceURL, destination}, + dir: args.directory, + stdin: args.options.stdin, + stdout: args.options.stdout, + stderr: args.options.stderr, + }); err != nil { + return fmt.Errorf("clone launchable repository: %w", err) + } + return nil +} + +func repositoryName(sourceURL string) string { + return strings.TrimSuffix(path.Base(strings.TrimRight(sourceURL, "/")), ".git") +} From 47a7f96aca563978fd49ad273504d0bdc3f31311 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 27 Aug 2026 21:17:31 -0700 Subject: [PATCH 04/11] simplify --- pkg/cmd/launch/launch.go | 49 +++++++++--------- pkg/cmd/launch/launch_test.go | 83 ++++++++++++++++++++++++++----- pkg/cmd/launch/local.go | 21 ++++---- pkg/cmd/launch/local_docker.go | 11 +++- pkg/cmd/launch/local_vm.go | 4 +- pkg/cmd/launch/local_workspace.go | 36 ++++---------- 6 files changed, 126 insertions(+), 78 deletions(-) diff --git a/pkg/cmd/launch/launch.go b/pkg/cmd/launch/launch.go index 1d4e73951..1b9c8c611 100644 --- a/pkg/cmd/launch/launch.go +++ b/pkg/cmd/launch/launch.go @@ -123,10 +123,13 @@ func runLaunchCommand(ctx context.Context, args launchCommandArgs) error { if err != nil { return err } + + // Explain the launchable without launching it if args.options.explain { return explainLaunchable(args.cmd.OutOrStdout(), args.store, launchableID) } - info, err := fetchLaunchable(args.store, launchableID) + + info, err := fetchLaunchableMetadata(args.store, launchableID) if err != nil { return err } @@ -155,11 +158,16 @@ func runLaunchCommand(ctx context.Context, args launchCommandArgs) error { return err } if args.options.local { + startupScript, err := fetchStartupScript(args.store, launchableID, info) + if err != nil { + return err + } return runLocalLaunchable(ctx, localLaunchArgs{ - terminal: args.terminal, - launchableID: launchableID, - info: info, - bindings: bindings, + terminal: args.terminal, + launchableID: launchableID, + info: info, + startupScript: startupScript, + bindings: bindings, options: localOptions{ name: name, detached: args.options.detached, @@ -300,7 +308,8 @@ func runRemoteLaunch(args remoteLaunchArgs) error { func remoteInstanceTypes(flagValue string, recommended string) ([]gpucreate.InstanceSpec, error) { if strings.TrimSpace(flagValue) == "" { - if strings.TrimSpace(recommended) == "" { + recommended = strings.TrimSpace(recommended) + if recommended == "" { return nil, breverrors.NewValidationError("launchable has no instance type configured; provide --type") } return []gpucreate.InstanceSpec{{Type: recommended}}, nil @@ -317,26 +326,22 @@ func remoteInstanceTypes(flagValue string, recommended string) ([]gpucreate.Inst return result, nil } -func fetchLaunchable(launchStore Store, launchableID string) (*store.LaunchableResponse, error) { - info, err := fetchLaunchableMetadata(launchStore, launchableID) - if err != nil { - return nil, err - } +func fetchStartupScript(launchStore Store, launchableID string, info *store.LaunchableResponse) (*store.LifeCycleScriptAttr, error) { if info.BuildRequest.VMBuild == nil || info.BuildRequest.VMBuild.LifeCycleScriptAttr == nil { - return info, nil + return nil, nil } - attr := info.BuildRequest.VMBuild.LifeCycleScriptAttr - if attr.ID == "" { - return info, nil + startupScript := *info.BuildRequest.VMBuild.LifeCycleScriptAttr + if startupScript.ID == "" { + return &startupScript, nil } - script, err := launchStore.GetLaunchableLifeCycleScript(launchableID, attr.ID) + script, err := launchStore.GetLaunchableLifeCycleScript(launchableID, startupScript.ID) if err != nil { - return nil, fmt.Errorf("fetch startup script %q for launchable %q: %w", attr.ID, launchableID, err) + return nil, fmt.Errorf("fetch startup script %q for launchable %q: %w", startupScript.ID, launchableID, err) } if script != nil && script.Attrs != nil { - attr.Script = script.Attrs.Script + startupScript.Script = script.Attrs.Script } - return info, nil + return &startupScript, nil } func fetchLaunchableMetadata(launchStore Store, launchableID string) (*store.LaunchableResponse, error) { @@ -360,14 +365,12 @@ func displayLaunchable(t *terminal.Terminal, info *store.LaunchableResponse, loc t.Vprintf("Description: %s\n", info.Description) } t.Vprintf("Build mode: %s\n", buildModeName(info.BuildRequest)) - for _, line := range parameterDisplayLines(info.BuildRequest.Parameters) { - t.Vprintf("%s\n", line) - } t.Vprint("") } func validateLaunchableID(id string) (string, error) { - if strings.TrimSpace(id) == "" || strings.ContainsAny(id, "/?&#") { + id = strings.TrimSpace(id) + if id == "" || strings.ContainsAny(id, "/?&#") { return "", fmt.Errorf("invalid launchable ID %q", id) } return id, nil diff --git a/pkg/cmd/launch/launch_test.go b/pkg/cmd/launch/launch_test.go index 389346709..19c0c4b94 100644 --- a/pkg/cmd/launch/launch_test.go +++ b/pkg/cmd/launch/launch_test.go @@ -101,9 +101,11 @@ type fakeComposeFetcher struct { } type fakeLaunchStore struct { - created []*store.CreateWorkspacesOptions - launchable *store.LaunchableResponse - getCalls []string + created []*store.CreateWorkspacesOptions + launchable *store.LaunchableResponse + lifecycle *store.LifeCycleScriptResponse + getCalls []string + lifecycleCalls [][2]string } func (f *fakeLaunchStore) GetAccessToken() (string, error) { return "token", nil } @@ -152,8 +154,12 @@ func (f *fakeLaunchStore) GetLaunchable(id string) (*store.LaunchableResponse, e return f.launchable, nil } -func (f *fakeLaunchStore) GetLaunchableLifeCycleScript(string, string) (*store.LifeCycleScriptResponse, error) { - return nil, errors.New("not used") +func (f *fakeLaunchStore) GetLaunchableLifeCycleScript(launchableID string, scriptID string) (*store.LifeCycleScriptResponse, error) { + f.lifecycleCalls = append(f.lifecycleCalls, [2]string{launchableID, scriptID}) + if f.lifecycle == nil { + return nil, errors.New("not used") + } + return f.lifecycle, nil } func (f *fakeLaunchStore) RedeemCouponCode(string, string) (*store.RedeemCouponCodeResponse, error) { @@ -381,6 +387,45 @@ func TestRemoteLaunchPassesManagedSecretBindingToCreate(t *testing.T) { assert.Equal(t, bindings, launchStore.created[0].LaunchableConfig.ParameterBindings) } +func TestRemoteLaunchDoesNotFetchStartupScript(t *testing.T) { + launchStore := &fakeLaunchStore{launchable: &store.LaunchableResponse{ + Name: "remote-launch", + CreateWorkspaceRequest: store.LaunchableWorkspaceRequest{ + InstanceType: "gpu.test", + CloudCredID: "cloud-cred-1", + }, + BuildRequest: store.LaunchableBuildRequest{VMBuild: &store.VMBuild{ + LifeCycleScriptAttr: &store.LifeCycleScriptAttr{ID: "script-1"}, + }}, + }} + cmd := newCmdLaunch(terminal.New(), launchStore, launchDeps{secrets: &fakeSecretResolver{}}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"env-abc", "--detached"}) + + err := cmd.Execute() + + require.NoError(t, err) + require.Len(t, launchStore.created, 1) + assert.Empty(t, launchStore.lifecycleCalls) +} + +func TestFetchStartupScriptDoesNotMutateLaunchable(t *testing.T) { + launchStore := &fakeLaunchStore{lifecycle: &store.LifeCycleScriptResponse{ + Attrs: &store.LifeCycleScriptAttr{Script: "echo hello"}, + }} + info := &store.LaunchableResponse{BuildRequest: store.LaunchableBuildRequest{ + VMBuild: &store.VMBuild{LifeCycleScriptAttr: &store.LifeCycleScriptAttr{ID: "script-1", Script: "original"}}, + }} + + startupScript, err := fetchStartupScript(launchStore, "env-abc", info) + + require.NoError(t, err) + assert.Equal(t, "echo hello", startupScript.Script) + assert.Equal(t, "original", info.BuildRequest.VMBuild.LifeCycleScriptAttr.Script) + assert.Equal(t, [][2]string{{"env-abc", "script-1"}}, launchStore.lifecycleCalls) +} + func TestRunLocalComposeFetchesYAMLAndKeepsSecretsOutOfArguments(t *testing.T) { //nolint:funlen // end-to-end local compose behavior runner := &fakeCommandRunner{paths: map[string]string{"docker": "/usr/bin/docker"}} fetcher := &fakeComposeFetcher{contents: []byte("services:\n app:\n image: example/app\n")} @@ -440,7 +485,7 @@ func TestRunLocalComposeFetchesYAMLAndKeepsSecretsOutOfArguments(t *testing.T) { func TestRunLocalContainerPassesParameterNamesThroughDockerEnvironment(t *testing.T) { runner := &fakeCommandRunner{paths: map[string]string{"docker": "/usr/bin/docker"}} info := &store.LaunchableResponse{BuildRequest: store.LaunchableBuildRequest{ - CustomContainer: &store.CustomContainer{ContainerURL: "example/app:latest", EntryPoint: "/start"}, + CustomContainer: &store.CustomContainer{ContainerURL: "example/app:latest", EntryPoint: "python -m server"}, Ports: []store.LaunchablePort{{Port: "8080"}}, }} bindings := []store.ParameterBinding{{Name: "TOKEN", Value: "secret-ish-direct-value"}} @@ -464,7 +509,9 @@ func TestRunLocalContainerPassesParameterNamesThroughDockerEnvironment(t *testin t.Cleanup(func() { _ = os.RemoveAll(command.dir) }) assert.Contains(t, command.args, "TOKEN") assert.Contains(t, command.args, "8080:8080") - assert.Contains(t, command.args, "/start") + entrypointIndex := slices.Index(command.args, "--entrypoint") + require.NotEqual(t, -1, entrypointIndex) + assert.Equal(t, []string{"--entrypoint", "python", "example/app:latest", "-m", "server"}, command.args[entrypointIndex:]) assert.Contains(t, command.args, command.dir+":/workspace") assert.Contains(t, command.args, "/workspace") assert.NotContains(t, strings.Join(command.args, " "), "secret-ish-direct-value") @@ -480,9 +527,10 @@ func TestRunLocalVMRequiresConfirmation(t *testing.T) { runner := &fakeCommandRunner{paths: map[string]string{"bash": "/bin/bash"}} confirmer := &fakeConfirmer{result: false} err := runLocalLaunchable(t.Context(), localLaunchArgs{ - terminal: terminal.New(), - launchableID: "env-abc", - info: info, + terminal: terminal.New(), + launchableID: "env-abc", + info: info, + startupScript: info.BuildRequest.VMBuild.LifeCycleScriptAttr, options: localOptions{ name: "vm-test", stdout: io.Discard, stderr: io.Discard, }, @@ -497,9 +545,10 @@ func TestRunLocalVMRequiresConfirmation(t *testing.T) { runner := &fakeCommandRunner{paths: map[string]string{"bash": "/bin/bash"}} confirmer := &fakeConfirmer{result: false} err := runLocalLaunchable(t.Context(), localLaunchArgs{ - terminal: terminal.New(), - launchableID: "env-abc", - info: info, + terminal: terminal.New(), + launchableID: "env-abc", + info: info, + startupScript: info.BuildRequest.VMBuild.LifeCycleScriptAttr, options: localOptions{ name: "vm-test", approve: true, stdout: io.Discard, stderr: io.Discard, }, @@ -551,6 +600,10 @@ func TestParseLaunchableID(t *testing.T) { require.NoError(t, err) assert.Equal(t, "env-abc", id) + id, err = parseLaunchableID(" env-abc ") + require.NoError(t, err) + assert.Equal(t, "env-abc", id) + _, err = parseLaunchableID("https://console.brev.dev/launchable/deploy?launchableID=env-abc/../../other") assert.Error(t, err) } @@ -561,6 +614,10 @@ func TestRemoteInstanceTypes(t *testing.T) { assert.Equal(t, "a100.large", types[0].Type) assert.Equal(t, "h100.large", types[1].Type) + types, err = remoteInstanceTypes("", " gpu.test ") + require.NoError(t, err) + assert.Equal(t, "gpu.test", types[0].Type) + _, err = remoteInstanceTypes("", "") assert.ErrorContains(t, err, "provide --type") } diff --git a/pkg/cmd/launch/local.go b/pkg/cmd/launch/local.go index 8619b35d6..92a287de2 100644 --- a/pkg/cmd/launch/local.go +++ b/pkg/cmd/launch/local.go @@ -24,12 +24,13 @@ type localOptions struct { } type localLaunchArgs struct { - terminal *terminal.Terminal - launchableID string - info *store.LaunchableResponse - bindings []store.ParameterBinding - options localOptions - deps launchDeps + terminal *terminal.Terminal + launchableID string + info *store.LaunchableResponse + startupScript *store.LifeCycleScriptAttr + bindings []store.ParameterBinding + options localOptions + deps launchDeps } type launchDeps struct { @@ -101,7 +102,7 @@ func runLocalLaunchable(ctx context.Context, args localLaunchArgs) error { case buildModeVM: return runVM(ctx, vmLaunchArgs{ terminal: args.terminal, - build: args.info.BuildRequest.VMBuild, + script: args.startupScript, env: mergeEnvironment(os.Environ(), parameterValues), workspace: workspace, options: args.options, @@ -158,11 +159,7 @@ func detectBuildMode(info *store.LaunchableResponse) (localBuildMode, error) { return modes[0], nil } -func localParameterValues( - ctx context.Context, - bindings []store.ParameterBinding, - resolver managedSecretResolver, -) (map[string]string, error) { +func localParameterValues(ctx context.Context, bindings []store.ParameterBinding, resolver managedSecretResolver) (map[string]string, error) { values := make(map[string]string, len(bindings)) for _, binding := range bindings { if binding.ManagedSecret == nil { diff --git a/pkg/cmd/launch/local_docker.go b/pkg/cmd/launch/local_docker.go index 6b63b554c..a8a5c63e7 100644 --- a/pkg/cmd/launch/local_docker.go +++ b/pkg/cmd/launch/local_docker.go @@ -33,10 +33,12 @@ func runContainer(ctx context.Context, args containerLaunchArgs) error { if strings.TrimSpace(args.build.ContainerURL) == "" { return breverrors.NewValidationError("container launchable has no image configured") } + docker, err := localDocker(args.deps.runner) if err != nil { return err } + if err := dockerLogin(ctx, dockerLoginArgs{ docker: docker, registry: args.build.Registry, @@ -45,6 +47,7 @@ func runContainer(ctx context.Context, args containerLaunchArgs) error { }); err != nil { return err } + dockerArgs := []string{"run", "--name", safeLocalName(args.options.name)} if args.options.detached { dockerArgs = append(dockerArgs, "--detach") @@ -58,10 +61,14 @@ func runContainer(ctx context.Context, args containerLaunchArgs) error { dockerArgs = append(dockerArgs, "--publish", port) } dockerArgs = append(dockerArgs, "--volume", args.workspace+":/workspace", "--workdir", "/workspace") - if entrypoint := strings.TrimSpace(args.build.EntryPoint); entrypoint != "" { - dockerArgs = append(dockerArgs, "--entrypoint", entrypoint) + entrypoint := strings.Fields(args.build.EntryPoint) + if len(entrypoint) > 0 { + dockerArgs = append(dockerArgs, "--entrypoint", entrypoint[0]) } dockerArgs = append(dockerArgs, args.build.ContainerURL) + if len(entrypoint) > 1 { + dockerArgs = append(dockerArgs, entrypoint[1:]...) + } args.terminal.Vprintf("Starting %q with Docker.\n", args.options.name) if err := args.deps.runner.Run(ctx, commandSpec{ diff --git a/pkg/cmd/launch/local_vm.go b/pkg/cmd/launch/local_vm.go index 046f0e32e..c60345ce4 100644 --- a/pkg/cmd/launch/local_vm.go +++ b/pkg/cmd/launch/local_vm.go @@ -12,7 +12,7 @@ import ( type vmLaunchArgs struct { terminal *terminal.Terminal - build *store.VMBuild + script *store.LifeCycleScriptAttr env []string workspace string options localOptions @@ -22,7 +22,7 @@ type vmLaunchArgs struct { type confirmFunc func(label string) bool func runVM(ctx context.Context, args vmLaunchArgs) error { - script := args.build.LifeCycleScriptAttr + script := args.script if script == nil || strings.TrimSpace(script.Script) == "" { args.terminal.Vprint("This VM launchable has no startup script; there is nothing to run locally.") return nil diff --git a/pkg/cmd/launch/local_workspace.go b/pkg/cmd/launch/local_workspace.go index 51b30151f..00bc0bc8e 100644 --- a/pkg/cmd/launch/local_workspace.go +++ b/pkg/cmd/launch/local_workspace.go @@ -28,48 +28,32 @@ func prepareLocalWorkspace(ctx context.Context, args localWorkspaceArgs) (string } args.terminal.Vprintf("Local workspace: %s\n", workspace) - if args.repository == nil || strings.TrimSpace(args.repository.URL) == "" { + // If the launchable has no repository, return the workspace immediately + if args.repository == nil { return workspace, nil } + + // If the launchable has a repository, clone it into the workspace directory := filepath.Join(workspace, args.repository.Path) if err := os.MkdirAll(directory, 0o700); err != nil { return "", fmt.Errorf("create launchable repository directory: %w", err) } - if err := cloneRepository(ctx, cloneRepositoryArgs{ - sourceURL: args.repository.URL, - directory: directory, - options: args.options, - runner: args.runner, - }); err != nil { - return "", err - } - return workspace, nil -} - -type cloneRepositoryArgs struct { - sourceURL string - directory string - options localOptions - runner commandRunner -} - -func cloneRepository(ctx context.Context, args cloneRepositoryArgs) error { git, err := args.runner.LookPath("git") if err != nil { - return breverrors.NewValidationError("repository-backed launchables require git on PATH") + return "", breverrors.NewValidationError("repository-backed launchables require git on PATH") } - destination := filepath.Join(args.directory, repositoryName(args.sourceURL)) + destination := filepath.Join(directory, repositoryName(args.repository.URL)) if err := args.runner.Run(ctx, commandSpec{ name: git, - args: []string{"clone", args.sourceURL, destination}, - dir: args.directory, + args: []string{"clone", args.repository.URL, destination}, + dir: directory, stdin: args.options.stdin, stdout: args.options.stdout, stderr: args.options.stderr, }); err != nil { - return fmt.Errorf("clone launchable repository: %w", err) + return "", fmt.Errorf("clone launchable repository: %w", err) } - return nil + return workspace, nil } func repositoryName(sourceURL string) string { From 8a096a4fe8569a53711714cbe982d641eecf782b Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 27 Aug 2026 21:23:25 -0700 Subject: [PATCH 05/11] columns for parameter descriptions --- pkg/cmd/launch/launch_test.go | 4 ++-- pkg/cmd/launch/parameters.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/launch/launch_test.go b/pkg/cmd/launch/launch_test.go index 19c0c4b94..a23d62e0c 100644 --- a/pkg/cmd/launch/launch_test.go +++ b/pkg/cmd/launch/launch_test.go @@ -207,8 +207,8 @@ Build mode: Docker Compose Parameters: MODEL (required, default: Qwen/Qwen3-8B) - VLLM_ARGS (required, default: --max-model-len 16384 --gpu-memory-utilization 0.9): Additional arguments passed to vLLM. - HF_TOKEN (optional): Hugging Face access token. + VLLM_ARGS (required, default: --max-model-len 16384 --gpu-memory-utilization 0.9) Additional arguments passed to vLLM. + HF_TOKEN (optional) Hugging Face access token. `, out.String()) assert.Equal(t, []string{"env-3IEl5O5SlUAYJ9X1GKjAIZxoSnm"}, launchStore.getCalls) assert.Empty(t, launchStore.created) diff --git a/pkg/cmd/launch/parameters.go b/pkg/cmd/launch/parameters.go index ac1cb363d..52c6d32b7 100644 --- a/pkg/cmd/launch/parameters.go +++ b/pkg/cmd/launch/parameters.go @@ -200,7 +200,7 @@ func parameterDisplayLines(parameters []store.Parameter) []string { } line := fmt.Sprintf(" %s\t(%s)", parameter.Name, details) if description := strings.TrimSpace(parameter.Description); description != "" { - line += ": " + description + line += "\t" + description } lines = append(lines, line) } From 54e8dd96fc29bd6c1ec253909478caf442fab2b8 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 27 Aug 2026 21:35:07 -0700 Subject: [PATCH 06/11] simplify description --- pkg/cmd/launch/launch.go | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/launch/launch.go b/pkg/cmd/launch/launch.go index 1b9c8c611..bdbcce2a7 100644 --- a/pkg/cmd/launch/launch.go +++ b/pkg/cmd/launch/launch.go @@ -68,12 +68,10 @@ func NewCmdLaunch(t *terminal.Terminal, launchStore Store) *cobra.Command { func newCmdLaunch(t *terminal.Terminal, launchStore Store, deps launchDeps) *cobra.Command { opts := commandOptions{} cmd := &cobra.Command{ - Annotations: map[string]string{"workspace": ""}, - Use: "launch ", - Short: "Launch a launchable locally or on a Brev instance", - Long: `Launch a launchable on its recommended remote Brev instance, or use ---local to run its build on this machine. Local mode supports VM startup -scripts, custom containers, and Docker Compose builds.`, + Annotations: map[string]string{"workspace": ""}, + Use: "launch ", + Short: "Launch a launchable locally or on a Brev instance", + Long: `Launch a launchable on its recommended remote Brev instance, or use --local to run its build on this machine. Local mode supports VM startup scripts, custom containers, and Docker Compose builds.`, DisableFlagsInUseLine: true, Args: cobra.ExactArgs(1), Example: ` # Provision the launchable on a remote Brev instance @@ -133,7 +131,7 @@ func runLaunchCommand(ctx context.Context, args launchCommandArgs) error { if err != nil { return err } - displayLaunchable(args.terminal, info, args.options.local) + displayLaunchable(args.terminal, info) values, err := parseParameterValues(args.options.parameters) if err != nil { @@ -355,15 +353,13 @@ func fetchLaunchableMetadata(launchStore Store, launchableID string) (*store.Lau return info, nil } -func displayLaunchable(t *terminal.Terminal, info *store.LaunchableResponse, local bool) { - target := "a remote Brev instance" - if local { - target = "this machine" - } - t.Vprintf("Launching %q on %s.\n", info.Name, target) - if info.Description != "" { - t.Vprintf("Description: %s\n", info.Description) +func displayLaunchable(t *terminal.Terminal, info *store.LaunchableResponse) { + t.Vprint(info.Name) + if description := strings.TrimSpace(info.Description); description != "" { + t.Vprint("") + t.Vprint(description) } + t.Vprint("") t.Vprintf("Build mode: %s\n", buildModeName(info.BuildRequest)) t.Vprint("") } From 17b868bca6a6feb592f67a8b33d04bb5118395b9 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 27 Aug 2026 21:41:31 -0700 Subject: [PATCH 07/11] accept only launchable IDs in launch command --- pkg/cmd/launch/launch.go | 28 +++++----------------------- pkg/cmd/launch/launch_test.go | 14 ++++++++------ 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/pkg/cmd/launch/launch.go b/pkg/cmd/launch/launch.go index bdbcce2a7..b1e6b46e9 100644 --- a/pkg/cmd/launch/launch.go +++ b/pkg/cmd/launch/launch.go @@ -69,7 +69,7 @@ func newCmdLaunch(t *terminal.Terminal, launchStore Store, deps launchDeps) *cob opts := commandOptions{} cmd := &cobra.Command{ Annotations: map[string]string{"workspace": ""}, - Use: "launch ", + Use: "launch ", Short: "Launch a launchable locally or on a Brev instance", Long: `Launch a launchable on its recommended remote Brev instance, or use --local to run its build on this machine. Local mode supports VM startup scripts, custom containers, and Docker Compose builds.`, DisableFlagsInUseLine: true, @@ -205,21 +205,11 @@ func validateCommandOptions(cmd *cobra.Command, opts commandOptions) error { } func parseLaunchableID(input string) (string, error) { - if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { - parsed, err := url.Parse(input) - if err != nil { - return "", fmt.Errorf("invalid launchable URL: %w", err) - } - if id := parsed.Query().Get("launchableID"); id != "" { - return validateLaunchableID(id) - } - parts := strings.Split(strings.TrimRight(parsed.Path, "/"), "/") - if len(parts) > 0 && strings.HasPrefix(parts[len(parts)-1], "env-") { - return validateLaunchableID(parts[len(parts)-1]) - } - return "", fmt.Errorf("could not extract a launchable ID from URL %q", input) + id := strings.TrimSpace(input) + if len(id) <= len("env-") || !strings.HasPrefix(id, "env-") || strings.ContainsAny(id, "/?&# \t\r\n") { + return "", fmt.Errorf("invalid launchable ID %q: expected env-", input) } - return validateLaunchableID(input) + return id, nil } func explainLaunchable(out io.Writer, launchStore Store, launchableID string) error { @@ -364,14 +354,6 @@ func displayLaunchable(t *terminal.Terminal, info *store.LaunchableResponse) { t.Vprint("") } -func validateLaunchableID(id string) (string, error) { - id = strings.TrimSpace(id) - if id == "" || strings.ContainsAny(id, "/?&#") { - return "", fmt.Errorf("invalid launchable ID %q", id) - } - return id, nil -} - func buildModeName(build store.LaunchableBuildRequest) string { switch { case build.VerbBuild != nil: diff --git a/pkg/cmd/launch/launch_test.go b/pkg/cmd/launch/launch_test.go index a23d62e0c..05d7a5bc1 100644 --- a/pkg/cmd/launch/launch_test.go +++ b/pkg/cmd/launch/launch_test.go @@ -255,7 +255,7 @@ func TestLaunchHelpWithIDRemainsCommandHelp(t *testing.T) { require.NoError(t, err) assert.Contains(t, out.String(), "Usage:") - assert.Contains(t, out.String(), "launch ") + assert.Contains(t, out.String(), "launch ") assert.Empty(t, launchStore.getCalls) } @@ -596,15 +596,17 @@ func TestDetectBuildModeRejectsAmbiguousLaunchable(t *testing.T) { } func TestParseLaunchableID(t *testing.T) { - id, err := parseLaunchableID("https://console.brev.dev/launchable/deploy?launchableID=env-abc") + id, err := parseLaunchableID(" env-abc ") require.NoError(t, err) assert.Equal(t, "env-abc", id) - id, err = parseLaunchableID(" env-abc ") - require.NoError(t, err) - assert.Equal(t, "env-abc", id) + _, err = parseLaunchableID("https://console.brev.dev/launchable/deploy?launchableID=env-abc") + assert.ErrorContains(t, err, "expected env-") + + _, err = parseLaunchableID("launchable-abc") + assert.ErrorContains(t, err, "expected env-") - _, err = parseLaunchableID("https://console.brev.dev/launchable/deploy?launchableID=env-abc/../../other") + _, err = parseLaunchableID("env-") assert.Error(t, err) } From a1326ff646ba716aa13c744ce9eb1c0768128dd8 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 27 Aug 2026 21:47:15 -0700 Subject: [PATCH 08/11] simplify --- pkg/cmd/launch/local_dockercompose.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/launch/local_dockercompose.go b/pkg/cmd/launch/local_dockercompose.go index 9e0c34767..e8a724c81 100644 --- a/pkg/cmd/launch/local_dockercompose.go +++ b/pkg/cmd/launch/local_dockercompose.go @@ -15,7 +15,7 @@ import ( "github.com/brevdev/brev-cli/pkg/terminal" ) -const maxComposeFileSize = 10 << 20 +const maxComposeFileSize = 10 * 1024 * 1024 // 10 MiB type composeLaunchArgs struct { terminal *terminal.Terminal @@ -111,7 +111,7 @@ func fetchComposeFile(ctx context.Context, sourceURL string) ([]byte, error) { return nil, fmt.Errorf("read compose file: %w", err) } if len(contents) > maxComposeFileSize { - return nil, fmt.Errorf("compose file exceeds %d MiB", maxComposeFileSize>>20) + return nil, fmt.Errorf("compose file exceeds 10 MiB") } return contents, nil } From 44c015ae77f1734c74d26209cb72e331ff3377c7 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 31 Aug 2026 12:34:44 -0700 Subject: [PATCH 09/11] cleanup --- pkg/cmd/launch/launch_test.go | 27 +++++++++++++++- pkg/cmd/launch/local.go | 2 +- pkg/cmd/launch/local_workspace.go | 54 ++++++++++++++++++++++++++----- pkg/cmd/launch/secrets.go | 32 ++++-------------- pkg/managedsecret/client.go | 45 ++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 35 deletions(-) create mode 100644 pkg/managedsecret/client.go diff --git a/pkg/cmd/launch/launch_test.go b/pkg/cmd/launch/launch_test.go index 05d7a5bc1..d7ff2c4e0 100644 --- a/pkg/cmd/launch/launch_test.go +++ b/pkg/cmd/launch/launch_test.go @@ -567,7 +567,7 @@ func TestPrepareLocalWorkspaceClonesRepositoryIntoRequestedDirectory(t *testing. workspace, err := prepareLocalWorkspace(t.Context(), localWorkspaceArgs{ terminal: terminal.New(), launchableID: "env-abc", - repository: &store.LaunchableFile{ + file: &store.LaunchableFile{ URL: "https://github.com/example/project.git", Path: "./source", }, @@ -587,6 +587,31 @@ func TestPrepareLocalWorkspaceClonesRepositoryIntoRequestedDirectory(t *testing. }, runner.commands[0].spec.args) } +func TestPrepareLocalWorkspaceDownloadsRawNotebook(t *testing.T) { + runner := &fakeCommandRunner{paths: map[string]string{"curl": "/usr/bin/curl"}} + fileURL := "https://github.com/brevdev/notebooks/raw/main/oobabooga.ipynb" + workspace, err := prepareLocalWorkspace(t.Context(), localWorkspaceArgs{ + terminal: terminal.New(), + launchableID: "env-abc", + file: &store.LaunchableFile{URL: fileURL}, + options: localOptions{stdout: io.Discard, stderr: io.Discard}, + runner: runner, + }) + + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(workspace) }) + require.Len(t, runner.commands, 1) + assert.Equal(t, "/usr/bin/curl", runner.commands[0].spec.name) + assert.Equal(t, workspace, runner.commands[0].spec.dir) + assert.Equal(t, []string{ + "--fail", + "--location", + "--output", + filepath.Join(workspace, "oobabooga.ipynb"), + fileURL, + }, runner.commands[0].spec.args) +} + func TestDetectBuildModeRejectsAmbiguousLaunchable(t *testing.T) { _, err := detectBuildMode(&store.LaunchableResponse{BuildRequest: store.LaunchableBuildRequest{ VMBuild: &store.VMBuild{}, diff --git a/pkg/cmd/launch/local.go b/pkg/cmd/launch/local.go index 92a287de2..0cbd2b5e2 100644 --- a/pkg/cmd/launch/local.go +++ b/pkg/cmd/launch/local.go @@ -90,7 +90,7 @@ func runLocalLaunchable(ctx context.Context, args localLaunchArgs) error { workspace, err := prepareLocalWorkspace(ctx, localWorkspaceArgs{ terminal: args.terminal, launchableID: args.launchableID, - repository: args.info.File, + file: args.info.File, options: args.options, runner: args.deps.runner, }) diff --git a/pkg/cmd/launch/local_workspace.go b/pkg/cmd/launch/local_workspace.go index 00bc0bc8e..02e8ef9d8 100644 --- a/pkg/cmd/launch/local_workspace.go +++ b/pkg/cmd/launch/local_workspace.go @@ -3,6 +3,7 @@ package launch import ( "context" "fmt" + "net/url" "os" "path" "path/filepath" @@ -16,7 +17,7 @@ import ( type localWorkspaceArgs struct { terminal *terminal.Terminal launchableID string - repository *store.LaunchableFile + file *store.LaunchableFile options localOptions runner commandRunner } @@ -28,24 +29,39 @@ func prepareLocalWorkspace(ctx context.Context, args localWorkspaceArgs) (string } args.terminal.Vprintf("Local workspace: %s\n", workspace) - // If the launchable has no repository, return the workspace immediately - if args.repository == nil { + if args.file == nil { return workspace, nil } - // If the launchable has a repository, clone it into the workspace - directory := filepath.Join(workspace, args.repository.Path) + directory := filepath.Join(workspace, args.file.Path) if err := os.MkdirAll(directory, 0o700); err != nil { - return "", fmt.Errorf("create launchable repository directory: %w", err) + return "", fmt.Errorf("create launchable file directory: %w", err) + } + if fileName, ok := rawFileName(args.file.URL); ok { + curl, err := args.runner.LookPath("curl") + if err != nil { + return "", breverrors.NewValidationError("file-backed launchables require curl on PATH") + } + if err := args.runner.Run(ctx, commandSpec{ + name: curl, + args: []string{"--fail", "--location", "--output", filepath.Join(directory, fileName), args.file.URL}, + dir: directory, + stdin: args.options.stdin, + stdout: args.options.stdout, + stderr: args.options.stderr, + }); err != nil { + return "", fmt.Errorf("download launchable file: %w", err) + } + return workspace, nil } git, err := args.runner.LookPath("git") if err != nil { return "", breverrors.NewValidationError("repository-backed launchables require git on PATH") } - destination := filepath.Join(directory, repositoryName(args.repository.URL)) + destination := filepath.Join(directory, repositoryName(args.file.URL)) if err := args.runner.Run(ctx, commandSpec{ name: git, - args: []string{"clone", args.repository.URL, destination}, + args: []string{"clone", args.file.URL, destination}, dir: directory, stdin: args.options.stdin, stdout: args.options.stdout, @@ -56,6 +72,28 @@ func prepareLocalWorkspace(ctx context.Context, args localWorkspaceArgs) (string return workspace, nil } +func rawFileName(sourceURL string) (string, bool) { + parsed, err := url.Parse(sourceURL) + if err != nil { + return "", false + } + escapedPath := parsed.EscapedPath() + host := strings.TrimPrefix(strings.ToLower(parsed.Hostname()), "www.") + isRawFile := host == "gitlab.com" && strings.Contains(escapedPath, "/-/raw/") + if host == "github.com" { + parts := strings.Split(strings.Trim(escapedPath, "/"), "/") + isRawFile = len(parts) >= 5 && parts[2] == "raw" + } + if !isRawFile { + return "", false + } + fileName := path.Base(escapedPath) + if fileName == "." || fileName == "/" { + return "", false + } + return fileName, true +} + func repositoryName(sourceURL string) string { return strings.TrimSuffix(path.Base(strings.TrimRight(sourceURL, "/")), ".git") } diff --git a/pkg/cmd/launch/secrets.go b/pkg/cmd/launch/secrets.go index 8262dba5a..accd9daa6 100644 --- a/pkg/cmd/launch/secrets.go +++ b/pkg/cmd/launch/secrets.go @@ -2,14 +2,10 @@ package launch import ( "context" - "fmt" - - devplanev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" - devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" - "connectrpc.com/connect" "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/config" + "github.com/brevdev/brev-cli/pkg/managedsecret" "github.com/brevdev/brev-cli/pkg/store" ) @@ -19,35 +15,21 @@ type managedSecretResolver interface { } type devplaneManagedSecretResolver struct { - client devplanev1connect.ManagedSecretServiceClient + client managedsecret.Client } func newDevplaneManagedSecretResolver(provider Store) managedSecretResolver { return devplaneManagedSecretResolver{ - client: register.NewManagedSecretServiceClient(provider, config.GlobalConfig.GetBrevPublicAPIURL()), + client: managedsecret.NewClient( + register.NewManagedSecretServiceClient(provider, config.GlobalConfig.GetBrevPublicAPIURL()), + ), } } func (r devplaneManagedSecretResolver) LatestVersion(ctx context.Context, secretID string) (string, error) { - response, err := r.client.GetSecret(ctx, connect.NewRequest(&devplanev1.ManagedSecretServiceGetSecretRequest{ - SecretId: secretID, - })) - if err != nil { - return "", fmt.Errorf("get managed secret metadata: %w", err) - } - if response.Msg.GetSecret() == nil || response.Msg.GetSecret().GetLatestVersionId() == "" { - return "", fmt.Errorf("managed secret has no latest version") - } - return response.Msg.GetSecret().GetLatestVersionId(), nil + return r.client.LatestVersion(ctx, secretID) } func (r devplaneManagedSecretResolver) Value(ctx context.Context, ref store.ManagedSecretReference) (string, error) { - response, err := r.client.GetSecretValue(ctx, connect.NewRequest(&devplanev1.ManagedSecretServiceGetSecretValueRequest{ - SecretId: ref.SecretID, - VersionId: ref.VersionID, - })) - if err != nil { - return "", fmt.Errorf("get managed secret value: %w", err) - } - return response.Msg.GetValue(), nil + return r.client.Value(ctx, ref.SecretID, ref.VersionID) } diff --git a/pkg/managedsecret/client.go b/pkg/managedsecret/client.go new file mode 100644 index 000000000..0fc108df2 --- /dev/null +++ b/pkg/managedsecret/client.go @@ -0,0 +1,45 @@ +// Package managedsecret provides an authenticated client for DevPlane managed secrets. +package managedsecret + +import ( + "context" + "fmt" + + devplanev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" +) + +// Client exposes the DevPlane managed-secret API and common read operations. +type Client struct { + devplanev1connect.ManagedSecretServiceClient +} + +// NewClient adds managed-secret helpers to an API client. +func NewClient(client devplanev1connect.ManagedSecretServiceClient) Client { + return Client{ManagedSecretServiceClient: client} +} + +func (c Client) LatestVersion(ctx context.Context, secretID string) (string, error) { + response, err := c.GetSecret(ctx, connect.NewRequest(&devplanev1.ManagedSecretServiceGetSecretRequest{ + SecretId: secretID, + })) + if err != nil { + return "", fmt.Errorf("get managed secret metadata: %w", err) + } + if response.Msg.GetSecret() == nil || response.Msg.GetSecret().GetLatestVersionId() == "" { + return "", fmt.Errorf("managed secret has no latest version") + } + return response.Msg.GetSecret().GetLatestVersionId(), nil +} + +func (c Client) Value(ctx context.Context, secretID string, versionID string) (string, error) { + response, err := c.GetSecretValue(ctx, connect.NewRequest(&devplanev1.ManagedSecretServiceGetSecretValueRequest{ + SecretId: secretID, + VersionId: versionID, + })) + if err != nil { + return "", fmt.Errorf("get managed secret value: %w", err) + } + return response.Msg.GetValue(), nil +} From 5cfe3df3282c9e139160bf291871bce9415a2401 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 31 Aug 2026 12:56:44 -0700 Subject: [PATCH 10/11] support direct file, support canonical version numbers --- pkg/cmd/launch/launch.go | 4 ++-- pkg/cmd/launch/launch_test.go | 39 +++++++++++++++++++++++++++---- pkg/cmd/launch/local.go | 2 +- pkg/cmd/launch/parameters.go | 33 ++++++++++++++++++++++---- pkg/cmd/launch/secrets.go | 13 +++++++---- pkg/managedsecret/client.go | 17 +++++++++++++- pkg/managedsecret/client_test.go | 40 ++++++++++++++++++++++++++++++++ 7 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 pkg/managedsecret/client_test.go diff --git a/pkg/cmd/launch/launch.go b/pkg/cmd/launch/launch.go index b1e6b46e9..40e5bf12c 100644 --- a/pkg/cmd/launch/launch.go +++ b/pkg/cmd/launch/launch.go @@ -88,7 +88,7 @@ func newCmdLaunch(t *terminal.Terminal, launchStore Store, deps launchDeps) *cob # Bind a text parameter to the latest or a specific managed-secret version brev launch env-abc --param-secret API_TOKEN=msec-abc - brev launch env-abc --param-secret API_TOKEN=msec-abc:msecv-123`, + brev launch env-abc --param-secret API_TOKEN=msec-abc:v1`, RunE: func(cmd *cobra.Command, args []string) error { return runLaunchCommand(cmd.Context(), launchCommandArgs{ cmd: cmd, @@ -103,7 +103,7 @@ func newCmdLaunch(t *terminal.Terminal, launchStore Store, deps launchDeps) *cob cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Instance, container, or Compose project name") cmd.Flags().StringVarP(&opts.instanceType, "type", "t", "", "Comma-separated remote instance types to try") cmd.Flags().StringArrayVar(&opts.parameters, "param", nil, "Launchable parameter NAME=VALUE (repeatable)") - cmd.Flags().StringArrayVar(&opts.secrets, "param-secret", nil, "Text parameter NAME=SECRET_ID[:VERSION_ID] (repeatable)") + cmd.Flags().StringArrayVar(&opts.secrets, "param-secret", nil, "Text parameter NAME=SECRET_ID[:VERSION] (repeatable)") cmd.Flags().BoolVar(&opts.local, "local", false, "Run on this machine without provisioning an instance") cmd.Flags().BoolVarP(&opts.detached, "detached", "d", false, "Do not wait for the remote instance or local Docker workload") cmd.Flags().BoolVar(&opts.approve, "approve", false, "Run a local startup script without prompting") diff --git a/pkg/cmd/launch/launch_test.go b/pkg/cmd/launch/launch_test.go index d7ff2c4e0..80ed82e9d 100644 --- a/pkg/cmd/launch/launch_test.go +++ b/pkg/cmd/launch/launch_test.go @@ -22,12 +22,13 @@ import ( ) type fakeSecretResolver struct { - latest map[string]string - values map[string]string - calls []store.ManagedSecretReference + latest map[string]string + versions map[string]string + values map[string]string + calls []store.ManagedSecretReference } -func (f *fakeSecretResolver) LatestVersion(_ context.Context, secretID string) (string, error) { +func (f *fakeSecretResolver) GetLatestVersionID(_ context.Context, secretID string) (string, error) { version, ok := f.latest[secretID] if !ok { return "", errors.New("secret not found") @@ -35,7 +36,15 @@ func (f *fakeSecretResolver) LatestVersion(_ context.Context, secretID string) ( return version, nil } -func (f *fakeSecretResolver) Value(_ context.Context, ref store.ManagedSecretReference) (string, error) { +func (f *fakeSecretResolver) GetVersionIDForVersionNumber(_ context.Context, secretID string, versionNumber int64) (string, error) { + version, ok := f.versions[fmt.Sprintf("%s:v%d", secretID, versionNumber)] + if !ok { + return "", errors.New("secret version not found") + } + return version, nil +} + +func (f *fakeSecretResolver) GetValue(_ context.Context, ref store.ManagedSecretReference) (string, error) { f.calls = append(f.calls, ref) value, ok := f.values[ref.SecretID+"@"+ref.VersionID] if !ok { @@ -267,10 +276,12 @@ func TestParseParameterInputs(t *testing.T) { secrets, err := parseParameterSecrets([]string{ "TOKEN=secret-1", "PINNED=secret-2:version-3", + "CANONICAL=secret-3:v1", }) require.NoError(t, err) assert.Equal(t, store.ManagedSecretReference{SecretID: "secret-1"}, secrets["TOKEN"]) assert.Equal(t, store.ManagedSecretReference{SecretID: "secret-2", VersionID: "version-3"}, secrets["PINNED"]) + assert.Equal(t, store.ManagedSecretReference{SecretID: "secret-3", VersionID: "v1"}, secrets["CANONICAL"]) _, err = parseParameterSecrets([]string{"TOKEN=secret-1:"}) assert.ErrorContains(t, err, "invalid --param-secret") @@ -304,6 +315,24 @@ func TestResolveParameterBindingsSupportsValuesDefaultsAndSecrets(t *testing.T) assert.Equal(t, store.ParameterBinding{Name: "PORT", Value: "8080"}, bindings[2]) } +func TestResolveParameterBindingsResolvesCanonicalSecretVersion(t *testing.T) { + resolver := &fakeSecretResolver{versions: map[string]string{"secret-1:v1": "msecv-1"}} + bindings, err := resolveParameterBindings( + t.Context(), + parameterBindingArgs{ + parameters: []store.Parameter{{Name: "TOKEN", Required: true, Text: &store.TextParameter{}}}, + secrets: map[string]store.ManagedSecretReference{ + "TOKEN": {SecretID: "secret-1", VersionID: "v1"}, + }, + resolver: resolver, + }, + ) + + require.NoError(t, err) + require.Len(t, bindings, 1) + assert.Equal(t, &store.ManagedSecretReference{SecretID: "secret-1", VersionID: "msecv-1"}, bindings[0].ManagedSecret) +} + func TestResolveParameterBindingsRejectsInvalidSecretBindings(t *testing.T) { parameters := []store.Parameter{ {Name: "TOKEN", Required: true, Text: &store.TextParameter{}}, diff --git a/pkg/cmd/launch/local.go b/pkg/cmd/launch/local.go index 0cbd2b5e2..3d7dcf271 100644 --- a/pkg/cmd/launch/local.go +++ b/pkg/cmd/launch/local.go @@ -169,7 +169,7 @@ func localParameterValues(ctx context.Context, bindings []store.ParameterBinding if resolver == nil { return nil, fmt.Errorf("managed-secret resolver is not configured") } - value, err := resolver.Value(ctx, *binding.ManagedSecret) + value, err := resolver.GetValue(ctx, *binding.ManagedSecret) if err != nil { return nil, fmt.Errorf("resolve managed secret for parameter %q: %w", binding.Name, err) } diff --git a/pkg/cmd/launch/parameters.go b/pkg/cmd/launch/parameters.go index 52c6d32b7..5fedf3ce3 100644 --- a/pkg/cmd/launch/parameters.go +++ b/pkg/cmd/launch/parameters.go @@ -5,6 +5,7 @@ import ( "fmt" "slices" "sort" + "strconv" "strings" breverrors "github.com/brevdev/brev-cli/pkg/errors" @@ -42,12 +43,12 @@ func parseParameterSecrets(values []string) (map[string]store.ManagedSecretRefer selector = strings.TrimSpace(selector) if !ok || name == "" || selector == "" { return nil, breverrors.NewValidationError(fmt.Sprintf( - "invalid --param-secret %q: expected NAME=SECRET_ID[:VERSION_ID]", item, + "invalid --param-secret %q: expected NAME=SECRET_ID[:VERSION]", item, )) } if strings.Contains(selector, "@") { return nil, breverrors.NewValidationError(fmt.Sprintf( - "invalid --param-secret %q: use ':' between SECRET_ID and VERSION_ID", item, + "invalid --param-secret %q: use ':' between SECRET_ID and VERSION", item, )) } if _, exists := parsed[name]; exists { @@ -149,13 +150,25 @@ func buildParameterBindings( func resolveSecretVersions(ctx context.Context, bindings []store.ParameterBinding, resolver managedSecretResolver) error { for i := range bindings { ref := bindings[i].ManagedSecret - if ref == nil || ref.VersionID != "" { + if ref == nil { + continue + } + versionNumber, canonicalVersion := canonicalSecretVersion(ref.VersionID) + if ref.VersionID != "" && !canonicalVersion { continue } if resolver == nil { return fmt.Errorf("managed-secret resolver is not configured") } - versionID, err := resolver.LatestVersion(ctx, ref.SecretID) + if canonicalVersion { + versionID, err := resolver.GetVersionIDForVersionNumber(ctx, ref.SecretID, versionNumber) + if err != nil { + return fmt.Errorf("resolve v%d for managed secret %q: %w", versionNumber, ref.SecretID, err) + } + ref.VersionID = versionID + continue + } + versionID, err := resolver.GetLatestVersionID(ctx, ref.SecretID) if err != nil { return fmt.Errorf("resolve latest version for managed secret %q: %w", ref.SecretID, err) } @@ -164,6 +177,18 @@ func resolveSecretVersions(ctx context.Context, bindings []store.ParameterBindin return nil } +func canonicalSecretVersion(version string) (int64, bool) { + numberText, ok := strings.CutPrefix(version, "v") + if !ok { + return 0, false + } + number, err := strconv.ParseInt(numberText, 10, 64) + if err != nil || number < 1 { + return 0, false + } + return number, true +} + func parameterDefault(parameter store.Parameter) string { if parameter.Text != nil { return parameter.Text.DefaultValue diff --git a/pkg/cmd/launch/secrets.go b/pkg/cmd/launch/secrets.go index accd9daa6..002e347b5 100644 --- a/pkg/cmd/launch/secrets.go +++ b/pkg/cmd/launch/secrets.go @@ -10,8 +10,9 @@ import ( ) type managedSecretResolver interface { - LatestVersion(ctx context.Context, secretID string) (string, error) - Value(ctx context.Context, ref store.ManagedSecretReference) (string, error) + GetLatestVersionID(ctx context.Context, secretID string) (string, error) + GetVersionIDForVersionNumber(ctx context.Context, secretID string, versionNumber int64) (string, error) + GetValue(ctx context.Context, ref store.ManagedSecretReference) (string, error) } type devplaneManagedSecretResolver struct { @@ -26,10 +27,14 @@ func newDevplaneManagedSecretResolver(provider Store) managedSecretResolver { } } -func (r devplaneManagedSecretResolver) LatestVersion(ctx context.Context, secretID string) (string, error) { +func (r devplaneManagedSecretResolver) GetLatestVersionID(ctx context.Context, secretID string) (string, error) { return r.client.LatestVersion(ctx, secretID) } -func (r devplaneManagedSecretResolver) Value(ctx context.Context, ref store.ManagedSecretReference) (string, error) { +func (r devplaneManagedSecretResolver) GetVersionIDForVersionNumber(ctx context.Context, secretID string, versionNumber int64) (string, error) { + return r.client.GetVersionIDForVersionNumber(ctx, secretID, versionNumber) +} + +func (r devplaneManagedSecretResolver) GetValue(ctx context.Context, ref store.ManagedSecretReference) (string, error) { return r.client.Value(ctx, ref.SecretID, ref.VersionID) } diff --git a/pkg/managedsecret/client.go b/pkg/managedsecret/client.go index 0fc108df2..03c78b8ed 100644 --- a/pkg/managedsecret/client.go +++ b/pkg/managedsecret/client.go @@ -1,4 +1,4 @@ -// Package managedsecret provides an authenticated client for DevPlane managed secrets. +// Package managedsecret provides shared operations for DevPlane managed secrets. package managedsecret import ( @@ -33,6 +33,21 @@ func (c Client) LatestVersion(ctx context.Context, secretID string) (string, err return response.Msg.GetSecret().GetLatestVersionId(), nil } +func (c Client) GetVersionIDForVersionNumber(ctx context.Context, secretID string, versionNumber int64) (string, error) { + response, err := c.ListSecretVersions(ctx, + connect.NewRequest(&devplanev1.ManagedSecretServiceListSecretVersionsRequest{SecretId: secretID}), + ) + if err != nil { + return "", fmt.Errorf("list managed secret versions: %w", err) + } + for _, version := range response.Msg.GetItems() { + if version.GetVersionNumber() == versionNumber { + return version.GetVersionId(), nil + } + } + return "", fmt.Errorf("managed secret has no version v%d", versionNumber) +} + func (c Client) Value(ctx context.Context, secretID string, versionID string) (string, error) { response, err := c.GetSecretValue(ctx, connect.NewRequest(&devplanev1.ManagedSecretServiceGetSecretValueRequest{ SecretId: secretID, diff --git a/pkg/managedsecret/client_test.go b/pkg/managedsecret/client_test.go new file mode 100644 index 000000000..9a45109a8 --- /dev/null +++ b/pkg/managedsecret/client_test.go @@ -0,0 +1,40 @@ +package managedsecret + +import ( + "context" + "testing" + + devplanev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + devplanev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeManagedSecretClient struct { + devplanev1connect.ManagedSecretServiceClient + secretID string + versions []*devplanev1.ManagedSecretVersion +} + +func TestVersionIDResolvesCanonicalVersion(t *testing.T) { + service := &fakeManagedSecretClient{versions: []*devplanev1.ManagedSecretVersion{ + {VersionId: "msecv-2", VersionNumber: 2}, + {VersionId: "msecv-1", VersionNumber: 1}, + }} + client := NewClient(service) + + versionID, err := client.GetVersionIDForVersionNumber(t.Context(), "msec-1", 1) + + require.NoError(t, err) + assert.Equal(t, "msec-1", service.secretID) + assert.Equal(t, "msecv-1", versionID) +} + +func (f *fakeManagedSecretClient) ListSecretVersions( + _ context.Context, + request *connect.Request[devplanev1.ManagedSecretServiceListSecretVersionsRequest], +) (*connect.Response[devplanev1.ManagedSecretServiceListSecretVersionsResponse], error) { + f.secretID = request.Msg.GetSecretId() + return connect.NewResponse(&devplanev1.ManagedSecretServiceListSecretVersionsResponse{Items: f.versions}), nil +} From 0172b4426600840ef867df3d3261712a14ee63f3 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 31 Aug 2026 13:47:06 -0700 Subject: [PATCH 11/11] lint --- pkg/cmd/launch/secrets.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/launch/secrets.go b/pkg/cmd/launch/secrets.go index 002e347b5..2e23afdcf 100644 --- a/pkg/cmd/launch/secrets.go +++ b/pkg/cmd/launch/secrets.go @@ -5,6 +5,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/config" + breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/managedsecret" "github.com/brevdev/brev-cli/pkg/store" ) @@ -28,13 +29,25 @@ func newDevplaneManagedSecretResolver(provider Store) managedSecretResolver { } func (r devplaneManagedSecretResolver) GetLatestVersionID(ctx context.Context, secretID string) (string, error) { - return r.client.LatestVersion(ctx, secretID) + id, err := r.client.LatestVersion(ctx, secretID) + if err != nil { + breverrors.WrapAndTrace(err) + } + return id, nil } func (r devplaneManagedSecretResolver) GetVersionIDForVersionNumber(ctx context.Context, secretID string, versionNumber int64) (string, error) { - return r.client.GetVersionIDForVersionNumber(ctx, secretID, versionNumber) + id, err := r.client.GetVersionIDForVersionNumber(ctx, secretID, versionNumber) + if err != nil { + breverrors.WrapAndTrace(err) + } + return id, nil } func (r devplaneManagedSecretResolver) GetValue(ctx context.Context, ref store.ManagedSecretReference) (string, error) { - return r.client.Value(ctx, ref.SecretID, ref.VersionID) + value, err := r.client.Value(ctx, ref.SecretID, ref.VersionID) + if err != nil { + breverrors.WrapAndTrace(err) + } + return value, nil }