diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 6912df3a..cfbe9d98 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -27,6 +27,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/importideconfig" "github.com/brevdev/brev-cli/pkg/cmd/initfile" "github.com/brevdev/brev-cli/pkg/cmd/invite" + "github.com/brevdev/brev-cli/pkg/cmd/launchable" "github.com/brevdev/brev-cli/pkg/cmd/login" "github.com/brevdev/brev-cli/pkg/cmd/logout" "github.com/brevdev/brev-cli/pkg/cmd/ls" @@ -322,6 +323,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(launchable.NewCmdLaunchable(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/launchable/launchable.go b/pkg/cmd/launchable/launchable.go new file mode 100644 index 00000000..deae55b8 --- /dev/null +++ b/pkg/cmd/launchable/launchable.go @@ -0,0 +1,225 @@ +// Package launchable implements the `brev launchable` command tree. +// +// The launchable create path POSTs to a private Brev control-plane endpoint +// (/api/organizations/{orgID}/v2/launchables) that is not part of a public API +// surface. The request shape was reverse-engineered from the Console's wizard +// payload; the endpoint may evolve without CLI-visible versioning. +package launchable + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/brevdev/brev-cli/pkg/entity" + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/store" + "github.com/brevdev/brev-cli/pkg/terminal" + "github.com/spf13/cobra" +) + +// LaunchableStore is the subset of store methods needed by this command. +type LaunchableStore interface { + GetActiveOrganizationOrDefault() (*entity.Organization, error) + CreateLaunchable(organizationID string, req *store.CreateLaunchableRequest) (*store.LaunchableResponse, error) +} + +// Valid values for --view-access. +const ( + viewAccessPublic = "public" + viewAccessPrivate = "private" +) + +// subcommandUsageTemplate is the stock cobra usage template, used to override +// the root command's category-based template (which hides generic subcommands). +const subcommandUsageTemplate = `Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +Examples: +{{.Example}}{{end}}{{if .HasAvailableSubCommands}} + +Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` + +func NewCmdLaunchable(t *terminal.Terminal, s LaunchableStore) *cobra.Command { + cmd := &cobra.Command{ + Use: "launchable", + Short: "Manage launchables", + Long: `Manage launchables — reusable, shareable instance + build templates. + +To deploy an existing launchable, use ` + "`brev create --launchable `" + `.`, + Annotations: map[string]string{"workspace": ""}, + } + cmd.AddCommand(newCmdCreate(t, s)) + + // The root command installs a category-based usage template that omits any + // subcommand without a category annotation. Restore the stock cobra + // template so `brev launchable --help` lists its subcommands. + cmd.SetUsageTemplate(subcommandUsageTemplate) + return cmd +} + +func newCmdCreate(t *terminal.Terminal, s LaunchableStore) *cobra.Command { + var ( + specPath string + nameFlag string + description string + viewAccess string + orgID string + ) + + cmd := &cobra.Command{ + Use: "create [name]", + Short: "Create a new launchable from a JSON spec", + Long: `Create a new launchable template. + +The spec file is JSON matching the body accepted by the Brev control-plane +launchable endpoint. At minimum it must define createWorkspaceRequest +(instanceType, workspaceGroupId) and buildRequest (one of dockerCompose, +containerBuild, vmBuild). + +The positional [name] and the --name flag both override the spec's "name" +field. --description, --view-access, and --org likewise override their +respective fields in the spec.`, + Example: ` # Create from a spec file + brev launchable create my-launchable -f spec.json + + # Override name, description, and visibility from the CLI + brev launchable create -f spec.json --name "CUDA Tutorial" --view-access public + + # Pin to a specific organization instead of the active one + brev launchable create -f spec.json --org org-XXXXXXXX`, + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + positional := "" + if len(args) == 1 { + positional = args[0] + } + return runCreate(t, s, specPath, positional, nameFlag, description, viewAccess, orgID) + }, + } + + cmd.Flags().StringVarP(&specPath, "from-file", "f", "", "Path to a JSON launchable spec (required)") + if err := cmd.MarkFlagRequired("from-file"); err != nil { + // Unreachable: MarkFlagRequired only fails when the flag name doesn't exist. + panic(fmt.Errorf("marking --from-file required: %w", err)) + } + cmd.Flags().StringVar(&nameFlag, "name", "", "Launchable name (overrides spec)") + cmd.Flags().StringVar(&description, "description", "", "Launchable description (overrides spec)") + cmd.Flags().StringVar(&viewAccess, "view-access", "", `"public" or "private" (overrides spec)`) + cmd.Flags().StringVar(&orgID, "org", "", "Organization ID (defaults to active org)") + + return cmd +} + +func runCreate(t *terminal.Terminal, s LaunchableStore, specPath, positionalName, nameFlag, description, viewAccess, orgID string) error { + req, err := loadSpec(specPath) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + applyOverrides(req, positionalName, nameFlag, description, viewAccess) + + if err := validateRequest(req); err != nil { + return breverrors.WrapAndTrace(err) + } + + // The API returns `"ports": null` as a validation error; the Console always + // sends an array. Normalize here so callers don't have to think about it. + if req.BuildRequest.Ports == nil { + req.BuildRequest.Ports = []store.LaunchablePort{} + } + + if orgID == "" { + org, err := s.GetActiveOrganizationOrDefault() + if err != nil { + return breverrors.WrapAndTrace(err) + } + if org == nil { + return fmt.Errorf("no active organization — pass --org or set one with `brev set`") + } + orgID = org.ID + } + + t.Vprintf("Creating launchable %s in org %s...\n", t.Yellow(req.Name), t.Yellow(orgID)) + + resp, err := s.CreateLaunchable(orgID, req) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + t.Vprintf("%s\n", t.Green(fmt.Sprintf("✓ Created launchable %s (%s)", resp.Name, resp.ID))) + t.Vprintf(" Deploy with: %s\n", t.Yellow(fmt.Sprintf("brev create --launchable %s", resp.ID))) + + return nil +} + +func loadSpec(path string) (*store.CreateLaunchableRequest, error) { + data, err := os.ReadFile(path) //nolint:gosec // path is user-supplied on purpose + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + var req store.CreateLaunchableRequest + if err := json.Unmarshal(data, &req); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + return &req, nil +} + +// applyOverrides layers CLI flag/positional values on top of the spec. The +// positional name wins over --name because `brev ` is the more +// conventional CLI form. +func applyOverrides(req *store.CreateLaunchableRequest, positionalName, nameFlag, description, viewAccess string) { + if positionalName != "" { + req.Name = positionalName + } else if nameFlag != "" { + req.Name = nameFlag + } + if description != "" { + req.Description = description + } + if viewAccess != "" { + req.ViewAccess = viewAccess + } +} + +func validateRequest(req *store.CreateLaunchableRequest) error { + if req.Name == "" { + return fmt.Errorf("name is required (set in spec, via --name, or as positional arg)") + } + if req.CreateWorkspaceRequest.InstanceType == "" { + return fmt.Errorf("createWorkspaceRequest.instanceType is required") + } + if req.CreateWorkspaceRequest.WorkspaceGroupID == "" { + return fmt.Errorf("createWorkspaceRequest.workspaceGroupId is required") + } + build := req.BuildRequest + if build.DockerCompose == nil && build.CustomContainer == nil && build.VMBuild == nil { + return fmt.Errorf("buildRequest must set one of dockerCompose, containerBuild, or vmBuild") + } + if req.ViewAccess != "" { + va := strings.ToLower(req.ViewAccess) + if va != viewAccessPublic && va != viewAccessPrivate { + return fmt.Errorf("viewAccess must be %q or %q, got %q", viewAccessPublic, viewAccessPrivate, req.ViewAccess) + } + req.ViewAccess = va + } + return nil +} diff --git a/pkg/cmd/launchable/launchable_test.go b/pkg/cmd/launchable/launchable_test.go new file mode 100644 index 00000000..8d972784 --- /dev/null +++ b/pkg/cmd/launchable/launchable_test.go @@ -0,0 +1,198 @@ +package launchable + +import ( + "testing" + + "github.com/brevdev/brev-cli/pkg/store" + "github.com/stretchr/testify/assert" +) + +// validSpec returns a request that passes validateRequest, so tests can mutate +// one field at a time to isolate what fails. +func validSpec() *store.CreateLaunchableRequest { + return &store.CreateLaunchableRequest{ + Name: "test", + CreateWorkspaceRequest: store.CreateLaunchableWorkspaceRequest{ + InstanceType: "g2-standard-4:nvidia-l4:1", + WorkspaceGroupID: "GCP", + }, + BuildRequest: store.LaunchableBuildRequest{ + DockerCompose: &store.DockerCompose{FileURL: "https://example.com/x.yml"}, + }, + } +} + +func TestValidateRequest(t *testing.T) { + tests := []struct { + name string + mutate func(*store.CreateLaunchableRequest) + wantErr string // substring of expected error; "" means no error + }{ + { + name: "valid", + mutate: func(_ *store.CreateLaunchableRequest) {}, + wantErr: "", + }, + { + name: "missing name", + mutate: func(r *store.CreateLaunchableRequest) { r.Name = "" }, + wantErr: "name is required", + }, + { + name: "missing instanceType", + mutate: func(r *store.CreateLaunchableRequest) { + r.CreateWorkspaceRequest.InstanceType = "" + }, + wantErr: "instanceType is required", + }, + { + name: "missing workspaceGroupId", + mutate: func(r *store.CreateLaunchableRequest) { + r.CreateWorkspaceRequest.WorkspaceGroupID = "" + }, + wantErr: "workspaceGroupId is required", + }, + { + name: "no build kind set", + mutate: func(r *store.CreateLaunchableRequest) { + r.BuildRequest = store.LaunchableBuildRequest{} + }, + wantErr: "dockerCompose, containerBuild, or vmBuild", + }, + { + name: "custom container build accepted", + mutate: func(r *store.CreateLaunchableRequest) { + r.BuildRequest = store.LaunchableBuildRequest{ + CustomContainer: &store.CustomContainer{}, + } + }, + wantErr: "", + }, + { + name: "vm build accepted", + mutate: func(r *store.CreateLaunchableRequest) { + r.BuildRequest = store.LaunchableBuildRequest{ + VMBuild: &store.VMBuild{}, + } + }, + wantErr: "", + }, + { + name: "viewAccess public ok", + mutate: func(r *store.CreateLaunchableRequest) { r.ViewAccess = "public" }, + wantErr: "", + }, + { + name: "viewAccess private ok", + mutate: func(r *store.CreateLaunchableRequest) { r.ViewAccess = "private" }, + wantErr: "", + }, + { + name: "viewAccess mixed case normalized", + mutate: func(r *store.CreateLaunchableRequest) { r.ViewAccess = "Public" }, + wantErr: "", + }, + { + name: "viewAccess invalid", + mutate: func(r *store.CreateLaunchableRequest) { r.ViewAccess = "shared" }, + wantErr: `viewAccess must be "public" or "private"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := validSpec() + tc.mutate(req) + err := validateRequest(req) + if tc.wantErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.wantErr) + } + }) + } +} + +func TestValidateRequestNormalizesViewAccess(t *testing.T) { + req := validSpec() + req.ViewAccess = "PUBLIC" + assert.NoError(t, validateRequest(req)) + assert.Equal(t, "public", req.ViewAccess, "validateRequest should lowercase viewAccess in place") +} + +func TestApplyOverrides(t *testing.T) { + tests := []struct { + name string + specName string + specDescription string + specViewAccess string + positional string + nameFlag string + description string + viewAccess string + wantName string + wantDescription string + wantViewAccess string + }{ + { + name: "positional overrides spec and --name", + specName: "spec-name", + positional: "positional-name", + nameFlag: "flag-name", + wantName: "positional-name", + }, + { + name: "--name overrides spec when no positional", + specName: "spec-name", + nameFlag: "flag-name", + wantName: "flag-name", + }, + { + name: "spec name kept when no positional and no --name", + specName: "spec-name", + wantName: "spec-name", + }, + { + name: "description override replaces spec", + specDescription: "spec-desc", + description: "flag-desc", + wantDescription: "flag-desc", + }, + { + name: "empty --description keeps spec", + specDescription: "spec-desc", + wantDescription: "spec-desc", + }, + { + name: "view-access override replaces spec", + specViewAccess: "private", + viewAccess: "public", + wantViewAccess: "public", + }, + { + name: "empty --view-access keeps spec", + specViewAccess: "private", + wantViewAccess: "private", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := &store.CreateLaunchableRequest{ + Name: tc.specName, + Description: tc.specDescription, + ViewAccess: tc.specViewAccess, + } + applyOverrides(req, tc.positional, tc.nameFlag, tc.description, tc.viewAccess) + if tc.wantName != "" { + assert.Equal(t, tc.wantName, req.Name) + } + if tc.wantDescription != "" { + assert.Equal(t, tc.wantDescription, req.Description) + } + if tc.wantViewAccess != "" { + assert.Equal(t, tc.wantViewAccess, req.ViewAccess) + } + }) + } +} diff --git a/pkg/store/workspace.go b/pkg/store/workspace.go index cefe77e8..0d27d8e1 100644 --- a/pkg/store/workspace.go +++ b/pkg/store/workspace.go @@ -155,6 +155,27 @@ type LaunchableFile struct { Path string `json:"path"` } +type CreateLaunchableRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ViewAccess string `json:"viewAccess,omitempty"` + CreateWorkspaceRequest CreateLaunchableWorkspaceRequest `json:"createWorkspaceRequest"` + BuildRequest LaunchableBuildRequest `json:"buildRequest"` + File *LaunchableFile `json:"file,omitempty"` +} + +type CreateLaunchableWorkspaceRequest struct { + WorkspaceGroupID string `json:"workspaceGroupId,omitempty"` + InstanceType string `json:"instanceType"` + Storage string `json:"storage,omitempty"` + FirewallRules []LaunchableFirewallRule `json:"firewallRules,omitempty"` +} + +type LaunchableFirewallRule struct { + Port string `json:"port"` + AllowedIPs string `json:"allowedIPs"` +} + var ( DefaultWorkspaceClassID = config.GlobalConfig.GetDefaultWorkspaceClass() UserWorkspaceClassID = "2x8" @@ -267,6 +288,28 @@ func (s AuthHTTPStore) CreateWorkspace(organizationID string, options *CreateWor return &result, nil } +var launchableOrgPath = fmt.Sprintf("api/organizations/{%s}/v2/launchables", orgIDParamName) + +func (s AuthHTTPStore) CreateLaunchable(organizationID string, req *CreateLaunchableRequest) (*LaunchableResponse, error) { + if req == nil { + return nil, fmt.Errorf("request can not be nil") + } + var result LaunchableResponse + res, err := s.authHTTPClient.restyClient.R(). + SetHeader("Content-Type", "application/json"). + SetPathParam(orgIDParamName, organizationID). + SetBody(req). + SetResult(&result). + Post(launchableOrgPath) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + if res.IsError() { + return nil, NewHTTPResponseError(res) + } + return &result, nil +} + func (s AuthHTTPStore) GetLaunchable(launchableID string) (*LaunchableResponse, error) { var result LaunchableResponse res, err := s.authHTTPClient.restyClient.R().