feat(launchable): add brev launchable create command#364
feat(launchable): add brev launchable create command#364brycelelbach wants to merge 2 commits intobrevdev:mainfrom
brev launchable create command#364Conversation
Adds a CLI path for creating new launchable templates, complementing the
existing `brev create --launchable <id>` which only deploys pre-existing
ones. Implemented against the Brev control-plane endpoint
`POST /api/organizations/{orgID}/v2/launchables`, reverse-engineered from
the Console wizard — the endpoint is private and not part of a public API
surface.
The command takes a JSON spec file (`--from-file`) mirroring the server's
accepted payload, with top-level overrides for `--name`, `--description`,
`--view-access`, and `--org`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new CLI surface to create “launchable” templates (in addition to deploying existing launchables), by POSTing a JSON spec to the control-plane /api/organizations/{orgID}/v2/launchables endpoint and printing the resulting launchable ID + deploy command.
Changes:
- Add
AuthHTTPStore.CreateLaunchableplus request types to model the create payload (includingviewAccessandfirewallRules). - Introduce
brev launchable createcommand with spec-file loading, CLI overrides, and basic validation. - Register the new
launchablecommand in the root CLI command tree.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| pkg/store/workspace.go | Adds create-request structs and an AuthHTTPStore.CreateLaunchable POST method for org-scoped launchable creation. |
| pkg/cmd/launchable/launchable.go | New brev launchable command tree with create subcommand, spec parsing, overrides, and validation. |
| pkg/cmd/cmd.go | Wires the new launchable command into the root command tree. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 <id>`" + `.`, | ||
| } |
| 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") | ||
| } | ||
| return nil |
| 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) | ||
| } | ||
|
|
||
| // Apply overrides. Positional wins over --name because the positional is | ||
| // the more conventional `brev <verb> <noun>` form. | ||
| if positionalName != "" { | ||
| req.Name = positionalName | ||
| } else if nameFlag != "" { | ||
| req.Name = nameFlag | ||
| } | ||
| if description != "" { | ||
| req.Description = description | ||
| } | ||
| if viewAccess != "" { | ||
| va := strings.ToLower(viewAccess) | ||
| if va != viewAccessPublic && va != viewAccessPrivate { | ||
| return fmt.Errorf("--view-access must be %q or %q, got %q", viewAccessPublic, viewAccessPrivate, viewAccess) | ||
| } | ||
| req.ViewAccess = va | ||
| } | ||
|
|
||
| if err := validateRequest(req); err != nil { | ||
| return breverrors.WrapAndTrace(err) | ||
| } | ||
|
|
| } | ||
|
|
||
| cmd.Flags().StringVarP(&specPath, "from-file", "f", "", "Path to a JSON launchable spec (required)") | ||
| _ = cmd.MarkFlagRequired("from-file") |
| 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"` | ||
| } |
| 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) | ||
| } | ||
|
|
||
| // Apply overrides. Positional wins over --name because the positional is | ||
| // the more conventional `brev <verb> <noun>` form. | ||
| if positionalName != "" { | ||
| req.Name = positionalName | ||
| } else if nameFlag != "" { | ||
| req.Name = nameFlag | ||
| } | ||
| if description != "" { | ||
| req.Description = description | ||
| } | ||
| if viewAccess != "" { | ||
| va := strings.ToLower(viewAccess) | ||
| if va != viewAccessPublic && va != viewAccessPrivate { | ||
| return fmt.Errorf("--view-access must be %q or %q, got %q", viewAccessPublic, viewAccessPrivate, viewAccess) | ||
| } | ||
| req.ViewAccess = va | ||
| } | ||
|
|
||
| if err := validateRequest(req); err != nil { | ||
| return breverrors.WrapAndTrace(err) | ||
| } | ||
|
|
|
@patelspratik (and my AI agent) it looks like a This PR suggests We probably cannot rename the entire E.g. |
- Register "workspace" annotation on the parent command so it appears in
`brev --help` (the root uses a category-based template that hides
un-annotated commands).
- Validate and normalize viewAccess from the spec file, not just from
the --view-access flag; reject values outside {public, private}.
- Normalize buildRequest.ports to an empty slice before POST so the
server never sees `"ports": null` when the spec omits the field.
- Propagate errors from MarkFlagRequired instead of silently dropping
them; fail fast at setup if the required flag is ever renamed.
- Extract override-precedence logic (applyOverrides) for testability.
- Add unit tests covering validateRequest (all required fields,
viewAccess normalization) and applyOverrides (positional vs --name
precedence, description/view-access overrides).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Addressed the 6 Copilot inline comments in 5fa6a5d:
On Happy to do that as a follow-up PR immediately after this one lands. Keeping this PR focused on "create" makes the deploy-unification diff easier to review on its own. |
|
Closing in favor of #357 (Tyler Fong's Left a follow-up note on #357 proposing |
Summary
Adds a CLI path for creating new launchable templates. Today the CLI can only deploy pre-existing launchables via
brev create --launchable <id>(added in #347); creating one still requires the Brev Console. This PR closes that gap.Endpoint
The command POSTs to a private Brev control-plane endpoint:
The request shape was captured from the Console's launchable-creation wizard (browser DevTools → Network tab) and is not part of a public/documented API. Payload fields mirror — and extend — the existing
LaunchableResponsestruct: it adds a top-levelviewAccessand afirewallRulesarray insidecreateWorkspaceRequestthat the current struct doesn't model.Caveats worth flagging to maintainers:
config.GlobalConfig.GetBrevAPIURl()(defaultbrevapi.us-west-2-prod.control-plane.brev.dev). The captured request in the browser hitbrevapi2.…— if the/v2/launchablesroute is only exposed on that host, users will need to setBREV_API_URL. If the control plane serves both hosts equivalently, this is a no-op.LaunchableResponse— unknown fields are ignored, known fields populate normally.UX
Primary mode: a JSON spec file that mirrors the API payload:
brev launchable create "CUDA C++ Tutorial" --from-file spec.jsonTop-level overrides avoid having to edit the spec file for small tweaks:
brev launchable create --from-file spec.json \ --name "CUDA Tutorial" \ --description "NVIDIA accelerated computing tutorial" \ --view-access public \ --org org-XXXXXXXX # defaults to active orgExample spec (matches the shape the Console sends):
{ "name": "CUDA C++ Tutorial", "description": "", "viewAccess": "public", "createWorkspaceRequest": { "instanceType": "g2-standard-4:nvidia-l4:1", "workspaceGroupId": "GCP", "storage": "256", "firewallRules": [ { "port": "3478", "allowedIPs": "all" }, { "port": "3479", "allowedIPs": "all" } ] }, "buildRequest": { "ports": [ { "name": "jupyter", "port": "8888" }, { "name": "nsys", "port": "8080" }, { "name": "ncu", "port": "8081" } ], "dockerCompose": { "fileUrl": "https://github.com/NVIDIA/accelerated-computing-hub/raw/main/tutorials/cuda-cpp/brev/docker-compose.yml", "jupyterInstall": false, "registries": [] } }, "file": { "url": "https://github.com/NVIDIA/accelerated-computing-hub/raw/main/tutorials/cuda-cpp/README.md", "path": "./" } }On success the command prints the new launchable ID and the deploy incantation:
Code changes
pkg/store/workspace.go— new types (CreateLaunchableRequest,CreateLaunchableWorkspaceRequest,LaunchableFirewallRule) and one newAuthHTTPStoremethod,CreateLaunchable(orgID, req). Follows the existingCreateWorkspacePOST pattern exactly.pkg/cmd/launchable/launchable.go— new package. Single subcommand (create), flag parsing, spec-file load/validate, active-org fallback, success output.pkg/cmd/cmd.go— import + singlecmd.AddCommand(launchable.NewCmdLaunchable(t, loginCmdStore))line.Validation before calling the API is conservative: the spec must set
name,createWorkspaceRequest.instanceType,createWorkspaceRequest.workspaceGroupId, and at least one ofbuildRequest.{dockerCompose,containerBuild,vmBuild}. Anything more nuanced (e.g., port-conflict checks, instance-type validity) is left to the server.Relationship to other PRs
Test plan
go build ./...go vet ./...go test ./pkg/...— all passing (e2etest/setupstill fails pre-existing / environmental, unrelated to this change).brev launchable --helpandbrev launchable create --helprender correctly (parent command resets usage template to show subcommands, since the root's category-based template omits generic ones).brevapiand (if needed)brevapi2, with a minimaldocker-composespec, and verify the returnedenv-XXXID is deployable viabrev create --launchable.--view-access publicvsprivate, verify server accepts both.--from-filewith a broken spec produces a readable error.