Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .nextchanges/cli/docker-duplicate-profile-hint.md

This file was deleted.

1 change: 1 addition & 0 deletions .nextchanges/cli/docker-shared-workspace-profiles.md
Original file line number Diff line number Diff line change
@@ -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))
5 changes: 0 additions & 5 deletions acceptance/cmd/auth/docker/configure-errors/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <profile-name> --delete

=== Nothing changed
Docker config not created; profiles unchanged
8 changes: 0 additions & 8 deletions acceptance/cmd/auth/docker/configure-errors/script
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@ cat >> "$HOME/.databrickscfg" <<ENDCFG
host = https://workspace.example.test
auth_type = databricks-cli

[duplicate]
host = $DATABRICKS_HOST_ORIG
auth_type = databricks-cli
workspace_id = 900800700600

[pat]
host = $DATABRICKS_HOST_ORIG
auth_type = pat
Expand Down Expand Up @@ -54,9 +49,6 @@ trace musterr $CLI auth docker configure pat
trace musterr $CLI auth docker configure m2m
trace musterr $CLI auth docker configure blank-auth

title "Another profile already maps to the workspace\n"
trace musterr $CLI auth docker configure docker-test

title "Nothing changed\n"
assert_not_exists.py "$DOCKER_CONFIG/config.json"
cmp "$HOME/databrickscfg.before" "$HOME/.databrickscfg"
Expand Down
25 changes: 25 additions & 0 deletions acceptance/cmd/auth/docker/configure/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,28 @@ Warning: ensure [TEST_TMP_DIR]/home/bin is on PATH before any other docker-crede
"method": "GET",
"path": "/api/2.1/unity-catalog/metastore_summary"
}

=== Profiles sharing the workspace

>>> 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"
}
12 changes: 12 additions & 0 deletions acceptance/cmd/auth/docker/configure/script
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<ENDCFG

[shared]
host = $DATABRICKS_HOST_ORIG
auth_type = databricks-cli
workspace_id = 900800700600
ENDCFG
trace databricks auth docker configure docker-test
trace print_requests.py --get //api
printf '%s\n' "$registry_host" | trace docker-credential-databricks get | jq -S .
12 changes: 7 additions & 5 deletions acceptance/cmd/auth/docker/help/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,13 @@ Use "databricks auth docker [command] --help" for more information about a comma
This command installs docker-credential-databricks and configures Docker to use
it for the selected workspace's Artifact Registry host. If the selected profile
does not already include a workspace_id, the command resolves and saves it so
the Docker helper can map the registry host back to the profile. The registry
region is inferred from the workspace's metastore. Select the workspace with
[PROFILE] or --profile; --host, --account-id, and --workspace-id are not
supported. The deprecated --region flag is retained for compatibility; omit it
because it will be fully removed in the next release.
the Docker helper can map the registry host back to the profile. If several
profiles match the workspace, the helper uses the first logged-in one, checking
[DEFAULT] first and then the others in file order. The registry region is
inferred from the workspace's metastore. Select the workspace with [PROFILE] or
--profile; --host, --account-id, and --workspace-id are not supported. The
deprecated --region flag is retained for compatibility; omit it because it will
be fully removed in the next release.

Usage:
databricks auth docker configure [PROFILE] [flags]
Expand Down
22 changes: 20 additions & 2 deletions acceptance/cmd/auth/docker/token/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,25 @@ Error: auth docker token does not support --workspace-id
>>> 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 <workspace-url> 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 <profile-name> --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 <profile-name> 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
31 changes: 29 additions & 2 deletions acceptance/cmd/auth/docker/token/script
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,39 @@ 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" <<ENDCFG

[duplicate]
host = $DATABRICKS_HOST_ORIG
auth_type = databricks-cli
workspace_id = 900800700600
ENDCFG
printf '%s\n' "$registry_host" | trace musterr $CLI auth docker token
cp token-cache.json "$HOME/.databricks/token-cache.json"
printf '%s\n' "$registry_host" | trace $CLI auth docker token --no-force-refresh | jq -S .

title "Profiles that are not logged in are skipped\n"
cat - "$HOME/.databrickscfg" > "$HOME/databrickscfg.new" <<ENDCFG
[logged-out]
host = $DATABRICKS_HOST_ORIG
auth_type = databricks-cli
workspace_id = 900800700600

ENDCFG
mv "$HOME/databrickscfg.new" "$HOME/.databrickscfg"
printf '%s\n' "$registry_host" | trace $CLI auth docker token | jq -S .

title "No logged-in profile for the workspace\n"
cat >> "$HOME/.databrickscfg" <<ENDCFG

[logged-out-a]
host = $DATABRICKS_HOST_ORIG
auth_type = databricks-cli
workspace_id = 555666777

[logged-out-b]
host = $DATABRICKS_HOST_ORIG
auth_type = databricks-cli
workspace_id = 555666777
ENDCFG
printf '%s\n' "555666777.container.us-west-2.localhost" | trace musterr $CLI auth docker token
17 changes: 17 additions & 0 deletions acceptance/cmd/auth/docker/token/token-cache.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"version": 1,
"tokens": {
"docker-test": {
"access_token": "docker-test-access-token",
"token_type": "Bearer",
"refresh_token": "docker-test-refresh-token",
"expiry": "2099-01-01T00:00:00Z"
},
"duplicate": {
"access_token": "duplicate-access-token",
"token_type": "Bearer",
"refresh_token": "duplicate-refresh-token",
"expiry": "2099-01-01T00:00:00Z"
}
}
}
31 changes: 23 additions & 8 deletions cmd/auth/docker/docker_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"path/filepath"
"strings"

"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/databrickscfg"
Expand All @@ -24,11 +25,13 @@ func newDockerConfigureCommand() *cobra.Command {
This command installs docker-credential-databricks and configures Docker to use
it for the selected workspace's Artifact Registry host. If the selected profile
does not already include a workspace_id, the command resolves and saves it so
the Docker helper can map the registry host back to the profile. The registry
region is inferred from the workspace's metastore. Select the workspace with
[PROFILE] or --profile; --host, --account-id, and --workspace-id are not
supported. The deprecated --region flag is retained for compatibility; omit it
because it will be fully removed in the next release.`,
the Docker helper can map the registry host back to the profile. If several
profiles match the workspace, the helper uses the first logged-in one, checking
[DEFAULT] first and then the others in file order. The registry region is
inferred from the workspace's metastore. Select the workspace with [PROFILE] or
--profile; --host, --account-id, and --workspace-id are not supported. The
deprecated --region flag is retained for compatibility; omit it because it will
be fully removed in the next release.`,
Args: cobra.MaximumNArgs(1),
PreRunE: func(cmd *cobra.Command, _ []string) error {
return errorOnUnsupportedDockerFlags(cmd, "[PROFILE] or --profile")
Expand Down Expand Up @@ -66,9 +69,6 @@ func configureDockerTarget(cmd *cobra.Command, args []string, region string) (*d
if err != nil {
return nil, err
}
if err := dockercredentials.EnsureUniqueProfile(ctx, profile.DefaultProfiler, w.profile, w.id); err != nil {
return nil, err
}
return w.target(ctx, explicitRegion)
}

Expand All @@ -83,9 +83,24 @@ func (t *dockerTarget) configure(ctx context.Context) (dockercredentials.Configu
return dockercredentials.Configuration{}, fmt.Errorf("save workspace ID to profile %q: %w", t.profile.Name, err)
}
}
if err := t.warnSharedWorkspace(ctx); err != nil {
return dockercredentials.Configuration{}, err
}
return dockercredentials.Configure(ctx, t.executable, t.registryHost)
}

func (t *dockerTarget) warnSharedWorkspace(ctx context.Context) error {
registry := dockercredentials.Registry{WorkspaceID: t.workspaceID, Host: t.registryHost}
profiles, err := dockercredentials.ProfilesForRegistry(ctx, profile.DefaultProfiler, registry)
if err != nil {
return err
}
if len(profiles) > 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))
Expand Down
24 changes: 11 additions & 13 deletions cmd/auth/docker/docker_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions cmd/auth/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions cmd/auth/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
Loading
Loading