From aad860526c114e9ddf36327f30882b48c25ef4a3 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Mon, 9 Mar 2026 14:45:17 +0100 Subject: [PATCH] Centralize host URL normalization to fix auth profile matching Replace four separate host normalization implementations with a single exported NormalizeHost() function in libs/databrickscfg/host.go. The key fix is adding https:// when no scheme is present, which was missing from the primary normalizeHost used for bundle auth profile matching. This caused "cannot resolve bundle auth configuration" errors when hosts were stored without the https:// prefix (e.g. via `databricks apps init`). Also normalize the host before saving profiles in `auth login` to prevent bare hostnames from being persisted to .databrickscfg. --- cmd/auth/env.go | 26 ++-------- cmd/auth/login.go | 2 +- cmd/configure/configure.go | 8 +-- cmd/configure/host.go | 21 -------- cmd/configure/host_test.go | 35 -------------- libs/databrickscfg/host.go | 32 ++++++++---- libs/databrickscfg/host_test.go | 67 +++++++++++++++++++++----- libs/databrickscfg/loader.go | 4 +- libs/databrickscfg/ops.go | 8 +-- libs/databrickscfg/profile/profiler.go | 15 ++---- 10 files changed, 98 insertions(+), 120 deletions(-) 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)