From 8c144a2de4a160fc2bb1a0587f6177b68ee314dd Mon Sep 17 00:00:00 2001 From: Andrew Nester Date: Wed, 3 Apr 2024 10:14:04 +0200 Subject: [PATCH] Added `auth describe` command (#1244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes This command provide details on auth configuration user is using as well as authenticated user and auth mechanism used. Relies on https://github.com/databricks/databricks-sdk-go/pull/838 (tests will fail until merged) Examples of output ``` Workspace: https://test.com User: andrew.nester@databricks.com Authenticated with: pat ----- Configuration: ✓ auth_type: pat ✓ host: https://test.com (from bundle) ✓ profile: DEFAULT (from --profile flag) ✓ token: ******** (from /Users/andrew.nester/.databrickscfg config file) ``` ``` DATABRICKS_AUTH_TYPE=azure-msi databricks auth describe -p "Azure 2" Unable to authenticate: inner token: Post "https://foobar.com/oauth2/token": AADSTS900023: Specified tenant identifier foobar_aaaaaaa' is neither a valid DNS name, nor a valid external domain. See https://login.microsoftonline.com/error?code=900023 ----- Configuration: ✓ auth_type: azure-msi (from DATABRICKS_AUTH_TYPE environment variable) ✓ azure_client_id: 8470f3ba-aaaa-bbbb-cccc-xxxxyyyyzzzz (from /Users/andrew.nester/.databrickscfg config file) ~ azure_client_secret: ******** (from /Users/andrew.nester/.databrickscfg config file, not used for auth type azure-msi) ~ azure_tenant_id: foobar_aaaaaaa (from /Users/andrew.nester/.databrickscfg config file, not used for auth type azure-msi) ✓ azure_use_msi: true (from /Users/andrew.nester/.databrickscfg config file) ✓ host: https://foobar.com (from /Users/andrew.nester/.databrickscfg config file) ✓ profile: Azure 2 (from --profile flag) ``` For account ``` Unable to authenticate: default auth: databricks-cli: cannot get access token: Error: token refresh: Post "https://xxxxxxx.com/v1/token": http 400: {"error":"invalid_request","error_description":"Refresh token is invalid"} . Config: host=https://xxxxxxx.com, account_id=ed0ca3c5-fae5-4619-bb38-eebe04a4af4b, profile=ACCOUNT-ed0ca3c5-fae5-4619-bb38-eebe04a4af4b ----- Configuration: ✓ account_id: ed0ca3c5-fae5-4619-bb38-eebe04a4af4b (from /Users/andrew.nester/.databrickscfg config file) ✓ auth_type: databricks-cli (from /Users/andrew.nester/.databrickscfg config file) ✓ host: https://xxxxxxxxx.com (from /Users/andrew.nester/.databrickscfg config file) ✓ profile: ACCOUNT-ed0ca3c5-fae5-4619-bb38-eebe04a4af4b ``` ## Tests Added unit tests --------- Co-authored-by: Julia Crawford (Databricks) --- bundle/config/workspace.go | 21 +++- cmd/auth/auth.go | 1 + cmd/auth/describe.go | 192 +++++++++++++++++++++++++++++++ cmd/auth/describe_test.go | 217 +++++++++++++++++++++++++++++++++++ cmd/root/auth.go | 71 +++++++++++- cmd/root/auth_test.go | 78 +++++++++++++ libs/cmdio/render.go | 6 + libs/databrickscfg/loader.go | 7 +- 8 files changed, 583 insertions(+), 10 deletions(-) create mode 100644 cmd/auth/describe.go create mode 100644 cmd/auth/describe_test.go diff --git a/bundle/config/workspace.go b/bundle/config/workspace.go index 5f8691babe..efc5caa663 100644 --- a/bundle/config/workspace.go +++ b/bundle/config/workspace.go @@ -78,8 +78,8 @@ func (s User) MarshalJSON() ([]byte, error) { return marshal.Marshal(s) } -func (w *Workspace) Client() (*databricks.WorkspaceClient, error) { - cfg := config.Config{ +func (w *Workspace) Config() *config.Config { + cfg := &config.Config{ // Generic Host: w.Host, Profile: w.Profile, @@ -101,6 +101,19 @@ func (w *Workspace) Client() (*databricks.WorkspaceClient, error) { AzureLoginAppID: w.AzureLoginAppID, } + for k := range config.ConfigAttributes { + attr := &config.ConfigAttributes[k] + if !attr.IsZero(cfg) { + cfg.SetAttrSource(attr, config.Source{Type: config.SourceType("bundle")}) + } + } + + return cfg +} + +func (w *Workspace) Client() (*databricks.WorkspaceClient, error) { + cfg := w.Config() + // If only the host is configured, we try and unambiguously match it to // a profile in the user's databrickscfg file. Override the default loaders. if w.Host != "" && w.Profile == "" { @@ -124,13 +137,13 @@ func (w *Workspace) Client() (*databricks.WorkspaceClient, error) { // Now that the configuration is resolved, we can verify that the host in the bundle configuration // is identical to the host associated with the selected profile. if w.Host != "" && w.Profile != "" { - err := databrickscfg.ValidateConfigAndProfileHost(&cfg, w.Profile) + err := databrickscfg.ValidateConfigAndProfileHost(cfg, w.Profile) if err != nil { return nil, err } } - return databricks.NewWorkspaceClient((*databricks.Config)(&cfg)) + return databricks.NewWorkspaceClient((*databricks.Config)(cfg)) } func init() { diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index e0c7c7c5bb..59de76111d 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -22,6 +22,7 @@ func New() *cobra.Command { cmd.AddCommand(newLoginCommand(&perisistentAuth)) cmd.AddCommand(newProfilesCommand()) cmd.AddCommand(newTokenCommand(&perisistentAuth)) + cmd.AddCommand(newDescribeCommand()) return cmd } diff --git a/cmd/auth/describe.go b/cmd/auth/describe.go new file mode 100644 index 0000000000..125b0731a6 --- /dev/null +++ b/cmd/auth/describe.go @@ -0,0 +1,192 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/flags" + "github.com/databricks/databricks-sdk-go/config" + "github.com/spf13/cobra" +) + +var authTemplate = `{{"Host:" | bold}} {{.Details.Host}} +{{- if .AccountID}} +{{"Account ID:" | bold}} {{.AccountID}} +{{- end}} +{{- if .Username}} +{{"User:" | bold}} {{.Username}} +{{- end}} +{{"Authenticated with:" | bold}} {{.Details.AuthType}} +----- +` + configurationTemplate + +var errorTemplate = `Unable to authenticate: {{.Error}} +----- +` + configurationTemplate + +const configurationTemplate = `Current configuration: + {{- $details := .Status.Details}} + {{- range $a := .ConfigAttributes}} + {{- $k := $a.Name}} + {{- if index $details.Configuration $k}} + {{- $v := index $details.Configuration $k}} + {{if $v.AuthTypeMismatch}}~{{else}}✓{{end}} {{$k | bold}}: {{$v.Value}} + {{- if not (eq $v.Source.String "dynamic configuration")}} + {{- " (from" | italic}} {{$v.Source.String | italic}} + {{- if $v.AuthTypeMismatch}}, {{ "not used for auth type " | red | italic }}{{$details.AuthType | red | italic}}{{end}}) + {{- end}} + {{- end}} + {{- end}} +` + +func newDescribeCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describes the credentials and the source of those credentials, being used by the CLI to authenticate", + } + + var showSensitive bool + cmd.Flags().BoolVar(&showSensitive, "sensitive", false, "Include sensitive fields like passwords and tokens in the output") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + var status *authStatus + var err error + status, err = getAuthStatus(cmd, args, showSensitive, func(cmd *cobra.Command, args []string) (*config.Config, bool, error) { + isAccount, err := root.MustAnyClient(cmd, args) + return root.ConfigUsed(cmd.Context()), isAccount, err + }) + + if err != nil { + return err + } + + if status.Error != nil { + return render(ctx, cmd, status, errorTemplate) + } + + return render(ctx, cmd, status, authTemplate) + } + + return cmd +} + +type tryAuth func(cmd *cobra.Command, args []string) (*config.Config, bool, error) + +func getAuthStatus(cmd *cobra.Command, args []string, showSensitive bool, fn tryAuth) (*authStatus, error) { + cfg, isAccount, err := fn(cmd, args) + ctx := cmd.Context() + if err != nil { + return &authStatus{ + Status: "error", + Error: err, + Details: getAuthDetails(cmd, cfg, showSensitive), + }, nil + } + + if isAccount { + a := root.AccountClient(ctx) + + // Doing a simple API call to check if the auth is valid + _, err := a.Workspaces.List(ctx) + if err != nil { + return &authStatus{ + Status: "error", + Error: err, + Details: getAuthDetails(cmd, cfg, showSensitive), + }, nil + } + + status := authStatus{ + Status: "success", + Details: getAuthDetails(cmd, a.Config, showSensitive), + AccountID: a.Config.AccountID, + Username: a.Config.Username, + } + + return &status, nil + } + + w := root.WorkspaceClient(ctx) + me, err := w.CurrentUser.Me(ctx) + if err != nil { + return &authStatus{ + Status: "error", + Error: err, + Details: getAuthDetails(cmd, cfg, showSensitive), + }, nil + } + + status := authStatus{ + Status: "success", + Details: getAuthDetails(cmd, w.Config, showSensitive), + Username: me.UserName, + } + + return &status, nil +} + +func render(ctx context.Context, cmd *cobra.Command, status *authStatus, template string) error { + switch root.OutputType(cmd) { + case flags.OutputText: + return cmdio.RenderWithTemplate(ctx, map[string]any{ + "Status": status, + "ConfigAttributes": config.ConfigAttributes, + }, "", template) + case flags.OutputJSON: + buf, err := json.MarshalIndent(status, "", " ") + if err != nil { + return err + } + cmd.OutOrStdout().Write(buf) + default: + return fmt.Errorf("unknown output type %s", root.OutputType(cmd)) + } + + return nil +} + +type authStatus struct { + Status string `json:"status"` + Error error `json:"error,omitempty"` + Username string `json:"username,omitempty"` + AccountID string `json:"account_id,omitempty"` + Details config.AuthDetails `json:"details"` +} + +func getAuthDetails(cmd *cobra.Command, cfg *config.Config, showSensitive bool) config.AuthDetails { + var opts []config.AuthDetailsOptions + if showSensitive { + opts = append(opts, config.ShowSensitive) + } + details := cfg.GetAuthDetails(opts...) + + for k, v := range details.Configuration { + if k == "profile" && cmd.Flag("profile").Changed { + v.Source = config.Source{Type: config.SourceType("flag"), Name: "--profile"} + } + + if k == "host" && cmd.Flag("host").Changed { + v.Source = config.Source{Type: config.SourceType("flag"), Name: "--host"} + } + } + + // If profile is not set explicitly, default to "default" + if _, ok := details.Configuration["profile"]; !ok { + profile := cfg.Profile + if profile == "" { + profile = "default" + } + details.Configuration["profile"] = &config.AttrConfig{Value: profile, Source: config.Source{Type: config.SourceDynamicConfig}} + } + + // Unset source for databricks_cli_path because it can't be overridden anyway + if v, ok := details.Configuration["databricks_cli_path"]; ok { + v.Source = config.Source{Type: config.SourceDynamicConfig} + } + + return details +} diff --git a/cmd/auth/describe_test.go b/cmd/auth/describe_test.go new file mode 100644 index 0000000000..d0260abc7b --- /dev/null +++ b/cmd/auth/describe_test.go @@ -0,0 +1,217 @@ +package auth + +import ( + "context" + "fmt" + "testing" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/databricks-sdk-go/config" + "github.com/databricks/databricks-sdk-go/experimental/mocks" + "github.com/databricks/databricks-sdk-go/service/iam" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestGetWorkspaceAuthStatus(t *testing.T) { + ctx := context.Background() + m := mocks.NewMockWorkspaceClient(t) + ctx = root.SetWorkspaceClient(ctx, m.WorkspaceClient) + + cmd := &cobra.Command{} + cmd.SetContext(ctx) + + showSensitive := false + + currentUserApi := m.GetMockCurrentUserAPI() + currentUserApi.EXPECT().Me(mock.Anything).Return(&iam.User{ + UserName: "test-user", + }, nil) + + cmd.Flags().String("host", "", "") + cmd.Flags().String("profile", "", "") + cmd.Flag("profile").Value.Set("my-profile") + cmd.Flag("profile").Changed = true + + cfg := &config.Config{ + Profile: "my-profile", + } + m.WorkspaceClient.Config = cfg + t.Setenv("DATABRICKS_AUTH_TYPE", "azure-cli") + config.ConfigAttributes.Configure(cfg) + + status, err := getAuthStatus(cmd, []string{}, showSensitive, func(cmd *cobra.Command, args []string) (*config.Config, bool, error) { + config.ConfigAttributes.ResolveFromStringMap(cfg, map[string]string{ + "host": "https://test.com", + "token": "test-token", + "auth_type": "azure-cli", + }) + return cfg, false, nil + }) + require.NoError(t, err) + require.NotNil(t, status) + require.Equal(t, "success", status.Status) + require.Equal(t, "test-user", status.Username) + require.Equal(t, "https://test.com", status.Details.Host) + require.Equal(t, "azure-cli", status.Details.AuthType) + + require.Equal(t, "azure-cli", status.Details.Configuration["auth_type"].Value) + require.Equal(t, "DATABRICKS_AUTH_TYPE environment variable", status.Details.Configuration["auth_type"].Source.String()) + require.False(t, status.Details.Configuration["auth_type"].AuthTypeMismatch) + + require.Equal(t, "********", status.Details.Configuration["token"].Value) + require.Equal(t, "dynamic configuration", status.Details.Configuration["token"].Source.String()) + require.True(t, status.Details.Configuration["token"].AuthTypeMismatch) + + require.Equal(t, "my-profile", status.Details.Configuration["profile"].Value) + require.Equal(t, "--profile flag", status.Details.Configuration["profile"].Source.String()) + require.False(t, status.Details.Configuration["profile"].AuthTypeMismatch) +} + +func TestGetWorkspaceAuthStatusError(t *testing.T) { + ctx := context.Background() + m := mocks.NewMockWorkspaceClient(t) + ctx = root.SetWorkspaceClient(ctx, m.WorkspaceClient) + + cmd := &cobra.Command{} + cmd.SetContext(ctx) + + showSensitive := false + + cmd.Flags().String("host", "", "") + cmd.Flags().String("profile", "", "") + cmd.Flag("profile").Value.Set("my-profile") + cmd.Flag("profile").Changed = true + + cfg := &config.Config{ + Profile: "my-profile", + } + m.WorkspaceClient.Config = cfg + t.Setenv("DATABRICKS_AUTH_TYPE", "azure-cli") + config.ConfigAttributes.Configure(cfg) + + status, err := getAuthStatus(cmd, []string{}, showSensitive, func(cmd *cobra.Command, args []string) (*config.Config, bool, error) { + config.ConfigAttributes.ResolveFromStringMap(cfg, map[string]string{ + "host": "https://test.com", + "token": "test-token", + "auth_type": "azure-cli", + }) + return cfg, false, fmt.Errorf("auth error") + }) + require.NoError(t, err) + require.NotNil(t, status) + require.Equal(t, "error", status.Status) + + require.Equal(t, "azure-cli", status.Details.Configuration["auth_type"].Value) + require.Equal(t, "DATABRICKS_AUTH_TYPE environment variable", status.Details.Configuration["auth_type"].Source.String()) + require.False(t, status.Details.Configuration["auth_type"].AuthTypeMismatch) + + require.Equal(t, "********", status.Details.Configuration["token"].Value) + require.Equal(t, "dynamic configuration", status.Details.Configuration["token"].Source.String()) + require.True(t, status.Details.Configuration["token"].AuthTypeMismatch) + + require.Equal(t, "my-profile", status.Details.Configuration["profile"].Value) + require.Equal(t, "--profile flag", status.Details.Configuration["profile"].Source.String()) + require.False(t, status.Details.Configuration["profile"].AuthTypeMismatch) +} + +func TestGetWorkspaceAuthStatusSensitive(t *testing.T) { + ctx := context.Background() + m := mocks.NewMockWorkspaceClient(t) + ctx = root.SetWorkspaceClient(ctx, m.WorkspaceClient) + + cmd := &cobra.Command{} + cmd.SetContext(ctx) + + showSensitive := true + + cmd.Flags().String("host", "", "") + cmd.Flags().String("profile", "", "") + cmd.Flag("profile").Value.Set("my-profile") + cmd.Flag("profile").Changed = true + + cfg := &config.Config{ + Profile: "my-profile", + } + m.WorkspaceClient.Config = cfg + t.Setenv("DATABRICKS_AUTH_TYPE", "azure-cli") + config.ConfigAttributes.Configure(cfg) + + status, err := getAuthStatus(cmd, []string{}, showSensitive, func(cmd *cobra.Command, args []string) (*config.Config, bool, error) { + config.ConfigAttributes.ResolveFromStringMap(cfg, map[string]string{ + "host": "https://test.com", + "token": "test-token", + "auth_type": "azure-cli", + }) + return cfg, false, fmt.Errorf("auth error") + }) + require.NoError(t, err) + require.NotNil(t, status) + require.Equal(t, "error", status.Status) + + require.Equal(t, "azure-cli", status.Details.Configuration["auth_type"].Value) + require.Equal(t, "DATABRICKS_AUTH_TYPE environment variable", status.Details.Configuration["auth_type"].Source.String()) + require.False(t, status.Details.Configuration["auth_type"].AuthTypeMismatch) + + require.Equal(t, "test-token", status.Details.Configuration["token"].Value) + require.Equal(t, "dynamic configuration", status.Details.Configuration["token"].Source.String()) + require.True(t, status.Details.Configuration["token"].AuthTypeMismatch) +} + +func TestGetAccountAuthStatus(t *testing.T) { + ctx := context.Background() + m := mocks.NewMockAccountClient(t) + ctx = root.SetAccountClient(ctx, m.AccountClient) + + cmd := &cobra.Command{} + cmd.SetContext(ctx) + + showSensitive := false + + cmd.Flags().String("host", "", "") + cmd.Flags().String("profile", "", "") + cmd.Flag("profile").Value.Set("my-profile") + cmd.Flag("profile").Changed = true + + cfg := &config.Config{ + Profile: "my-profile", + } + m.AccountClient.Config = cfg + t.Setenv("DATABRICKS_AUTH_TYPE", "azure-cli") + config.ConfigAttributes.Configure(cfg) + + wsApi := m.GetMockWorkspacesAPI() + wsApi.EXPECT().List(mock.Anything).Return(nil, nil) + + status, err := getAuthStatus(cmd, []string{}, showSensitive, func(cmd *cobra.Command, args []string) (*config.Config, bool, error) { + config.ConfigAttributes.ResolveFromStringMap(cfg, map[string]string{ + "account_id": "test-account-id", + "username": "test-user", + "host": "https://test.com", + "token": "test-token", + "auth_type": "azure-cli", + }) + return cfg, true, nil + }) + require.NoError(t, err) + require.NotNil(t, status) + require.Equal(t, "success", status.Status) + + require.Equal(t, "test-user", status.Username) + require.Equal(t, "https://test.com", status.Details.Host) + require.Equal(t, "azure-cli", status.Details.AuthType) + require.Equal(t, "test-account-id", status.AccountID) + + require.Equal(t, "azure-cli", status.Details.Configuration["auth_type"].Value) + require.Equal(t, "DATABRICKS_AUTH_TYPE environment variable", status.Details.Configuration["auth_type"].Source.String()) + require.False(t, status.Details.Configuration["auth_type"].AuthTypeMismatch) + + require.Equal(t, "********", status.Details.Configuration["token"].Value) + require.Equal(t, "dynamic configuration", status.Details.Configuration["token"].Source.String()) + require.True(t, status.Details.Configuration["token"].AuthTypeMismatch) + + require.Equal(t, "my-profile", status.Details.Configuration["profile"].Value) + require.Equal(t, "--profile flag", status.Details.Configuration["profile"].Source.String()) + require.False(t, status.Details.Configuration["profile"].AuthTypeMismatch) +} diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 0edfaaa838..387b67f0d7 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -17,6 +17,23 @@ import ( // Placeholders to use as unique keys in context.Context. var workspaceClient int var accountClient int +var configUsed int + +type ErrNoWorkspaceProfiles struct { + path string +} + +func (e ErrNoWorkspaceProfiles) Error() string { + return fmt.Sprintf("%s does not contain workspace profiles; please create one by running 'databricks configure'", e.path) +} + +type ErrNoAccountProfiles struct { + path string +} + +func (e ErrNoAccountProfiles) Error() string { + return fmt.Sprintf("%s does not contain account profiles", e.path) +} func initProfileFlag(cmd *cobra.Command) { cmd.PersistentFlags().StringP("profile", "p", "", "~/.databrickscfg profile") @@ -67,6 +84,29 @@ func accountClientOrPrompt(ctx context.Context, cfg *config.Config, allowPrompt return a, err } +func MustAnyClient(cmd *cobra.Command, args []string) (bool, error) { + // Try to create a workspace client + werr := MustWorkspaceClient(cmd, args) + if werr == nil { + return false, nil + } + + // If the error is other than "not a workspace client error" or "no workspace profiles", + // return it because configuration is for workspace client + // and we don't want to try to create an account client. + if !errors.Is(werr, databricks.ErrNotWorkspaceClient) && !errors.As(werr, &ErrNoWorkspaceProfiles{}) { + return false, werr + } + + // Otherwise, the config used is account client one, so try to create an account client + aerr := MustAccountClient(cmd, args) + if errors.As(aerr, &ErrNoAccountProfiles{}) { + return false, aerr + } + + return true, aerr +} + func MustAccountClient(cmd *cobra.Command, args []string) error { cfg := &config.Config{} @@ -76,6 +116,10 @@ func MustAccountClient(cmd *cobra.Command, args []string) error { cfg.Profile = profile } + ctx := cmd.Context() + ctx = context.WithValue(ctx, &configUsed, cfg) + cmd.SetContext(ctx) + if cfg.Profile == "" { // account-level CLI was not really done before, so here are the assumptions: // 1. only admins will have account configured @@ -98,7 +142,8 @@ func MustAccountClient(cmd *cobra.Command, args []string) error { return err } - cmd.SetContext(context.WithValue(cmd.Context(), &accountClient, a)) + ctx = context.WithValue(ctx, &accountClient, a) + cmd.SetContext(ctx) return nil } @@ -146,13 +191,20 @@ func MustWorkspaceClient(cmd *cobra.Command, args []string) error { cfg.Profile = profile } + ctx := cmd.Context() + ctx = context.WithValue(ctx, &configUsed, cfg) + cmd.SetContext(ctx) + // Try to load a bundle configuration if we're allowed to by the caller (see `./auth_options.go`). if !shouldSkipLoadBundle(cmd.Context()) { b, diags := TryConfigureBundle(cmd) if err := diags.Error(); err != nil { return err } + if b != nil { + ctx = context.WithValue(ctx, &configUsed, b.Config.Workspace.Config()) + cmd.SetContext(ctx) client, err := b.InitializeWorkspaceClient() if err != nil { return err @@ -167,7 +219,6 @@ func MustWorkspaceClient(cmd *cobra.Command, args []string) error { return err } - ctx := cmd.Context() ctx = context.WithValue(ctx, &workspaceClient, w) cmd.SetContext(ctx) return nil @@ -177,6 +228,10 @@ func SetWorkspaceClient(ctx context.Context, w *databricks.WorkspaceClient) cont return context.WithValue(ctx, &workspaceClient, w) } +func SetAccountClient(ctx context.Context, a *databricks.AccountClient) context.Context { + return context.WithValue(ctx, &accountClient, a) +} + func AskForWorkspaceProfile(ctx context.Context) (string, error) { path, err := databrickscfg.GetPath(ctx) if err != nil { @@ -188,7 +243,7 @@ func AskForWorkspaceProfile(ctx context.Context) (string, error) { } switch len(profiles) { case 0: - return "", fmt.Errorf("%s does not contain workspace profiles; please create one by running 'databricks configure'", path) + return "", ErrNoWorkspaceProfiles{path: path} case 1: return profiles[0].Name, nil } @@ -221,7 +276,7 @@ func AskForAccountProfile(ctx context.Context) (string, error) { } switch len(profiles) { case 0: - return "", fmt.Errorf("%s does not contain account profiles; please create one by running 'databricks configure'", path) + return "", ErrNoAccountProfiles{path} case 1: return profiles[0].Name, nil } @@ -269,3 +324,11 @@ func AccountClient(ctx context.Context) *databricks.AccountClient { } return a } + +func ConfigUsed(ctx context.Context) *config.Config { + cfg, ok := ctx.Value(&configUsed).(*config.Config) + if !ok { + panic("cannot get *config.Config. Please report it as a bug") + } + return cfg +} diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index 7864c254ee..486f587ef3 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -229,3 +229,81 @@ func TestMustAccountClientErrorsWithNoDatabricksCfg(t *testing.T) { err := MustAccountClient(cmd, []string{}) require.ErrorContains(t, err, "no configuration file found at") } + +func TestMustAnyClientCanCreateWorkspaceClient(t *testing.T) { + testutil.CleanupEnvironment(t) + + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + err := os.WriteFile( + configFile, + []byte(` + [workspace-1111] + host = https://adb-1111.11.azuredatabricks.net/ + token = foobar + `), + 0755) + require.NoError(t, err) + + ctx, tt := cmdio.SetupTest(context.Background()) + t.Cleanup(tt.Done) + cmd := New(ctx) + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + isAccount, err := MustAnyClient(cmd, []string{}) + require.False(t, isAccount) + require.NoError(t, err) + + w := WorkspaceClient(cmd.Context()) + require.NotNil(t, w) +} + +func TestMustAnyClientCanCreateAccountClient(t *testing.T) { + testutil.CleanupEnvironment(t) + + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + err := os.WriteFile( + configFile, + []byte(` + [account-1111] + host = https://accounts.azuredatabricks.net/ + account_id = 1111 + token = foobar + `), + 0755) + require.NoError(t, err) + + ctx, tt := cmdio.SetupTest(context.Background()) + t.Cleanup(tt.Done) + cmd := New(ctx) + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + isAccount, err := MustAnyClient(cmd, []string{}) + require.NoError(t, err) + require.True(t, isAccount) + + a := AccountClient(cmd.Context()) + require.NotNil(t, a) +} + +func TestMustAnyClientWithEmptyDatabricksCfg(t *testing.T) { + testutil.CleanupEnvironment(t) + + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + err := os.WriteFile( + configFile, + []byte(""), // empty file + 0755) + require.NoError(t, err) + + ctx, tt := cmdio.SetupTest(context.Background()) + t.Cleanup(tt.Done) + cmd := New(ctx) + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + + _, err = MustAnyClient(cmd, []string{}) + require.ErrorContains(t, err, "does not contain account profiles") +} diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index 40cdde354d..ec851b8fff 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -306,6 +306,12 @@ func renderUsingTemplate(ctx context.Context, r templateRenderer, w io.Writer, h "yellow": color.YellowString, "magenta": color.MagentaString, "cyan": color.CyanString, + "bold": func(format string, a ...interface{}) string { + return color.New(color.Bold).Sprintf(format, a...) + }, + "italic": func(format string, a ...interface{}) string { + return color.New(color.Italic).Sprintf(format, a...) + }, "replace": strings.ReplaceAll, "join": strings.Join, "bool": func(v bool) string { diff --git a/libs/databrickscfg/loader.go b/libs/databrickscfg/loader.go index 1dc2a94525..2e22ee9502 100644 --- a/libs/databrickscfg/loader.go +++ b/libs/databrickscfg/loader.go @@ -98,7 +98,10 @@ func (l profileFromHostLoader) Configure(cfg *config.Config) error { } log.Debugf(ctx, "Loading profile %s because of host match", match.Name()) - err = config.ConfigAttributes.ResolveFromStringMap(cfg, match.KeysHash()) + err = config.ConfigAttributes.ResolveFromStringMapWithSource(cfg, match.KeysHash(), config.Source{ + Type: config.SourceFile, + Name: configFile.Path(), + }) if err != nil { return fmt.Errorf("%s %s profile: %w", configFile.Path(), match.Name(), err) } @@ -110,7 +113,7 @@ func (l profileFromHostLoader) Configure(cfg *config.Config) error { func (l profileFromHostLoader) isAnyAuthConfigured(cfg *config.Config) bool { // If any of the auth-specific attributes are set, we can skip profile resolution. for _, a := range config.ConfigAttributes { - if a.Auth == "" { + if !a.HasAuthAttribute() { continue } if !a.IsZero(cfg) {