diff --git a/acceptance/help/output.txt b/acceptance/help/output.txt index 19b491dd57..73693cc9f9 100644 --- a/acceptance/help/output.txt +++ b/acceptance/help/output.txt @@ -157,6 +157,7 @@ Postgres Developer Tools bundle Databricks Asset Bundles let you express data/AI/analytics projects as code. + doctor Validate your Databricks CLI setup sync Synchronize a local directory to a workspace directory Additional Commands: diff --git a/cmd/cmd.go b/cmd/cmd.go index 014471f763..217476f450 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -14,6 +14,7 @@ import ( "github.com/databricks/cli/cmd/cache" "github.com/databricks/cli/cmd/completion" "github.com/databricks/cli/cmd/configure" + "github.com/databricks/cli/cmd/doctor" "github.com/databricks/cli/cmd/experimental" "github.com/databricks/cli/cmd/fs" "github.com/databricks/cli/cmd/labs" @@ -101,6 +102,7 @@ func New(ctx context.Context) *cobra.Command { cli.AddCommand(experimental.New()) cli.AddCommand(psql.New()) cli.AddCommand(configure.New()) + cli.AddCommand(doctor.New()) cli.AddCommand(fs.New()) cli.AddCommand(labs.New(ctx)) cli.AddCommand(sync.New()) diff --git a/cmd/doctor/checks.go b/cmd/doctor/checks.go new file mode 100644 index 0000000000..5e08d526cc --- /dev/null +++ b/cmd/doctor/checks.go @@ -0,0 +1,373 @@ +package doctor + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "time" + + "github.com/databricks/cli/internal/build" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/env" + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/config" + "github.com/spf13/cobra" +) + +const ( + statusPass = "pass" + statusFail = "fail" + statusWarn = "warn" + statusInfo = "info" + statusSkip = "skip" + + networkTimeout = 10 * time.Second + checkTimeout = 15 * time.Second +) + +// runChecks runs all diagnostic checks and returns the results. +func runChecks(cmd *cobra.Command) []CheckResult { + cfg, err := resolveConfig(cmd) + + var results []CheckResult + + results = append(results, checkCLIVersion()) + results = append(results, checkConfigFile(cmd)) + results = append(results, checkCurrentProfile(cmd)) + + authResult, authCfg := checkAuth(cmd, cfg, err) + results = append(results, authResult) + + if authCfg != nil { + results = append(results, checkIdentity(cmd, authCfg)) + } else { + results = append(results, CheckResult{ + Name: "Identity", + Status: statusSkip, + Message: "Skipped (authentication failed)", + }) + } + + results = append(results, checkNetwork(cmd, cfg, err, authCfg)) + return results +} + +func checkCLIVersion() CheckResult { + info := build.GetInfo() + return CheckResult{ + Name: "CLI Version", + Status: statusInfo, + Message: info.Version, + } +} + +func checkConfigFile(cmd *cobra.Command) CheckResult { + ctx := cmd.Context() + profiler := profile.GetProfiler(ctx) + + path, err := profiler.GetPath(ctx) + if err != nil { + return CheckResult{ + Name: "Config File", + Status: statusFail, + Message: "Cannot determine config file path", + Detail: err.Error(), + } + } + + profiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil { + // Config file absence is not a hard failure since auth can work via env vars. + if errors.Is(err, profile.ErrNoConfiguration) { + return CheckResult{ + Name: "Config File", + Status: statusWarn, + Message: "No config file found (auth can still work via environment variables)", + } + } + return CheckResult{ + Name: "Config File", + Status: statusFail, + Message: "Cannot read " + path, + Detail: err.Error(), + } + } + + return CheckResult{ + Name: "Config File", + Status: statusPass, + Message: fmt.Sprintf("%s (%d profiles)", path, len(profiles)), + } +} + +func checkCurrentProfile(cmd *cobra.Command) CheckResult { + ctx := cmd.Context() + + profileFlag := cmd.Flag("profile") + if profileFlag != nil && profileFlag.Changed { + return CheckResult{ + Name: "Current Profile", + Status: statusInfo, + Message: profileFlag.Value.String(), + } + } + + if envProfile := env.Get(ctx, "DATABRICKS_CONFIG_PROFILE"); envProfile != "" { + return CheckResult{ + Name: "Current Profile", + Status: statusInfo, + Message: envProfile + " (from DATABRICKS_CONFIG_PROFILE)", + } + } + + return CheckResult{ + Name: "Current Profile", + Status: statusInfo, + Message: "none (using environment or defaults)", + } +} + +func resolveConfig(cmd *cobra.Command) (*config.Config, error) { + ctx := cmd.Context() + cfg := &config.Config{} + + if configFile := env.Get(ctx, "DATABRICKS_CONFIG_FILE"); configFile != "" { + cfg.ConfigFile = configFile + } else if home := env.Get(ctx, env.HomeEnvVar()); home != "" { + cfg.ConfigFile = filepath.Join(home, ".databrickscfg") + } + + cfg.Loaders = []config.Loader{ + env.NewConfigLoader(ctx), + config.ConfigAttributes, + config.ConfigFile, + } + + profileFlag := cmd.Flag("profile") + if profileFlag != nil && profileFlag.Changed { + cfg.Profile = profileFlag.Value.String() + } + + return cfg, cfg.EnsureResolved() +} + +// isAccountLevelConfig returns true if the resolved config targets account-level APIs. +func isAccountLevelConfig(cfg *config.Config) bool { + return cfg.AccountID != "" && cfg.Host != "" && cfg.HostType() == config.AccountHost +} + +// checkAuth uses the resolved config to authenticate. +// On success it returns the authenticated config for use in subsequent checks. +func checkAuth(cmd *cobra.Command, cfg *config.Config, resolveErr error) (CheckResult, *config.Config) { + ctx, cancel := context.WithTimeout(cmd.Context(), checkTimeout) + defer cancel() + + if resolveErr != nil { + return CheckResult{ + Name: "Authentication", + Status: statusFail, + Message: "Cannot resolve config", + Detail: resolveErr.Error(), + }, nil + } + + // Detect account-level configs and use the appropriate client constructor + // so that account profiles are not incorrectly reported as broken. + var authCfg *config.Config + if isAccountLevelConfig(cfg) { + a, err := databricks.NewAccountClient((*databricks.Config)(cfg)) + if err != nil { + return CheckResult{ + Name: "Authentication", + Status: statusFail, + Message: "Cannot create account client", + Detail: err.Error(), + }, nil + } + authCfg = a.Config + } else { + w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg)) + if err != nil { + return CheckResult{ + Name: "Authentication", + Status: statusFail, + Message: "Cannot create workspace client", + Detail: err.Error(), + }, nil + } + authCfg = w.Config + } + + req, err := http.NewRequestWithContext(ctx, "", "", nil) + if err != nil { + return CheckResult{ + Name: "Authentication", + Status: statusFail, + Message: "Internal error", + Detail: err.Error(), + }, nil + } + + err = authCfg.Authenticate(req) + if err != nil { + return CheckResult{ + Name: "Authentication", + Status: statusFail, + Message: "Authentication failed", + Detail: err.Error(), + }, nil + } + + msg := fmt.Sprintf("OK (%s)", authCfg.AuthType) + if isAccountLevelConfig(cfg) { + msg += " [account-level]" + } + + return CheckResult{ + Name: "Authentication", + Status: statusPass, + Message: msg, + }, authCfg +} + +func checkIdentity(cmd *cobra.Command, authCfg *config.Config) CheckResult { + ctx, cancel := context.WithTimeout(cmd.Context(), checkTimeout) + defer cancel() + + // Account-level configs don't support the /me endpoint for workspace identity. + if authCfg.HostType() == config.AccountHost { + return CheckResult{ + Name: "Identity", + Status: statusSkip, + Message: "Skipped (account-level profile, workspace identity not available)", + } + } + + w, err := databricks.NewWorkspaceClient((*databricks.Config)(authCfg)) + if err != nil { + return CheckResult{ + Name: "Identity", + Status: statusFail, + Message: "Cannot create workspace client", + Detail: err.Error(), + } + } + + me, err := w.CurrentUser.Me(ctx) + if err != nil { + return CheckResult{ + Name: "Identity", + Status: statusFail, + Message: "Cannot fetch current user", + Detail: err.Error(), + } + } + + return CheckResult{ + Name: "Identity", + Status: statusPass, + Message: me.UserName, + } +} + +func checkNetwork(cmd *cobra.Command, cfg *config.Config, resolveErr error, authCfg *config.Config) CheckResult { + // Prefer the authenticated config (it has the fully resolved host). + if authCfg != nil { + return checkNetworkWithHost(cmd, authCfg.Host, configuredNetworkHTTPClient(authCfg)) + } + + // Auth failed or was skipped. If we still have a host from config resolution + // (even if resolution had other errors), attempt the network check. + if cfg != nil && cfg.Host != "" { + log.Warnf(cmd.Context(), "authenticated client unavailable for network check, using config-based HTTP client") + return checkNetworkWithHost(cmd, cfg.Host, configuredNetworkHTTPClient(cfg)) + } + + // No host available at all. + detail := "no host configured" + if resolveErr != nil { + detail = resolveErr.Error() + } + return CheckResult{ + Name: "Network", + Status: statusFail, + Message: "No host configured", + Detail: detail, + } +} + +func checkNetworkWithHost(cmd *cobra.Command, host string, client *http.Client) CheckResult { + ctx, cancel := context.WithTimeout(cmd.Context(), networkTimeout) + defer cancel() + + if host == "" { + return CheckResult{ + Name: "Network", + Status: statusFail, + Message: "No host configured", + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, host, nil) + if err != nil { + return CheckResult{ + Name: "Network", + Status: statusFail, + Message: "Cannot create request for " + host, + Detail: err.Error(), + } + } + + resp, err := client.Do(req) + if err != nil { + return CheckResult{ + Name: "Network", + Status: statusFail, + Message: "Cannot reach " + host, + Detail: err.Error(), + } + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + + return CheckResult{ + Name: "Network", + Status: statusPass, + Message: host + " is reachable", + } +} + +func configuredNetworkHTTPClient(cfg *config.Config) *http.Client { + return &http.Client{ + Transport: configuredNetworkHTTPTransport(cfg), + } +} + +func configuredNetworkHTTPTransport(cfg *config.Config) http.RoundTripper { + if cfg.HTTPTransport != nil { + return cfg.HTTPTransport + } + + if !cfg.InsecureSkipVerify { + return http.DefaultTransport + } + + transport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return http.DefaultTransport + } + + clone := transport.Clone() + if clone.TLSClientConfig != nil { + clone.TLSClientConfig = clone.TLSClientConfig.Clone() + } else { + clone.TLSClientConfig = &tls.Config{} + } + clone.TLSClientConfig.InsecureSkipVerify = true + return clone +} diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go new file mode 100644 index 0000000000..faadb349f7 --- /dev/null +++ b/cmd/doctor/doctor.go @@ -0,0 +1,99 @@ +package doctor + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/flags" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// CheckResult holds the outcome of a single diagnostic check. +type CheckResult struct { + Name string `json:"name"` + Status string `json:"status"` // "pass", "fail", "warn", "info" + Message string `json:"message"` + Detail string `json:"detail,omitempty"` +} + +// New returns the doctor command. +func New() *cobra.Command { + cmd := &cobra.Command{ + Use: "doctor", + Args: root.NoArgs, + Short: "Validate your Databricks CLI setup", + GroupID: "development", + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + results := runChecks(cmd) + + switch root.OutputType(cmd) { + case flags.OutputJSON: + buf, err := json.MarshalIndent(results, "", " ") + if err != nil { + return err + } + buf = append(buf, '\n') + _, err = cmd.OutOrStdout().Write(buf) + if err != nil { + return err + } + case flags.OutputText: + renderResults(cmd.OutOrStdout(), results) + default: + return fmt.Errorf("unknown output type %s", root.OutputType(cmd)) + } + + if hasFailedChecks(results) { + return errors.New("one or more checks failed") + } + return nil + } + + return cmd +} + +func renderResults(w io.Writer, results []CheckResult) { + green := color.New(color.FgGreen).SprintFunc() + red := color.New(color.FgRed).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + cyan := color.New(color.FgCyan).SprintFunc() + bold := color.New(color.Bold).SprintFunc() + + for _, r := range results { + var icon string + switch r.Status { + case statusPass: + icon = green("[ok]") + case statusFail: + icon = red("[FAIL]") + case statusWarn: + icon = yellow("[warn]") + case statusInfo: + icon = cyan("[info]") + case statusSkip: + icon = yellow("[skip]") + } + msg := fmt.Sprintf("%s %s: %s", icon, bold(r.Name), r.Message) + if r.Detail != "" { + msg += fmt.Sprintf(" (%s)", r.Detail) + } + fmt.Fprintln(w, msg) + } +} + +func hasFailedChecks(results []CheckResult) bool { + for _, result := range results { + if result.Status == statusFail { + return true + } + } + return false +} diff --git a/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go new file mode 100644 index 0000000000..dbe899bb43 --- /dev/null +++ b/cmd/doctor/doctor_test.go @@ -0,0 +1,581 @@ +package doctor + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/env" + "github.com/databricks/cli/libs/flags" + "github.com/databricks/databricks-sdk-go/config" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockProfiler struct { + profiles profile.Profiles + path string + err error +} + +func (m *mockProfiler) LoadProfiles(_ context.Context, match profile.ProfileMatchFunction) (profile.Profiles, error) { + if m.err != nil { + return nil, m.err + } + var result profile.Profiles + for _, p := range m.profiles { + if match(p) { + result = append(result, p) + } + } + return result, nil +} + +func (m *mockProfiler) GetPath(_ context.Context) (string, error) { + if m.err != nil { + return "", m.err + } + return m.path, nil +} + +// noConfigProfiler returns a path but ErrNoConfiguration from LoadProfiles. +type noConfigProfiler struct { + path string +} + +func (m *noConfigProfiler) LoadProfiles(_ context.Context, _ profile.ProfileMatchFunction) (profile.Profiles, error) { + return nil, profile.ErrNoConfiguration +} + +func (m *noConfigProfiler) GetPath(_ context.Context) (string, error) { + return m.path, nil +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +func newTestCmd(ctx context.Context) *cobra.Command { + cmd := &cobra.Command{} + cmd.SetContext(ctx) + cmd.Flags().String("profile", "", "") + return cmd +} + +func clearConfigEnv(t *testing.T) { + t.Helper() + + for _, attr := range config.ConfigAttributes { + for _, key := range attr.EnvVars { + t.Setenv(key, "") + } + } + + t.Setenv(env.HomeEnvVar(), t.TempDir()) +} + +func TestCheckCLIVersion(t *testing.T) { + result := checkCLIVersion() + assert.Equal(t, "CLI Version", result.Name) + assert.Equal(t, statusInfo, result.Status) + assert.NotEmpty(t, result.Message) +} + +func TestCheckConfigFilePass(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + ctx = profile.WithProfiler(ctx, &mockProfiler{ + path: "/home/user/.databrickscfg", + profiles: profile.Profiles{ + {Name: "default", Host: "https://example.com"}, + {Name: "staging", Host: "https://staging.example.com"}, + }, + }) + cmd := newTestCmd(ctx) + + result := checkConfigFile(cmd) + assert.Equal(t, "Config File", result.Name) + assert.Equal(t, statusPass, result.Status) + assert.Contains(t, result.Message, "2 profiles") + assert.Contains(t, result.Message, "/home/user/.databrickscfg") +} + +func TestCheckConfigFileMissingWarn(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + ctx = profile.WithProfiler(ctx, &mockProfiler{ + path: "/home/user/.databrickscfg", + err: profile.ErrNoConfiguration, + }) + cmd := newTestCmd(ctx) + + result := checkConfigFile(cmd) + assert.Equal(t, "Config File", result.Name) + // GetPath returns err first, so this hits the first failure branch. + // To test the warn path, we need GetPath to succeed but LoadProfiles to fail. + assert.Equal(t, statusFail, result.Status) +} + +func TestCheckConfigFileAbsentIsWarn(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + // Profiler that returns a path but fails on LoadProfiles with ErrNoConfiguration. + ctx = profile.WithProfiler(ctx, &noConfigProfiler{path: "/home/user/.databrickscfg"}) + cmd := newTestCmd(ctx) + + result := checkConfigFile(cmd) + assert.Equal(t, "Config File", result.Name) + assert.Equal(t, statusWarn, result.Status) + assert.Contains(t, result.Message, "environment variables") +} + +func TestCheckCurrentProfileDefault(t *testing.T) { + clearConfigEnv(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + result := checkCurrentProfile(cmd) + assert.Equal(t, "Current Profile", result.Name) + assert.Equal(t, statusInfo, result.Status) + assert.Equal(t, "none (using environment or defaults)", result.Message) +} + +func TestCheckCurrentProfileFromFlag(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + err := cmd.Flag("profile").Value.Set("staging") + require.NoError(t, err) + cmd.Flag("profile").Changed = true + + result := checkCurrentProfile(cmd) + assert.Equal(t, "Current Profile", result.Name) + assert.Equal(t, statusInfo, result.Status) + assert.Equal(t, "staging", result.Message) +} + +func TestCheckCurrentProfileFromEnv(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + ctx = env.Set(ctx, "DATABRICKS_CONFIG_PROFILE", "from-env") + cmd := newTestCmd(ctx) + + result := checkCurrentProfile(cmd) + assert.Equal(t, statusInfo, result.Status) + assert.Equal(t, "from-env (from DATABRICKS_CONFIG_PROFILE)", result.Message) +} + +func TestCheckCurrentProfileFlagOverridesEnv(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + ctx = env.Set(ctx, "DATABRICKS_CONFIG_PROFILE", "from-env") + cmd := newTestCmd(ctx) + err := cmd.Flag("profile").Value.Set("from-flag") + require.NoError(t, err) + cmd.Flag("profile").Changed = true + + result := checkCurrentProfile(cmd) + assert.Equal(t, "from-flag", result.Message) +} + +func TestCheckAuthSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + clearConfigEnv(t) + t.Setenv("DATABRICKS_HOST", srv.URL) + t.Setenv("DATABRICKS_TOKEN", "test-token") + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + cfg, err := resolveConfig(cmd) + require.NoError(t, err) + + result, authCfg := checkAuth(cmd, cfg, err) + assert.Equal(t, "Authentication", result.Name) + assert.Equal(t, statusPass, result.Status) + assert.Contains(t, result.Message, "OK") + assert.NotNil(t, authCfg) +} + +func TestCheckAuthFailure(t *testing.T) { + clearConfigEnv(t) + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + cfg, err := resolveConfig(cmd) + result, authCfg := checkAuth(cmd, cfg, err) + assert.Equal(t, "Authentication", result.Name) + assert.Equal(t, statusFail, result.Status) + assert.Nil(t, authCfg) +} + +func TestCheckAuthAccountLevel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + clearConfigEnv(t) + t.Setenv("DATABRICKS_HOST", "https://accounts.cloud.databricks.com") + t.Setenv("DATABRICKS_ACCOUNT_ID", "test-account-123") + t.Setenv("DATABRICKS_TOKEN", "test-token") + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + cfg, err := resolveConfig(cmd) + require.NoError(t, err) + + result, authCfg := checkAuth(cmd, cfg, err) + assert.Equal(t, "Authentication", result.Name) + assert.Equal(t, statusPass, result.Status) + assert.Contains(t, result.Message, "account-level") + assert.NotNil(t, authCfg) +} + +func TestResolveConfigUsesCommandContextEnv(t *testing.T) { + clearConfigEnv(t) + t.Setenv("DATABRICKS_HOST", "https://real.example.com") + t.Setenv("DATABRICKS_TOKEN", "real-token") + + ctx := cmdio.MockDiscard(t.Context()) + ctx = env.Set(ctx, "DATABRICKS_HOST", "https://context.example.com") + ctx = env.Set(ctx, "DATABRICKS_TOKEN", "context-token") + cmd := newTestCmd(ctx) + + cfg, err := resolveConfig(cmd) + require.NoError(t, err) + assert.Equal(t, "https://context.example.com", cfg.Host) + assert.Equal(t, "context-token", cfg.Token) +} + +func TestCheckIdentitySuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/2.0/preview/scim/v2/Me" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"userName": "test@example.com"}`)) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := &config.Config{ + Host: srv.URL, + Token: "test-token", + } + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + result := checkIdentity(cmd, cfg) + assert.Equal(t, "Identity", result.Name) + assert.Equal(t, statusPass, result.Status) + assert.Equal(t, "test@example.com", result.Message) +} + +func TestCheckIdentityFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + cfg := &config.Config{ + Host: srv.URL, + Token: "bad-token", + } + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + result := checkIdentity(cmd, cfg) + assert.Equal(t, "Identity", result.Name) + assert.Equal(t, statusFail, result.Status) +} + +func TestCheckIdentitySkippedForAccountLevel(t *testing.T) { + cfg := &config.Config{ + Host: "https://accounts.cloud.databricks.com", + AccountID: "test-account-123", + Token: "test-token", + } + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + result := checkIdentity(cmd, cfg) + assert.Equal(t, "Identity", result.Name) + assert.Equal(t, statusSkip, result.Status) + assert.Contains(t, result.Message, "account-level") +} + +func TestCheckNetworkReachable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + result := checkNetworkWithHost(cmd, srv.URL, http.DefaultClient) + assert.Equal(t, "Network", result.Name) + assert.Equal(t, statusPass, result.Status) + assert.Contains(t, result.Message, "reachable") +} + +func TestCheckNetworkNoHost(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + result := checkNetworkWithHost(cmd, "", http.DefaultClient) + assert.Equal(t, "Network", result.Name) + assert.Equal(t, statusFail, result.Status) + assert.Contains(t, result.Message, "No host configured") +} + +func TestCheckNetworkUsesAuthConfigTransport(t *testing.T) { + cfg := &config.Config{ + Host: "https://example.com", + Token: "test-token", + HTTPTransport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, http.MethodHead, r.Method) + assert.Equal(t, "https://example.com", r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + Header: make(http.Header), + Request: r, + }, nil + }), + } + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + result := checkNetwork(cmd, cfg, nil, cfg) + assert.Equal(t, "Network", result.Name) + assert.Equal(t, statusPass, result.Status) + assert.Contains(t, result.Message, "reachable") +} + +func TestCheckNetworkFallbackUsesConfigTransport(t *testing.T) { + called := false + cfg := &config.Config{ + Host: "https://example.com", + Token: "test-token", + HTTPTransport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + called = true + assert.Equal(t, http.MethodHead, r.Method) + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + Header: make(http.Header), + Request: r, + }, nil + }), + } + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + result := checkNetwork(cmd, cfg, nil, nil) + assert.True(t, called, "expected config's HTTPTransport to be used") + assert.Equal(t, "Network", result.Name) + assert.Equal(t, statusPass, result.Status) + assert.Contains(t, result.Message, "reachable") +} + +func TestCheckNetworkConfigResolutionFailureNoHost(t *testing.T) { + clearConfigEnv(t) + + configFile := filepath.Join(t.TempDir(), ".databrickscfg") + err := os.WriteFile(configFile, []byte("[DEFAULT]\nhost = https://example.com\n"), 0o600) + require.NoError(t, err) + + ctx := cmdio.MockDiscard(t.Context()) + ctx = env.Set(ctx, "DATABRICKS_CONFIG_FILE", configFile) + ctx = env.Set(ctx, "DATABRICKS_CONFIG_PROFILE", "missing") + cmd := newTestCmd(ctx) + + cfg, resolveErr := resolveConfig(cmd) + require.Error(t, resolveErr) + + // Config resolution failed and host is empty, so network check reports failure. + result := checkNetwork(cmd, cfg, resolveErr, nil) + assert.Equal(t, "Network", result.Name) + assert.Equal(t, statusFail, result.Status) + assert.Equal(t, "No host configured", result.Message) +} + +func TestCheckNetworkConfigResolutionFailureWithHost(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // Config resolution may fail (e.g. missing credentials) but if the host + // was partially resolved we should still attempt the network check. + cfg := &config.Config{ + Host: srv.URL, + } + resolveErr := errors.New("validate: missing credentials") + + ctx := cmdio.MockDiscard(t.Context()) + cmd := newTestCmd(ctx) + + result := checkNetwork(cmd, cfg, resolveErr, nil) + assert.Equal(t, "Network", result.Name) + assert.Equal(t, statusPass, result.Status) + assert.Contains(t, result.Message, "reachable") +} + +func TestRenderResultsText(t *testing.T) { + results := []CheckResult{ + {Name: "Test", Status: statusPass, Message: "all good"}, + {Name: "Another", Status: statusFail, Message: "broken", Detail: "details here"}, + {Name: "Version", Status: statusInfo, Message: "1.0.0"}, + {Name: "Config", Status: statusWarn, Message: "not found"}, + } + + var buf bytes.Buffer + renderResults(&buf, results) + output := buf.String() + assert.Contains(t, output, "Test") + assert.Contains(t, output, "all good") + assert.Contains(t, output, "broken") + assert.Contains(t, output, "details here") +} + +func TestRenderResultsJSON(t *testing.T) { + results := []CheckResult{ + {Name: "Test", Status: statusPass, Message: "all good"}, + {Name: "Another", Status: statusFail, Message: "broken", Detail: "details here"}, + } + + buf, err := json.MarshalIndent(results, "", " ") + require.NoError(t, err) + + var parsed []CheckResult + err = json.Unmarshal(buf, &parsed) + require.NoError(t, err) + assert.Len(t, parsed, 2) + assert.Equal(t, "Test", parsed[0].Name) + assert.Equal(t, statusPass, parsed[0].Status) + assert.Equal(t, "broken", parsed[1].Message) + assert.Equal(t, "details here", parsed[1].Detail) +} + +func TestRenderResultsJSONOmitsEmptyDetail(t *testing.T) { + results := []CheckResult{ + {Name: "Test", Status: statusPass, Message: "ok"}, + } + + buf, err := json.Marshal(results) + require.NoError(t, err) + assert.NotContains(t, string(buf), "detail") +} + +func TestHasFailedChecks(t *testing.T) { + tests := []struct { + name string + results []CheckResult + want bool + }{ + { + name: "no failures", + results: []CheckResult{ + {Name: "Test", Status: statusPass}, + {Name: "Info", Status: statusInfo}, + {Name: "Warn", Status: statusWarn}, + {Name: "Skip", Status: statusSkip}, + }, + want: false, + }, + { + name: "has failure", + results: []CheckResult{ + {Name: "Test", Status: statusPass}, + {Name: "Broken", Status: statusFail}, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, hasFailedChecks(tt.results)) + }) + } +} + +func TestNewCommandJSON(t *testing.T) { + clearConfigEnv(t) + + ctx := cmdio.MockDiscard(t.Context()) + ctx = profile.WithProfiler(ctx, &mockProfiler{ + path: "/tmp/.databrickscfg", + profiles: profile.Profiles{ + {Name: "default", Host: "https://example.com"}, + }, + }) + + cmd := New() + cmd.SetContext(ctx) + + outputFlag := flags.OutputText + cmd.PersistentFlags().VarP(&outputFlag, "output", "o", "output type: text or json") + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--output", "json"}) + + err := cmd.Execute() + require.ErrorContains(t, err, "one or more checks failed") + + var results []CheckResult + err = json.Unmarshal(buf.Bytes(), &results) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(results), 4) + + assert.Equal(t, "CLI Version", results[0].Name) + assert.Equal(t, statusInfo, results[0].Status) +} + +func TestNewCommandJSONTrailingNewline(t *testing.T) { + clearConfigEnv(t) + + ctx := cmdio.MockDiscard(t.Context()) + ctx = profile.WithProfiler(ctx, &mockProfiler{ + path: "/tmp/.databrickscfg", + profiles: profile.Profiles{ + {Name: "default", Host: "https://example.com"}, + }, + }) + + cmd := New() + cmd.SetContext(ctx) + + outputFlag := flags.OutputText + cmd.PersistentFlags().VarP(&outputFlag, "output", "o", "output type: text or json") + + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--output", "json"}) + + err := cmd.Execute() + require.ErrorContains(t, err, "one or more checks failed") + assert.Positive(t, buf.Len()) + assert.Equal(t, byte('\n'), buf.Bytes()[buf.Len()-1]) +}