diff --git a/cmd/auth/env.go b/cmd/auth/env.go index 11149af8c0..592bc28263 100644 --- a/cmd/auth/env.go +++ b/cmd/auth/env.go @@ -7,35 +7,20 @@ import ( "fmt" "io/fs" "net/http" - "net/url" "strings" + "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/databricks-sdk-go/config" "github.com/spf13/cobra" "gopkg.in/ini.v1" ) -func canonicalHost(host string) (string, error) { - parsedHost, err := url.Parse(host) - if err != nil { - return "", err - } - // If the host is empty, assume the scheme wasn't included. - if parsedHost.Host == "" { - return "https://" + host, nil - } - return "https://" + parsedHost.Host, nil -} - var ErrNoMatchingProfiles = errors.New("no matching profiles found") func resolveSection(cfg *config.Config, iniFile *config.File) (*ini.Section, error) { var candidates []*ini.Section - configuredHost, err := canonicalHost(cfg.Host) - if err != nil { - return nil, err - } + configuredHost := databrickscfg.NormalizeHost(cfg.Host) for _, section := range iniFile.Sections() { hash := section.KeysHash() host, ok := hash["host"] @@ -43,12 +28,7 @@ func resolveSection(cfg *config.Config, iniFile *config.File) (*ini.Section, err // if host is not set continue } - canonical, err := canonicalHost(host) - if err != nil { - // we're fine with other corrupt profiles - continue - } - if canonical != configuredHost { + if databrickscfg.NormalizeHost(host) != configuredHost { continue } candidates = append(candidates, section) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index deab15d286..7b00887a4c 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -243,7 +243,7 @@ depends on the existing profiles you have set in your configuration file if profileName != "" { err := databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: profileName, - Host: authArguments.Host, + Host: databrickscfg.NormalizeHost(authArguments.Host), AuthType: authTypeDatabricksCLI, AccountID: authArguments.AccountID, WorkspaceID: authArguments.WorkspaceID, diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go index f134c91191..adae227e18 100644 --- a/cmd/configure/configure.go +++ b/cmd/configure/configure.go @@ -30,14 +30,14 @@ func configureInteractive(cmd *cobra.Command, flags *configureFlags, cfg *config prompt.Label = "Databricks workspace host (https://...)" prompt.AllowEdit = true prompt.Validate = func(input string) error { - normalized := normalizeHost(input) + normalized := databrickscfg.NormalizeHost(input) return validateHost(normalized) } out, err := prompt.Run() if err != nil { return err } - cfg.Host = normalizeHost(out) + cfg.Host = databrickscfg.NormalizeHost(out) } // Ask user to specify the token is not already set. @@ -121,7 +121,7 @@ The host must be specified with the --host flag or the DATABRICKS_HOST environme // Populate configuration from flags (if set). if flags.Host != "" { - cfg.Host = normalizeHost(flags.Host) + cfg.Host = databrickscfg.NormalizeHost(flags.Host) } if flags.Profile != "" { cfg.Profile = flags.Profile @@ -129,7 +129,7 @@ The host must be specified with the --host flag or the DATABRICKS_HOST environme // Normalize and verify that the host is valid (if set). if cfg.Host != "" { - cfg.Host = normalizeHost(cfg.Host) + cfg.Host = databrickscfg.NormalizeHost(cfg.Host) err = validateHost(cfg.Host) if err != nil { return err diff --git a/cmd/configure/host.go b/cmd/configure/host.go index 1b31f79994..0a454c6d10 100644 --- a/cmd/configure/host.go +++ b/cmd/configure/host.go @@ -3,29 +3,8 @@ package configure import ( "errors" "net/url" - "strings" ) -// normalizeHost normalizes host input to prevent double https:// prefixes. -// If the input already starts with https://, it returns it as-is. -// If the input doesn't start with https://, it prepends https://. -func normalizeHost(input string) string { - input = strings.TrimSpace(input) - u, err := url.Parse(input) - // If the input is not a valid URL, return it as-is - if err != nil { - return input - } - - // If it already starts with https:// or http://, return as-is - if u.Scheme == "https" || u.Scheme == "http" { - return input - } - - // Otherwise, prepend https:// - return "https://" + input -} - func validateHost(s string) error { u, err := url.Parse(s) if err != nil { diff --git a/cmd/configure/host_test.go b/cmd/configure/host_test.go index 65813fb9f7..a4af199d69 100644 --- a/cmd/configure/host_test.go +++ b/cmd/configure/host_test.go @@ -6,41 +6,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNormalizeHost(t *testing.T) { - tests := []struct { - input string - expected string - }{ - // Empty input - {"", "https://"}, - {" ", "https://"}, - - // Already has https:// - {"https://example.databricks.com", "https://example.databricks.com"}, - {"HTTPS://EXAMPLE.DATABRICKS.COM", "HTTPS://EXAMPLE.DATABRICKS.COM"}, - {"https://example.databricks.com/", "https://example.databricks.com/"}, - - // Missing protocol (should add https://) - {"example.databricks.com", "https://example.databricks.com"}, - {" example.databricks.com ", "https://example.databricks.com"}, - {"subdomain.example.databricks.com", "https://subdomain.example.databricks.com"}, - - // Edge cases - {"https://", "https://"}, - {"example.com", "https://example.com"}, - {"https://example.databricks.com/path", "https://example.databricks.com/path"}, - {"https://example.databricks.com/path/", "https://example.databricks.com/path/"}, - {"http://localhost:8080", "http://localhost:8080"}, - } - - for _, test := range tests { - t.Run(test.input, func(t *testing.T) { - result := normalizeHost(test.input) - assert.Equal(t, test.expected, result) - }) - } -} - func TestValidateHost(t *testing.T) { var err error diff --git a/libs/databrickscfg/host.go b/libs/databrickscfg/host.go index dc9f503c92..af11868689 100644 --- a/libs/databrickscfg/host.go +++ b/libs/databrickscfg/host.go @@ -1,22 +1,36 @@ package databrickscfg -import "net/url" +import ( + "net/url" + "strings" +) + +// NormalizeHost returns the canonical representation of a Databricks host. +// It ensures the host has an https:// scheme and strips any path, query, +// or fragment components, returning only scheme://host[:port]. +func NormalizeHost(host string) string { + host = strings.TrimSpace(host) + if host == "" { + return host + } + + // If no scheme, prepend https:// before parsing. + // This is necessary because url.Parse treats schemeless input + // (e.g. "myhost.com") as a path, not a host. + if !strings.Contains(host, "://") { + host = "https://" + host + } -// normalizeHost returns the string representation of only -// the scheme and host part of the specified host. -func normalizeHost(host string) string { u, err := url.Parse(host) if err != nil { return host } - if u.Scheme == "" || u.Host == "" { + if u.Host == "" { return host } - normalized := &url.URL{ + return (&url.URL{ Scheme: u.Scheme, Host: u.Host, - } - - return normalized.String() + }).String() } diff --git a/libs/databrickscfg/host_test.go b/libs/databrickscfg/host_test.go index 0117aa612a..18e78fccb8 100644 --- a/libs/databrickscfg/host_test.go +++ b/libs/databrickscfg/host_test.go @@ -7,20 +7,65 @@ import ( ) func TestNormalizeHost(t *testing.T) { - assert.Equal(t, "invalid", normalizeHost("invalid")) + tests := []struct { + input string + expected string + }{ + // Empty and whitespace. + {"", ""}, + {" ", ""}, - // With port. - assert.Equal(t, "http://foo:123", normalizeHost("http://foo:123")) + // Bare hostnames (no scheme). + {"foo.com", "https://foo.com"}, + {"foo.com:8080", "https://foo.com:8080"}, + {"e2-dogfood.staging.cloud.databricks.com", "https://e2-dogfood.staging.cloud.databricks.com"}, - // With trailing slash. - assert.Equal(t, "http://foo", normalizeHost("http://foo/")) + // With https:// scheme. + {"https://foo.com", "https://foo.com"}, + {"https://foo.com/", "https://foo.com"}, + {"https://foo.com/path", "https://foo.com"}, + {"https://foo.com?q=1", "https://foo.com"}, + {"https://foo.com#frag", "https://foo.com"}, + {"https://foo.com:443", "https://foo.com:443"}, - // With path. - assert.Equal(t, "http://foo", normalizeHost("http://foo/bar")) + // With http:// scheme (preserved for local dev). + {"http://foo.com", "http://foo.com"}, + {"http://localhost:8080", "http://localhost:8080"}, + {"http://foo.com/path", "http://foo.com"}, - // With query string. - assert.Equal(t, "http://foo", normalizeHost("http://foo?bar")) + // Port preserved. + {"http://foo:123", "http://foo:123"}, - // With anchor. - assert.Equal(t, "http://foo", normalizeHost("http://foo#bar")) + // Whitespace trimmed. + {" https://foo.com ", "https://foo.com"}, + {" foo.com ", "https://foo.com"}, + + // Scheme is lowercased; host case is preserved (Go's url package behavior). + {"HTTPS://FOO.COM", "https://FOO.COM"}, + + // Idempotent. + {"https://foo.com", "https://foo.com"}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + result := NormalizeHost(test.input) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestNormalizeHostIdempotent(t *testing.T) { + inputs := []string{ + "foo.com", + "https://foo.com/path?q=1#frag", + "http://localhost:8080", + " HTTPS://FOO.COM ", + } + + for _, input := range inputs { + first := NormalizeHost(input) + second := NormalizeHost(first) + assert.Equal(t, first, second, "NormalizeHost should be idempotent for input: %s", input) + } } diff --git a/libs/databrickscfg/loader.go b/libs/databrickscfg/loader.go index 4113c80f1e..d1b6969e46 100644 --- a/libs/databrickscfg/loader.go +++ b/libs/databrickscfg/loader.go @@ -84,7 +84,7 @@ func (l profileFromHostLoader) Configure(cfg *config.Config) error { return fmt.Errorf("cannot parse config file: %w", err) } // Normalized version of the configured host. - host := normalizeHost(cfg.Host) + host := NormalizeHost(cfg.Host) match, err := findMatchingProfile(configFile, func(s *ini.Section) bool { key, err := s.GetKey("host") if err != nil { @@ -93,7 +93,7 @@ func (l profileFromHostLoader) Configure(cfg *config.Config) error { } // Check if this section matches the normalized host - return normalizeHost(key.Value()) == host + return NormalizeHost(key.Value()) == host }) if err == errNoMatchingProfiles { return nil diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index bf602b6c60..05ba6184d5 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -68,7 +68,7 @@ func matchOrCreateSection(ctx context.Context, configFile *config.File, cfg *con return false } // Check if this section matches the normalized host - return normalizeHost(host) == normalizeHost(cfg.Host) + return NormalizeHost(host) == NormalizeHost(cfg.Host) }) if err == errNoMatchingProfiles { section, err = configFile.NewSection(cfg.Profile) @@ -160,7 +160,7 @@ func ValidateConfigAndProfileHost(cfg *config.Config, profile string) error { } // Normalized version of the configured host. - host := normalizeHost(cfg.Host) + host := NormalizeHost(cfg.Host) match, err := findMatchingProfile(configFile, func(s *ini.Section) bool { return profile == s.Name() }) @@ -168,11 +168,11 @@ func ValidateConfigAndProfileHost(cfg *config.Config, profile string) error { return err } - hostFromProfile := normalizeHost(match.Key("host").Value()) + hostFromProfile := NormalizeHost(match.Key("host").Value()) if hostFromProfile != "" && host != "" && hostFromProfile != host { // Try to find if there's a profile which uses the same host as the bundle and suggest in error message match, err = findMatchingProfile(configFile, func(s *ini.Section) bool { - return normalizeHost(s.Key("host").Value()) == host + return NormalizeHost(s.Key("host").Value()) == host }) if err == nil && match != nil { profileName := match.Name() diff --git a/libs/databrickscfg/profile/profiler.go b/libs/databrickscfg/profile/profiler.go index 8eff2675b9..b51227f1de 100644 --- a/libs/databrickscfg/profile/profiler.go +++ b/libs/databrickscfg/profile/profiler.go @@ -3,7 +3,7 @@ package profile import ( "context" - "github.com/databricks/databricks-sdk-go/config" + "github.com/databricks/cli/libs/databrickscfg" ) type ProfileMatchFunction func(Profile) bool @@ -47,26 +47,21 @@ func WithName(name string) ProfileMatchFunction { // WithHost returns a ProfileMatchFunction that matches profiles whose // canonical host equals the given host. func WithHost(host string) ProfileMatchFunction { - target := canonicalizeHost(host) + target := databrickscfg.NormalizeHost(host) return func(p Profile) bool { - return p.Host != "" && canonicalizeHost(p.Host) == target + return p.Host != "" && databrickscfg.NormalizeHost(p.Host) == target } } // WithHostAndAccountID returns a ProfileMatchFunction that matches profiles // by both canonical host and account ID. func WithHostAndAccountID(host, accountID string) ProfileMatchFunction { - target := canonicalizeHost(host) + target := databrickscfg.NormalizeHost(host) return func(p Profile) bool { - return p.Host != "" && canonicalizeHost(p.Host) == target && p.AccountID == accountID + return p.Host != "" && databrickscfg.NormalizeHost(p.Host) == target && p.AccountID == accountID } } -// canonicalizeHost normalizes a host using the SDK's canonical host logic. -func canonicalizeHost(host string) string { - return (&config.Config{Host: host}).CanonicalHostName() -} - type Profiler interface { LoadProfiles(context.Context, ProfileMatchFunction) (Profiles, error) GetPath(context.Context) (string, error)