Skip to content

feat(launchable): add brev launchable create command#364

Closed
brycelelbach wants to merge 2 commits intobrevdev:mainfrom
brycelelbach:feat/launchable-create
Closed

feat(launchable): add brev launchable create command#364
brycelelbach wants to merge 2 commits intobrevdev:mainfrom
brycelelbach:feat/launchable-create

Conversation

@brycelelbach
Copy link
Copy Markdown
Contributor

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.

brev launchable create [name] --from-file spec.json [flags]

Endpoint

The command POSTs to a private Brev control-plane endpoint:

POST /api/organizations/{orgID}/v2/launchables

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 LaunchableResponse struct: it adds a top-level viewAccess and a firewallRules array inside createWorkspaceRequest that the current struct doesn't model.

Caveats worth flagging to maintainers:

  • Base URL uses config.GlobalConfig.GetBrevAPIURl() (default brevapi.us-west-2-prod.control-plane.brev.dev). The captured request in the browser hit brevapi2.… — if the /v2/launchables route is only exposed on that host, users will need to set BREV_API_URL. If the control plane serves both hosts equivalently, this is a no-op.
  • Since this is a private endpoint, response-shape drift is a real risk. We parse leniently into the existing 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.json

Top-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 org

Example 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:

✓ Created launchable CUDA C++ Tutorial (env-ABC123...)
  Deploy with: brev create --launchable env-ABC123...

Code changes

  • pkg/store/workspace.go — new types (CreateLaunchableRequest, CreateLaunchableWorkspaceRequest, LaunchableFirewallRule) and one new AuthHTTPStore method, CreateLaunchable(orgID, req). Follows the existing CreateWorkspace POST 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 + single cmd.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 of buildRequest.{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/setup still fails pre-existing / environmental, unrelated to this change).
  • brev launchable --help and brev launchable create --help render correctly (parent command resets usage template to show subcommands, since the root's category-based template omits generic ones).
  • Manual: end-to-end create against brevapi and (if needed) brevapi2, with a minimal docker-compose spec, and verify the returned env-XXX ID is deployable via brev create --launchable.
  • Manual: --view-access public vs private, verify server accepts both.
  • Manual: --from-file with a broken spec produces a readable error.

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>
Copilot AI review requested due to automatic review settings April 18, 2026 11:02
@brycelelbach brycelelbach requested a review from a team as a code owner April 18, 2026 11:02
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.CreateLaunchable plus request types to model the create payload (including viewAccess and firewallRules).
  • Introduce brev launchable create command with spec-file loading, CLI overrides, and basic validation.
  • Register the new launchable command 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.

Comment on lines +61 to +68
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>`" + `.`,
}
Comment on lines +192 to +206
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
Comment on lines +128 to +155
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)
}

Comment thread pkg/cmd/launchable/launchable.go Outdated
}

cmd.Flags().StringVarP(&specPath, "from-file", "f", "", "Path to a JSON launchable spec (required)")
_ = cmd.MarkFlagRequired("from-file")
Comment thread pkg/store/workspace.go
Comment on lines +158 to +165
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"`
}
Comment on lines +128 to +155
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)
}

@brycelelbach
Copy link
Copy Markdown
Contributor Author

@patelspratik (and my AI agent) it looks like a brev create --launchable was recently added, that deploys an existing launchable. I think that naming is going to be confusing, because we should also be able to create launchables from the CLI (as this PR proposes).

This PR suggests brev launchable create. It would be unfortunate to have:

brev launchable create # Create a launchable.
brev create --launchable # Deploy a launchable.

We probably cannot rename the entire brev create command. So, I think the best option would be better to merge the brev create --launchable functionality into brev launchable, calling it brev launchable deploy.

E.g.

brev launchable create # Create a launchable.
brev launchable deploy # Deploy a launchable.

- 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>
@brycelelbach
Copy link
Copy Markdown
Contributor Author

Addressed the 6 Copilot inline comments in 5fa6a5d:

  1. Added Annotations: {"workspace": ""} on the parent command so brev launchable is listed in the root --help.
  2. Validate and normalize viewAccess in validateRequest (previously only the --view-access flag path was checked, so an invalid value in the spec would reach the server).
  3. Normalize buildRequest.ports to [] before POST to avoid "ports": null on the wire when the spec omits it.
  4. Propagate the MarkFlagRequired error — now a panic-on-setup rather than a silent drop.
  5. Extracted override precedence (applyOverrides) so it's unit-testable.
  6. Added launchable_test.go with table tests for validateRequest (required fields, viewAccess normalization, each build-kind accepted) and for applyOverrides (positional beats --name, spec fields preserved when no override).

On brev create --launchablebrev launchable deploy: agreed on the naming wart. I didn't fold that into this PR because the deploy path in gpucreate.go is tightly coupled to the full brev create surface — it shares ~15 flags (--type, --count, --parallel, --timeout, --startup-script, --mode, --jupyter, --container-image, --compose-file, the searchFilterFlags set, etc.) and most of the pipeline (resolveInstanceTypes, applyLaunchableConfig, coupon redemption, the dry-run path). A clean brev launchable deploy needs either (a) an extracted shared helper or (b) programmatic re-entry into the gpucreate command with the launchable flag preset — both are bigger than a rename and warrant review from @patelspratik since they touch #347's code paths.

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.

@brycelelbach
Copy link
Copy Markdown
Contributor Author

Closing in favor of #357 (Tyler Fong's brev launchable create), which I didn't see when I started this. His is further along in review with @patelspratik, has richer validation (instance-type availability, storage bounds, compose YAML server-side validation, port-conflict checks), defaults to the safer organization visibility tier, and ships with docs.

Left a follow-up note on #357 proposing --from-file + a published JSON schema for the launchable config as a separate PR after it lands: #357 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants