Skip to content

Commit 0de2614

Browse files
Detect Databricks CLI version to gate --profile support
`--profile` on `databricks auth token` is a global Cobra flag, so old CLIs (< v0.207.1) silently accept it and fail later with `cannot fetch credentials` instead of `unknown flag: --profile`. The previous error-based fallback never matched, leaving the `--host` fallback as dead code. This commit replaces the runtime fallback chain with version-based capability detection: * `CliVersion` carries a (major, minor, patch) triple plus an `UNKNOWN` sentinel and a default-dev-build (0,0,0) check. * `DatabricksCliCredentialsProvider` runs `databricks version --output json` once per CLI path (cached on success only, with a 5s timeout) and gates `--profile` on >= v0.207.1; everything else falls back to `--host` with a precise warning. * `CliTokenSource` is simplified to a single `cmd`; the `fallbackCmd` parameter and the runtime "unknown flag" retry loop are removed. Mirrors the equivalent refactors in the Go and Python SDKs: * databricks/databricks-sdk-go#1605 * databricks/databricks-sdk-py#1377 Co-authored-by: Isaac
1 parent f850f56 commit 0de2614

7 files changed

Lines changed: 416 additions & 196 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
### Breaking Changes
88

99
### Bug Fixes
10+
* Fixed Databricks CLI `--profile` fallback by detecting the CLI version at init time. The previous error-based detection was broken because `--profile` is a global Cobra flag silently accepted by old CLIs.
1011

1112
### Security Vulnerabilities
1213

1314
### Documentation
1415

1516
### Internal Changes
17+
* Detect Databricks CLI version at init time via `databricks version --output json`, enabling version-gated flag support. Successful detections are cached per CLI path; subprocess failures fall back to the most conservative command and are retried on the next call.
1618

1719
### API Changes

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

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,6 @@ public class CliTokenSource implements TokenSource {
3030
private String accessTokenField;
3131
private String expiryField;
3232
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;
3833

3934
/**
4035
* Internal exception that carries the clean stderr message but exposes full output for checks.
@@ -58,24 +53,11 @@ public CliTokenSource(
5853
String accessTokenField,
5954
String expiryField,
6055
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) {
71-
super();
7256
this.cmd = OSUtils.get(env).getCliExecutableCommand(cmd);
7357
this.tokenTypeField = tokenTypeField;
7458
this.accessTokenField = accessTokenField;
7559
this.expiryField = expiryField;
7660
this.env = env;
77-
this.fallbackCmd =
78-
fallbackCmd != null ? OSUtils.get(env).getCliExecutableCommand(fallbackCmd) : null;
7961
}
8062

8163
/**
@@ -158,22 +140,6 @@ public Token getToken() {
158140
try {
159141
return execCliCommand(this.cmd);
160142
} 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-
}
177143
throw new DatabricksException(e.getMessage(), e);
178144
}
179145
}

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

Lines changed: 159 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@
66
import com.databricks.sdk.core.oauth.OAuthHeaderFactory;
77
import com.databricks.sdk.core.oauth.Token;
88
import com.databricks.sdk.core.oauth.TokenSource;
9+
import com.databricks.sdk.core.utils.Environment;
910
import com.databricks.sdk.core.utils.OSUtils;
1011
import com.databricks.sdk.support.InternalApi;
1112
import com.fasterxml.jackson.core.JsonProcessingException;
13+
import com.fasterxml.jackson.databind.JsonNode;
1214
import com.fasterxml.jackson.databind.ObjectMapper;
15+
import java.io.IOException;
16+
import java.io.InputStream;
1317
import java.nio.charset.StandardCharsets;
1418
import java.util.*;
19+
import java.util.concurrent.ConcurrentHashMap;
20+
import java.util.concurrent.TimeUnit;
21+
import org.apache.commons.io.IOUtils;
1522

1623
@InternalApi
1724
public class DatabricksCliCredentialsProvider implements CredentialsProvider {
@@ -22,6 +29,18 @@ public class DatabricksCliCredentialsProvider implements CredentialsProvider {
2229

2330
private static final ObjectMapper MAPPER = new ObjectMapper();
2431

32+
// --profile support added in CLI v0.207.1: https://github.com/databricks/cli/pull/855
33+
static final DatabricksCliVersion CLI_VERSION_FOR_PROFILE = new DatabricksCliVersion(0, 207, 1);
34+
35+
// 5-second cap on `databricks version` so a hung CLI (slow first-run scan, antivirus, blocked
36+
// stdin) does not wedge SDK init indefinitely.
37+
private static final long VERSION_PROBE_TIMEOUT_SECONDS = 5;
38+
39+
// Successful version probes keyed by cliPath. Failures are deliberately not cached, so a
40+
// transient error (timeout, AV scan) does not pin every later token source to the conservative
41+
// fallback for the rest of the process lifetime.
42+
private static final Map<String, DatabricksCliVersion> VERSION_CACHE = new ConcurrentHashMap<>();
43+
2544
/** Thrown when the cached CLI token's scopes don't match the SDK's configured scopes. */
2645
static class ScopeMismatchException extends DatabricksException {
2746
ScopeMismatchException(String message) {
@@ -58,6 +77,144 @@ List<String> buildHostArgs(String cliPath, DatabricksConfig config) {
5877
return cmd;
5978
}
6079

80+
/**
81+
* Builds the {@code auth token} command for the given CLI version.
82+
*
83+
* <p>Falls back to {@code --host} when {@code --profile} is either not configured or not
84+
* supported by the installed CLI.
85+
*/
86+
List<String> buildCliCommand(String cliPath, DatabricksConfig config, DatabricksCliVersion version) {
87+
if (config.getProfile() == null) {
88+
return buildHostArgs(cliPath, config);
89+
}
90+
91+
// Flag --profile is a global CLI flag and is recognized for all commands even the ones that
92+
// do not support it. Only use --profile in CLI versions known to support it in `auth token`.
93+
if (!version.atLeast(CLI_VERSION_FOR_PROFILE)) {
94+
if (version.equals(DatabricksCliVersion.UNKNOWN) || version.isDefaultDevBuild()) {
95+
// We didn't actually prove the CLI lacks --profile; we just failed to confirm it.
96+
LOG.warn(
97+
"Could not confirm --profile support for Databricks CLI {} (requires >= {}). "
98+
+ "Falling back to --host.",
99+
version,
100+
CLI_VERSION_FOR_PROFILE);
101+
} else {
102+
LOG.warn(
103+
"Databricks CLI {} does not support --profile (requires >= {}). Falling back to --host.",
104+
version,
105+
CLI_VERSION_FOR_PROFILE);
106+
}
107+
return buildHostArgs(cliPath, config);
108+
}
109+
110+
return new ArrayList<>(
111+
Arrays.asList(cliPath, "auth", "token", "--profile", config.getProfile()));
112+
}
113+
114+
/**
115+
* Detects the installed CLI version and builds the {@code auth token} command. Falls back to the
116+
* most conservative command when version detection fails.
117+
*/
118+
List<String> resolveCliCommand(String cliPath, DatabricksConfig config) {
119+
DatabricksCliVersion version = getCliVersion(cliPath, config.getEnv());
120+
if (version.isDefaultDevBuild()) {
121+
// A default-marker dev build has no injected version, so every feature gate fails.
122+
// Surface an informational hint so users know why their feature flags aren't taking effect.
123+
LOG.info(
124+
"Databricks CLI {} is a development build; feature detection will use conservative "
125+
+ "fallbacks. Rebuild the CLI with an explicit version to enable capability-based "
126+
+ "flag selection.",
127+
version);
128+
}
129+
return buildCliCommand(cliPath, config, version);
130+
}
131+
132+
/**
133+
* Returns the CLI version, catching subprocess failures so the caller can proceed with the
134+
* conservative fallback. Successful results are cached per {@code cliPath} for the process
135+
* lifetime; failures are not cached and will be retried on the next call.
136+
*/
137+
DatabricksCliVersion getCliVersion(String cliPath, Environment env) {
138+
DatabricksCliVersion cached = VERSION_CACHE.get(cliPath);
139+
if (cached != null) {
140+
return cached;
141+
}
142+
143+
try {
144+
DatabricksCliVersion version = probeCliVersion(cliPath, env);
145+
VERSION_CACHE.put(cliPath, version);
146+
return version;
147+
} catch (Exception e) {
148+
LOG.warn(
149+
"Failed to detect Databricks CLI version: {}. Falling back to conservative flag set.",
150+
e.getMessage());
151+
return DatabricksCliVersion.UNKNOWN;
152+
}
153+
}
154+
155+
/** Runs {@code databricks version --output json} and returns the parsed {@link DatabricksCliVersion}. */
156+
DatabricksCliVersion probeCliVersion(String cliPath, Environment env) throws IOException {
157+
List<String> versionArgs = Arrays.asList(cliPath, "version", "--output", "json");
158+
List<String> cmd = OSUtils.get(env).getCliExecutableCommand(versionArgs);
159+
160+
ProcessBuilder pb = new ProcessBuilder(cmd);
161+
pb.environment().putAll(env.getEnv());
162+
Process process = pb.start();
163+
164+
try {
165+
if (!process.waitFor(VERSION_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
166+
process.destroyForcibly();
167+
throw new IOException(
168+
"timed out after "
169+
+ VERSION_PROBE_TIMEOUT_SECONDS
170+
+ "s waiting for `databricks version`");
171+
}
172+
} catch (InterruptedException e) {
173+
Thread.currentThread().interrupt();
174+
throw new IOException("interrupted waiting for `databricks version`", e);
175+
}
176+
177+
if (process.exitValue() != 0) {
178+
String stderr = readStream(process.getErrorStream());
179+
throw new IOException(
180+
"`databricks version` exited with code " + process.exitValue() + ": " + stderr);
181+
}
182+
183+
return parseCliVersion(readStream(process.getInputStream()));
184+
}
185+
186+
private static String readStream(InputStream stream) throws IOException {
187+
return new String(IOUtils.toByteArray(stream), StandardCharsets.UTF_8);
188+
}
189+
190+
/**
191+
* Parses the JSON output of {@code databricks version --output json}.
192+
*
193+
* <p>Takes Major/Minor/Patch from the JSON's pre-parsed numeric fields. The Prerelease field and
194+
* the Version string are intentionally ignored: for our feature-gate purposes the base triple is
195+
* sufficient, and the (0, 0, 0) case already identifies the default dev build (a CLI built
196+
* without version metadata leaves these fields at their zero defaults).
197+
*
198+
* <p>Returns {@link DatabricksCliVersion#UNKNOWN} on failure so that an unparseable version disables every
199+
* feature gate.
200+
*/
201+
static DatabricksCliVersion parseCliVersion(String output) {
202+
try {
203+
JsonNode node = MAPPER.readTree(output);
204+
JsonNode major = node.get("Major");
205+
JsonNode minor = node.get("Minor");
206+
JsonNode patch = node.get("Patch");
207+
if (major == null || minor == null || patch == null) {
208+
LOG.debug("Failed to parse Databricks CLI version: missing Major/Minor/Patch in {}", output);
209+
return DatabricksCliVersion.UNKNOWN;
210+
}
211+
return new DatabricksCliVersion(major.asInt(), minor.asInt(), patch.asInt());
212+
} catch (JsonProcessingException e) {
213+
LOG.debug("Failed to parse Databricks CLI version from output: {} ({})", output, e.getMessage());
214+
return DatabricksCliVersion.UNKNOWN;
215+
}
216+
}
217+
61218
private CliTokenSource getDatabricksCliTokenSource(DatabricksConfig config) {
62219
String cliPath = config.getDatabricksCliPath();
63220
if (cliPath == null) {
@@ -68,25 +225,8 @@ private CliTokenSource getDatabricksCliTokenSource(DatabricksConfig config) {
68225
return null;
69226
}
70227

71-
List<String> cmd;
72-
List<String> fallbackCmd = null;
73-
74-
if (config.getProfile() != null) {
75-
// When profile is set, use --profile as the primary command.
76-
// The profile contains the full config (host, account_id, etc.).
77-
cmd =
78-
new ArrayList<>(
79-
Arrays.asList(cliPath, "auth", "token", "--profile", config.getProfile()));
80-
// Build a --host fallback for older CLIs that don't support --profile.
81-
if (config.getHost() != null) {
82-
fallbackCmd = buildHostArgs(cliPath, config);
83-
}
84-
} else {
85-
cmd = buildHostArgs(cliPath, config);
86-
}
87-
88-
return new CliTokenSource(
89-
cmd, "token_type", "access_token", "expiry", config.getEnv(), fallbackCmd);
228+
List<String> cmd = resolveCliCommand(cliPath, config);
229+
return new CliTokenSource(cmd, "token_type", "access_token", "expiry", config.getEnv());
90230
}
91231

92232
@Override
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.databricks.sdk.core;
2+
3+
import com.databricks.sdk.support.InternalApi;
4+
import java.util.Objects;
5+
6+
/**
7+
* Semver version triple of the Databricks CLI used for capability gating.
8+
*
9+
* <p>Three sentinel states in the (major, minor, patch) tuple:
10+
*
11+
* <ul>
12+
* <li>{@code (-1, -1, -1)} — the {@link #UNKNOWN} sentinel, meaning version detection failed. It
13+
* compares less than every real release so every feature gate fails.
14+
* <li>{@code (0, 0, 0)} — the CLI's default dev build, emitted when the binary was built without
15+
* version metadata. See {@link #isDefaultDevBuild()}.
16+
* <li>anything else — a real CLI version.
17+
* </ul>
18+
*
19+
* <p>Prerelease tags are deliberately ignored: feature gates are release-based, so a prerelease of
20+
* a version with a flag is assumed to have the flag too.
21+
*/
22+
@InternalApi
23+
public final class DatabricksCliVersion implements Comparable<DatabricksCliVersion> {
24+
public static final DatabricksCliVersion UNKNOWN = new DatabricksCliVersion(-1, -1, -1);
25+
26+
private final int major;
27+
private final int minor;
28+
private final int patch;
29+
30+
public DatabricksCliVersion(int major, int minor, int patch) {
31+
this.major = major;
32+
this.minor = minor;
33+
this.patch = patch;
34+
}
35+
36+
public int getMajor() {
37+
return major;
38+
}
39+
40+
public int getMinor() {
41+
return minor;
42+
}
43+
44+
public int getPatch() {
45+
return patch;
46+
}
47+
48+
/** Returns true when {@code this} is greater than or equal to {@code other}. */
49+
public boolean atLeast(DatabricksCliVersion other) {
50+
return compareTo(other) >= 0;
51+
}
52+
53+
/**
54+
* Returns true when the version is the CLI's default dev build {@code (0, 0, 0)}. A CLI built
55+
* without version metadata leaves these fields at their zero defaults.
56+
*/
57+
public boolean isDefaultDevBuild() {
58+
return major == 0 && minor == 0 && patch == 0;
59+
}
60+
61+
@Override
62+
public int compareTo(DatabricksCliVersion o) {
63+
int c = Integer.compare(major, o.major);
64+
if (c != 0) return c;
65+
c = Integer.compare(minor, o.minor);
66+
if (c != 0) return c;
67+
return Integer.compare(patch, o.patch);
68+
}
69+
70+
@Override
71+
public boolean equals(Object o) {
72+
if (this == o) return true;
73+
if (!(o instanceof DatabricksCliVersion)) return false;
74+
DatabricksCliVersion that = (DatabricksCliVersion) o;
75+
return major == that.major && minor == that.minor && patch == that.patch;
76+
}
77+
78+
@Override
79+
public int hashCode() {
80+
return Objects.hash(major, minor, patch);
81+
}
82+
83+
@Override
84+
public String toString() {
85+
if (equals(UNKNOWN)) {
86+
return "unknown";
87+
}
88+
if (isDefaultDevBuild()) {
89+
return "v0.0.0-dev";
90+
}
91+
return "v" + major + "." + minor + "." + patch;
92+
}
93+
}

0 commit comments

Comments
 (0)