Skip to content

Commit b65a733

Browse files
committed
Merge PR #4930: Add Lakebox CLI for managing Databricks sandbox environments
Brings in the original cmd/lakebox/* sources from #4930 with full commit-history attribution. Subsequent commits adapt the standalone CLI into a 'databricks lakebox' subcommand, replace hand-rolled HTTP/spinner/color plumbing with libs primitives, and add unit tests.
2 parents e4a77e1 + b87b712 commit b65a733

13 files changed

Lines changed: 1451 additions & 0 deletions

File tree

cmd/lakebox/api.go

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
package lakebox
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
"time"
13+
14+
"github.com/databricks/databricks-sdk-go"
15+
)
16+
17+
// Sandboxes live under the `/sandboxes` sub-collection of the lakebox service
18+
// namespace (see `lakebox.proto` `LakeboxService.CreateSandbox`).
19+
const lakeboxAPIPath = "/api/2.0/lakebox/sandboxes"
20+
21+
// lakeboxAPI wraps raw HTTP calls to the lakebox REST API.
22+
type lakeboxAPI struct {
23+
w *databricks.WorkspaceClient
24+
}
25+
26+
// createRequest is the JSON body for POST /api/2.0/lakebox/sandboxes.
27+
//
28+
// The proto-defined `CreateSandboxRequest` carries a `Sandbox sandbox = 1`
29+
// field today (every member is server-chosen), but JSON transcoding accepts
30+
// the unwrapped form for forward-compatible callers. Keep `public_key` here
31+
// as a no-op compat shim so older `lakebox create --public-key-file=...`
32+
// invocations don't error — the manager ignores it on the wire.
33+
type createRequest struct {
34+
PublicKey string `json:"public_key,omitempty"`
35+
}
36+
37+
// createResponse is the JSON body returned by POST /api/2.0/lakebox/sandboxes.
38+
// Mirrors the `Sandbox` proto message after JSON transcoding.
39+
type createResponse struct {
40+
SandboxID string `json:"sandboxId"`
41+
Status string `json:"status"`
42+
FQDN string `json:"fqdn"`
43+
}
44+
45+
// sandboxEntry is a single item in the list response.
46+
// Mirrors the `Sandbox` proto message after JSON transcoding.
47+
//
48+
// IdleTimeout and NoAutostop correspond to the proto's `optional` fields;
49+
// they're pointers so we can tell "field absent on the wire" (server has
50+
// the global default) from "explicitly set to 0 / false."
51+
//
52+
// `IdleTimeout` is a `google.protobuf.Duration`. Proto3 JSON canonical
53+
// form serializes Duration as a string with an `s` suffix (e.g.
54+
// `"900s"`), so the Go field is `*string` and we parse on read.
55+
type sandboxEntry struct {
56+
SandboxID string `json:"sandboxId"`
57+
Status string `json:"status"`
58+
FQDN string `json:"fqdn"`
59+
IdleTimeout *string `json:"idleTimeout,omitempty"`
60+
NoAutostop *bool `json:"noAutostop,omitempty"`
61+
}
62+
63+
// idleTimeoutSecs parses the proto3-canonical Duration string off
64+
// `IdleTimeout` (e.g. `"900s"` → `900`). Returns 0 when unset or when
65+
// the string is not a recognizable Duration. Sub-second precision is
66+
// dropped — the watchdog only acts on whole seconds.
67+
func (e *sandboxEntry) idleTimeoutSecs() int64 {
68+
if e.IdleTimeout == nil {
69+
return 0
70+
}
71+
s := *e.IdleTimeout
72+
if !strings.HasSuffix(s, "s") {
73+
return 0
74+
}
75+
d, err := time.ParseDuration(s)
76+
if err != nil {
77+
return 0
78+
}
79+
return int64(d.Seconds())
80+
}
81+
82+
// defaultAutoStopSecs mirrors the manager's `watchdog_idle_grace_secs`
83+
// fallback (10 minutes) used when a sandbox has no per-record override.
84+
// The value is also documented in `lakebox/CLAUDE.md` ("Sandbox
85+
// Watchdog" section). Hardcoded here so list/status can render the
86+
// effective timeout without an extra round-trip to fetch manager config.
87+
const defaultAutoStopSecs int64 = 600
88+
89+
// autoStopLabel renders the auto-stop policy advertised by the manager
90+
// for one sandbox into a short human-readable string. Mirrors the wire
91+
// semantics from `lakebox/proto/lakebox.proto`:
92+
// - `no_autostop == true` → never auto-stops
93+
// - `idle_timeout` set and positive → that many seconds
94+
// - otherwise → manager's global default (`defaultAutoStopSecs`)
95+
func (e *sandboxEntry) autoStopLabel() string {
96+
if e.NoAutostop != nil && *e.NoAutostop {
97+
return "never"
98+
}
99+
if secs := e.idleTimeoutSecs(); secs > 0 {
100+
return formatDurationSecs(secs)
101+
}
102+
return formatDurationSecs(defaultAutoStopSecs)
103+
}
104+
105+
// formatDurationSecs prints `secs` as a compact duration (e.g. `90s`,
106+
// `15m`, `2h`, `1h30m`). Falls back to seconds if it's not a clean
107+
// minute/hour multiple. Avoids pulling in a dependency just for this.
108+
func formatDurationSecs(secs int64) string {
109+
if secs < 60 {
110+
return fmt.Sprintf("%ds", secs)
111+
}
112+
if secs%3600 == 0 {
113+
return fmt.Sprintf("%dh", secs/3600)
114+
}
115+
if secs >= 3600 {
116+
return fmt.Sprintf("%dh%dm", secs/3600, (secs%3600)/60)
117+
}
118+
if secs%60 == 0 {
119+
return fmt.Sprintf("%dm", secs/60)
120+
}
121+
return fmt.Sprintf("%ds", secs)
122+
}
123+
124+
// listResponse is the JSON body returned by GET /api/2.0/lakebox/sandboxes.
125+
type listResponse struct {
126+
Sandboxes []sandboxEntry `json:"sandboxes"`
127+
}
128+
129+
// apiError is the error body returned by the lakebox API.
130+
type apiError struct {
131+
ErrorCode string `json:"error_code"`
132+
Message string `json:"message"`
133+
}
134+
135+
func (e *apiError) Error() string {
136+
return fmt.Sprintf("%s: %s", e.ErrorCode, e.Message)
137+
}
138+
139+
func newLakeboxAPI(w *databricks.WorkspaceClient) *lakeboxAPI {
140+
return &lakeboxAPI{w: w}
141+
}
142+
143+
// create calls POST /api/2.0/lakebox with an optional public key.
144+
func (a *lakeboxAPI) create(ctx context.Context, publicKey string) (*createResponse, error) {
145+
body := createRequest{PublicKey: publicKey}
146+
jsonBody, err := json.Marshal(body)
147+
if err != nil {
148+
return nil, fmt.Errorf("failed to marshal request: %w", err)
149+
}
150+
151+
resp, err := a.doRequest(ctx, "POST", lakeboxAPIPath, bytes.NewReader(jsonBody))
152+
if err != nil {
153+
return nil, err
154+
}
155+
defer resp.Body.Close()
156+
157+
if resp.StatusCode != http.StatusOK {
158+
return nil, parseAPIError(resp)
159+
}
160+
161+
var result createResponse
162+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
163+
return nil, fmt.Errorf("failed to decode response: %w", err)
164+
}
165+
return &result, nil
166+
}
167+
168+
// list calls GET /api/2.0/lakebox/sandboxes.
169+
func (a *lakeboxAPI) list(ctx context.Context) ([]sandboxEntry, error) {
170+
resp, err := a.doRequest(ctx, "GET", lakeboxAPIPath, nil)
171+
if err != nil {
172+
return nil, err
173+
}
174+
defer resp.Body.Close()
175+
176+
if resp.StatusCode != http.StatusOK {
177+
return nil, parseAPIError(resp)
178+
}
179+
180+
var result listResponse
181+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
182+
return nil, fmt.Errorf("failed to decode response: %w", err)
183+
}
184+
return result.Sandboxes, nil
185+
}
186+
187+
// get calls GET /api/2.0/lakebox/sandboxes/{id}.
188+
func (a *lakeboxAPI) get(ctx context.Context, id string) (*sandboxEntry, error) {
189+
resp, err := a.doRequest(ctx, "GET", lakeboxAPIPath+"/"+id, nil)
190+
if err != nil {
191+
return nil, err
192+
}
193+
defer resp.Body.Close()
194+
195+
if resp.StatusCode != http.StatusOK {
196+
return nil, parseAPIError(resp)
197+
}
198+
199+
var result sandboxEntry
200+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
201+
return nil, fmt.Errorf("failed to decode response: %w", err)
202+
}
203+
return &result, nil
204+
}
205+
206+
// updateBody is the PATCH request body. The proto declares
207+
// `UpdateSandboxRequest { Sandbox sandbox = 1 }` with `body: "sandbox"`
208+
// in the (google.api.http) annotation, so the HTTP body is the inner
209+
// `Sandbox` message directly — there is no `{"sandbox": {...}}`
210+
// wrapping on the wire.
211+
//
212+
// Pointer fields encode the proto3 `optional` semantics — only the
213+
// fields we explicitly set are emitted, leaving everything else
214+
// server-untouched. `IdleTimeout` is a proto3-canonical Duration
215+
// string (e.g. `"900s"`); the server-side wire type is
216+
// `google.protobuf.Duration`.
217+
type updateBody struct {
218+
SandboxID string `json:"sandbox_id"`
219+
IdleTimeout *string `json:"idle_timeout,omitempty"`
220+
NoAutostop *bool `json:"no_autostop,omitempty"`
221+
}
222+
223+
// update calls PATCH /api/2.0/lakebox/sandboxes/{id} with whichever of
224+
// `idle_timeout` / `no_autostop` the caller chose to set. Fields left
225+
// nil are omitted from the wire payload, so the server preserves their
226+
// current values. Returns the refreshed `sandboxEntry`.
227+
func (a *lakeboxAPI) update(
228+
ctx context.Context,
229+
id string,
230+
idleTimeoutSecs *int64,
231+
noAutostop *bool,
232+
) (*sandboxEntry, error) {
233+
var idleTimeout *string
234+
if idleTimeoutSecs != nil {
235+
s := fmt.Sprintf("%ds", *idleTimeoutSecs)
236+
idleTimeout = &s
237+
}
238+
body := updateBody{
239+
SandboxID: id,
240+
IdleTimeout: idleTimeout,
241+
NoAutostop: noAutostop,
242+
}
243+
jsonBody, err := json.Marshal(body)
244+
if err != nil {
245+
return nil, fmt.Errorf("failed to marshal request: %w", err)
246+
}
247+
248+
resp, err := a.doRequest(ctx, "PATCH", lakeboxAPIPath+"/"+id, bytes.NewReader(jsonBody))
249+
if err != nil {
250+
return nil, err
251+
}
252+
defer resp.Body.Close()
253+
254+
if resp.StatusCode != http.StatusOK {
255+
return nil, parseAPIError(resp)
256+
}
257+
258+
var result sandboxEntry
259+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
260+
return nil, fmt.Errorf("failed to decode response: %w", err)
261+
}
262+
return &result, nil
263+
}
264+
265+
// delete calls DELETE /api/2.0/lakebox/sandboxes/{id}.
266+
func (a *lakeboxAPI) delete(ctx context.Context, id string) error {
267+
resp, err := a.doRequest(ctx, "DELETE", lakeboxAPIPath+"/"+id, nil)
268+
if err != nil {
269+
return err
270+
}
271+
defer resp.Body.Close()
272+
273+
if resp.StatusCode != http.StatusOK {
274+
return parseAPIError(resp)
275+
}
276+
return nil
277+
}
278+
279+
// doRequest makes an authenticated HTTP request to the workspace.
280+
func (a *lakeboxAPI) doRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
281+
// The configured host may be just a hostname or may carry a workspace
282+
// selector in the query (e.g. `https://dogfood.staging.databricks.com/?o=...`).
283+
// Parse it so we can append the API path while preserving the query, and so
284+
// we can pull the workspace ID out of `?o=<id>` when the SDK config doesn't
285+
// carry it on a separate `workspace_id` field.
286+
parsed, err := url.Parse(a.w.Config.Host)
287+
if err != nil {
288+
return nil, fmt.Errorf("failed to parse host %q: %w", a.w.Config.Host, err)
289+
}
290+
wsid := a.w.Config.WorkspaceID
291+
if wsid == "" {
292+
if v := parsed.Query().Get("o"); v != "" {
293+
wsid = v
294+
}
295+
}
296+
parsed.Path = strings.TrimRight(parsed.Path, "/") + path
297+
298+
req, err := http.NewRequestWithContext(ctx, method, parsed.String(), body)
299+
if err != nil {
300+
return nil, fmt.Errorf("failed to create request: %w", err)
301+
}
302+
303+
if err := a.w.Config.Authenticate(req); err != nil {
304+
return nil, fmt.Errorf("failed to authenticate: %w", err)
305+
}
306+
307+
// Multi-workspace gateways (e.g. dogfood.staging.databricks.com) need a
308+
// workspace selector to route the request — without it the gateway can't
309+
// scope the credential and rejects with "Credential was not sent or was of
310+
// an unsupported type for this API". `?o=<id>` in the URL works as a
311+
// fallback, but the explicit header is the well-defined contract.
312+
if wsid != "" {
313+
req.Header.Set("X-Databricks-Org-Id", wsid)
314+
}
315+
316+
if body != nil {
317+
req.Header.Set("Content-Type", "application/json")
318+
}
319+
320+
return http.DefaultClient.Do(req)
321+
}
322+
323+
func parseAPIError(resp *http.Response) error {
324+
body, _ := io.ReadAll(resp.Body)
325+
var apiErr apiError
326+
if json.Unmarshal(body, &apiErr) == nil && apiErr.Message != "" {
327+
return &apiErr
328+
}
329+
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
330+
}
331+
332+
// SSH keys are now nested under the lakebox service namespace alongside
333+
// `sandboxes/` (see `LakeboxService.CreateSshKey`).
334+
const lakeboxKeysAPIPath = "/api/2.0/lakebox/ssh-keys"
335+
336+
// registerKeyRequest is the JSON body for POST /api/2.0/lakebox/ssh-keys.
337+
type registerKeyRequest struct {
338+
PublicKey string `json:"public_key"`
339+
Name string `json:"name,omitempty"`
340+
}
341+
342+
// registerKey calls POST /api/2.0/lakebox/ssh-keys.
343+
func (a *lakeboxAPI) registerKey(ctx context.Context, publicKey string) error {
344+
body := registerKeyRequest{PublicKey: publicKey}
345+
jsonBody, err := json.Marshal(body)
346+
if err != nil {
347+
return fmt.Errorf("failed to marshal request: %w", err)
348+
}
349+
350+
resp, err := a.doRequest(ctx, "POST", lakeboxKeysAPIPath, bytes.NewReader(jsonBody))
351+
if err != nil {
352+
return err
353+
}
354+
defer resp.Body.Close()
355+
356+
if resp.StatusCode != http.StatusOK {
357+
return parseAPIError(resp)
358+
}
359+
return nil
360+
}

0 commit comments

Comments
 (0)