Skip to content

Commit a3ff68b

Browse files
Pass --profile to CLI token source (#682)
## Why Today \`databricks auth token --profile <name>\` fails to find a valid token even when one exists — because the token was stored under the host URL key (via \`--host\`), not the profile name key. The SDK's \`DatabricksCliCredentialsProvider\` always passed \`--host\` regardless of whether a profile was configured, so the CLI never had a chance to do profile-based lookup. This is a port of [databricks-sdk-py#1297](databricks/databricks-sdk-py#1297) / [databricks-sdk-go#1497](databricks/databricks-sdk-go#1497). ## Changes ### \`CliTokenSource\` - Added \`fallbackCmd\` optional field: a fallback command for CLIs too old to support \`--profile\`. - Added inner \`CliCommandException extends IOException\`: its \`getMessage()\` returns the clean stderr-based message (what users see), while \`getFullOutput()\` exposes the combined stdout+stderr for flag detection — avoiding verbose combined output in user-facing errors. - Extracted \`execCliCommand()\` helper to run a single CLI invocation. - Updated \`getToken()\` to try the primary command first; if it fails with \`"unknown flag: --profile"\` (checked in full output across both streams), retries with \`fallbackCmd\` and emits a warning to upgrade the CLI. ### \`DatabricksCliCredentialsProvider\` - Renamed \`buildCliCommand\` → \`buildHostArgs\` (reflects its role as the \`--host\` legacy path). - When \`cfg.profile\` is set: uses \`--profile <name>\` as the primary command. If \`cfg.host\` is also present, builds a \`--host\` fallback via \`buildHostArgs()\` for compatibility with older CLIs. - When \`cfg.profile\` is empty: unchanged — existing \`--host\` path is used. ## Test plan - [x] \`--profile\` fails with \`"unknown flag: --profile"\` in stderr → retries with \`--host\`, succeeds - [x] \`--profile\` fails with \`"unknown flag: --profile"\` in stdout → fallback still triggered - [x] \`--profile\` fails with real auth error → no retry, error propagated - [x] \`fallbackCmd\` is \`null\` and \`--profile\` fails → original error raised - [x] All existing \`buildHostArgs\` tests (workspace, account, unified host variants) pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8f000b1 commit a3ff68b

File tree

5 files changed

+280
-25
lines changed

5 files changed

+280
-25
lines changed

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### New Features and Improvements
66

77
### Bug Fixes
8+
* Pass `--profile` to CLI token source when profile is set, and add `--host` fallback for older CLIs that don't support `--profile` ([#682](https://github.com/databricks/databricks-sdk-java/pull/682)).
89

910
### Security Vulnerabilities
1011

databricks-sdk-java/src/main/java/com/databricks/sdk/core/CliTokenSource.java

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,64 @@
1818
import java.util.Arrays;
1919
import java.util.List;
2020
import org.apache.commons.io.IOUtils;
21+
import org.slf4j.Logger;
22+
import org.slf4j.LoggerFactory;
2123

2224
@InternalApi
2325
public class CliTokenSource implements TokenSource {
26+
private static final Logger LOG = LoggerFactory.getLogger(CliTokenSource.class);
27+
2428
private List<String> cmd;
2529
private String tokenTypeField;
2630
private String accessTokenField;
2731
private String expiryField;
2832
private Environment env;
33+
// fallbackCmd is tried when the primary command fails with "unknown flag: --profile",
34+
// indicating the CLI is too old to support --profile. Can be removed once support
35+
// for CLI versions predating --profile is dropped.
36+
// See: https://github.com/databricks/databricks-sdk-go/pull/1497
37+
private List<String> fallbackCmd;
38+
39+
/**
40+
* Internal exception that carries the clean stderr message but exposes full output for checks.
41+
*/
42+
static class CliCommandException extends IOException {
43+
private final String fullOutput;
44+
45+
CliCommandException(String message, String fullOutput) {
46+
super(message);
47+
this.fullOutput = fullOutput;
48+
}
49+
50+
String getFullOutput() {
51+
return fullOutput;
52+
}
53+
}
2954

3055
public CliTokenSource(
3156
List<String> cmd,
3257
String tokenTypeField,
3358
String accessTokenField,
3459
String expiryField,
3560
Environment env) {
61+
this(cmd, tokenTypeField, accessTokenField, expiryField, env, null);
62+
}
63+
64+
public CliTokenSource(
65+
List<String> cmd,
66+
String tokenTypeField,
67+
String accessTokenField,
68+
String expiryField,
69+
Environment env,
70+
List<String> fallbackCmd) {
3671
super();
3772
this.cmd = OSUtils.get(env).getCliExecutableCommand(cmd);
3873
this.tokenTypeField = tokenTypeField;
3974
this.accessTokenField = accessTokenField;
4075
this.expiryField = expiryField;
4176
this.env = env;
77+
this.fallbackCmd =
78+
fallbackCmd != null ? OSUtils.get(env).getCliExecutableCommand(fallbackCmd) : null;
4279
}
4380

4481
/**
@@ -87,10 +124,9 @@ private String getProcessStream(InputStream stream) throws IOException {
87124
return new String(bytes);
88125
}
89126

90-
@Override
91-
public Token getToken() {
127+
private Token execCliCommand(List<String> cmdToRun) throws IOException {
92128
try {
93-
ProcessBuilder processBuilder = new ProcessBuilder(cmd);
129+
ProcessBuilder processBuilder = new ProcessBuilder(cmdToRun);
94130
processBuilder.environment().putAll(env.getEnv());
95131
Process process = processBuilder.start();
96132
String stdout = getProcessStream(process.getInputStream());
@@ -99,9 +135,10 @@ public Token getToken() {
99135
if (exitCode != 0) {
100136
if (stderr.contains("not found")) {
101137
throw new DatabricksException(stderr);
102-
} else {
103-
throw new IOException(stderr);
104138
}
139+
// getMessage() returns the clean stderr-based message; getFullOutput() exposes
140+
// both streams so the caller can check for "unknown flag: --profile" in either.
141+
throw new CliCommandException("cannot get access token: " + stderr, stdout + "\n" + stderr);
105142
}
106143
JsonNode jsonNode = new ObjectMapper().readTree(stdout);
107144
String tokenType = jsonNode.get(tokenTypeField).asText();
@@ -111,8 +148,33 @@ public Token getToken() {
111148
return new Token(accessToken, tokenType, expiresOn);
112149
} catch (DatabricksException e) {
113150
throw e;
114-
} catch (InterruptedException | IOException e) {
115-
throw new DatabricksException("cannot get access token: " + e.getMessage(), e);
151+
} catch (InterruptedException e) {
152+
throw new IOException("cannot get access token: " + e.getMessage(), e);
153+
}
154+
}
155+
156+
@Override
157+
public Token getToken() {
158+
try {
159+
return execCliCommand(this.cmd);
160+
} catch (IOException e) {
161+
String textToCheck =
162+
e instanceof CliCommandException
163+
? ((CliCommandException) e).getFullOutput()
164+
: e.getMessage();
165+
if (fallbackCmd != null
166+
&& textToCheck != null
167+
&& textToCheck.contains("unknown flag: --profile")) {
168+
LOG.warn(
169+
"Databricks CLI does not support --profile flag. Falling back to --host. "
170+
+ "Please upgrade your CLI to the latest version.");
171+
try {
172+
return execCliCommand(this.fallbackCmd);
173+
} catch (IOException fallbackException) {
174+
throw new DatabricksException(fallbackException.getMessage(), fallbackException);
175+
}
176+
}
177+
throw new DatabricksException(e.getMessage(), e);
116178
}
117179
}
118180
}

databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ public String authType() {
2121
}
2222

2323
/**
24-
* Builds the CLI command arguments for the databricks auth token command.
24+
* Builds the CLI command arguments using --host (legacy path).
2525
*
2626
* @param cliPath Path to the databricks CLI executable
2727
* @param config Configuration containing host, account ID, workspace ID, etc.
2828
* @return List of command arguments
2929
*/
30-
List<String> buildCliCommand(String cliPath, DatabricksConfig config) {
30+
List<String> buildHostArgs(String cliPath, DatabricksConfig config) {
3131
List<String> cmd =
3232
new ArrayList<>(Arrays.asList(cliPath, "auth", "token", "--host", config.getHost()));
3333
if (config.getExperimentalIsUnifiedHost() != null && config.getExperimentalIsUnifiedHost()) {
@@ -57,8 +57,26 @@ private CliTokenSource getDatabricksCliTokenSource(DatabricksConfig config) {
5757
LOG.debug("Databricks CLI could not be found");
5858
return null;
5959
}
60-
List<String> cmd = buildCliCommand(cliPath, config);
61-
return new CliTokenSource(cmd, "token_type", "access_token", "expiry", config.getEnv());
60+
61+
List<String> cmd;
62+
List<String> fallbackCmd = null;
63+
64+
if (config.getProfile() != null) {
65+
// When profile is set, use --profile as the primary command.
66+
// The profile contains the full config (host, account_id, etc.).
67+
cmd =
68+
new ArrayList<>(
69+
Arrays.asList(cliPath, "auth", "token", "--profile", config.getProfile()));
70+
// Build a --host fallback for older CLIs that don't support --profile.
71+
if (config.getHost() != null) {
72+
fallbackCmd = buildHostArgs(cliPath, config);
73+
}
74+
} else {
75+
cmd = buildHostArgs(cliPath, config);
76+
}
77+
78+
return new CliTokenSource(
79+
cmd, "token_type", "access_token", "expiry", config.getEnv(), fallbackCmd);
6280
}
6381

6482
@Override

databricks-sdk-java/src/test/java/com/databricks/sdk/core/CliTokenSourceTest.java

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
44
import static org.junit.jupiter.api.Assertions.assertThrows;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
56
import static org.mockito.ArgumentMatchers.any;
67
import static org.mockito.Mockito.mock;
78
import static org.mockito.Mockito.mockConstruction;
@@ -27,9 +28,11 @@
2728
import java.util.List;
2829
import java.util.Map;
2930
import java.util.TimeZone;
31+
import java.util.concurrent.atomic.AtomicInteger;
3032
import java.util.stream.Collectors;
3133
import java.util.stream.IntStream;
3234
import java.util.stream.Stream;
35+
import org.junit.jupiter.api.Test;
3336
import org.junit.jupiter.params.ParameterizedTest;
3437
import org.junit.jupiter.params.provider.Arguments;
3538
import org.junit.jupiter.params.provider.MethodSource;
@@ -213,4 +216,175 @@ public void testParseExpiry(String input, Instant expectedInstant, String descri
213216
assertEquals(expectedInstant, parsedInstant);
214217
}
215218
}
219+
220+
// ---- Fallback tests for --profile flag handling ----
221+
222+
private CliTokenSource makeTokenSource(
223+
Environment env, List<String> primaryCmd, List<String> fallbackCmd) {
224+
OSUtilities osUtils = mock(OSUtilities.class);
225+
when(osUtils.getCliExecutableCommand(any())).thenAnswer(inv -> inv.getArgument(0));
226+
try (MockedStatic<OSUtils> mockedOSUtils = mockStatic(OSUtils.class)) {
227+
mockedOSUtils.when(() -> OSUtils.get(any())).thenReturn(osUtils);
228+
return new CliTokenSource(
229+
primaryCmd, "token_type", "access_token", "expiry", env, fallbackCmd);
230+
}
231+
}
232+
233+
private String validTokenJson(String accessToken) {
234+
String expiry =
235+
ZonedDateTime.now()
236+
.plusHours(1)
237+
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"));
238+
return String.format(
239+
"{\"token_type\":\"Bearer\",\"access_token\":\"%s\",\"expiry\":\"%s\"}",
240+
accessToken, expiry);
241+
}
242+
243+
@Test
244+
public void testFallbackOnUnknownProfileFlagInStderr() {
245+
Environment env = mock(Environment.class);
246+
when(env.getEnv()).thenReturn(new HashMap<>());
247+
248+
List<String> primaryCmd =
249+
Arrays.asList("databricks", "auth", "token", "--profile", "my-profile");
250+
List<String> fallbackCmdList =
251+
Arrays.asList("databricks", "auth", "token", "--host", "https://workspace.databricks.com");
252+
253+
CliTokenSource tokenSource = makeTokenSource(env, primaryCmd, fallbackCmdList);
254+
255+
AtomicInteger callCount = new AtomicInteger(0);
256+
try (MockedConstruction<ProcessBuilder> mocked =
257+
mockConstruction(
258+
ProcessBuilder.class,
259+
(pb, context) -> {
260+
if (callCount.getAndIncrement() == 0) {
261+
Process failProcess = mock(Process.class);
262+
when(failProcess.getInputStream())
263+
.thenReturn(new ByteArrayInputStream(new byte[0]));
264+
when(failProcess.getErrorStream())
265+
.thenReturn(
266+
new ByteArrayInputStream("Error: unknown flag: --profile".getBytes()));
267+
when(failProcess.waitFor()).thenReturn(1);
268+
when(pb.start()).thenReturn(failProcess);
269+
} else {
270+
Process successProcess = mock(Process.class);
271+
when(successProcess.getInputStream())
272+
.thenReturn(
273+
new ByteArrayInputStream(validTokenJson("fallback-token").getBytes()));
274+
when(successProcess.getErrorStream())
275+
.thenReturn(new ByteArrayInputStream(new byte[0]));
276+
when(successProcess.waitFor()).thenReturn(0);
277+
when(pb.start()).thenReturn(successProcess);
278+
}
279+
})) {
280+
Token token = tokenSource.getToken();
281+
assertEquals("fallback-token", token.getAccessToken());
282+
assertEquals(2, mocked.constructed().size());
283+
}
284+
}
285+
286+
@Test
287+
public void testFallbackTriggeredWhenUnknownFlagInStdout() {
288+
// Fallback triggers even when "unknown flag" appears in stdout rather than stderr.
289+
Environment env = mock(Environment.class);
290+
when(env.getEnv()).thenReturn(new HashMap<>());
291+
292+
List<String> primaryCmd =
293+
Arrays.asList("databricks", "auth", "token", "--profile", "my-profile");
294+
List<String> fallbackCmdList =
295+
Arrays.asList("databricks", "auth", "token", "--host", "https://workspace.databricks.com");
296+
297+
CliTokenSource tokenSource = makeTokenSource(env, primaryCmd, fallbackCmdList);
298+
299+
AtomicInteger callCount = new AtomicInteger(0);
300+
try (MockedConstruction<ProcessBuilder> mocked =
301+
mockConstruction(
302+
ProcessBuilder.class,
303+
(pb, context) -> {
304+
if (callCount.getAndIncrement() == 0) {
305+
Process failProcess = mock(Process.class);
306+
when(failProcess.getInputStream())
307+
.thenReturn(
308+
new ByteArrayInputStream("Error: unknown flag: --profile".getBytes()));
309+
when(failProcess.getErrorStream())
310+
.thenReturn(new ByteArrayInputStream(new byte[0]));
311+
when(failProcess.waitFor()).thenReturn(1);
312+
when(pb.start()).thenReturn(failProcess);
313+
} else {
314+
Process successProcess = mock(Process.class);
315+
when(successProcess.getInputStream())
316+
.thenReturn(
317+
new ByteArrayInputStream(validTokenJson("fallback-token").getBytes()));
318+
when(successProcess.getErrorStream())
319+
.thenReturn(new ByteArrayInputStream(new byte[0]));
320+
when(successProcess.waitFor()).thenReturn(0);
321+
when(pb.start()).thenReturn(successProcess);
322+
}
323+
})) {
324+
Token token = tokenSource.getToken();
325+
assertEquals("fallback-token", token.getAccessToken());
326+
assertEquals(2, mocked.constructed().size());
327+
}
328+
}
329+
330+
@Test
331+
public void testNoFallbackOnRealAuthError() {
332+
// When the primary fails with a real error (not unknown flag), no fallback is attempted.
333+
Environment env = mock(Environment.class);
334+
when(env.getEnv()).thenReturn(new HashMap<>());
335+
336+
List<String> primaryCmd =
337+
Arrays.asList("databricks", "auth", "token", "--profile", "my-profile");
338+
List<String> fallbackCmdList =
339+
Arrays.asList("databricks", "auth", "token", "--host", "https://workspace.databricks.com");
340+
341+
CliTokenSource tokenSource = makeTokenSource(env, primaryCmd, fallbackCmdList);
342+
343+
try (MockedConstruction<ProcessBuilder> mocked =
344+
mockConstruction(
345+
ProcessBuilder.class,
346+
(pb, context) -> {
347+
Process failProcess = mock(Process.class);
348+
when(failProcess.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[0]));
349+
when(failProcess.getErrorStream())
350+
.thenReturn(
351+
new ByteArrayInputStream(
352+
"databricks OAuth is not configured for this host".getBytes()));
353+
when(failProcess.waitFor()).thenReturn(1);
354+
when(pb.start()).thenReturn(failProcess);
355+
})) {
356+
DatabricksException ex = assertThrows(DatabricksException.class, tokenSource::getToken);
357+
assertTrue(ex.getMessage().contains("databricks OAuth is not configured"));
358+
assertEquals(1, mocked.constructed().size());
359+
}
360+
}
361+
362+
@Test
363+
public void testNoFallbackWhenFallbackCmdNotSet() {
364+
// When fallbackCmd is null and the primary fails with unknown flag, original error propagates.
365+
Environment env = mock(Environment.class);
366+
when(env.getEnv()).thenReturn(new HashMap<>());
367+
368+
List<String> primaryCmd =
369+
Arrays.asList("databricks", "auth", "token", "--profile", "my-profile");
370+
371+
CliTokenSource tokenSource = makeTokenSource(env, primaryCmd, null);
372+
373+
try (MockedConstruction<ProcessBuilder> mocked =
374+
mockConstruction(
375+
ProcessBuilder.class,
376+
(pb, context) -> {
377+
Process failProcess = mock(Process.class);
378+
when(failProcess.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[0]));
379+
when(failProcess.getErrorStream())
380+
.thenReturn(
381+
new ByteArrayInputStream("Error: unknown flag: --profile".getBytes()));
382+
when(failProcess.waitFor()).thenReturn(1);
383+
when(pb.start()).thenReturn(failProcess);
384+
})) {
385+
DatabricksException ex = assertThrows(DatabricksException.class, tokenSource::getToken);
386+
assertTrue(ex.getMessage().contains("unknown flag: --profile"));
387+
assertEquals(1, mocked.constructed().size());
388+
}
389+
}
216390
}

0 commit comments

Comments
 (0)