Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
756dfa2
Add Lakebox CLI for managing Databricks sandbox environments
shuochen0311 Apr 10, 2026
c20c6df
Remove KEY column from list, add register-key command
shuochen0311 Apr 13, 2026
f8f8cc1
Simplify SSH flow: register command, direct SSH args, remove config w…
shuochen0311 Apr 14, 2026
4b41861
Auto-register SSH key after auth login, fix login hook matching
shuochen0311 Apr 14, 2026
df599e9
Support passthrough args and remote commands in lakebox ssh
shuochen0311 Apr 14, 2026
cd25797
Fix workspace client init after login, persist last profile
Apr 14, 2026
81e6f6f
Merge pull request #1 from kelvich/lakebox-cli
kelvich Apr 15, 2026
ebda5a0
Merge fork changes + add SSH passthrough args support
shuochen0311 Apr 15, 2026
c1168a4
Add consistent terminal UI: spinners, colors, aligned output
shuochen0311 Apr 16, 2026
f9de788
Fix CLI to match new lakebox API contract
shuochen0311 Apr 29, 2026
97e916e
Update CLI to lakebox sandbox/ssh-keys API surface
shuochen0311 Apr 30, 2026
46642d1
Show auto-stop policy in lakebox list and status
shuochen0311 May 1, 2026
412ff70
Add lakebox config command for setting auto-stop policy
shuochen0311 May 1, 2026
03a6240
Rename persist → no_autostop and document auto-clear behavior
shuochen0311 May 1, 2026
8cfe3bb
Switch idle_timeout wire type to google.protobuf.Duration
shuochen0311 May 1, 2026
b87b712
[lakebox] Support staging workspaces in CLI ssh + api routing
shuochen0311 May 2, 2026
b65a733
Merge PR #4930: Add Lakebox CLI for managing Databricks sandbox envir…
pietern May 8, 2026
86b4295
lakebox: integrate as a 'databricks lakebox' subcommand
pietern May 8, 2026
ea75d2c
lakebox: rewrite ui.go on top of cmdio
pietern May 6, 2026
49cdfc3
lakebox: replace local ANSI consts with cmdio color helpers
pietern May 6, 2026
43807fa
cmdio: add Bold and Dim color helpers; restore lakebox parity
pietern May 6, 2026
205edce
lakebox: restore status('creating') bold and field column alignment
pietern May 6, 2026
f66fe2a
lakebox: drop unix-only exec_unix.go and runtime.GOOS branch
pietern May 7, 2026
102f279
lakebox: use libs/execv for ssh process replacement
pietern May 7, 2026
08a56be
lakebox: rewrite api.go on top of the SDK ApiClient
pietern May 7, 2026
205d545
lakebox: make spinner Close() idempotent; defer it at every spin site
pietern May 7, 2026
d356344
lakebox: hold cmdio spinner via interface; drop redundant 'finished' …
pietern May 7, 2026
e6e461f
lakebox: expose Update through the spinner wrapper
pietern May 7, 2026
adb7d73
lakebox: validate saved default before reusing it on ssh
pietern May 7, 2026
a6eece8
lakebox: add unit tests for state.go
pietern May 7, 2026
bd72f85
lakebox: add keyHash helper matching the server's algorithm
pietern May 7, 2026
4d4ca9e
lakebox: simplify keyHash with strings.SplitSeq; correct doc comment
pietern May 7, 2026
9b696ba
lakebox: simplify keyHash to byte iteration
pietern May 7, 2026
34aaad6
lakebox: correct misleading comment on keyHash test inputs
pietern May 7, 2026
4df1daf
lakebox: drop superfluous TestKeyHashIsStableLength
pietern May 7, 2026
670f66e
lakebox: align org-ID header with the rest of the codebase
pietern May 8, 2026
f6f28eb
lakebox: skip state file writes when nothing changed
pietern May 8, 2026
5807f24
lakebox: hide the subcommand from the top-level help listing
pietern May 8, 2026
29b16f9
Merge branch 'main' into demo-lakebox
pietern May 11, 2026
33f949d
lakebox: skip Unix perm assertions in state test on Windows
pietern May 11, 2026
40b66ad
lakebox: fix CreateSandbox wire format, paginate list, surface name
pietern May 18, 2026
9439c8a
lakebox: add ssh-key list/delete, default register --name, fix list n…
akshaysingla-db May 20, 2026
23861c7
[lakebox] Default staging SSH gateway to ue1.s.dbrx.dev (#5289)
akshaysingla-db May 20, 2026
4714f92
Merge branch 'main' into demo-lakebox
akshaysingla-db May 21, 2026
5eb3f99
lakebox: add stop command (#5291)
akshaysingla-db May 21, 2026
db3e3b9
lakebox: read SSH gateway host from Sandbox response, cache per profi…
akshaysingla-db May 22, 2026
f874894
lakebox: add start command, fail-fast on unknown sandbox in ssh (#5309)
akshaysingla-db May 22, 2026
44caa6a
lakebox: show "never" when server has no idle_timeout (#5321)
akshaysingla-db May 24, 2026
2d60a10
lakebox: fixes from Mitch's bug bash on demo-lakebox (#5350)
akshaysingla-db May 27, 2026
10da4bc
lakebox: URL-encode sandbox/key IDs in path segments (#5351)
akshaysingla-db May 27, 2026
43282f4
lakebox: shorten ssh "stopped sandbox" notice (#5352)
akshaysingla-db May 27, 2026
fd6387d
lakebox: switch state_test empty-string assertions to assert.Empty (#…
akshaysingla-db May 27, 2026
a9812bb
lakebox: prompt before delete; --auto-approve to skip (#5358)
akshaysingla-db May 28, 2026
6110948
bugbash: add install.sh — persistent install of demo-lakebox snapshot…
akshaysingla-db May 28, 2026
b14f692
Merge remote-tracking branch 'origin/main' into demo-lakebox
pietern May 28, 2026
221bae3
Merge remote-tracking branch 'origin/main' into demo-lakebox
pietern May 28, 2026
51b669a
lakebox: tab completion for sandbox IDs and ssh-key hashes (#5310)
akshaysingla-db May 29, 2026
60997ef
lakebox: resolve names → IDs from local cache (#5371)
akshaysingla-db May 29, 2026
524846b
Akshay/lakebox bugbash followups (#5372)
akshaysingla-db May 29, 2026
df4fe57
Bug fixes (#5380)
akshaysingla-db May 29, 2026
08b5a56
Bug bash followups (#5381)
akshaysingla-db May 29, 2026
e72597d
lakebox: always show NAME column in `lakebox list` (#5382)
akshaysingla-db May 29, 2026
4fc810f
cmdio: add Width, PadRight, and PadLeft helpers for column alignment
pietern Jun 1, 2026
8ac0297
Merge branch 'cmdio-visible-width' into demo-lakebox
pietern Jun 1, 2026
87d0ee3
lakebox: align list columns with cmdio width helpers
pietern Jun 1, 2026
dc2f203
lakebox: use errors.New for static error string
pietern Jun 1, 2026
36318b1
lakebox: color "stopping" status yellow (#5419)
akshaysingla-db Jun 3, 2026
d9cadc3
Merge remote-tracking branch 'origin/main' into demo-lakebox
pietern Jun 3, 2026
41d1215
lakebox: explicitly start stopped sandbox before ssh (#5429)
akshaysingla-db Jun 4, 2026
f5ac8e0
lakebox: `register` writes `~/.ssh/config` alias; ed25519 keys; verif…
akshaysingla-db Jun 6, 2026
2e5b9e5
lakebox: clean comments and drop bugbash dir for open-source publicat…
akshaysingla-db Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/databricks/cli/cmd/experimental"
"github.com/databricks/cli/cmd/fs"
"github.com/databricks/cli/cmd/labs"
"github.com/databricks/cli/cmd/lakebox"
"github.com/databricks/cli/cmd/pipelines"
"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/cmd/selftest"
Expand Down Expand Up @@ -121,6 +122,7 @@ func New(ctx context.Context) *cobra.Command {
cli.AddCommand(configure.New())
cli.AddCommand(fs.New())
cli.AddCommand(labs.New(ctx))
cli.AddCommand(lakebox.New())
cli.AddCommand(sync.New())
cli.AddCommand(version.New())
cli.AddCommand(selftest.New())
Expand Down
1 change: 1 addition & 0 deletions cmd/fuzz_panic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ func isAutoGenerated(leaf leafCommand) bool {
"configure": true,
"experimental": true,
"labs": true,
"lakebox": true,
"pipelines": true,
"psql": true,
"selftest": true,
Expand Down
342 changes: 342 additions & 0 deletions cmd/lakebox/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,342 @@
package lakebox

import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"

"github.com/databricks/cli/libs/auth"
"github.com/databricks/databricks-sdk-go"
"github.com/databricks/databricks-sdk-go/client"
)

// sandboxPath returns the URL path for a single sandbox resource. The ID is
// path-escaped so a value like `foo;rm -rf /` lands on
// `/sandboxes/foo%3Brm%20-rf%20%2F` and gets a clean 400 from the server,
// rather than its unescaped `/` re-routing the request to the list endpoint
// (which silently returns an empty result the CLI then renders as an
// all-zero sandbox record).
func sandboxPath(id string) string {
return lakeboxAPIPath + "/" + url.PathEscape(id)
}

// Sub-collections under the lakebox service namespace.
const (
lakeboxAPIPath = "/api/2.0/lakebox/sandboxes"
lakeboxKeysAPIPath = "/api/2.0/lakebox/ssh-keys"
)

// orgIDHeader scopes the credential to a workspace on multi-workspace
// gateways. Without it, requests fail with "Credential was not sent or was
// of an unsupported type for this API."
const orgIDHeader = "X-Databricks-Org-Id"

// maxNameBytes mirrors the server-side `Sandbox.name` cap. The server
// measures bytes (not runes), so emoji hit the limit faster than expected;
// mirroring it client-side lets us fail fast with the observed byte count.
const maxNameBytes = 256

// validateName rejects names that exceed the wire limit (counted in bytes).
func validateName(name string) error {
if n := len(name); n > maxNameBytes {
return fmt.Errorf("--name is %d bytes; limit is %d (emoji and most non-ASCII characters count as 2-4 bytes each)", n, maxNameBytes)
}
return nil
}

// lakeboxAPI wraps the SDK ApiClient with workspace-id-aware request headers.
type lakeboxAPI struct {
c *client.DatabricksClient
}

// sandboxCreateBody is the inner `Sandbox` message in the create payload.
// Only `name` is caller-settable; the rest are server-chosen.
type sandboxCreateBody struct {
Name string `json:"name,omitempty"`
}

// createRequest is the wrapped POST body for sandbox creation.
type createRequest struct {
Sandbox sandboxCreateBody `json:"sandbox"`
}

// createResponse mirrors the Sandbox proto after JSON transcoding. FQDN is
// the manager's internal routing host (not user-actionable); GatewayHost is
// the public SSH gateway. Both are `omitempty` so old and new server
// versions round-trip cleanly.
type createResponse struct {
SandboxID string `json:"sandboxId"`
Status string `json:"status"`
FQDN string `json:"fqdn,omitempty"`
GatewayHost string `json:"gatewayHost,omitempty"`
}

// sandboxEntry mirrors the Sandbox proto after JSON transcoding.
// IdleTimeout and NoAutostop are pointer-typed so we can distinguish
// "field absent on the wire" (server uses its default) from "explicitly
// set to 0 / false". IdleTimeout is a proto3-canonical Duration string
// (see idleTimeoutSecs).
type sandboxEntry struct {
SandboxID string `json:"sandboxId"`
Status string `json:"status"`
FQDN string `json:"fqdn,omitempty"`
GatewayHost string `json:"gatewayHost,omitempty"`
Name string `json:"name,omitempty"`
CreateTime string `json:"createTime,omitempty"`
LastStartTime string `json:"lastStartTime,omitempty"`
IdleTimeout *string `json:"idleTimeout,omitempty"`
NoAutostop *bool `json:"noAutostop,omitempty"`
}

// idleTimeoutSecs parses the proto3-canonical Duration string off
// `IdleTimeout` (e.g. `"900s"` → `900`). Returns 0 when unset or when
// the string is not a recognizable Duration. Sub-second precision is
// dropped — the watchdog only acts on whole seconds.
func (e *sandboxEntry) idleTimeoutSecs() int64 {
if e.IdleTimeout == nil {
return 0
}
s := *e.IdleTimeout
if !strings.HasSuffix(s, "s") {
return 0
}
d, err := time.ParseDuration(s)
if err != nil {
return 0
}
return int64(d.Seconds())
}

// autoStopLabel renders the auto-stop policy for one sandbox:
// - `no_autostop == true` → never auto-stops
// - `idle_timeout` set and positive → that many seconds
// - otherwise → no enforcement today; render as "never"
//
// If the manager later enforces an idle-grace default, render it here.
func (e *sandboxEntry) autoStopLabel() string {
if e.NoAutostop != nil && *e.NoAutostop {
return "never"
}
if secs := e.idleTimeoutSecs(); secs > 0 {
return formatDurationSecs(secs)
}
return "never"
}

// formatDurationSecs prints `secs` as a compact duration (e.g. `90s`,
// `15m`, `2h`, `1h30m`). Falls back to seconds if it's not a clean
// minute/hour multiple.
func formatDurationSecs(secs int64) string {
if secs < 60 {
return fmt.Sprintf("%ds", secs)
}
if secs%3600 == 0 {
return fmt.Sprintf("%dh", secs/3600)
}
if secs >= 3600 {
return fmt.Sprintf("%dh%dm", secs/3600, (secs%3600)/60)
}
if secs%60 == 0 {
return fmt.Sprintf("%dm", secs/60)
}
return fmt.Sprintf("%ds", secs)
}

// listResponse is the JSON body returned by GET /api/2.0/lakebox/sandboxes.
type listResponse struct {
Sandboxes []sandboxEntry `json:"sandboxes"`
NextPageToken string `json:"nextPageToken,omitempty"`
}

// listPageSize matches the manager-side default.
const listPageSize = 100

// updateBody is the PATCH body; the server takes the inner `Sandbox`
// message directly with no `{"sandbox": ...}` wrapping. Pointer fields
// encode proto3 optional semantics (see sandboxEntry).
type updateBody struct {
SandboxID string `json:"sandbox_id"`
Name *string `json:"name,omitempty"`
IdleTimeout *string `json:"idle_timeout,omitempty"`
NoAutostop *bool `json:"no_autostop,omitempty"`
}

// registerKeyRequest is the JSON body for POST /api/2.0/lakebox/ssh-keys.
type registerKeyRequest struct {
PublicKey string `json:"public_key"`
Name string `json:"name,omitempty"`
}

// newLakeboxAPI returns a lakeboxAPI bound to the workspace client's config.
func newLakeboxAPI(w *databricks.WorkspaceClient) (*lakeboxAPI, error) {
c, err := client.New(w.Config)
if err != nil {
return nil, fmt.Errorf("failed to create lakebox API client: %w", err)
}
return &lakeboxAPI{c: c}, nil
}

// headers attaches the workspace routing identifier so multi-workspace
// gateways (e.g. SPOG hosts) can scope the credential. The
// auth.WorkspaceIDNone sentinel ("none") is treated as unset so the
// literal string never goes on the wire.
func (a *lakeboxAPI) headers() map[string]string {
wsID := a.c.Config.WorkspaceID
if wsID == "" || wsID == auth.WorkspaceIDNone {
return nil
}
return map[string]string{orgIDHeader: wsID}
}

// create calls POST /api/2.0/lakebox/sandboxes. An empty `name` is omitted
// so the server treats it as "unset" rather than "explicit empty string".
func (a *lakeboxAPI) create(ctx context.Context, name string) (*createResponse, error) {
body := createRequest{Sandbox: sandboxCreateBody{Name: name}}
var resp createResponse
err := a.c.Do(ctx, http.MethodPost, lakeboxAPIPath, a.headers(), nil, body, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}

// list calls GET /api/2.0/lakebox/sandboxes, following pagination until the
// server stops sending `next_page_token`.
func (a *lakeboxAPI) list(ctx context.Context) ([]sandboxEntry, error) {
var all []sandboxEntry
pageToken := ""
for {
page, err := a.listPage(ctx, pageToken)
if err != nil {
return nil, err
}
all = append(all, page.Sandboxes...)
if page.NextPageToken == "" {
return all, nil
}
pageToken = page.NextPageToken
}
}

// listPage fetches a single page of sandboxes.
//
// `query` is passed in slot 6 (`request`), not slot 5 (`queryParams`). On
// GET, the SDK's makeRequestBody serializes `request` into the URL query
// string and sends an empty body. Routing through `queryParams` instead
// makes it write a literal `null` body, which the lakebox manager rejects
// with `INVALID_PARAMETER_VALUE: Request body must be a JSON object`. See
// databricks-sdk-go/httpclient/request.go:makeRequestBody.
func (a *lakeboxAPI) listPage(ctx context.Context, pageToken string) (*listResponse, error) {
query := map[string]any{"page_size": listPageSize}
if pageToken != "" {
query["page_token"] = pageToken
}
var resp listResponse
err := a.c.Do(ctx, http.MethodGet, lakeboxAPIPath, a.headers(), nil, query, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}

// get calls GET /api/2.0/lakebox/sandboxes/{id}.
func (a *lakeboxAPI) get(ctx context.Context, id string) (*sandboxEntry, error) {
var resp sandboxEntry
err := a.c.Do(ctx, http.MethodGet, sandboxPath(id), a.headers(), nil, nil, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}

// update calls PATCH /api/2.0/lakebox/sandboxes/{id} with whichever of
// `idle_timeout` / `no_autostop` the caller chose to set. Fields left nil
// are omitted from the wire payload, so the server preserves their current
// values. Returns the refreshed `sandboxEntry`.
func (a *lakeboxAPI) update(ctx context.Context, id string, name *string, idleTimeoutSecs *int64, noAutostop *bool) (*sandboxEntry, error) {
var idleTimeout *string
if idleTimeoutSecs != nil {
s := fmt.Sprintf("%ds", *idleTimeoutSecs)
idleTimeout = &s
}
body := updateBody{
SandboxID: id,
Name: name,
IdleTimeout: idleTimeout,
NoAutostop: noAutostop,
}
var resp sandboxEntry
err := a.c.Do(ctx, http.MethodPatch, sandboxPath(id), a.headers(), nil, body, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}

// delete calls DELETE /api/2.0/lakebox/sandboxes/{id}.
func (a *lakeboxAPI) delete(ctx context.Context, id string) error {
return a.c.Do(ctx, http.MethodDelete, sandboxPath(id), a.headers(), nil, nil, nil)
}

// stop calls POST /api/2.0/lakebox/sandboxes/{id}/stop and returns the
// refreshed sandbox.
func (a *lakeboxAPI) stop(ctx context.Context, id string) (*sandboxEntry, error) {
body := map[string]string{"sandbox_id": id}
var resp sandboxEntry
err := a.c.Do(ctx, http.MethodPost, sandboxPath(id)+"/stop", a.headers(), nil, body, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}

// start calls POST /api/2.0/lakebox/sandboxes/{id}/start and returns the
// refreshed sandbox.
func (a *lakeboxAPI) start(ctx context.Context, id string) (*sandboxEntry, error) {
body := map[string]string{"sandbox_id": id}
var resp sandboxEntry
err := a.c.Do(ctx, http.MethodPost, sandboxPath(id)+"/start", a.headers(), nil, body, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}

// registerKey calls POST /api/2.0/lakebox/ssh-keys. An empty `name` is
// omitted so the server records "unset" rather than an explicit empty string.
func (a *lakeboxAPI) registerKey(ctx context.Context, publicKey, name string) error {
return a.c.Do(ctx, http.MethodPost, lakeboxKeysAPIPath, a.headers(), nil, registerKeyRequest{PublicKey: publicKey, Name: name}, nil)
}

// sshKeyEntry is a single item in the ssh-key list response.
type sshKeyEntry struct {
KeyHash string `json:"keyHash"`
Name string `json:"name,omitempty"`
CreateTime string `json:"createTime,omitempty"`
LastUseTime string `json:"lastUseTime,omitempty"`
}

// listKeysResponse is the JSON body returned by GET /api/2.0/lakebox/ssh-keys.
// Per-user keys are hard-capped server-side, so the full set fits in one
// response — no pagination.
type listKeysResponse struct {
SshKeys []sshKeyEntry `json:"sshKeys"`
}

// listKeys calls GET /api/2.0/lakebox/ssh-keys.
func (a *lakeboxAPI) listKeys(ctx context.Context) ([]sshKeyEntry, error) {
var resp listKeysResponse
err := a.c.Do(ctx, http.MethodGet, lakeboxKeysAPIPath, a.headers(), nil, nil, &resp)
if err != nil {
return nil, err
}
return resp.SshKeys, nil
}

// deleteKey calls DELETE /api/2.0/lakebox/ssh-keys/{key_hash}.
func (a *lakeboxAPI) deleteKey(ctx context.Context, keyHash string) error {
return a.c.Do(ctx, http.MethodDelete, lakeboxKeysAPIPath+"/"+url.PathEscape(keyHash), a.headers(), nil, nil, nil)
}
31 changes: 31 additions & 0 deletions cmd/lakebox/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package lakebox

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestValidateNameAcceptsAscii(t *testing.T) {
require.NoError(t, validateName(""))
require.NoError(t, validateName("my-project"))
require.NoError(t, validateName(strings.Repeat("a", 256))) // boundary: exactly the limit
}

func TestValidateNameRejectsOversize(t *testing.T) {
err := validateName(strings.Repeat("a", 257))
require.Error(t, err)
assert.Contains(t, err.Error(), "257 bytes")
assert.Contains(t, err.Error(), "256")
}

func TestValidateNameCountsBytesNotRunes(t *testing.T) {
// 64 panda emoji = 64 × 4 bytes = 256 bytes — at the limit, OK.
require.NoError(t, validateName(strings.Repeat("🐼", 64)))
// 65 = 260 bytes, rejected.
err := validateName(strings.Repeat("🐼", 65))
require.Error(t, err)
assert.Contains(t, err.Error(), "260 bytes")
}
Loading
Loading