Skip to content

Commit 79ac794

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 69a7c95 commit 79ac794

3 files changed

Lines changed: 206 additions & 132 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
### Internal Changes
2424

25+
* Generalize CLI token source into a progressive command list for forward-compatible flag support.
2526
* Normalize internal token sources on `auth.TokenSource` for proper context propagation ([#1577](https://github.com/databricks/databricks-sdk-go/pull/1577)).
2627
* 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)).
2728
* Bump golang.org/x/crypto from 0.21.0 to 0.45.0 in /examples/slog ([#1566](https://github.com/databricks/databricks-sdk-go/pull/1566)).

config/cli_token_source.go

Lines changed: 92 additions & 64 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,89 +32,110 @@ type cliTokenResponse struct {
3132
Expiry string `json:"expiry"`
3233
}
3334

34-
// 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.
37-
type CliTokenSource struct {
38-
// forceCmd uses --profile with --force-refresh to bypass the CLI's token cache.
39-
// Nil when cfg.Profile is empty (--force-refresh requires --profile support).
40-
forceCmd []string
35+
// cliError wraps stderr output from a failed CLI invocation.
36+
type cliError struct {
37+
stderr string
38+
}
4139

42-
// profileCmd uses --profile for token lookup. Nil when cfg.Profile is empty.
43-
profileCmd []string
40+
func (e *cliError) Error() string {
41+
return e.stderr
42+
}
43+
44+
// cliCommand is a single CLI invocation with an associated warning message
45+
// that is logged when this command fails and we fall back to the next one.
46+
type cliCommand struct {
47+
args []string
48+
// usedFlags lists all flags passed in this command. When the CLI reports
49+
// "unknown flag: <flag>" for one of these, we fall back to the next command.
50+
usedFlags []string
51+
warningMessage string
52+
}
4453

45-
// hostCmd uses --host as a fallback for CLIs that predate --profile support.
46-
// Nil when cfg.Host is empty.
47-
hostCmd []string
54+
// CliTokenSource fetches OAuth tokens by shelling out to the Databricks CLI.
55+
// It holds a list of commands ordered from most feature-rich to simplest,
56+
// falling back progressively for older CLI versions that lack newer flags.
57+
type CliTokenSource struct {
58+
commands []cliCommand
59+
// activeCommandIndex is the index of the CLI command known to work, or -1
60+
// if not yet resolved. Once resolved it never changes — older CLIs don't
61+
// gain new flags. We use atomic.Int32 instead of sync.Once because
62+
// probing must be retryable on transient errors: concurrent callers may
63+
// redundantly probe, but all converge to the same index.
64+
activeCommandIndex atomic.Int32
4865
}
4966

5067
func NewCliTokenSource(cfg *Config) (*CliTokenSource, error) {
5168
cliPath, err := findDatabricksCli(cfg.DatabricksCliPath)
5269
if err != nil {
5370
return nil, err
5471
}
55-
forceCmd, profileCmd, hostCmd := buildCliCommands(cliPath, cfg)
56-
return &CliTokenSource{forceCmd: forceCmd, profileCmd: profileCmd, hostCmd: hostCmd}, nil
72+
commands := buildCliCommands(cliPath, cfg)
73+
if len(commands) == 0 {
74+
return nil, fmt.Errorf("cannot configure CLI token source: neither profile nor host is set")
75+
}
76+
ts := &CliTokenSource{commands: commands}
77+
ts.activeCommandIndex.Store(-1)
78+
return ts, nil
5779
}
5880

59-
// buildCliCommands constructs the CLI commands for fetching an auth token.
60-
// When cfg.Profile is set, three commands are built: a --force-refresh variant,
61-
// a plain --profile variant, and (when host is available) a --host fallback.
62-
// When cfg.Profile is empty, only --host is returned — the CLI must support
63-
// --profile before --force-refresh can be used (monotonic feature assumption).
64-
func buildCliCommands(cliPath string, cfg *Config) ([]string, []string, []string) {
65-
var forceCmd, profileCmd, hostCmd []string
81+
// buildCliCommands constructs the list of CLI commands for fetching an auth
82+
// token, ordered from most feature-rich to simplest. The order defines the
83+
// fallback chain: when a command fails with an unknown flag error, the next
84+
// one is tried.
85+
func buildCliCommands(cliPath string, cfg *Config) []cliCommand {
86+
var commands []cliCommand
6687
if cfg.Profile != "" {
67-
profileCmd = []string{cliPath, "auth", "token", "--profile", cfg.Profile}
68-
forceCmd = append(profileCmd, "--force-refresh")
88+
commands = append(commands, cliCommand{
89+
args: []string{cliPath, "auth", "token", "--profile", cfg.Profile, "--force-refresh"},
90+
usedFlags: []string{"--force-refresh", "--profile"},
91+
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.",
92+
})
93+
commands = append(commands, cliCommand{
94+
args: []string{cliPath, "auth", "token", "--profile", cfg.Profile},
95+
usedFlags: []string{"--profile"},
96+
warningMessage: "Databricks CLI does not support --profile flag. Falling back to --host. Please upgrade your CLI to the latest version.",
97+
})
6998
}
7099
if cfg.Host != "" {
71-
hostCmd = buildHostCommand(cliPath, cfg)
72-
}
73-
return forceCmd, profileCmd, hostCmd
74-
}
75-
76-
// buildHostCommand constructs the legacy --host based CLI command.
77-
func buildHostCommand(cliPath string, cfg *Config) []string {
78-
cmd := []string{cliPath, "auth", "token", "--host", cfg.Host}
79-
switch cfg.HostType() {
80-
case AccountHost:
81-
cmd = append(cmd, "--account-id", cfg.AccountID)
100+
args := []string{cliPath, "auth", "token", "--host", cfg.Host}
101+
switch cfg.HostType() {
102+
case AccountHost:
103+
args = append(args, "--account-id", cfg.AccountID)
104+
}
105+
commands = append(commands, cliCommand{args: args})
82106
}
83-
return cmd
107+
return commands
84108
}
85109

86110
// Token fetches an OAuth token by shelling out to the Databricks CLI.
87-
// Commands are tried in order: forceCmd -> profileCmd -> hostCmd, skipping nil
88-
// entries. Each command falls through to the next on "unknown flag" errors,
89-
// logging a warning about the unsupported feature.
111+
// If the working command has already been identified, it is called directly.
112+
// Otherwise, [probeAndExec] tries each command in order to find one that works.
90113
func (c *CliTokenSource) Token(ctx context.Context) (*oauth2.Token, error) {
91-
if c.forceCmd != nil {
92-
tok, err := c.execCliCommand(ctx, c.forceCmd)
93-
if err == nil {
94-
return tok, nil
95-
}
96-
if !isUnknownFlagError(err, "--force-refresh") && !isUnknownFlagError(err, "--profile") {
97-
return nil, err
98-
}
99-
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.")
114+
if idx := int(c.activeCommandIndex.Load()); idx >= 0 {
115+
return c.execCliCommand(ctx, c.commands[idx].args)
100116
}
117+
return c.probeAndExec(ctx)
118+
}
101119

102-
if c.profileCmd != nil {
103-
tok, err := c.execCliCommand(ctx, c.profileCmd)
120+
// probeAndExec walks the command list from most-featured to simplest, looking
121+
// for a CLI command that succeeds. When a command fails with "unknown flag" for
122+
// one of its [cliCommand.usedFlags], it logs a warning and tries the next.
123+
// On success, [activeCommandIndex] is stored so future calls skip probing.
124+
func (c *CliTokenSource) probeAndExec(ctx context.Context) (*oauth2.Token, error) {
125+
for i := range c.commands {
126+
cmd := c.commands[i]
127+
tok, err := c.execCliCommand(ctx, cmd.args)
104128
if err == nil {
129+
c.activeCommandIndex.Store(int32(i))
105130
return tok, nil
106131
}
107-
if !isUnknownFlagError(err, "--profile") {
132+
lastCommand := i == len(c.commands)-1
133+
if lastCommand || !isUnknownFlagError(err, cmd.usedFlags) {
108134
return nil, err
109135
}
110-
logger.Warnf(ctx, "Databricks CLI does not support --profile flag. Falling back to --host. Please upgrade your CLI to the latest version.")
111-
}
112-
113-
if c.hostCmd == nil {
114-
return nil, fmt.Errorf("cannot get access token: no CLI commands available")
136+
logger.Warnf(ctx, cmd.warningMessage)
115137
}
116-
return c.execCliCommand(ctx, c.hostCmd)
138+
return nil, fmt.Errorf("cannot get access token: no CLI commands configured")
117139
}
118140

119141
func (c *CliTokenSource) execCliCommand(ctx context.Context, args []string) (*oauth2.Token, error) {
@@ -122,7 +144,8 @@ func (c *CliTokenSource) execCliCommand(ctx context.Context, args []string) (*oa
122144
if err != nil {
123145
var exitErr *exec.ExitError
124146
if errors.As(err, &exitErr) {
125-
return nil, fmt.Errorf("cannot get access token: %q", strings.TrimSpace(string(exitErr.Stderr)))
147+
return nil, fmt.Errorf("cannot get access token: %w",
148+
&cliError{stderr: strings.TrimSpace(string(exitErr.Stderr))})
126149
}
127150
return nil, fmt.Errorf("cannot get access token: %w", err)
128151
}
@@ -141,14 +164,19 @@ func (c *CliTokenSource) execCliCommand(ctx context.Context, args []string) (*oa
141164
}, nil
142165
}
143166

144-
// isUnknownFlagError returns true if the error indicates the CLI does not
145-
// recognize a flag. Pass a specific flag (e.g. "--profile") to check for that
146-
// flag, or pass "" to match any "unknown flag:" error.
147-
func isUnknownFlagError(err error, flag string) bool {
148-
if flag == "" {
149-
return strings.Contains(err.Error(), "unknown flag:")
167+
// isUnknownFlagError returns true if the error wraps a [cliError] whose stderr
168+
// indicates the CLI does not recognize one of the given flags.
169+
func isUnknownFlagError(err error, flags []string) bool {
170+
var cliErr *cliError
171+
if !errors.As(err, &cliErr) {
172+
return false
173+
}
174+
for _, flag := range flags {
175+
if strings.Contains(cliErr.stderr, "unknown flag: "+flag) {
176+
return true
177+
}
150178
}
151-
return strings.Contains(err.Error(), "unknown flag: "+flag)
179+
return false
152180
}
153181

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

0 commit comments

Comments
 (0)