Skip to content

Commit 2c7c896

Browse files
misha-dbrclarey
andauthored
Start ssh tunnel (#1989)
## Changes Adds a new Start SSH Tunnel entry point in the Databricks configuration view that lets users open a remote development window over databricks ssh connect. Users can pick their compute — serverless (including serverless GPU accelerators), or a dedicated single-user cluster they own — and the CLI opens a remote IDE window connected to it. Also renames user-facing "Cluster" terminology to "Compute" across the UI to match current Databricks conventions. ## Tests CliWrapper.test.ts --------- Co-authored-by: Russell Clarey <r.e.clarey@gmail.com>
1 parent a7eea2c commit 2c7c896

13 files changed

Lines changed: 641 additions & 18 deletions

File tree

packages/databricks-vscode/DATABRICKS.quickstart.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@ The Databricks extension for Visual Studio Code enables you to connect to your r
4848

4949
If your folder has multiple [Declarative Automation Bundles](#dabs), you can select which one to use by clicking "Open Existing Databricks project" button and selecting the desired project.
5050

51-
## <a id="select-cluster"></a>Select a cluster
51+
## <a id="select-cluster"></a>Select compute
5252

53-
The extension uses an interactive cluster to run code. To select an interactive cluster:
53+
The extension uses interactive compute to run code. To select interactive compute:
5454

5555
1. Open the Databricks panel by clicking on the Databricks icon on the left
56-
2. Click on the "Select Cluster" button.
57-
- If you wish to change the selected cluster, click on the "Configure Cluster" gear icon, next to the name of the selected cluster.
56+
2. Click on the "Select Compute" button.
57+
- If you wish to change the selected compute, click on the "Configure compute" gear icon, next to the name of the selected compute.
5858

5959
## <a id="running-code"></a>Run Python code
6060

packages/databricks-vscode/package.json

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@
5252
"types": "out/extension.d.ts",
5353
"contributes": {
5454
"commands": [
55+
{
56+
"command": "databricks.ssh.startTunnel",
57+
"title": "Start SSH Tunnel",
58+
"category": "Databricks",
59+
"icon": "$(remote)",
60+
"enablement": "!databricks.context.remoteMode"
61+
},
5562
{
5663
"command": "databricks.connection.logout",
5764
"title": "Logout",
@@ -73,20 +80,20 @@
7380
},
7481
{
7582
"command": "databricks.connection.attachCluster",
76-
"title": "Attach cluster",
83+
"title": "Attach compute",
7784
"enablement": "databricks.context.activated && databricks.context.loggedIn && !databricks.context.remoteMode",
7885
"icon": "$(plug)"
7986
},
8087
{
8188
"command": "databricks.connection.attachClusterQuickPick",
82-
"title": "Configure cluster",
89+
"title": "Configure compute",
8390
"category": "Databricks",
8491
"enablement": "databricks.context.activated && databricks.context.loggedIn && !databricks.context.remoteMode",
8592
"icon": "$(gear)"
8693
},
8794
{
8895
"command": "databricks.connection.detachCluster",
89-
"title": "Detach cluster",
96+
"title": "Detach compute",
9097
"category": "Databricks",
9198
"enablement": "databricks.context.activated && databricks.context.loggedIn && !databricks.context.remoteMode",
9299
"icon": "$(debug-disconnect)"
@@ -151,14 +158,14 @@
151158
},
152159
{
153160
"command": "databricks.cluster.start",
154-
"title": "Start Cluster",
161+
"title": "Start Compute",
155162
"icon": "$(debug-start)",
156163
"enablement": "databricks.context.activated && databricks.context.loggedIn && !databricks.context.remoteMode",
157164
"category": "Databricks"
158165
},
159166
{
160167
"command": "databricks.cluster.stop",
161-
"title": "Stop Cluster",
168+
"title": "Stop Compute",
162169
"icon": "$(stop-circle)",
163170
"enablement": "databricks.context.activated && databricks.context.loggedIn && !databricks.context.remoteMode",
164171
"category": "Databricks"
@@ -539,10 +546,15 @@
539546
"name": "Configuration",
540547
"when": "!databricks.context.remoteMode"
541548
},
549+
{
550+
"id": "sshTunnelView",
551+
"name": "SSH Tunnel",
552+
"when": "!databricks.context.remoteMode"
553+
},
542554
{
543555
"id": "clusterView",
544556
"when": "databricks.feature.views.cluster && !databricks.context.remoteMode",
545-
"name": "Clusters"
557+
"name": "Compute"
546558
},
547559
{
548560
"id": "dabsResourceExplorerView",
@@ -707,6 +719,10 @@
707719
{
708720
"view": "configurationView",
709721
"contents": "To learn more about how to use the Databricks extension for Visual Studio Code [read our docs](https://docs.databricks.com/dev-tools/vscode-ext.html) or [Quickstart guide](command:databricks.quickstart.open)"
722+
},
723+
{
724+
"view": "sshTunnelView",
725+
"contents": "Connect your IDE to a Databricks SSH Tunnel so you can run all Python and SQL workloads using Databricks compute.\n[Start SSH Tunnel](command:databricks.ssh.startTunnel)\nTo learn more about the SSH tunnel [read our docs](https://docs.databricks.com/aws/en/dev-tools/ssh-tunnel)."
710726
}
711727
],
712728
"menus": {
@@ -1233,7 +1249,7 @@
12331249
"submenus": [
12341250
{
12351251
"id": "databricks.cluster.filter",
1236-
"label": "Filter clusters ...",
1252+
"label": "Filter compute ...",
12371253
"icon": "$(filter)"
12381254
},
12391255
{
@@ -1439,7 +1455,7 @@
14391455
"databricks.clusters.onlyShowAccessibleClusters": {
14401456
"type": "boolean",
14411457
"default": false,
1442-
"description": "Enable/disable filtering for only accessible clusters (clusters on which the current user can run code)"
1458+
"description": "Enable/disable filtering for only accessible compute (compute on which the current user can run code)"
14431459
},
14441460
"databricks.overrideDatabricksConfigFile": {
14451461
"type": "string",

packages/databricks-vscode/src/cli/CliWrapper.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {execFile as execFileCb} from "node:child_process";
66
import {withFile} from "tmp-promise";
77
import {writeFile, readFile} from "node:fs/promises";
88
import {when, spy, reset, instance, mock} from "ts-mockito";
9-
import {CliWrapper, waitForProcess} from "./CliWrapper";
9+
import {CliWrapper, getSshConnectCommand, waitForProcess} from "./CliWrapper";
1010
import path from "node:path";
1111
import os from "node:os";
1212
import crypto from "node:crypto";
@@ -128,6 +128,45 @@ describe(__filename, function () {
128128
assert.equal([command, ...args].join(" "), syncCommand);
129129
});
130130

131+
it("should create ssh connect commands", () => {
132+
// Logging is configured via env vars, not CLI flags, so no --log-*
133+
// args appear on the ssh connect command line.
134+
135+
// Serverless: no --cluster / --auto-start-cluster.
136+
let {args} = getSshConnectCommand({compute: {type: "serverless"}});
137+
assert.deepStrictEqual(args, [
138+
"ssh",
139+
"connect",
140+
"--ide=vscode",
141+
"--auto-approve",
142+
]);
143+
144+
// Serverless GPU: --accelerator, no --cluster / --auto-start-cluster.
145+
({args} = getSshConnectCommand({
146+
compute: {type: "serverless", accelerator: "GPU_1xA10"},
147+
}));
148+
assert.deepStrictEqual(args, [
149+
"ssh",
150+
"connect",
151+
"--ide=vscode",
152+
"--auto-approve",
153+
"--accelerator=GPU_1xA10",
154+
]);
155+
156+
// Dedicated cluster: --cluster and --auto-start-cluster.
157+
({args} = getSshConnectCommand({
158+
compute: {type: "cluster", clusterId: "1234-clusterid"},
159+
}));
160+
assert.deepStrictEqual(args, [
161+
"ssh",
162+
"connect",
163+
"--ide=vscode",
164+
"--auto-approve",
165+
"--cluster=1234-clusterid",
166+
"--auto-start-cluster",
167+
]);
168+
});
169+
131170
it("should list profiles when no config file exists", async () => {
132171
const logFilePath = getTempLogFilePath();
133172
const cli = createCliWrapper(logFilePath);

packages/databricks-vscode/src/cli/CliWrapper.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Uri,
1111
commands,
1212
CancellationToken,
13+
env,
1314
} from "vscode";
1415
import {workspaceConfigs} from "../vscode-objs/WorkspaceConfigs";
1516
import {promisify} from "node:util";
@@ -104,6 +105,37 @@ export interface ConfigEntry {
104105
}
105106

106107
export type SyncType = "full" | "incremental";
108+
109+
export type SshConnectCompute =
110+
| {type: "serverless"; accelerator?: string}
111+
| {type: "cluster"; clusterId: string};
112+
113+
/**
114+
* Constructs the `databricks ssh connect` command args for opening a remote
115+
* IDE window. Serverless is the default when no cluster is given.
116+
*
117+
* The --ide flag matches the host editor so the CLI opens the right remote
118+
* window: Cursor identifies itself via env.uriScheme === "cursor",
119+
* everything else (VS Code, Insiders) uses vscode.
120+
*
121+
* Logging is configured out of band via the DATABRICKS_LOG_* env vars (see
122+
* CliWrapper.getSshConnectEnvVars), so we do not pass --log-* flags here.
123+
*/
124+
export function getSshConnectCommand(opts: {compute: SshConnectCompute}): {
125+
args: string[];
126+
} {
127+
const ide = env.uriScheme === "cursor" ? "cursor" : "vscode";
128+
const args = ["ssh", "connect", `--ide=${ide}`, "--auto-approve"];
129+
if (opts.compute.type === "cluster") {
130+
// Start a stopped single-user cluster when connecting.
131+
args.push(`--cluster=${opts.compute.clusterId}`);
132+
args.push("--auto-start-cluster");
133+
} else if (opts.compute.accelerator) {
134+
// Serverless GPU: request a specific accelerator type.
135+
args.push(`--accelerator=${opts.compute.accelerator}`);
136+
}
137+
return {args};
138+
}
107139
export class ProcessError extends Error {
108140
constructor(
109141
message: string,
@@ -574,6 +606,24 @@ export class CliWrapper {
574606
});
575607
}
576608

609+
/**
610+
* Env vars for interactive CLI commands run in a terminal (e.g. `ssh
611+
* connect`). Auth is forwarded via env vars, matching the bundle init flow.
612+
*/
613+
getSshConnectEnvVars(authProvider: AuthProvider) {
614+
return removeUndefinedKeys({
615+
...EnvVarGenerators.getEnvVarsForCli(
616+
this.extensionContext,
617+
workspaceConfigs.databrickscfgLocation
618+
),
619+
...EnvVarGenerators.getProxyEnvVars(),
620+
...this.getLogginEnvVars(),
621+
...authProvider.toEnv(),
622+
// eslint-disable-next-line @typescript-eslint/naming-convention
623+
DATABRICKS_OUTPUT_FORMAT: "text",
624+
});
625+
}
626+
577627
async bundleInit(
578628
templateDirPath: string,
579629
outputDirPath: string,

packages/databricks-vscode/src/cluster/ClusterModel.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ export class ClusterModel implements Disposable {
6767
);
6868
}
6969

70+
/**
71+
* All loaded clusters, ignoring the explorer's active filter. Consumers that
72+
* need the complete set (e.g. the SSH tunnel picker, which starts stopped
73+
* clusters via --auto-start-cluster) must not inherit the explorer's
74+
* `ALL`/`ME`/`RUNNING` filter.
75+
*/
76+
public get allRoots(): Cluster[] | undefined {
77+
return sortClusters(Array.from(this.clusterLoader.clusters.values()));
78+
}
79+
7080
private applyFilter(nodes: Cluster[] | undefined): Cluster[] | undefined {
7181
if (!nodes) {
7282
return nodes;

packages/databricks-vscode/src/configuration/ConnectionCommands.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import {ApiClient} from "@databricks/sdk-experimental";
33
import {Cluster} from "../sdk-extensions";
44
import assert from "assert";
55
import {mock} from "ts-mockito";
6-
import {formatQuickPickClusterDetails} from "./ConnectionCommands";
6+
import {
7+
formatClusterState,
8+
formatQuickPickClusterDetails,
9+
} from "./ConnectionCommands";
710

811
describe(__filename, () => {
912
it("attach cluster quickpick: correctly format cluster details", () => {
@@ -21,4 +24,14 @@ describe(__filename, () => {
2124

2225
assert.equal(clusterDetails, `2 GB | 4 Cores | spark-version | user-2`);
2326
});
27+
28+
it("formatClusterState: maps RUNNING/TERMINATED to Active/Inactive and title-cases the rest", () => {
29+
assert.equal(formatClusterState("RUNNING"), "Active");
30+
assert.equal(formatClusterState("TERMINATED"), "Inactive");
31+
assert.equal(formatClusterState("PENDING"), "Pending");
32+
assert.equal(formatClusterState("RESTARTING"), "Restarting");
33+
assert.equal(formatClusterState("TERMINATING"), "Terminating");
34+
assert.equal(formatClusterState("ERROR"), "Error");
35+
assert.equal(formatClusterState("UNKNOWN"), "Unknown");
36+
});
2437
});

packages/databricks-vscode/src/configuration/ConnectionCommands.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {Cluster} from "../sdk-extensions";
2+
import {compute} from "@databricks/sdk-experimental";
23
import {
34
Disposable,
45
QuickPickItem,
@@ -40,6 +41,20 @@ function formatQuickPickClusterSize(sizeInMB: number): string {
4041
return `${sizeInMB} MB`;
4142
}
4243
}
44+
// Formats a compute state for display in the picker. RUNNING/TERMINATED map to
45+
// the softer "Active"/"Inactive" (matching the Workspace UI); other states are
46+
// title-cased ("PENDING" -> "Pending") so they read less harshly.
47+
export function formatClusterState(state: compute.State): string {
48+
switch (state) {
49+
case "RUNNING":
50+
return "Active";
51+
case "TERMINATED":
52+
return "Inactive";
53+
default:
54+
return state.charAt(0) + state.slice(1).toLowerCase();
55+
}
56+
}
57+
4358
export function formatQuickPickClusterDetails(cluster: Cluster) {
4459
const details = [];
4560
if (cluster.memoryMb) {
@@ -143,7 +158,7 @@ export class ConnectionCommands implements Disposable {
143158
ClusterItem | QuickPickItem
144159
>();
145160
quickPick.title =
146-
typeof title === "string" ? title : "Select Cluster";
161+
typeof title === "string" ? title : "Select Compute";
147162
quickPick.keepScrollPosition = true;
148163
quickPick.busy = true;
149164
quickPick.canSelectMany = false;

packages/databricks-vscode/src/configuration/ConnectionManager.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ export class ConnectionManager implements Disposable {
6868
this.onDidChangeSyncDestinationEmitter.event;
6969

7070
private readonly initialization = new Barrier();
71+
// Set once init() has resolved the initialization barrier. Guards callers
72+
// (e.g. the SSH tunnel flow) that must not `await login()` before init(),
73+
// since that would block on the barrier forever when the workspace is not a
74+
// Databricks project and init() is never called.
75+
private _initialized = false;
76+
77+
/**
78+
* Whether the connection manager has been initialized (init() ran).
79+
* Only true once the workspace has been set up as a Databricks project.
80+
*/
81+
get isInitialized(): boolean {
82+
return this._initialized;
83+
}
7184

7285
get projectRoot() {
7386
return this.workspaceFolderManager.activeProjectUri;
@@ -228,6 +241,7 @@ export class ConnectionManager implements Disposable {
228241
)
229242
)
230243
);
244+
this._initialized = true;
231245
this.initialization.resolve();
232246
}
233247
}

packages/databricks-vscode/src/extension.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {Events, Metadata} from "./telemetry/constants";
5353
import {EnvironmentDependenciesInstaller} from "./language/EnvironmentDependenciesInstaller";
5454
import {setDbnbCellLimits} from "./language/notebooks/DatabricksNbCellLimits";
5555
import {DbConnectStatusBarButton} from "./language/DbConnectStatusBarButton";
56+
import {SshCommands} from "./ssh/SshCommands";
5657
import {NotebookInitScriptManager} from "./language/notebooks/NotebookInitScriptManager";
5758
import {showRestartNotebookDialogue} from "./language/notebooks/restartNotebookDialogue";
5859
import {
@@ -169,6 +170,18 @@ export async function activate(
169170
}
170171
)
171172
);
173+
// The SSH Tunnel panel is always visible, including on the start screen
174+
// with no folder open. There is no ConnectionManager/ClusterModel here,
175+
// so wire the command to the standalone (login-then-tunnel) flow.
176+
const sshCommands = new SshCommands(cli);
177+
context.subscriptions.push(
178+
sshCommands,
179+
telemetry.registerCommand(
180+
"databricks.ssh.startTunnel",
181+
sshCommands.startTunnelCommand,
182+
sshCommands
183+
)
184+
);
172185
// We show a welcome view when there's no workspace folders, prompting users
173186
// to either open a new folder or to initialize a new databricks project.
174187
// In both cases we expect the workspace to be reloaded and the extension will
@@ -836,6 +849,17 @@ export async function activate(
836849
)
837850
);
838851

852+
// SSH tunnel (remote development) group
853+
const sshCommands = new SshCommands(cli, connectionManager, clusterModel);
854+
context.subscriptions.push(
855+
sshCommands,
856+
telemetry.registerCommand(
857+
"databricks.ssh.startTunnel",
858+
sshCommands.startTunnelCommand,
859+
sshCommands
860+
)
861+
);
862+
839863
// Cluster group
840864
const clusterTreeDataProvider = new ClusterListDataProvider(clusterModel);
841865
const clusterCommands = new ClusterCommands(

0 commit comments

Comments
 (0)