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