Skip to content

Commit 19a0cf8

Browse files
Fix CLI token source --profile fallback with version detection (#1377)
## 🥞 Stacked PR Use this [link](https://github.com/databricks/databricks-sdk-py/pull/1377/files) to review incremental changes. - [**stack/cli-force-refresh**](#1377) [[Files changed](https://github.com/databricks/databricks-sdk-py/pull/1377/files)] - [stack/cli-progressive-token-commands](#1378) [[Files changed](https://github.com/databricks/databricks-sdk-py/pull/1378/files/c714a135fb7e9a105b78baa73f203a3875903c78..cfbf51b7c0f71e81183e62b69a81733dbdeea2ea)] --------- ## Summary Replace the broken error-based `--profile` fallback in `CliTokenSource` with version-based CLI detection at init time. Mirrors [databricks/databricks-sdk-go#1605](databricks/databricks-sdk-go#1605). ## Why `--profile` on `databricks auth token` is a global Cobra flag, so old CLIs (< v0.207.1) **silently accept it** and then fail with `"cannot fetch credentials"` instead of `"unknown flag: --profile"`. The existing retry check was matching on the latter and never fired — the `--host` fallback it gated was effectively dead code. Switching to `databricks version` + a minimum-version constant makes the fallback reliable and sets up future capability-gated flags (e.g. `--force-refresh` in [#1378](#1378)) without additional subprocess calls. ## What changed ### Interface changes None. `CliTokenSource` and `DatabricksCliTokenSource` are not part of the public API surface. ### Behavioral changes - `cfg.profile` + CLI < v0.207.1 now correctly falls back to `--host` (previously broken). - `databricks version` failures log a `WARNING` and fall back to the most conservative command. - A default dev build (`v0.0.0-dev[+commit]`) logs an `INFO` explaining why feature gates are conservative. - Error messages for missing configuration are precise (`"neither profile nor host is configured"` / `"…does not support --profile … and no host fallback is configured"`) instead of a single generic message. `AzureCliTokenSource` is untouched. ### Internal changes - New `CliVersion` dataclass with semver-aware comparison operators and an `is_default_dev_build` property. - `CliTokenSource` simplified to a single `cmd`; the `fallback_cmd` parameter and its retry logic are removed. - `DatabricksCliTokenSource` gains `_get_cli_version`, `_resolve_cli_command`, `_build_cli_command`, and `_build_host_command` helpers. ## How is this tested? Unit tests in `tests/test_credentials_provider.py` cover version parsing, comparison operators, dev-build detection, command assembly for every profile/host/version combination, precise error messages, and logging behavior on detection failure and dev builds. Existing `tests/test_core.py::test_databricks_cli_token_source_*` tests pass unchanged.
1 parent 0aa545c commit 19a0cf8

3 files changed

Lines changed: 471 additions & 195 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77
### Security
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
### Documentation
1213

1314
### Breaking Changes
1415

1516
### Internal Changes
17+
* Detect Databricks CLI version at init time via `databricks version`, enabling version-gated flag support without additional subprocess calls.
18+
* Validate Databricks CLI configuration at `DatabricksCliTokenSource.__init__` time. Misconfiguration (missing profile and host, or `--profile`-unsupported CLI without a host fallback) now surfaces as `IOError` synchronously from construction rather than lazily from the first `refresh()` call. The exception type matches the previous `refresh()`-time behaviour, so callers who already catch `IOError` are unaffected.
1619

1720
### API Changes
1821
* Add [w.temporary_volume_credentials](https://databricks-sdk-py.readthedocs.io/en/latest/workspace/catalog/temporary_volume_credentials.html) workspace-level service.

databricks/sdk/credentials_provider.py

Lines changed: 187 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import abc
22
import base64
3+
import dataclasses
34
import functools
45
import io
56
import json
@@ -662,23 +663,57 @@ def refreshed_headers() -> Dict[str, str]:
662663
return OAuthCredentialsProvider(refreshed_headers, token)
663664

664665

666+
@dataclasses.dataclass(order=True)
667+
class CliVersion:
668+
"""Semver version triple of the Databricks CLI.
669+
670+
Three sentinel states in the (major, minor, patch) tuple:
671+
* `(-1, -1, -1)` — the default, meaning version detection failed. It
672+
compares less than every real release so every feature gate fails.
673+
* `(0, 0, 0)` — the CLI's default dev build, emitted when the binary
674+
was built without version metadata. See `is_default_dev_build`.
675+
* anything else — a real CLI version.
676+
677+
Prerelease tags (e.g. "-rc.1", "-dev+commit") are deliberately ignored:
678+
for our purposes the base triple is sufficient, and feature gates are
679+
release-based so a prerelease of a version with a flag is assumed to
680+
have the flag too.
681+
"""
682+
683+
major: int = -1
684+
minor: int = -1
685+
patch: int = -1
686+
687+
@property
688+
def is_default_dev_build(self) -> bool:
689+
"""True when the CLI reports (0, 0, 0), i.e. its default dev build.
690+
691+
Narrowly matches the CLI's "no version injected" marker: its version
692+
metadata stays at the zero defaults. A version the user explicitly
693+
set (e.g. v1.0.0-dev) is intentional and is not flagged here.
694+
"""
695+
return (self.major, self.minor, self.patch) == (0, 0, 0)
696+
697+
def __str__(self) -> str:
698+
if self == CliVersion():
699+
return "unknown"
700+
if self.is_default_dev_build:
701+
return "v0.0.0-dev"
702+
return f"v{self.major}.{self.minor}.{self.patch}"
703+
704+
665705
class CliTokenSource(oauth.Refreshable):
706+
666707
def __init__(
667708
self,
668709
cmd: List[str],
669710
token_type_field: str,
670711
access_token_field: str,
671712
expiry_field: str,
672713
disable_async: bool = True,
673-
fallback_cmd: Optional[List[str]] = None,
674714
):
675715
super().__init__(disable_async=disable_async)
676716
self._cmd = cmd
677-
# fallback_cmd is tried when the primary command fails with "unknown flag: --profile",
678-
# indicating the CLI is too old to support --profile. Can be removed once support
679-
# for CLI versions predating --profile is dropped.
680-
# See: https://github.com/databricks/databricks-sdk-go/pull/1497
681-
self._fallback_cmd = fallback_cmd
682717
self._token_type_field = token_type_field
683718
self._access_token_field = access_token_field
684719
self._expiry_field = expiry_field
@@ -713,16 +748,7 @@ def _exec_cli_command(self, cmd: List[str]) -> oauth.Token:
713748
raise IOError(f"cannot get access token: {message}") from e
714749

715750
def refresh(self) -> oauth.Token:
716-
try:
717-
return self._exec_cli_command(self._cmd)
718-
except IOError as e:
719-
if self._fallback_cmd is not None and "unknown flag: --profile" in str(e):
720-
logger.warning(
721-
"Databricks CLI does not support --profile flag. Falling back to --host. "
722-
"Please upgrade your CLI to the latest version."
723-
)
724-
return self._exec_cli_command(self._fallback_cmd)
725-
raise
751+
return self._exec_cli_command(self._cmd)
726752

727753

728754
def _run_subprocess(
@@ -911,16 +937,7 @@ def __init__(self, cfg: "Config"):
911937
elif cli_path.count("/") == 0:
912938
cli_path = self.__class__._find_executable(cli_path)
913939

914-
fallback_cmd = None
915-
if cfg.profile:
916-
# When profile is set, use --profile as the primary command.
917-
# The profile contains the full config (host, account_id, etc.).
918-
args = ["auth", "token", "--profile", cfg.profile]
919-
# Build a --host fallback for older CLIs that don't support --profile.
920-
if cfg.host:
921-
fallback_cmd = [cli_path, *self.__class__._build_host_args(cfg)]
922-
else:
923-
args = self.__class__._build_host_args(cfg)
940+
cmd = self.__class__._resolve_cli_command(cli_path, cfg)
924941

925942
# get_scopes() defaults to ["all-apis"] when nothing is configured, which would
926943
# cause false-positive mismatches against every token that wasn't issued with
@@ -930,12 +947,11 @@ def __init__(self, cfg: "Config"):
930947
self._host = cfg.host
931948

932949
super().__init__(
933-
cmd=[cli_path, *args],
950+
cmd=cmd,
934951
token_type_field="token_type",
935952
access_token_field="access_token",
936953
expiry_field="expiry",
937954
disable_async=cfg.disable_async_token_refresh,
938-
fallback_cmd=fallback_cmd,
939955
)
940956

941957
def refresh(self) -> oauth.Token:
@@ -993,15 +1009,151 @@ def _validate_token_scopes(self, token: oauth.Token):
9931009
f"Scopes default to all-apis."
9941010
)
9951011

1012+
# --profile support added in CLI v0.207.1: https://github.com/databricks/cli/pull/855
1013+
_CLI_VERSION_FOR_PROFILE = CliVersion(0, 207, 1)
1014+
1015+
@staticmethod
1016+
def _parse_cli_version(output: str) -> CliVersion:
1017+
"""Parse the JSON output of `databricks version --output json`.
1018+
1019+
Takes Major/Minor/Patch from the JSON's pre-parsed numeric fields. The
1020+
`Prerelease` JSON field and the `Version` string are intentionally
1021+
ignored: for our feature-gate purposes the base triple is sufficient,
1022+
and the (0, 0, 0) case already identifies the default dev build (a
1023+
CLI built without version metadata leaves these fields at their zero
1024+
defaults).
1025+
1026+
Returns CliVersion() (unknown, (-1, -1, -1)) on failure so that an
1027+
unparseable version disables every feature gate.
1028+
"""
1029+
try:
1030+
data = json.loads(output)
1031+
return CliVersion(
1032+
int(data["Major"]),
1033+
int(data["Minor"]),
1034+
int(data["Patch"]),
1035+
)
1036+
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as e:
1037+
logger.debug(f"Failed to parse Databricks CLI version from output: {output!r} ({e})")
1038+
return CliVersion()
1039+
1040+
@staticmethod
1041+
@functools.lru_cache(maxsize=8)
1042+
def _probe_cli_version(cli_path: str) -> "CliVersion":
1043+
"""Run `databricks version --output json` and return the parsed CliVersion.
1044+
1045+
Cached by `cli_path` for the Python process lifetime: a successful probe
1046+
against an immutable binary doesn't need to be repeated. Subprocess-level
1047+
failures (timeout, OSError, CalledProcessError) raise out instead of
1048+
returning a sentinel, so transient errors are NOT cached and the next
1049+
caller retries — see `_get_cli_version` for the catching wrapper.
1050+
1051+
The 5-second timeout prevents a hung CLI (first-run disk scan, antivirus,
1052+
stdin wait) from wedging SDK init indefinitely.
1053+
"""
1054+
out = _run_subprocess(
1055+
[cli_path, "version", "--output", "json"],
1056+
capture_output=True,
1057+
check=True,
1058+
timeout=5,
1059+
)
1060+
return DatabricksCliTokenSource._parse_cli_version(out.stdout.decode())
1061+
1062+
@staticmethod
1063+
def _get_cli_version(cli_path: str) -> "CliVersion":
1064+
"""Probe wrapper that catches subprocess failures and returns CliVersion().
1065+
1066+
The catch is intentionally outside the cached helper so a transient failure
1067+
(timeout, AV scan, sandbox quota, etc.) does not pin every subsequent
1068+
DatabricksCliTokenSource to the conservative fallback for the rest of the
1069+
process lifetime.
1070+
"""
1071+
try:
1072+
return DatabricksCliTokenSource._probe_cli_version(cli_path)
1073+
except Exception as e:
1074+
logger.warning(f"Failed to detect Databricks CLI version: {e}. Falling back to conservative flag set.")
1075+
return CliVersion()
1076+
1077+
@staticmethod
1078+
def _resolve_cli_command(cli_path: str, cfg: "Config") -> List[str]:
1079+
"""Detect CLI version and build the auth-token command.
1080+
1081+
Raises IOError with a precise message when no usable command can be
1082+
built (missing profile/host, or --profile-unsupported CLI with no host
1083+
fallback configured). IOError matches the exception type already
1084+
handled by the `databricks_cli()` credential-chain strategy, so
1085+
graceful-skip behaviour is preserved.
1086+
"""
1087+
version = DatabricksCliTokenSource._get_cli_version(cli_path)
1088+
if version.is_default_dev_build:
1089+
# A default-marker dev build has no injected version, so every
1090+
# feature gate fails via at_least. Surface an informational hint so
1091+
# users know why their feature flags aren't taking effect.
1092+
logger.info(
1093+
f"Databricks CLI {version} is a development build; feature detection will use "
1094+
"conservative fallbacks. Rebuild the CLI with an explicit version to enable "
1095+
"capability-based flag selection."
1096+
)
1097+
try:
1098+
return DatabricksCliTokenSource._build_cli_command(cli_path, cfg, version)
1099+
except IOError as e:
1100+
raise IOError(f"cannot configure CLI token source: {e}") from e
1101+
1102+
@staticmethod
1103+
def _build_cli_command(cli_path: str, cfg: "Config", version: CliVersion) -> List[str]:
1104+
"""Build the `auth token` command.
1105+
1106+
Falls back to --host when --profile is either not configured or not
1107+
supported by the installed CLI. Raises IOError describing which
1108+
piece of configuration is missing when no command can be built.
1109+
"""
1110+
if not cfg.profile:
1111+
try:
1112+
return DatabricksCliTokenSource._build_host_command(cli_path, cfg)
1113+
except IOError as e:
1114+
raise IOError(f"neither profile nor host is configured: {e}") from e
1115+
1116+
# Flag --profile is a global flag and is recognized for all
1117+
# commands even the ones that do not support it. Only use --profile
1118+
# in CLI versions that are known to support it in `auth token`.
1119+
if version < DatabricksCliTokenSource._CLI_VERSION_FOR_PROFILE:
1120+
# For the unknown (detection failed) and dev-build (no version
1121+
# metadata) cases we can't actually prove the CLI lacks --profile;
1122+
# we just failed to confirm it. Log accordingly.
1123+
if version == CliVersion() or version.is_default_dev_build:
1124+
logger.warning(
1125+
f"Could not confirm --profile support for Databricks CLI {version} "
1126+
f"(requires >= {DatabricksCliTokenSource._CLI_VERSION_FOR_PROFILE}). "
1127+
"Falling back to --host."
1128+
)
1129+
else:
1130+
logger.warning(
1131+
f"Databricks CLI {version} does not support --profile "
1132+
f"(requires >= {DatabricksCliTokenSource._CLI_VERSION_FOR_PROFILE}). "
1133+
"Falling back to --host."
1134+
)
1135+
try:
1136+
return DatabricksCliTokenSource._build_host_command(cli_path, cfg)
1137+
except IOError as e:
1138+
raise IOError(
1139+
f"Databricks CLI {version} does not support --profile "
1140+
f"(requires >= {DatabricksCliTokenSource._CLI_VERSION_FOR_PROFILE}) "
1141+
f"and no host fallback is configured: {e}"
1142+
) from e
1143+
1144+
return [cli_path, "auth", "token", "--profile", cfg.profile]
1145+
9961146
@staticmethod
997-
def _build_host_args(cfg: "Config") -> List[str]:
998-
"""Build CLI arguments using --host (legacy path)."""
999-
args = ["auth", "token", "--host", cfg.host]
1000-
# This is here to support older versions of the Databricks CLI, so we need to keep the client type check.
1001-
# This won't work for unified hosts, but it is not supposed to.
1147+
def _build_host_command(cli_path: str, cfg: "Config") -> List[str]:
1148+
"""Build the --host based auth token command. Raises when cfg.host is unset."""
1149+
if not cfg.host:
1150+
raise IOError("host is not set")
1151+
cmd = [cli_path, "auth", "token", "--host", cfg.host]
1152+
# Older Databricks CLIs need --account-id for account-scoped calls;
1153+
# unified hosts don't use this path.
10021154
if cfg.client_type == ClientType.ACCOUNT:
1003-
args += ["--account-id", cfg.account_id]
1004-
return args
1155+
cmd += ["--account-id", cfg.account_id]
1156+
return cmd
10051157

10061158
@staticmethod
10071159
def _find_executable(name) -> str:

0 commit comments

Comments
 (0)