diff --git a/.nextchanges/cli/docker-duplicate-profile-hint.md b/.nextchanges/cli/docker-duplicate-profile-hint.md deleted file mode 100644 index 320e3b61460..00000000000 --- a/.nextchanges/cli/docker-duplicate-profile-hint.md +++ /dev/null @@ -1 +0,0 @@ -* Suggest `databricks auth logout --profile --delete` when multiple profiles match the same workspace in `databricks auth docker` commands. ([#7363](https://github.com/databricks/cli/pull/7363)) diff --git a/.nextchanges/cli/docker-shared-workspace-profiles.md b/.nextchanges/cli/docker-shared-workspace-profiles.md new file mode 100644 index 00000000000..50a76a6a4f6 --- /dev/null +++ b/.nextchanges/cli/docker-shared-workspace-profiles.md @@ -0,0 +1 @@ +* Allow `databricks auth docker configure` when multiple profiles match the same workspace. It now prints a warning, and the Docker credential helper uses the first logged-in profile, checking `[DEFAULT]` first and then the others in `.databrickscfg` order. ([#7383](https://github.com/databricks/cli/pull/7383)) diff --git a/acceptance/cmd/auth/docker/configure-errors/output.txt b/acceptance/cmd/auth/docker/configure-errors/output.txt index c3083199390..0db9fa72ed0 100644 --- a/acceptance/cmd/auth/docker/configure-errors/output.txt +++ b/acceptance/cmd/auth/docker/configure-errors/output.txt @@ -45,10 +45,5 @@ Error: profile "m2m" uses client credentials. Docker credential helper requires >>> musterr [CLI] auth docker configure blank-auth Error: profile "blank-auth" uses auth_type "". Docker credential helper requires a profile created by databricks auth login -=== Another profile already maps to the workspace - ->>> musterr [CLI] auth docker configure docker-test -Error: multiple Databricks profiles match workspace ID [WORKSPACE_ID]: duplicate and docker-test. Docker credential helper requires exactly one profile per workspace. Remove the duplicate profile with: databricks auth logout --profile --delete - === Nothing changed Docker config not created; profiles unchanged diff --git a/acceptance/cmd/auth/docker/configure-errors/script b/acceptance/cmd/auth/docker/configure-errors/script index b05752e3027..485618bb07e 100644 --- a/acceptance/cmd/auth/docker/configure-errors/script +++ b/acceptance/cmd/auth/docker/configure-errors/script @@ -11,11 +11,6 @@ cat >> "$HOME/.databrickscfg" <>> databricks auth docker configure docker-test +Warning: multiple Databricks profiles match workspace ID [WORKSPACE_ID]: docker-test, shared. Docker credential helper uses the first logged-in profile in this order +Configured Docker credential helper for [WORKSPACE_ID].container.us-west-2.localhost +Updated Docker config: [TEST_TMP_DIR]/home/docker/config.json +Installed Docker credential helper: [TEST_TMP_DIR]/home/bin/docker-credential-databricks + +>>> print_requests.py --get //api +{ + "headers": { + "X-Databricks-Workspace-Id": [ + "[WORKSPACE_ID]" + ] + }, + "method": "GET", + "path": "/api/2.1/unity-catalog/metastore_summary" +} + +>>> docker-credential-databricks get +{ + "Secret": "oauth-token", + "Username": "oauthtoken" +} diff --git a/acceptance/cmd/auth/docker/configure/script b/acceptance/cmd/auth/docker/configure/script index 96b62b88d8e..d0ae140c5f8 100644 --- a/acceptance/cmd/auth/docker/configure/script +++ b/acceptance/cmd/auth/docker/configure/script @@ -63,3 +63,15 @@ trace print_requests.py --get //api title "Configure with the installation directory absent from PATH\n" PATH="$path_without_cli" trace command "$HOME/bin/databricks" auth docker configure docker-test trace print_requests.py --get //api + +title "Profiles sharing the workspace\n" +cat >> "$HOME/.databrickscfg" <>> musterr [CLI] auth docker token Error: no Databricks profile found for workspace ID 444555666 from registry host 444555666.container.us-west-2.localhost. Run databricks auth login --host and set workspace_id for that profile -=== Multiple profiles for the workspace +=== Multiple profiles for the workspace use the first logged-in profile + +>>> [CLI] auth docker token --no-force-refresh +{ + "Secret": "docker-test-access-token", + "Username": "oauthtoken" +} + +=== Profiles that are not logged in are skipped + +>>> [CLI] auth docker token +{ + "Secret": "oauth-token", + "Username": "oauthtoken" +} + +=== No logged-in profile for the workspace >>> musterr [CLI] auth docker token -Error: multiple Databricks profiles match workspace ID [WORKSPACE_ID]: docker-test and duplicate. Docker credential helper requires exactly one profile per workspace. Remove the duplicate profile with: databricks auth logout --profile --delete +Error: no logged-in Databricks profile for workspace ID 555666777 from registry host 555666777.container.us-west-2.localhost. Run databricks auth login --profile for one of these profiles: +profile "logged-out-a": cache: databricks OAuth is not configured for this host. no cached credentials; run `databricks auth login` to sign in +profile "logged-out-b": cache: databricks OAuth is not configured for this host. no cached credentials; run `databricks auth login` to sign in diff --git a/acceptance/cmd/auth/docker/token/script b/acceptance/cmd/auth/docker/token/script index a272f6af120..686cfd25538 100644 --- a/acceptance/cmd/auth/docker/token/script +++ b/acceptance/cmd/auth/docker/token/script @@ -37,7 +37,7 @@ workspace_id = 444555666 ENDCFG printf '%s\n' "444555666.container.us-west-2.localhost" | trace musterr $CLI auth docker token -title "Multiple profiles for the workspace\n" +title "Multiple profiles for the workspace use the first logged-in profile\n" cat >> "$HOME/.databrickscfg" < "$HOME/databrickscfg.new" <> "$HOME/.databrickscfg" < 1 { + cmdio.LogString(ctx, fmt.Sprintf("Warning: multiple Databricks profiles match workspace ID %s: %s. Docker credential helper uses the first logged-in profile in this order", t.workspaceID, strings.Join(profiles.Names(), ", "))) + } + return nil +} + func (t *dockerTarget) logConfigured(ctx context.Context, result dockercredentials.Configuration) { cmdio.LogString(ctx, "Configured Docker credential helper for "+t.registryHost) cmdio.LogString(ctx, "Updated Docker config: "+filepath.ToSlash(result.ConfigPath)) diff --git a/cmd/auth/docker/docker_token.go b/cmd/auth/docker/docker_token.go index db82f58b3ef..af2dc19065c 100644 --- a/cmd/auth/docker/docker_token.go +++ b/cmd/auth/docker/docker_token.go @@ -41,15 +41,17 @@ func newDockerTokenCommand(load TokenLoader) *cobra.Command { cmd.Flags().BoolVar(&noForceRefresh, "no-force-refresh", false, "Use a valid cached token instead of forcing a refresh.") cmd.RunE = func(cmd *cobra.Command, _ []string) error { - p, err := dockerTokenProfile(cmd.Context(), cmd.InOrStdin()) + registry, err := dockerTokenRegistry(cmd.InOrStdin()) if err != nil { return err } - token, err := load(cmd.Context(), TokenRequest{ - Profile: p, - Timeout: tokenTimeout, - // Docker may reuse one credential for a long upload, so maximize its lifetime. - ForceRefresh: !noForceRefresh, + token, err := dockercredentials.TokenForRegistry(cmd.Context(), profile.DefaultProfiler, registry, func(ctx context.Context, p profile.Profile) (*oauth2.Token, error) { + return load(ctx, TokenRequest{ + Profile: p, + Timeout: tokenTimeout, + // Docker may reuse one credential for a long upload, so maximize its lifetime. + ForceRefresh: !noForceRefresh, + }) }) if err != nil { return err @@ -62,16 +64,12 @@ func newDockerTokenCommand(load TokenLoader) *cobra.Command { return cmd } -func dockerTokenProfile(ctx context.Context, input io.Reader) (profile.Profile, error) { +func dockerTokenRegistry(input io.Reader) (dockercredentials.Registry, error) { raw, err := io.ReadAll(input) if err != nil { - return profile.Profile{}, fmt.Errorf("read Docker credential request: %w", err) + return dockercredentials.Registry{}, fmt.Errorf("read Docker credential request: %w", err) } - registry, err := dockercredentials.ParseRegistryHost(string(raw)) - if err != nil { - return profile.Profile{}, err - } - return dockercredentials.ProfileForRegistry(ctx, profile.DefaultProfiler, registry) + return dockercredentials.ParseRegistryHost(string(raw)) } func validateDockerTokenRequest(cmd *cobra.Command, args []string) error { diff --git a/cmd/auth/token.go b/cmd/auth/token.go index bbde8dc5eff..8cabd59e444 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -327,9 +327,9 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) { // this issue" trailer. The hint is more specific and avoids // users reporting expected post-upgrade behavior as a bug. if hint := storage.HintForNotFound(err); hint != "" { - return nil, fmt.Errorf("cache: %s. %s", compatSubstring, hint) + return nil, storage.NewNotFoundHint(fmt.Sprintf("cache: %s. %s", compatSubstring, hint)) } - err = errors.New("cache: " + compatSubstring) + err = storage.NewNotFoundHint("cache: " + compatSubstring) } if rewritten, rewrittenErr := auth.RewriteAuthError(ctx, args.authArguments.Host, args.authArguments.AccountID, args.profileName, err); rewritten { return nil, rewrittenErr diff --git a/cmd/auth/token_test.go b/cmd/auth/token_test.go index 001aa527034..9b4f785fe6a 100644 --- a/cmd/auth/token_test.go +++ b/cmd/auth/token_test.go @@ -171,6 +171,10 @@ func TestToken_loadToken(t *testing.T) { Name: "valid-token", Host: "https://valid-token.cloud.databricks.com", }, + { + Name: "no-refresh-token", + Host: "https://no-refresh-token.cloud.databricks.com", + }, { Name: "custom-client", Host: "https://accounts.cloud.databricks.com", @@ -232,6 +236,10 @@ func TestToken_loadToken(t *testing.T) { "custom-client": { RefreshToken: "custom-refresh-token", }, + "no-refresh-token": { + AccessToken: "cached-access-token", + Expiry: time.Now().Add(1 * time.Hour), + }, }, } validateToken := func(got *oauth2.Token) { @@ -246,6 +254,7 @@ func TestToken_loadToken(t *testing.T) { validateToken func(*oauth2.Token) wantErr string wantInvalidRefreshToken bool + wantErrIs error }{ { name: "prints helpful login message on refresh failure when profile is specified", @@ -472,6 +481,7 @@ func TestToken_loadToken(t *testing.T) { wantErr: "cache: databricks OAuth is not configured for this host. " + "Try logging in again with `databricks auth login --host https://nonexistent.cloud.databricks.com` before retrying. " + "If this fails, please report this issue to the Databricks CLI maintainers at https://github.com/databricks/cli/issues/new", + wantErrIs: storage.ErrNotFound, }, { // Regression test: when notFoundHintCache wraps ErrNotFound @@ -501,6 +511,7 @@ func TestToken_loadToken(t *testing.T) { "stored credentials from older CLI versions are no longer used; " + "run `databricks auth login` to sign in again, " + "or set DATABRICKS_AUTH_STORAGE=plaintext to keep using the file cache", + wantErrIs: storage.ErrNotFound, }, { name: "errors with clear message for non-host non-profile positional arg", @@ -901,6 +912,26 @@ func TestToken_loadToken(t *testing.T) { $ databricks auth login --profile valid-token`, wantInvalidRefreshToken: true, }, + { + name: "force refresh without a cached refresh token", + args: loadTokenArgs{ + authArguments: &auth.AuthArguments{}, + profileName: "no-refresh-token", + args: []string{}, + tokenTimeout: 1 * time.Hour, + forceRefresh: true, + profiler: profiler, + tokenStore: tokenStore, + persistentAuthOpts: []u2m.PersistentAuthOption{ + u2m.WithTokenStore(tokenStore), + u2m.WithOAuthEndpointSupplier(&MockApiClient{}), + }, + }, + wantErr: "forced token refresh: cached token has no refresh token. " + + "Try logging in again with `databricks auth login --profile no-refresh-token` before retrying. " + + "If this fails, please report this issue to the Databricks CLI maintainers at https://github.com/databricks/cli/issues/new", + wantErrIs: u2m.ErrMissingRefreshToken, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -913,6 +944,9 @@ func TestToken_loadToken(t *testing.T) { assert.Equal(t, c.wantErr, err.Error()) _, isInvalidRefreshToken := errors.AsType[*u2m.InvalidRefreshTokenError](err) assert.Equal(t, c.wantInvalidRefreshToken, isInvalidRefreshToken) + if c.wantErrIs != nil { + assert.ErrorIs(t, err, c.wantErrIs) + } } else { assert.NoError(t, err) c.validateToken(got) diff --git a/libs/dockercredentials/profile.go b/libs/dockercredentials/profile.go index 9acdb84edbf..6ec0143e5f5 100644 --- a/libs/dockercredentials/profile.go +++ b/libs/dockercredentials/profile.go @@ -2,12 +2,16 @@ package dockercredentials import ( "context" + "errors" "fmt" - "strings" "github.com/databricks/cli/libs/auth" + "github.com/databricks/cli/libs/auth/storage" + "github.com/databricks/cli/libs/auth/u2m" "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go/config" + "golang.org/x/oauth2" ) // ValidateProfile verifies that p can be used by the Docker credential helper. @@ -50,41 +54,51 @@ func LoadProfile(ctx context.Context, profiler profile.Profiler, name string) (p return profiles[0], nil } -// EnsureUniqueProfile verifies that workspaceID resolves to only p. -func EnsureUniqueProfile(ctx context.Context, profiler profile.Profiler, p profile.Profile, workspaceID string) error { - matches, err := profiler.LoadProfiles(ctx, func(candidate profile.Profile) bool { - return candidate.WorkspaceID == workspaceID - }) +// TokenForRegistry returns a token from the first logged-in profile that ProfilesForRegistry returns. +// Only a missing or rejected login moves on to the next profile; any other error is returned. +func TokenForRegistry(ctx context.Context, profiler profile.Profiler, registry Registry, load func(context.Context, profile.Profile) (*oauth2.Token, error)) (*oauth2.Token, error) { + profiles, err := ProfilesForRegistry(ctx, profiler, registry) if err != nil { - return err + return nil, err } - - if p.WorkspaceID == "" || p.WorkspaceID == profile.WorkspaceIDNone { - matches = append(matches, p) + // With one candidate there is nothing to fall back to, so its error is returned unchanged. + if len(profiles) == 1 { + return load(ctx, profiles[0]) } - var names []string - for _, candidate := range matches { - if ValidateProfile(candidate) == nil { - names = append(names, candidate.Name) + var errs []error + for _, p := range profiles { + if err := ctx.Err(); err != nil { + return nil, err } + token, err := load(ctx, p) + if err == nil { + return token, nil + } + err = fmt.Errorf("profile %q: %w", p.Name, err) + if !isLoggedOut(err) { + return nil, err + } + log.Debugf(ctx, "Skipping logged-out profile for registry %s: %v", registry.Host, err) + errs = append(errs, err) } - if len(names) <= 1 { - return nil - } + return nil, fmt.Errorf("no logged-in Databricks profile for workspace ID %s from registry host %s. Run databricks auth login --profile for one of these profiles:\n%w", registry.WorkspaceID, registry.Host, errors.Join(errs...)) +} - return duplicateProfilesError(workspaceID, names) +func isLoggedOut(err error) bool { + _, invalidRefreshToken := errors.AsType[*u2m.InvalidRefreshTokenError](err) + return invalidRefreshToken || errors.Is(err, storage.ErrNotFound) || errors.Is(err, u2m.ErrMissingRefreshToken) } -// ProfileForRegistry returns the Docker credential helper profile for registry. -func ProfileForRegistry(ctx context.Context, profiler profile.Profiler, registry Registry) (profile.Profile, error) { +// ProfilesForRegistry returns the profiles the Docker credential helper can use for registry, in profiler order. +func ProfilesForRegistry(ctx context.Context, profiler profile.Profiler, registry Registry) (profile.Profiles, error) { workspaceProfiles, err := profiler.LoadProfiles(ctx, func(p profile.Profile) bool { return p.WorkspaceID == registry.WorkspaceID && registry.ServesWorkspaceHost(p.Host) }) if err != nil { - return profile.Profile{}, err + return nil, err } if len(workspaceProfiles) == 0 { - return profile.Profile{}, fmt.Errorf("no Databricks profile found for workspace ID %s from registry host %s. Run databricks auth login --host and set workspace_id for that profile", registry.WorkspaceID, registry.Host) + return nil, fmt.Errorf("no Databricks profile found for workspace ID %s from registry host %s. Run databricks auth login --host and set workspace_id for that profile", registry.WorkspaceID, registry.Host) } var matchingProfiles profile.Profiles @@ -93,18 +107,10 @@ func ProfileForRegistry(ctx context.Context, profiler profile.Profiler, registry matchingProfiles = append(matchingProfiles, p) } } - switch len(matchingProfiles) { - case 0: - return profile.Profile{}, ValidateProfile(workspaceProfiles[0]) - case 1: - return matchingProfiles[0], nil - default: - return profile.Profile{}, duplicateProfilesError(registry.WorkspaceID, matchingProfiles.Names()) + if len(matchingProfiles) == 0 { + return nil, ValidateProfile(workspaceProfiles[0]) } -} - -func duplicateProfilesError(workspaceID string, names []string) error { - return fmt.Errorf("multiple Databricks profiles match workspace ID %s: %s. Docker credential helper requires exactly one profile per workspace. Remove the duplicate profile with: databricks auth logout --profile --delete", workspaceID, strings.Join(names, " and ")) + return matchingProfiles, nil } // RewriteProfileError adds Docker profile context to authentication errors. diff --git a/libs/dockercredentials/profile_test.go b/libs/dockercredentials/profile_test.go index 0586d7e1e7a..5b68d8a7c9b 100644 --- a/libs/dockercredentials/profile_test.go +++ b/libs/dockercredentials/profile_test.go @@ -1,16 +1,19 @@ package dockercredentials_test import ( + "context" "errors" "fmt" "testing" "github.com/databricks/cli/libs/auth" + "github.com/databricks/cli/libs/auth/storage" "github.com/databricks/cli/libs/auth/u2m" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/dockercredentials" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" ) const ( @@ -139,98 +142,141 @@ func TestLoadProfile(t *testing.T) { } } -func TestEnsureUniqueProfile(t *testing.T) { - selected := workspaceProfile("prod") +func TestProfilesForRegistry(t *testing.T) { + registry := dockercredentials.Registry{ + WorkspaceID: workspaceID, + Host: "123456789.container.us-west-2.cloud.databricks.test", + } + m2m := profile.Profile{ + Name: "m2m", + Host: workspaceHost, + WorkspaceID: workspaceID, + HasClientCredentials: true, + } + loopback := profile.Profile{ + Name: "loopback", + Host: "http://127.0.0.1:8080", + WorkspaceID: workspaceID, + AuthType: auth.AuthTypeDatabricksCli, + } tests := []struct { name string profiles profile.Profiles - selected profile.Profile + want []string wantErr string }{ - {name: "single profile", profiles: profile.Profiles{selected}, selected: selected}, + {name: "selects workspace profile", profiles: profile.Profiles{workspaceProfile("workspace")}, want: []string{"workspace"}}, { - name: "duplicate workspace ID", - profiles: profile.Profiles{selected, workspaceProfile("dev")}, - selected: selected, - wantErr: "multiple Databricks profiles match workspace ID 123456789: prod and dev. Docker credential helper requires exactly one profile per workspace. Remove the duplicate profile with: databricks auth logout --profile --delete", + name: "shared workspace ID in file order", + profiles: profile.Profiles{workspaceProfile("prod"), workspaceProfile("dev")}, + want: []string{"prod", "dev"}, }, + {name: "ignores unsupported profile", profiles: profile.Profiles{m2m, workspaceProfile("workspace")}, want: []string{"workspace"}}, + {name: "ignores host the registry does not serve", profiles: profile.Profiles{loopback, workspaceProfile("workspace")}, want: []string{"workspace"}}, { - name: "unsupported duplicate", - profiles: profile.Profiles{selected, { - Name: "m2m", - Host: workspaceHost, - WorkspaceID: workspaceID, - HasClientCredentials: true, - }}, - selected: selected, + name: "only unsupported profiles", + profiles: profile.Profiles{m2m}, + wantErr: `profile "m2m" uses client credentials. Docker credential helper requires a profile created by databricks auth login`, }, { - name: "resolved workspace ID appends selected profile", - profiles: profile.Profiles{selected}, - selected: profile.Profile{ - Name: "workspace", - Host: workspaceHost, - AuthType: auth.AuthTypeDatabricksCli, - }, - wantErr: "multiple Databricks profiles match workspace ID 123456789: prod and workspace. Docker credential helper requires exactly one profile per workspace. Remove the duplicate profile with: databricks auth logout --profile --delete", + name: "no matching profile", + wantErr: "no Databricks profile found for workspace ID 123456789 from registry host 123456789.container.us-west-2.cloud.databricks.test. Run databricks auth login --host and set workspace_id for that profile", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := dockercredentials.EnsureUniqueProfile(t.Context(), profile.InMemoryProfiler{Profiles: tt.profiles}, tt.selected, workspaceID) - if tt.wantErr == "" { - assert.NoError(t, err) + got, err := dockercredentials.ProfilesForRegistry(t.Context(), profile.InMemoryProfiler{Profiles: tt.profiles}, registry) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) return } - assert.EqualError(t, err, tt.wantErr) + require.NoError(t, err) + assert.Equal(t, tt.want, got.Names()) }) } } -func TestProfileForRegistry(t *testing.T) { +func TestTokenForRegistry(t *testing.T) { registry := dockercredentials.Registry{ WorkspaceID: workspaceID, Host: "123456789.container.us-west-2.cloud.databricks.test", } - workspace := workspaceProfile("workspace") + errUnavailable := errors.New("service unavailable") tests := []struct { - name string - profiles profile.Profiles - want string - wantErr string + name string + profiles profile.Profiles + errs map[string]error + canceled bool + want string + wantTried []string + wantErr string }{ - {name: "selects workspace profile", profiles: profile.Profiles{workspace}, want: workspace.Name}, { - name: "duplicate workspace ID", - profiles: profile.Profiles{workspaceProfile("prod"), workspaceProfile("dev")}, - wantErr: "multiple Databricks profiles match workspace ID 123456789: prod and dev. Docker credential helper requires exactly one profile per workspace. Remove the duplicate profile with: databricks auth logout --profile --delete", + name: "first logged-in profile wins", + profiles: profile.Profiles{workspaceProfile("prod"), workspaceProfile("dev")}, + want: "prod", + wantTried: []string{"prod"}, }, { - name: "ignores unsupported duplicate", - profiles: profile.Profiles{workspace, { - Name: "m2m", - Host: workspaceHost, - WorkspaceID: workspaceID, - HasClientCredentials: true, - }}, - want: workspace.Name, + name: "skips profiles that are logged out", + profiles: profile.Profiles{workspaceProfile("missing"), workspaceProfile("no-refresh"), workspaceProfile("revoked"), workspaceProfile("dev")}, + errs: map[string]error{"missing": storage.ErrNotFound, "no-refresh": u2m.ErrMissingRefreshToken, "revoked": &u2m.InvalidRefreshTokenError{}}, + want: "dev", + wantTried: []string{"missing", "no-refresh", "revoked", "dev"}, }, { - name: "no matching profile", - wantErr: "no Databricks profile found for workspace ID 123456789 from registry host 123456789.container.us-west-2.cloud.databricks.test. Run databricks auth login --host and set workspace_id for that profile", + name: "stops at other errors", + profiles: profile.Profiles{workspaceProfile("prod"), workspaceProfile("dev")}, + errs: map[string]error{"prod": errUnavailable}, + wantTried: []string{"prod"}, + wantErr: `profile "prod": load token: service unavailable`, + }, + { + name: "no logged-in profile", + profiles: profile.Profiles{workspaceProfile("prod"), workspaceProfile("dev")}, + errs: map[string]error{"prod": storage.ErrNotFound, "dev": storage.ErrNotFound}, + wantTried: []string{"prod", "dev"}, + wantErr: "no logged-in Databricks profile for workspace ID 123456789 from registry host 123456789.container.us-west-2.cloud.databricks.test. Run databricks auth login --profile for one of these profiles:\nprofile \"prod\": load token: token not found\nprofile \"dev\": load token: token not found", + }, + { + name: "stops when the context is canceled", + profiles: profile.Profiles{workspaceProfile("prod"), workspaceProfile("dev")}, + canceled: true, + wantErr: "context canceled", + }, + { + name: "single profile keeps its error", + profiles: profile.Profiles{workspaceProfile("workspace")}, + errs: map[string]error{"workspace": storage.ErrNotFound}, + wantTried: []string{"workspace"}, + wantErr: "load token: token not found", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := dockercredentials.ProfileForRegistry(t.Context(), profile.InMemoryProfiler{Profiles: tt.profiles}, registry) + var tried []string + load := func(_ context.Context, p profile.Profile) (*oauth2.Token, error) { + tried = append(tried, p.Name) + if err := tt.errs[p.Name]; err != nil { + return nil, fmt.Errorf("load token: %w", err) + } + return &oauth2.Token{AccessToken: p.Name}, nil + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if tt.canceled { + cancel() + } + got, err := dockercredentials.TokenForRegistry(ctx, profile.InMemoryProfiler{Profiles: tt.profiles}, registry, load) + assert.Equal(t, tt.wantTried, tried) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) return } require.NoError(t, err) - assert.Equal(t, tt.want, got.Name) + assert.Equal(t, tt.want, got.AccessToken) }) } }