Skip to content

Commit b5e1754

Browse files
authored
Enable Unity Catalog (and Docs) in Databricks Remote SSH mode (#2016)
## Changes When the extension runs inside a Databricks Remote SSH session ("remote mode"), it previously short-circuited activation and surfaced no data views. This PR surfaces the Unity Catalog browser (and the Documentation view) in remote mode, connecting Unity Catalog directly from the ambient environment credentials — no bundle/config project (host + target) required. ## Tests ConnectionManager.test.ts
1 parent 2c7c896 commit b5e1754

7 files changed

Lines changed: 587 additions & 169 deletions

File tree

packages/databricks-vscode/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@
263263
"command": "databricks.unityCatalog.refresh",
264264
"title": "Refresh Unity Catalog view",
265265
"icon": "$(refresh)",
266-
"enablement": "databricks.context.activated && databricks.context.loggedIn",
266+
"enablement": "databricks.context.activated && (databricks.context.loggedIn || databricks.context.remoteMode)",
267267
"category": "Databricks"
268268
},
269269
{
@@ -576,12 +576,12 @@
576576
{
577577
"id": "unityCatalogView",
578578
"name": "Unity Catalog",
579-
"when": "databricks.context.activated && databricks.context.loggedIn"
579+
"when": "databricks.context.activated && (databricks.context.loggedIn || databricks.context.remoteMode)"
580580
},
581581
{
582582
"id": "databricksDocsView",
583583
"name": "Documentation",
584-
"when": "databricks.context.activated && !databricks.context.remoteMode"
584+
"when": "databricks.context.activated"
585585
}
586586
]
587587
},
Lines changed: 144 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,160 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22

3+
import assert from "assert";
34
import {Disposable} from "vscode";
5+
import {anything, instance, mock, reset, verify, when} from "ts-mockito";
6+
import {WorkspaceClient} from "@databricks/sdk-experimental";
7+
import {ConnectionManager} from "./ConnectionManager";
8+
import {ConfigModel} from "./models/ConfigModel";
9+
import {CliWrapper} from "../cli/CliWrapper";
10+
import {WorkspaceFolderManager} from "../vscode-objs/WorkspaceFolderManager";
11+
import {CustomWhenContext} from "../vscode-objs/CustomWhenContext";
12+
import {Telemetry} from "../telemetry";
13+
import {AuthProvider} from "./auth/AuthProvider";
414

515
describe(__filename, () => {
616
let disposables: Array<Disposable>;
717

18+
let mockCli: CliWrapper;
19+
let mockConfigModel: ConfigModel;
20+
let mockWorkspaceFolderManager: WorkspaceFolderManager;
21+
let mockCustomWhenContext: CustomWhenContext;
22+
let mockAuthProvider: AuthProvider;
23+
let mockWorkspaceClient: WorkspaceClient;
24+
25+
function buildConnectionManager(): ConnectionManager {
26+
return new ConnectionManager(
27+
instance(mockCli),
28+
instance(mockConfigModel),
29+
instance(mockWorkspaceFolderManager),
30+
instance(mockCustomWhenContext),
31+
new Telemetry()
32+
);
33+
}
34+
835
beforeEach(() => {
936
disposables = [];
37+
mockCli = mock(CliWrapper);
38+
mockConfigModel = mock(ConfigModel);
39+
mockWorkspaceFolderManager = mock(WorkspaceFolderManager);
40+
mockCustomWhenContext = mock(CustomWhenContext);
41+
mockAuthProvider = mock<AuthProvider>();
42+
mockWorkspaceClient = mock(WorkspaceClient);
43+
44+
// DatabricksWorkspace.load() reads the org id from a header on the
45+
// currentUser.me() response and (best-effort) the workspace conf.
46+
when(mockWorkspaceClient.currentUser).thenReturn({
47+
me: async () =>
48+
({
49+
"userName": "test@databricks.com",
50+
"x-databricks-org-id": "1234",
51+
}) as any,
52+
} as any);
53+
when(mockWorkspaceClient.apiClient).thenReturn(undefined as any);
54+
when(mockAuthProvider.getWorkspaceClient()).thenResolve(
55+
instance(mockWorkspaceClient)
56+
);
57+
when(mockAuthProvider.host).thenReturn(
58+
new URL("https://test.databricks.com")
59+
);
1060
});
1161

1262
afterEach(() => {
1363
disposables.forEach((d) => d.dispose());
64+
reset(mockConfigModel);
65+
});
66+
67+
it("connectFromEnvironment connects using the injected auth provider", async () => {
68+
const cm = buildConnectionManager();
69+
disposables.push(cm);
70+
71+
await cm.connectFromEnvironment(instance(mockAuthProvider));
72+
73+
assert.equal(cm.state, "CONNECTED");
74+
assert.ok(cm.workspaceClient);
75+
assert.ok(cm.databricksWorkspace);
76+
assert.equal(
77+
cm.databricksWorkspace?.host.toString(),
78+
"https://test.databricks.com/"
79+
);
80+
verify(mockCustomWhenContext.setLoggedIn(true)).atLeast(1);
81+
});
82+
83+
it("connectFromEnvironment does not touch the config model (no bundle coupling)", async () => {
84+
const cm = buildConnectionManager();
85+
disposables.push(cm);
86+
87+
await cm.connectFromEnvironment(instance(mockAuthProvider));
88+
89+
verify(mockConfigModel.set(anything(), anything())).never();
90+
verify(mockConfigModel.setAuthProvider(anything())).never();
1491
});
1592

16-
// TODO
17-
// login
18-
// logout
19-
// configure
20-
// attach cluster
21-
// detach cluster
22-
// attach workspace
23-
// detach workspace
93+
it("connectFromEnvironment disconnects and rethrows on failure", async () => {
94+
when(mockAuthProvider.getWorkspaceClient()).thenReject(
95+
new Error("no credentials")
96+
);
97+
const cm = buildConnectionManager();
98+
disposables.push(cm);
99+
100+
await assert.rejects(
101+
() => cm.connectFromEnvironment(instance(mockAuthProvider)),
102+
/no credentials/
103+
);
104+
105+
assert.equal(cm.state, "DISCONNECTED");
106+
assert.equal(cm.workspaceClient, undefined);
107+
assert.equal(cm.databricksWorkspace, undefined);
108+
verify(mockCustomWhenContext.setLoggedIn(false)).atLeast(1);
109+
});
110+
111+
describe("connectFromEnvironment without an injected auth provider", () => {
112+
// These exercise the production credential path (new Config with an
113+
// EnvironmentLoader, PAT-only enforcement) which is skipped when a test
114+
// injects an AuthProvider. We only cover the fail-fast branches here:
115+
// the successful connect builds a real WorkspaceClient and calls
116+
// currentUser.me() against the host, which would hit the network - that
117+
// path is already covered by the injected-AuthProvider tests above. We
118+
// drive these purely through env vars and restore the environment
119+
// afterwards.
120+
let savedEnv: NodeJS.ProcessEnv;
121+
122+
beforeEach(() => {
123+
savedEnv = process.env;
124+
process.env = {...savedEnv};
125+
// Clear anything a local ~/.databrickscfg-style env would set so the
126+
// EnvironmentLoader only sees what each test injects.
127+
delete process.env.DATABRICKS_HOST;
128+
delete process.env.DATABRICKS_TOKEN;
129+
delete process.env.DATABRICKS_CONFIG_PROFILE;
130+
});
131+
132+
afterEach(() => {
133+
process.env = savedEnv;
134+
});
135+
136+
it("fails fast when no host is present in the environment", async () => {
137+
process.env.DATABRICKS_TOKEN = "dapi1234567890";
138+
const cm = buildConnectionManager();
139+
disposables.push(cm);
140+
141+
await assert.rejects(
142+
() => cm.connectFromEnvironment(),
143+
/No Databricks host found in the environment/
144+
);
145+
assert.equal(cm.state, "DISCONNECTED");
146+
});
147+
148+
it("fails fast when a host but no token is present", async () => {
149+
process.env.DATABRICKS_HOST = "https://test.databricks.com";
150+
const cm = buildConnectionManager();
151+
disposables.push(cm);
152+
153+
await assert.rejects(
154+
() => cm.connectFromEnvironment(),
155+
/No Databricks token found in the environment/
156+
);
157+
assert.equal(cm.state, "DISCONNECTED");
158+
});
159+
});
24160
});

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

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
Config,
3+
EnvironmentLoader,
24
WorkspaceClient,
35
ApiClient,
46
logging,
@@ -18,7 +20,12 @@ import {DatabricksWorkspace} from "./DatabricksWorkspace";
1820
import {CustomWhenContext} from "../vscode-objs/CustomWhenContext";
1921
import {ConfigModel} from "./models/ConfigModel";
2022
import {onError, withOnErrorHandler} from "../utils/onErrorDecorator";
21-
import {AuthProvider, ProfileAuthProvider} from "./auth/AuthProvider";
23+
import {
24+
AuthProvider,
25+
PersonalAccessTokenAuthProvider,
26+
ProfileAuthProvider,
27+
} from "./auth/AuthProvider";
28+
import {normalizeHost} from "../utils/urlUtils";
2229
import {Mutex} from "../locking";
2330
import {MetadataService} from "./auth/MetadataService";
2431
import {Events, Telemetry} from "../telemetry";
@@ -41,6 +48,7 @@ export type ConnectionState = "CONNECTED" | "CONNECTING" | "DISCONNECTED";
4148
export class ConnectionManager implements Disposable {
4249
private disposables: Disposable[] = [];
4350
private _state: ConnectionState = "DISCONNECTED";
51+
private _connectionError?: string;
4452
private loginLogoutMutex: Mutex = new Mutex();
4553
private savedAuthMutex: Mutex = new Mutex();
4654
private configureLoginMutex: Mutex = new Mutex();
@@ -250,6 +258,16 @@ export class ConnectionManager implements Disposable {
250258
return this._state;
251259
}
252260

261+
/**
262+
* The error message from the most recent failed connection attempt, if any.
263+
* Cleared on a successful connection. Used to surface why an
264+
* environment-based connection (remote mode) failed instead of showing an
265+
* empty view.
266+
*/
267+
get connectionError(): string | undefined {
268+
return this._connectionError;
269+
}
270+
253271
get cluster(): Cluster | undefined {
254272
return this._clusterManager?.cluster;
255273
}
@@ -301,6 +319,78 @@ export class ConnectionManager implements Disposable {
301319
}
302320
}
303321

322+
/**
323+
* Connect using the host and token that the SDK resolves from the ambient
324+
* environment (e.g. the DATABRICKS_HOST / DATABRICKS_TOKEN variables that
325+
* the Databricks Remote SSH session injects). Only PAT credentials are
326+
* supported here - the remote environment always provides a token, so we
327+
* fail fast if one isn't present rather than attempting other auth types.
328+
*
329+
* Unlike the normal login flow this does not depend on a bundle/config
330+
* project (host + target) and skips all sync/cluster/config machinery. It's
331+
* used in Databricks Remote SSH sessions where only Unity Catalog is
332+
* surfaced and credentials come from the environment.
333+
*/
334+
async connectFromEnvironment(authProvider?: AuthProvider): Promise<void> {
335+
await this.loginLogoutMutex.synchronise(async () => {
336+
// We intentionally inline the connect/disconnect steps here rather
337+
// than delegating to _connect()/disconnect(): both of those acquire
338+
// loginLogoutMutex, which is non-reentrant, so calling them while we
339+
// already hold it would deadlock. We also deliberately skip the
340+
// sync/cluster/config-project machinery they run, since remote mode
341+
// only needs a workspace client for the Unity Catalog view.
342+
this._connectionError = undefined;
343+
// Clear any previously-connected client before re-authenticating so
344+
// a concurrent getChildren() (which only checks workspaceClient)
345+
// can't briefly use a stale client during a reconnect.
346+
this._workspaceClient = undefined;
347+
this._databricksWorkspace = undefined;
348+
this.updateState("CONNECTING");
349+
try {
350+
// The authProvider is only injected by tests; in production it
351+
// is resolved solely from the ambient environment. We use an
352+
// explicit EnvironmentLoader (instead of the SDK default chain)
353+
// so a stray ~/.databrickscfg DEFAULT profile can't silently
354+
// satisfy the checks below and connect to the wrong workspace -
355+
// if the remote env didn't inject credentials, we fail fast.
356+
if (authProvider === undefined) {
357+
const config = new Config({
358+
loaders: [new EnvironmentLoader()],
359+
});
360+
await config.ensureResolved();
361+
if (config.host === undefined) {
362+
throw new Error(
363+
"No Databricks host found in the environment"
364+
);
365+
}
366+
if (config.token === undefined) {
367+
throw new Error(
368+
"No Databricks token found in the environment"
369+
);
370+
}
371+
authProvider = new PersonalAccessTokenAuthProvider(
372+
normalizeHost(config.host),
373+
config.token,
374+
this.cli
375+
);
376+
}
377+
this._workspaceClient = await authProvider.getWorkspaceClient();
378+
this._databricksWorkspace = await DatabricksWorkspace.load(
379+
this._workspaceClient,
380+
authProvider
381+
);
382+
this.updateState("CONNECTED");
383+
} catch (e) {
384+
this._workspaceClient = undefined;
385+
this._databricksWorkspace = undefined;
386+
this._connectionError =
387+
e instanceof Error ? e.message : String(e);
388+
this.updateState("DISCONNECTED");
389+
throw e;
390+
}
391+
});
392+
}
393+
304394
private async loginWithSavedAuth(source: AutoLoginSource) {
305395
if (this.savedAuthMutex.locked) {
306396
return;

0 commit comments

Comments
 (0)