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..40e5bf12c --- /dev/null +++ b/pkg/cmd/launch/launch.go @@ -0,0 +1,370 @@ +// Package launch provides local and remote launchable execution. +package launch + +import ( + "context" + "fmt" + "io" + "math/rand/v2" + "net/url" + "strings" + "text/tabwriter" + "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:v1`, + 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] (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 + } + + // Explain the launchable without launching it + if args.options.explain { + return explainLaunchable(args.cmd.OutOrStdout(), args.store, launchableID) + } + + info, err := fetchLaunchableMetadata(args.store, launchableID) + if err != nil { + return err + } + displayLaunchable(args.terminal, info) + + 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 { + startupScript, err := fetchStartupScript(args.store, launchableID, info) + if err != nil { + return err + } + return runLocalLaunchable(ctx, localLaunchArgs{ + terminal: args.terminal, + launchableID: launchableID, + info: info, + startupScript: startupScript, + 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) { + 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 id, nil +} + +func explainLaunchable(out io.Writer, launchStore Store, launchableID string) error { + info, err := fetchLaunchableMetadata(launchStore, launchableID) + if err != nil { + return err + } + 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), "") + 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(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 +} + +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) == "" { + recommended = strings.TrimSpace(recommended) + if 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 fetchStartupScript(launchStore Store, launchableID string, info *store.LaunchableResponse) (*store.LifeCycleScriptAttr, error) { + if info.BuildRequest.VMBuild == nil || info.BuildRequest.VMBuild.LifeCycleScriptAttr == nil { + return nil, nil + } + startupScript := *info.BuildRequest.VMBuild.LifeCycleScriptAttr + if startupScript.ID == "" { + return &startupScript, nil + } + script, err := launchStore.GetLaunchableLifeCycleScript(launchableID, startupScript.ID) + if err != nil { + return nil, fmt.Errorf("fetch startup script %q for launchable %q: %w", startupScript.ID, launchableID, err) + } + if script != nil && script.Attrs != nil { + startupScript.Script = script.Attrs.Script + } + return &startupScript, 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) { + 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("") +} + +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..80ed82e9d --- /dev/null +++ b/pkg/cmd/launch/launch_test.go @@ -0,0 +1,679 @@ +package launch + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "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 + versions map[string]string + values map[string]string + calls []store.ManagedSecretReference +} + +func (f *fakeSecretResolver) GetLatestVersionID(_ 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) 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 { + 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 + lifecycle *store.LifeCycleScriptResponse + getCalls []string + lifecycleCalls [][2]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(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) { + 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", 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"}}, + }, + }, + }} + 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, `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) 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) +} + +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, `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", + "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") + + _, 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 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{}}, + {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 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")} + 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] + 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") +} + +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: "python -m server"}, + 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 + t.Cleanup(func() { _ = os.RemoveAll(command.dir) }) + assert.Contains(t, command.args, "TOKEN") + assert.Contains(t, command.args, "8080:8080") + 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") + 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, + startupScript: info.BuildRequest.VMBuild.LifeCycleScriptAttr, + 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, + startupScript: info.BuildRequest.VMBuild.LifeCycleScriptAttr, + 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) + 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", + file: &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 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{}, + DockerCompose: &store.DockerCompose{YamlString: "services: {}"}, + }}) + assert.ErrorContains(t, err, "multiple build modes") +} + +func TestParseLaunchableID(t *testing.T) { + 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("env-") + 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) + + 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 new file mode 100644 index 000000000..3d7dcf271 --- /dev/null +++ b/pkg/cmd/launch/local.go @@ -0,0 +1,248 @@ +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 + startupScript *store.LifeCycleScriptAttr + 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") + } + 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, + file: 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, + script: args.startupScript, + env: mergeEnvironment(os.Environ(), parameterValues), + workspace: workspace, + 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), + workspace: workspace, + options: args.options, + deps: args.deps, + }) + case buildModeCompose: + return runCompose(ctx, composeLaunchArgs{ + terminal: args.terminal, + build: args.info.BuildRequest.DockerCompose, + parameterValues: parameterValues, + workspace: workspace, + 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.GetValue(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..a8a5c63e7 --- /dev/null +++ b/pkg/cmd/launch/local_docker.go @@ -0,0 +1,127 @@ +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 + workspace 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) + } + dockerArgs = append(dockerArgs, "--volume", args.workspace+":/workspace", "--workdir", "/workspace") + 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{ + name: docker, + args: dockerArgs, + dir: args.workspace, + 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) + } + 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..e8a724c81 --- /dev/null +++ b/pkg/cmd/launch/local_dockercompose.go @@ -0,0 +1,117 @@ +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 * 1024 * 1024 // 10 MiB + +type composeLaunchArgs struct { + terminal *terminal.Terminal + build *store.DockerCompose + parameterValues map[string]string + workspace 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 + } + 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) + } + 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", args.workspace, + "--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: args.workspace, + 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) + } + 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 10 MiB") + } + 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..c60345ce4 --- /dev/null +++ b/pkg/cmd/launch/local_vm.go @@ -0,0 +1,74 @@ +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 + script *store.LifeCycleScriptAttr + env []string + workspace string + options localOptions + deps launchDeps +} + +type confirmFunc func(label string) bool + +func runVM(ctx context.Context, args vmLaunchArgs) error { + 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 + } + 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}, + dir: args.workspace, + 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/local_workspace.go b/pkg/cmd/launch/local_workspace.go new file mode 100644 index 000000000..02e8ef9d8 --- /dev/null +++ b/pkg/cmd/launch/local_workspace.go @@ -0,0 +1,99 @@ +package launch + +import ( + "context" + "fmt" + "net/url" + "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 + file *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.file == nil { + return workspace, nil + } + + directory := filepath.Join(workspace, args.file.Path) + if err := os.MkdirAll(directory, 0o700); err != nil { + 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.file.URL)) + if err := args.runner.Run(ctx, commandSpec{ + name: git, + args: []string{"clone", args.file.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 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/parameters.go b/pkg/cmd/launch/parameters.go new file mode 100644 index 000000000..5fedf3ce3 --- /dev/null +++ b/pkg/cmd/launch/parameters.go @@ -0,0 +1,233 @@ +package launch + +import ( + "context" + "fmt" + "slices" + "sort" + "strconv" + "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]", item, + )) + } + if strings.Contains(selector, "@") { + return nil, breverrors.NewValidationError(fmt.Sprintf( + "invalid --param-secret %q: use ':' between SECRET_ID and VERSION", 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 { + continue + } + versionNumber, canonicalVersion := canonicalSecretVersion(ref.VersionID) + if ref.VersionID != "" && !canonicalVersion { + continue + } + if resolver == nil { + return fmt.Errorf("managed-secret resolver is not configured") + } + 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) + } + ref.VersionID = versionID + } + 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 + } + 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, ", ") + } + line := fmt.Sprintf(" %s\t(%s)", parameter.Name, details) + if description := strings.TrimSpace(parameter.Description); description != "" { + line += "\t" + description + } + lines = append(lines, line) + } + return lines +} diff --git a/pkg/cmd/launch/secrets.go b/pkg/cmd/launch/secrets.go new file mode 100644 index 000000000..2e23afdcf --- /dev/null +++ b/pkg/cmd/launch/secrets.go @@ -0,0 +1,53 @@ +package launch + +import ( + "context" + + "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" +) + +type managedSecretResolver interface { + 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 { + client managedsecret.Client +} + +func newDevplaneManagedSecretResolver(provider Store) managedSecretResolver { + return devplaneManagedSecretResolver{ + client: managedsecret.NewClient( + register.NewManagedSecretServiceClient(provider, config.GlobalConfig.GetBrevPublicAPIURL()), + ), + } +} + +func (r devplaneManagedSecretResolver) GetLatestVersionID(ctx context.Context, secretID string) (string, error) { + 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) { + 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) { + value, err := r.client.Value(ctx, ref.SecretID, ref.VersionID) + if err != nil { + breverrors.WrapAndTrace(err) + } + return value, 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/managedsecret/client.go b/pkg/managedsecret/client.go new file mode 100644 index 000000000..03c78b8ed --- /dev/null +++ b/pkg/managedsecret/client.go @@ -0,0 +1,60 @@ +// Package managedsecret provides shared operations 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) 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, + VersionId: versionID, + })) + if err != nil { + return "", fmt.Errorf("get managed secret value: %w", err) + } + return response.Msg.GetValue(), nil +} 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 +} 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"`