Skip to content

Commit 9439c8a

Browse files
lakebox: add ssh-key list/delete, default register --name, fix list null body (#5290)
Three related ssh-key UX changes plus a wire-format bug in `list`: 1. `ssh-key list` (new subcommand): GET /api/2.0/lakebox/ssh-keys. Renders the user's registered keys with a `*` gutter marker on the key matching this machine (using the existing keyHash helper from bd72f85). `--json` for scripting. 2. `ssh-key delete <key-hash>` (new subcommand): DELETE /api/2.0/lakebox/ssh-keys/{key_hash}. 3. `register --name` (new flag): defaults to this machine's hostname when not provided so `ssh-key list` is meaningful across multiple machines. Previously every locally-registered key landed with an empty name and surfaced as `(unset)`. 4. Fix `lakebox list` against any workspace that actually has lakebox deployed. Pieter's SDK ApiClient rewrite (08a56be) wrote the pagination call as `Do(..., headers, query, nil, &resp)` — but the SDK's makeRequestBody treats slot 6 (`request`) as the GET query string and slot 5 (`queryParams`) as ignored on GET. Routing the query through slot 5 with a nil body in slot 6 makes the SDK serialize nil as the literal JSON `null`, sent as the request body on a GET, which the lakebox manager rejects with `INVALID_PARAMETER_VALUE: Request body must be a JSON object`. Swap the args. Co-authored-by: Isaac ## Changes <!-- Brief summary of your changes that is easy to understand --> ## Why <!-- Why are these changes needed? Provide the context that the reviewer might be missing. For example, were there any decisions behind the change that are not reflected in the code itself? --> ## Tests <!-- How have you tested the changes? --> Tested against `dbsql-dev-testing-default` (workspace with lakebox deployed but no sandboxes): - [x] `lakebox list` returns `200 OK` with no body, prints "No lakeboxes found." (previously 400'd) - [x] `lakebox ssh-key list` renders both keys with correct column alignment; `*` gutter marks the local key - [x] `lakebox ssh-key list --json` round-trips through `jq` - [x] `lakebox ssh-key delete <hash>` — not yet exercised end-to-end - [x] `lakebox register --name laptop` against a clean workspace — confirm name lands <!-- If your PR needs to be included in the release notes for next release, add a separate entry in NEXT_CHANGELOG.md as part of your PR. -->
1 parent 40b66ad commit 9439c8a

4 files changed

Lines changed: 252 additions & 10 deletions

File tree

cmd/lakebox/api.go

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,15 @@ type createRequest struct {
4646

4747
// createResponse is the JSON body returned by POST /api/2.0/lakebox/sandboxes.
4848
// Mirrors the `Sandbox` proto message after JSON transcoding.
49+
//
50+
// `FQDN` is the manager's internal routing hostname — not user-actionable,
51+
// SSH always goes through the gateway. Tagged `omitempty` so the day the
52+
// manager stops returning it, both this struct and downstream `--json`
53+
// output drop the field cleanly instead of leaking a ghost empty string.
4954
type createResponse struct {
5055
SandboxID string `json:"sandboxId"`
5156
Status string `json:"status"`
52-
FQDN string `json:"fqdn"`
57+
FQDN string `json:"fqdn,omitempty"`
5358
}
5459

5560
// sandboxEntry is a single item in the list response.
@@ -65,7 +70,7 @@ type createResponse struct {
6570
type sandboxEntry struct {
6671
SandboxID string `json:"sandboxId"`
6772
Status string `json:"status"`
68-
FQDN string `json:"fqdn"`
73+
FQDN string `json:"fqdn,omitempty"`
6974
Name string `json:"name,omitempty"`
7075
CreateTime string `json:"createTime,omitempty"`
7176
LastStartTime string `json:"lastStartTime,omitempty"`
@@ -224,13 +229,20 @@ func (a *lakeboxAPI) list(ctx context.Context) ([]sandboxEntry, error) {
224229

225230
// listPage fetches a single page of sandboxes. An empty `pageToken` requests
226231
// the first page; the server enforces ordering across pages.
232+
//
233+
// `query` is passed in slot 6 (`request`), not slot 5 (`queryParams`). On
234+
// GET, the SDK's makeRequestBody serializes `request` into the URL query
235+
// string and sends an empty body. Routing through `queryParams` instead
236+
// makes it write a literal `null` body, which the lakebox manager rejects
237+
// with `INVALID_PARAMETER_VALUE: Request body must be a JSON object`. See
238+
// databricks-sdk-go/httpclient/request.go:makeRequestBody.
227239
func (a *lakeboxAPI) listPage(ctx context.Context, pageToken string) (*listResponse, error) {
228240
query := map[string]any{"page_size": listPageSize}
229241
if pageToken != "" {
230242
query["page_token"] = pageToken
231243
}
232244
var resp listResponse
233-
err := a.c.Do(ctx, http.MethodGet, lakeboxAPIPath, a.headers(), query, nil, &resp)
245+
err := a.c.Do(ctx, http.MethodGet, lakeboxAPIPath, a.headers(), nil, query, &resp)
234246
if err != nil {
235247
return nil, err
236248
}
@@ -276,7 +288,41 @@ func (a *lakeboxAPI) delete(ctx context.Context, id string) error {
276288
return a.c.Do(ctx, http.MethodDelete, lakeboxAPIPath+"/"+id, a.headers(), nil, nil, nil)
277289
}
278290

279-
// registerKey calls POST /api/2.0/lakebox/ssh-keys.
280-
func (a *lakeboxAPI) registerKey(ctx context.Context, publicKey string) error {
281-
return a.c.Do(ctx, http.MethodPost, lakeboxKeysAPIPath, a.headers(), nil, registerKeyRequest{PublicKey: publicKey}, nil)
291+
// registerKey calls POST /api/2.0/lakebox/ssh-keys. An empty `name` is
292+
// omitted from the wire payload so the server records "unset" rather than
293+
// an explicit empty string.
294+
func (a *lakeboxAPI) registerKey(ctx context.Context, publicKey, name string) error {
295+
return a.c.Do(ctx, http.MethodPost, lakeboxKeysAPIPath, a.headers(), nil, registerKeyRequest{PublicKey: publicKey, Name: name}, nil)
296+
}
297+
298+
// sshKeyEntry is a single item in the ssh-key list response. Mirrors the
299+
// `SshKey` proto message after JSON transcoding (`key_hash` → `keyHash`,
300+
// timestamps as RFC 3339 strings).
301+
type sshKeyEntry struct {
302+
KeyHash string `json:"keyHash"`
303+
Name string `json:"name,omitempty"`
304+
CreateTime string `json:"createTime,omitempty"`
305+
LastUseTime string `json:"lastUseTime,omitempty"`
306+
}
307+
308+
// listKeysResponse is the JSON body returned by GET /api/2.0/lakebox/ssh-keys.
309+
// Per-user keys are hard-capped at 100 server-side, so the full set fits in
310+
// one response — no pagination.
311+
type listKeysResponse struct {
312+
SshKeys []sshKeyEntry `json:"sshKeys"`
313+
}
314+
315+
// listKeys calls GET /api/2.0/lakebox/ssh-keys.
316+
func (a *lakeboxAPI) listKeys(ctx context.Context) ([]sshKeyEntry, error) {
317+
var resp listKeysResponse
318+
err := a.c.Do(ctx, http.MethodGet, lakeboxKeysAPIPath, a.headers(), nil, nil, &resp)
319+
if err != nil {
320+
return nil, err
321+
}
322+
return resp.SshKeys, nil
323+
}
324+
325+
// deleteKey calls DELETE /api/2.0/lakebox/ssh-keys/{key_hash}.
326+
func (a *lakeboxAPI) deleteKey(ctx context.Context, keyHash string) error {
327+
return a.c.Do(ctx, http.MethodDelete, lakeboxKeysAPIPath+"/"+keyHash, a.headers(), nil, nil, nil)
282328
}

cmd/lakebox/lakebox.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Common workflows:
3232
}
3333

3434
cmd.AddCommand(newRegisterCommand())
35+
cmd.AddCommand(newSSHKeyCommand())
3536
cmd.AddCommand(newSetDefaultCommand())
3637
cmd.AddCommand(newSSHCommand())
3738
cmd.AddCommand(newListCommand())

cmd/lakebox/register.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,25 @@ import (
1818
const lakeboxKeyName = "lakebox_rsa"
1919

2020
func newRegisterCommand() *cobra.Command {
21+
var name string
22+
2123
cmd := &cobra.Command{
2224
Use: "register",
2325
Short: "Register this machine for lakebox SSH access",
2426
Long: `Generate a dedicated SSH key for lakebox and register it with the service.
2527
2628
This command:
2729
1. Generates an RSA SSH key at ~/.ssh/lakebox_rsa (if it doesn't exist)
28-
2. Registers the public key with the lakebox service
30+
2. Registers the public key with the lakebox service, labeled with --name
31+
(defaults to this machine's hostname so 'ssh-key list' is meaningful
32+
across multiple machines)
2933
3034
After registration, 'databricks lakebox ssh' will use this key automatically.
3135
Run this once per machine.
3236
33-
Example:
34-
databricks lakebox register`,
37+
Examples:
38+
databricks lakebox register
39+
databricks lakebox register --name my-laptop`,
3540
PreRunE: root.MustWorkspaceClient,
3641
RunE: func(cmd *cobra.Command, args []string) error {
3742
ctx := cmd.Context()
@@ -58,9 +63,19 @@ Example:
5863
return fmt.Errorf("failed to read public key %s.pub: %w", keyPath, err)
5964
}
6065

66+
// Default the registered key's label to this machine's hostname so
67+
// `lakebox ssh-key list` is meaningful when the user has keys from
68+
// multiple machines. Failed hostname lookups fall through to the
69+
// server's "unset" default rather than blocking registration.
70+
if name == "" {
71+
if host, err := os.Hostname(); err == nil {
72+
name = host
73+
}
74+
}
75+
6176
s := spin(ctx, "Registering key…")
6277
defer s.Close()
63-
if err := api.registerKey(ctx, string(pubKeyData)); err != nil {
78+
if err := api.registerKey(ctx, string(pubKeyData), name); err != nil {
6479
s.fail("Failed to register key")
6580
return fmt.Errorf("failed to register key: %w", err)
6681
}
@@ -72,6 +87,10 @@ Example:
7287
},
7388
}
7489

90+
cmd.Flags().StringVar(&name, "name", "",
91+
"Label for the registered key (defaults to this machine's hostname). "+
92+
"Pass --name= to register without a label.")
93+
7594
return cmd
7695
}
7796

cmd/lakebox/sshkey.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package lakebox
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"strings"
8+
"time"
9+
10+
"github.com/databricks/cli/cmd/root"
11+
"github.com/databricks/cli/libs/cmdctx"
12+
"github.com/databricks/cli/libs/cmdio"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
func newSSHKeyCommand() *cobra.Command {
17+
cmd := &cobra.Command{
18+
Use: "ssh-key",
19+
Short: "Manage SSH keys registered with the lakebox service",
20+
}
21+
cmd.AddCommand(newSSHKeyListCommand())
22+
cmd.AddCommand(newSSHKeyDeleteCommand())
23+
return cmd
24+
}
25+
26+
func newSSHKeyDeleteCommand() *cobra.Command {
27+
cmd := &cobra.Command{
28+
Use: "delete <key-hash>",
29+
Short: "Delete an SSH key registered with the lakebox service",
30+
Long: `Delete an SSH key registered with the lakebox service.
31+
32+
The key hash is the identifier shown by 'databricks lakebox ssh-key list'.
33+
Once deleted, future SSH attempts authenticated by the corresponding
34+
private key will fail until the key is re-registered.
35+
36+
Example:
37+
databricks lakebox ssh-key delete a1b2c3d4e5f6...`,
38+
Args: cobra.ExactArgs(1),
39+
PreRunE: root.MustWorkspaceClient,
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
ctx := cmd.Context()
42+
w := cmdctx.WorkspaceClient(ctx)
43+
api, err := newLakeboxAPI(w)
44+
if err != nil {
45+
return err
46+
}
47+
48+
hash := args[0]
49+
s := spin(ctx, "Deleting key "+hash+"…")
50+
defer s.Close()
51+
if err := api.deleteKey(ctx, hash); err != nil {
52+
s.fail("Failed to delete key")
53+
return fmt.Errorf("failed to delete ssh key %s: %w", hash, err)
54+
}
55+
s.ok("SSH key " + cmdio.Bold(ctx, hash) + " deleted")
56+
return nil
57+
},
58+
}
59+
return cmd
60+
}
61+
62+
func newSSHKeyListCommand() *cobra.Command {
63+
var outputJSON bool
64+
65+
cmd := &cobra.Command{
66+
Use: "list",
67+
Short: "List SSH keys registered with the lakebox service",
68+
Long: `List SSH keys registered with the lakebox service.
69+
70+
Each row shows the server-assigned key hash (the identifier used to
71+
delete the key), the user-supplied name, and create / last-use
72+
timestamps. The locally-registered key (from 'databricks lakebox
73+
register') is marked when its hash matches one of the listed entries.
74+
75+
Examples:
76+
databricks lakebox ssh-key list
77+
databricks lakebox ssh-key list --json`,
78+
PreRunE: root.MustWorkspaceClient,
79+
RunE: func(cmd *cobra.Command, args []string) error {
80+
ctx := cmd.Context()
81+
w := cmdctx.WorkspaceClient(ctx)
82+
api, err := newLakeboxAPI(w)
83+
if err != nil {
84+
return err
85+
}
86+
87+
keys, err := api.listKeys(ctx)
88+
if err != nil {
89+
return fmt.Errorf("failed to list ssh keys: %w", err)
90+
}
91+
92+
if outputJSON {
93+
enc := json.NewEncoder(cmd.OutOrStdout())
94+
enc.SetIndent("", " ")
95+
return enc.Encode(keys)
96+
}
97+
98+
if len(keys) == 0 {
99+
fmt.Fprintf(cmd.ErrOrStderr(), " %s\n",
100+
cmdio.Dim(ctx, "No SSH keys registered. Run 'databricks lakebox register' to add one."))
101+
return nil
102+
}
103+
104+
// Best-effort: compute the hash of the locally-registered key so
105+
// we can highlight which row belongs to this machine. Missing key
106+
// file or read errors are non-fatal — just skip the marker.
107+
localHash := ""
108+
if path, err := lakeboxKeyPath(ctx); err == nil {
109+
if data, err := os.ReadFile(path + ".pub"); err == nil {
110+
localHash = keyHash(string(data))
111+
}
112+
}
113+
114+
out := cmd.OutOrStdout()
115+
blank(out)
116+
117+
nameCol := 4
118+
for _, k := range keys {
119+
if l := len(k.Name); l > nameCol {
120+
nameCol = l
121+
}
122+
}
123+
nameCol += 2
124+
const hashCol = 32
125+
const timeCol = 20
126+
127+
// Leading 4-char gutter reserves space for a per-row `*` marker on
128+
// the key matching this machine; header and separator leave it blank.
129+
header := fmt.Sprintf("%-*s %-*s %-*s %s",
130+
nameCol, "NAME", hashCol, "KEY HASH", timeCol, "CREATED", "LAST USED")
131+
fmt.Fprintf(out, " %s\n", cmdio.Dim(ctx, header))
132+
fmt.Fprintf(out, " %s\n", cmdio.Dim(ctx, strings.Repeat("─", nameCol+hashCol+timeCol+timeCol+6)))
133+
134+
for _, k := range keys {
135+
// Pad NAME manually from the raw width because cmdio.Dim
136+
// wraps the cell in ANSI escapes that throw off `%-*s`.
137+
displayName, visibleNameLen := k.Name, len(k.Name)
138+
if displayName == "" {
139+
displayName = cmdio.Dim(ctx, "(unset)")
140+
visibleNameLen = len("(unset)")
141+
}
142+
namePad := max(nameCol-visibleNameLen, 0)
143+
gutter := " "
144+
if localHash != "" && k.KeyHash == localHash {
145+
gutter = " " + cmdio.Cyan(ctx, "*") + " "
146+
}
147+
fmt.Fprintf(out, "%s%s%s %-*s %-*s %s\n",
148+
gutter,
149+
displayName, strings.Repeat(" ", namePad),
150+
hashCol, k.KeyHash,
151+
timeCol, formatTimeShort(k.CreateTime),
152+
formatTimeShort(k.LastUseTime))
153+
}
154+
blank(out)
155+
return nil
156+
},
157+
}
158+
159+
cmd.Flags().BoolVar(&outputJSON, "json", false, "Output as JSON")
160+
return cmd
161+
}
162+
163+
// formatTimeShort renders an RFC 3339 timestamp as a short, compact date
164+
// for table display. Returns "-" for empty input; passes through the raw
165+
// value if it doesn't parse (so server-side schema changes don't silently
166+
// hide data).
167+
func formatTimeShort(rfc3339 string) string {
168+
if rfc3339 == "" {
169+
return "-"
170+
}
171+
t, err := time.Parse(time.RFC3339, rfc3339)
172+
if err != nil {
173+
return rfc3339
174+
}
175+
return t.Format("2006-01-02 15:04")
176+
}

0 commit comments

Comments
 (0)