66import com .databricks .sdk .core .oauth .OAuthHeaderFactory ;
77import com .databricks .sdk .core .oauth .Token ;
88import com .databricks .sdk .core .oauth .TokenSource ;
9+ import com .databricks .sdk .core .utils .Environment ;
910import com .databricks .sdk .core .utils .OSUtils ;
1011import com .databricks .sdk .support .InternalApi ;
1112import com .fasterxml .jackson .core .JsonProcessingException ;
13+ import com .fasterxml .jackson .databind .JsonNode ;
1214import com .fasterxml .jackson .databind .ObjectMapper ;
15+ import java .io .IOException ;
16+ import java .io .InputStream ;
1317import java .nio .charset .StandardCharsets ;
1418import java .util .*;
19+ import java .util .concurrent .ConcurrentHashMap ;
20+ import java .util .concurrent .TimeUnit ;
21+ import org .apache .commons .io .IOUtils ;
1522
1623@ InternalApi
1724public 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
0 commit comments