Skip to content

Commit a19dab0

Browse files
Generalize CLI token source into progressive command list
Replace the three explicit command fields (forceCmd, profileCmd, hostCmd) with a []cliCommand list and an activeCommandIndex. Feature flags are defined statically in cliFeatureFlags and stripped progressively to build commands for older CLI versions. Token() now iterates from activeCommandIndex, falling back on unknown flag errors and caching the working command index for subsequent calls. Adding future flags (e.g. --scopes) requires only a one-line addition to the cliFeatureFlags slice. Signed-off-by: Mihai Mitrea <mihai.mitrea@databricks.com>
1 parent a314000 commit a19dab0

3 files changed

Lines changed: 226 additions & 109 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
### Internal Changes
2525

2626
* Pass `--force-refresh` to Databricks CLI `auth token` command to bypass the CLI's internal token cache.
27+
* Generalize CLI token source into a progressive command list for forward-compatible flag support.
2728
* Use resolved host type from host metadata in `HostType()` method, falling back to URL pattern matching when metadata is unavailable.
2829
* Normalize internal token sources on `auth.TokenSource` for proper context propagation ([#1577](https://github.com/databricks/databricks-sdk-go/pull/1577)).
2930
* Fix `TestAzureGithubOIDCCredentials` hang caused by missing `HTTPTransport` stub: `EnsureResolved` now calls `resolveHostMetadata`, which makes a real network request when no transport is set ([#1550](https://github.com/databricks/databricks-sdk-go/pull/1550)).

config/cli_token_source.go

Lines changed: 99 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"path/filepath"
1111
"runtime"
1212
"strings"
13+
"sync/atomic"
1314
"time"
1415

1516
"github.com/databricks/databricks-sdk-go/logger"
@@ -31,96 +32,114 @@ type cliTokenResponse struct {
3132
Expiry string `json:"expiry"`
3233
}
3334

35+
// cliCommand is a single CLI invocation with an associated warning message
36+
// that is logged when this command fails and we fall back to the next one.
37+
type cliCommand struct {
38+
args []string
39+
// usedFlags lists all flags passed in this command. When the CLI reports
40+
// "unknown flag: <flag>" for one of these, we fall back to the next command.
41+
usedFlags []string
42+
warningMessage string
43+
}
44+
3445
// CliTokenSource fetches OAuth tokens by shelling out to the Databricks CLI.
35-
// Commands are tried in order: forceCmd -> profileCmd -> hostCmd, progressively
36-
// falling back to simpler invocations for older CLI versions.
46+
// It holds a list of commands ordered from most feature-rich to simplest,
47+
// falling back progressively for older CLI versions that lack newer flags.
3748
type CliTokenSource struct {
38-
// forceCmd appends --force-refresh to the base command (profileCmd when a
39-
// profile is configured, hostCmd otherwise) to bypass the CLI's token cache.
40-
// Nil when neither profile nor host is set.
41-
// CLI support: >= v0.296.0 (databricks/cli#4767).
42-
forceCmd []string
43-
44-
// profileCmd uses --profile for token lookup. Nil when cfg.Profile is empty.
45-
// CLI support: >= v0.207.1 (databricks/cli#855).
46-
profileCmd []string
47-
48-
// hostCmd uses --host as a fallback for CLIs that predate --profile support.
49-
// Nil when cfg.Host is empty.
50-
hostCmd []string
49+
commands []cliCommand
50+
// activeCommandIndex is the index of the CLI command known to work, or -1
51+
// if not yet resolved. Once resolved it never changes — older CLIs don't
52+
// gain new flags. We use atomic.Int32 instead of sync.Once because
53+
// probing must be retryable on transient errors: concurrent callers may
54+
// redundantly probe, but all converge to the same index.
55+
activeCommandIndex atomic.Int32
5156
}
5257

5358
func NewCliTokenSource(cfg *Config) (*CliTokenSource, error) {
5459
cliPath, err := findDatabricksCli(cfg.DatabricksCliPath)
5560
if err != nil {
5661
return nil, err
5762
}
58-
forceCmd, profileCmd, hostCmd := buildCliCommands(cliPath, cfg)
59-
return &CliTokenSource{forceCmd: forceCmd, profileCmd: profileCmd, hostCmd: hostCmd}, nil
63+
commands := buildCliCommands(cliPath, cfg)
64+
if len(commands) == 0 {
65+
return nil, fmt.Errorf("cannot configure CLI token source: neither profile nor host is set")
66+
}
67+
ts := &CliTokenSource{commands: commands}
68+
ts.activeCommandIndex.Store(-1)
69+
return ts, nil
6070
}
6171

62-
// buildCliCommands constructs the CLI commands for fetching an auth token.
63-
// When cfg.Profile is set, three commands are built: a --force-refresh variant
64-
// (based on profileCmd), a plain --profile variant, and (when host is available)
65-
// a --host fallback. When cfg.Profile is empty, --force-refresh is based on the
66-
// --host command instead.
67-
func buildCliCommands(cliPath string, cfg *Config) ([]string, []string, []string) {
68-
var forceCmd, profileCmd, hostCmd []string
72+
// buildCliCommands constructs the list of CLI commands for fetching an auth
73+
// token, ordered from most feature-rich to simplest. The order defines the
74+
// fallback chain: when a command fails with an unknown flag error, the next
75+
// one is tried.
76+
//
77+
// When cfg.Profile is set, --force-refresh is based on the --profile command.
78+
// When cfg.Profile is empty, --force-refresh is based on the --host command
79+
// instead, so host-only configurations still benefit from cache bypass.
80+
func buildCliCommands(cliPath string, cfg *Config) []cliCommand {
81+
var commands []cliCommand
6982
if cfg.Profile != "" {
70-
profileCmd = []string{cliPath, "auth", "token", "--profile", cfg.Profile}
83+
commands = append(commands, cliCommand{
84+
args: []string{cliPath, "auth", "token", "--profile", cfg.Profile, "--force-refresh"},
85+
usedFlags: []string{"--force-refresh", "--profile"},
86+
warningMessage: "Databricks CLI does not support --force-refresh flag. The CLI's token cache may provide stale tokens. Please upgrade your CLI to the latest version.",
87+
})
88+
commands = append(commands, cliCommand{
89+
args: []string{cliPath, "auth", "token", "--profile", cfg.Profile},
90+
usedFlags: []string{"--profile"},
91+
warningMessage: "Databricks CLI does not support --profile flag. Falling back to --host. Please upgrade your CLI to the latest version.",
92+
})
7193
}
7294
if cfg.Host != "" {
73-
hostCmd = buildHostCommand(cliPath, cfg)
74-
}
75-
if profileCmd != nil {
76-
forceCmd = append(profileCmd, "--force-refresh")
77-
} else if hostCmd != nil {
78-
forceCmd = append(hostCmd, "--force-refresh")
79-
}
80-
return forceCmd, profileCmd, hostCmd
81-
}
82-
83-
// buildHostCommand constructs the legacy --host based CLI command.
84-
func buildHostCommand(cliPath string, cfg *Config) []string {
85-
cmd := []string{cliPath, "auth", "token", "--host", cfg.Host}
86-
switch cfg.HostType() {
87-
case AccountHost:
88-
cmd = append(cmd, "--account-id", cfg.AccountID)
95+
hostArgs := []string{cliPath, "auth", "token", "--host", cfg.Host}
96+
switch cfg.HostType() {
97+
case AccountHost:
98+
hostArgs = append(hostArgs, "--account-id", cfg.AccountID)
99+
}
100+
if cfg.Profile == "" {
101+
forceArgs := append(hostArgs[:len(hostArgs):len(hostArgs)], "--force-refresh")
102+
commands = append(commands, cliCommand{
103+
args: forceArgs,
104+
usedFlags: []string{"--force-refresh"},
105+
warningMessage: "Databricks CLI does not support --force-refresh flag. The CLI's token cache may provide stale tokens. Please upgrade your CLI to the latest version.",
106+
})
107+
}
108+
commands = append(commands, cliCommand{args: hostArgs})
89109
}
90-
return cmd
110+
return commands
91111
}
92112

93113
// Token fetches an OAuth token by shelling out to the Databricks CLI.
94-
// Commands are tried in order: forceCmd -> profileCmd -> hostCmd, skipping nil
95-
// entries. Each command falls through to the next on "unknown flag" errors,
96-
// logging a warning about the unsupported feature.
97114
func (c *CliTokenSource) Token(ctx context.Context) (*oauth2.Token, error) {
98-
if c.forceCmd != nil {
99-
tok, err := c.execCliCommand(ctx, c.forceCmd)
100-
if err == nil {
101-
return tok, nil
102-
}
103-
if !isUnknownFlagError(err, "--force-refresh") && !isUnknownFlagError(err, "--profile") {
104-
return nil, err
105-
}
106-
logger.Warnf(ctx, "Databricks CLI does not support --force-refresh flag. The CLI's token cache may provide stale tokens. Please upgrade your CLI to the latest version.")
115+
// If the working command has already been identified, call it directly.
116+
// Otherwise, probe each command in order to find one that works.
117+
if idx := int(c.activeCommandIndex.Load()); idx >= 0 {
118+
return c.execCliCommand(ctx, c.commands[idx].args)
107119
}
120+
return c.probeAndExec(ctx)
121+
}
108122

109-
if c.profileCmd != nil {
110-
tok, err := c.execCliCommand(ctx, c.profileCmd)
123+
// probeAndExec walks the command list from most-featured to simplest, looking
124+
// for a CLI command that succeeds. When a command fails with "unknown flag" for
125+
// one of its [cliCommand.usedFlags], it logs a warning and tries the next. Any
126+
// other error stops probing immediately and is returned to the caller.
127+
// On success, [activeCommandIndex] is stored so future calls skip probing.
128+
func (c *CliTokenSource) probeAndExec(ctx context.Context) (*oauth2.Token, error) {
129+
for i := range c.commands {
130+
cmd := c.commands[i]
131+
tok, err := c.execCliCommand(ctx, cmd.args)
111132
if err == nil {
133+
c.activeCommandIndex.Store(int32(i))
112134
return tok, nil
113135
}
114-
if !isUnknownFlagError(err, "--profile") {
136+
lastCommand := i == len(c.commands)-1
137+
if lastCommand || !isUnknownFlagError(err, cmd.usedFlags) {
115138
return nil, err
116139
}
117-
logger.Warnf(ctx, "Databricks CLI does not support --profile flag. Falling back to --host. Please upgrade your CLI to the latest version.")
118-
}
119-
120-
if c.hostCmd == nil {
121-
return nil, fmt.Errorf("cannot get access token: no CLI commands available")
140+
logger.Warnf(ctx, cmd.warningMessage)
122141
}
123-
return c.execCliCommand(ctx, c.hostCmd)
142+
return nil, fmt.Errorf("cannot get access token: no CLI commands configured")
124143
}
125144

126145
func (c *CliTokenSource) execCliCommand(ctx context.Context, args []string) (*oauth2.Token, error) {
@@ -129,7 +148,13 @@ func (c *CliTokenSource) execCliCommand(ctx context.Context, args []string) (*oa
129148
if err != nil {
130149
var exitErr *exec.ExitError
131150
if errors.As(err, &exitErr) {
132-
return nil, fmt.Errorf("cannot get access token: %q", strings.TrimSpace(string(exitErr.Stderr)))
151+
// Format with %q: the CLI's stderr can be multi-line (includes usage
152+
// text on unknown-flag errors) and quoting keeps it on one line so
153+
// log aggregators don't split a single error across multiple entries.
154+
// We intentionally discard exec.ExitError — the stderr text is the
155+
// CLI's error contract; exit codes and process state are not useful.
156+
return nil, fmt.Errorf("cannot get access token: %q",
157+
strings.TrimSpace(string(exitErr.Stderr)))
133158
}
134159
return nil, fmt.Errorf("cannot get access token: %w", err)
135160
}
@@ -148,10 +173,16 @@ func (c *CliTokenSource) execCliCommand(ctx context.Context, args []string) (*oa
148173
}, nil
149174
}
150175

151-
// isUnknownFlagError returns true if the error indicates the CLI does not
152-
// recognize the given flag (e.g. "--profile", "--force-refresh").
153-
func isUnknownFlagError(err error, flag string) bool {
154-
return strings.Contains(err.Error(), "unknown flag: "+flag)
176+
// isUnknownFlagError returns true if the error message indicates the CLI does
177+
// not recognize one of the given flags.
178+
func isUnknownFlagError(err error, flags []string) bool {
179+
msg := err.Error()
180+
for _, flag := range flags {
181+
if strings.Contains(msg, "unknown flag: "+flag) {
182+
return true
183+
}
184+
}
185+
return false
155186
}
156187

157188
// parseExpiry parses an expiry time string in multiple formats for cross-SDK compatibility.

0 commit comments

Comments
 (0)