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
40 changes: 37 additions & 3 deletions cmd/root/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ func initProfileFlag(cmd *cobra.Command) {
cmd.RegisterFlagCompletionFunc("profile", profile.ProfileCompletion)
}

// ErrAccountOnlyProfile signals that the resolved profile has an account_id
// but no workspace_id, so workspace APIs can't be reached. Workspace-only
// commands surface this as an actionable error; MustAnyClient (used by `auth
// describe` and similar) recognizes the type and falls through to the account
// client so account-only profiles still describe cleanly.
type ErrAccountOnlyProfile struct {
profileName string
}

func (e ErrAccountOnlyProfile) Error() string {
return fmt.Sprintf("profile %q has no workspace_id set (account-only); this command requires a workspace. Edit the profile to set workspace_id to a real ID, or pass --profile with a workspace-scoped profile", e.profileName)
}

// accountOnlyProfileError describes why a workspace command can't run against
// a profile that has an account_id but no workspace_id.
func accountOnlyProfileError(profileName string) error {
return ErrAccountOnlyProfile{profileName: profileName}
}

func profileFlagValue(cmd *cobra.Command) (string, bool) {
profileFlag := cmd.Flag("profile")
if profileFlag == nil {
Expand Down Expand Up @@ -127,9 +146,11 @@ func MustAnyClient(cmd *cobra.Command, args []string) (bool, error) {
}

// If the error indicates a wrong config type (workspace host used for account client,
// or config type mismatch detected by workspaceClientOrPrompt), fall through to try
// account client.
if _, ok := errors.AsType[ErrNoWorkspaceProfiles](werr); !errors.Is(werr, errNotWorkspaceClient) && !ok {
// or config type mismatch detected by workspaceClientOrPrompt), or an account-only
// profile (no workspace_id), fall through to try the account client.
_, noWorkspaceProfiles := errors.AsType[ErrNoWorkspaceProfiles](werr)
_, accountOnly := errors.AsType[ErrAccountOnlyProfile](werr)
if !errors.Is(werr, errNotWorkspaceClient) && !noWorkspaceProfiles && !accountOnly {
return false, werr
}

Expand Down Expand Up @@ -189,6 +210,19 @@ func MustAccountClient(cmd *cobra.Command, args []string) error {
// Helper function to create a workspace client or prompt once if the given configuration is not valid.
func workspaceClientOrPrompt(ctx context.Context, cfg *config.Config, allowPrompt bool) (*databricks.WorkspaceClient, error) {
w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg))
if err == nil && cfg.Profile != "" && cfg.AccountID != "" &&
(cfg.WorkspaceID == "" || cfg.WorkspaceID == auth.WorkspaceIDNone) {
// Account-only profile (created with --skip-workspace): account_id is
// set but workspace_id is absent (new shape) or the legacy "none"
// sentinel. Without a workspace_id the SDK would either send "none" as
// a routing identifier or fail later with an opaque auth error.
// Reject up front with a message the user can act on.
//
// We require cfg.Profile to be set so we don't reject env-var-only
// configs targeting a unified host where workspace APIs are also
// served from the account host.
return nil, accountOnlyProfileError(cfg.Profile)
}
if err == nil {
err = w.Config.Authenticate(emptyHttpRequest(ctx))
}
Expand Down
65 changes: 65 additions & 0 deletions cmd/root/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,71 @@ func TestAccountClientOrPromptReturnsErrorForWrongHostType(t *testing.T) {
assert.ErrorIs(t, err, databricks.ErrNotAccountClient)
}

func TestWorkspaceClientOrPromptRejectsAccountOnlyProfile(t *testing.T) {
tests := []struct {
name string
workspaceID string
}{
// New shape: --skip-workspace omits workspace_id entirely.
{name: "empty workspace_id", workspaceID: ""},
// Legacy shape: older CLIs persisted the "none" sentinel.
{name: "legacy none sentinel", workspaceID: "none"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testutil.CleanupEnvironment(t)
t.Setenv("PATH", "")

cfg := &config.Config{
Host: "https://example.test/",
AccountID: "abc-123",
WorkspaceID: tt.workspaceID,
Token: "foobar",
Profile: "bb",
HTTPTransport: noNetworkTransport,
}

w, err := workspaceClientOrPrompt(t.Context(), cfg, false)
assert.Nil(t, w)
require.Error(t, err)
var accountOnly ErrAccountOnlyProfile
require.ErrorAs(t, err, &accountOnly)
assert.Contains(t, err.Error(), `profile "bb"`)
assert.Contains(t, err.Error(), "account-only")
assert.Contains(t, err.Error(), "no workspace_id set")
})
}
}

func TestMustAnyClientFallsThroughOnAccountOnlyProfile(t *testing.T) {
testutil.CleanupEnvironment(t)
t.Setenv("PATH", "")

configFile := filepath.Join(t.TempDir(), ".databrickscfg")
err := os.WriteFile(configFile, []byte(`
[skipws]
host = https://accounts.azuredatabricks.net/
account_id = abc-123
token = foobar
workspace_id = none
`), 0o600)
require.NoError(t, err)
t.Setenv("DATABRICKS_CONFIG_FILE", configFile)

ctx, tt := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true})
t.Cleanup(tt.Done)
cmd := New(ctx)
require.NoError(t, cmd.PersistentFlags().Set("profile", "skipws"))

// Workspace path returns ErrAccountOnlyProfile. MustAnyClient must
// recognize the type and fall through to the account client so
// `auth describe` shows account info for account-only profiles.
isAccount, err := MustAnyClient(cmd, []string{})
require.NoError(t, err)
require.True(t, isAccount, "expected fall-through to account client")
require.NotNil(t, cmdctx.AccountClient(cmd.Context()))
}

func TestWorkspaceClientOrPromptReturnsSuccessWhenAuthSucceeds(t *testing.T) {
testutil.CleanupEnvironment(t)
t.Setenv("PATH", "")
Expand Down
Loading