diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 370e2f8bd8..c47f906bce 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -7,6 +7,7 @@ ### CLI * Added `--limit` flag to all paginated list commands for client-side result capping ([#4984](https://github.com/databricks/cli/pull/4984)). +* Deprecated `auth env`. The command is now hidden from help listings and prints a deprecation warning to stderr; it will be removed in a future release. It has also been refactored to use the CLI's standard auth resolution and added `--output text` support. Breaking: removed the command-specific `--host`/`--profile` flags (use the inherited ones) and only the primary env var per attribute is emitted ([#4904](https://github.com/databricks/cli/pull/4904)). * Accept `yes` in addition to `y` for confirmation prompts, and show `[y/N]` to indicate that no is the default. ### Bundles diff --git a/cmd/auth/env.go b/cmd/auth/env.go index 11149af8c0..21a236e831 100644 --- a/cmd/auth/env.go +++ b/cmd/auth/env.go @@ -1,146 +1,81 @@ package auth import ( - "context" "encoding/json" - "errors" "fmt" - "io/fs" - "net/http" - "net/url" + "io" + "maps" + "slices" "strings" - "github.com/databricks/cli/libs/databrickscfg/profile" - "github.com/databricks/databricks-sdk-go/config" + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/auth" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/flags" "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 -} +const envDeprecationWarning = "Warning: 'databricks auth env' is deprecated and will be removed in a future release.\n" -var ErrNoMatchingProfiles = errors.New("no matching profiles found") +func newEnvCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "env", + Short: "Get authentication environment variables for the current CLI context", + Hidden: true, + Long: `Output the environment variables needed to authenticate as the same identity +the CLI is currently authenticated as. This is useful for configuring downstream +tools that accept Databricks authentication via environment variables. -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 +Deprecated: this command will be removed in a future release.`, } - for _, section := range iniFile.Sections() { - hash := section.KeysHash() - host, ok := hash["host"] - if !ok { - // if host is not set - continue - } - canonical, err := canonicalHost(host) + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + fmt.Fprint(cmd.ErrOrStderr(), envDeprecationWarning) + + _, err := root.MustAnyClient(cmd, args) if err != nil { - // we're fine with other corrupt profiles - continue - } - if canonical != configuredHost { - continue - } - candidates = append(candidates, section) - } - if len(candidates) == 0 { - return nil, ErrNoMatchingProfiles - } - // in the real situations, we don't expect this to happen often - // (if not at all), hence we don't trim the list - if len(candidates) > 1 { - var profiles []string - for _, v := range candidates { - profiles = append(profiles, v.Name()) + return err } - return nil, fmt.Errorf("%s match %s in %s", - strings.Join(profiles, " and "), cfg.Host, cfg.ConfigFile) + + cfg := cmdctx.ConfigUsed(cmd.Context()) + textMode := cmd.Flag("output").Changed && root.OutputType(cmd) == flags.OutputText + return writeEnvOutput(cmd.OutOrStdout(), auth.Env(cfg), textMode) } - return candidates[0], nil + + return cmd } -func loadFromDatabricksCfg(ctx context.Context, cfg *config.Config) error { - iniFile, err := profile.DefaultProfiler.Get(ctx) - if errors.Is(err, fs.ErrNotExist) { - // it's fine not to have ~/.databrickscfg - return nil - } - if err != nil { - return err - } - profile, err := resolveSection(cfg, iniFile) - if err == ErrNoMatchingProfiles { - // it's also fine for Azure CLI or Databricks CLI, which - // are resolved by unified auth handling in the Go SDK. +// writeEnvOutput writes the env var map as sorted KEY=VALUE lines (textMode) or +// indented JSON. In text mode values are quoted for shell safety. +func writeEnvOutput(w io.Writer, envVars map[string]string, textMode bool) error { + if textMode { + for _, k := range slices.Sorted(maps.Keys(envVars)) { + if _, err := fmt.Fprintf(w, "%s=%s\n", k, quoteEnvValue(envVars[k])); err != nil { + return err + } + } return nil } + raw, err := json.MarshalIndent(map[string]any{"env": envVars}, "", " ") if err != nil { return err } - cfg.Profile = profile.Name() - return nil + _, err = fmt.Fprintln(w, string(raw)) + return err } -func newEnvCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "env", - Short: "Get env", - } - - var host string - var profile string - cmd.Flags().StringVar(&host, "host", host, "Hostname to get auth env for") - cmd.Flags().StringVar(&profile, "profile", profile, "Profile to get auth env for") +const shellQuotedSpecialChars = " \t\n\r\"\\$`!#&|;(){}[]<>?*~'" - cmd.RunE = func(cmd *cobra.Command, args []string) error { - cfg := &config.Config{ - Host: host, - Profile: profile, - } - if profile != "" { - cfg.Profile = profile - } else if cfg.Host == "" { - cfg.Profile = "DEFAULT" - } else if err := loadFromDatabricksCfg(cmd.Context(), cfg); err != nil { - return err - } - // Go SDK is lazy loaded because of Terraform semantics, - // so we're creating a dummy HTTP request as a placeholder - // for headers. - r := &http.Request{Header: http.Header{}} - err := cfg.Authenticate(r.WithContext(cmd.Context())) - if err != nil { - return err - } - vars := map[string]string{} - for _, a := range config.ConfigAttributes { - if a.IsZero(cfg) { - continue - } - envValue := a.GetString(cfg) - for _, envName := range a.EnvVars { - vars[envName] = envValue - } - } - raw, err := json.MarshalIndent(map[string]any{ - "env": vars, - }, "", " ") - if err != nil { - return err - } - _, _ = cmd.OutOrStdout().Write(raw) - return nil +// quoteEnvValue quotes a value for KEY=VALUE output if it contains spaces or +// shell-special characters. The value is wrapped in single quotes to prevent +// shell expansion; embedded single quotes are escaped POSIX-style by closing +// the quoted string, emitting a backslash-escaped quote, and reopening. +func quoteEnvValue(v string) string { + if v == "" { + return `''` } - - return cmd + if !strings.ContainsAny(v, shellQuotedSpecialChars) { + return v + } + return "'" + strings.ReplaceAll(v, "'", "'\\''") + "'" } diff --git a/cmd/auth/env_test.go b/cmd/auth/env_test.go new file mode 100644 index 0000000000..0c54d032d4 --- /dev/null +++ b/cmd/auth/env_test.go @@ -0,0 +1,71 @@ +package auth + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQuoteEnvValue(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {name: "simple value", in: "hello", want: "hello"}, + {name: "empty value", in: "", want: `''`}, + {name: "value with space", in: "hello world", want: "'hello world'"}, + {name: "value with tab", in: "hello\tworld", want: "'hello\tworld'"}, + {name: "value with double quote", in: `say "hi"`, want: "'say \"hi\"'"}, + {name: "value with backslash", in: `path\to`, want: "'path\\to'"}, + {name: "url value", in: "https://example.com", want: "https://example.com"}, + {name: "value with dollar", in: "price$5", want: "'price$5'"}, + {name: "value with backtick", in: "hello`world", want: "'hello`world'"}, + {name: "value with bang", in: "hello!world", want: "'hello!world'"}, + {name: "value with single quote", in: "it's", want: "'it'\\''s'"}, + {name: "value with newline", in: "line1\nline2", want: "'line1\nline2'"}, + {name: "value with carriage return", in: "line1\rline2", want: "'line1\rline2'"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, quoteEnvValue(c.in)) + }) + } +} + +func TestNewEnvCommandDeprecation(t *testing.T) { + cmd := newEnvCommand() + assert.True(t, cmd.Hidden, "env command must remain hidden") + assert.Contains(t, cmd.Long, "Deprecated", "Long description should mention deprecation") + assert.Contains(t, envDeprecationWarning, "deprecated") + assert.Contains(t, envDeprecationWarning, "databricks auth env") +} + +func TestWriteEnvOutput(t *testing.T) { + envVars := map[string]string{ + "DATABRICKS_HOST": "https://test.cloud.databricks.com", + "DATABRICKS_TOKEN": "secret value", + } + + t.Run("text mode emits sorted shell-quoted KEY=VALUE lines", func(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, writeEnvOutput(&buf, envVars, true)) + assert.Equal(t, "DATABRICKS_HOST=https://test.cloud.databricks.com\nDATABRICKS_TOKEN='secret value'\n", buf.String()) + }) + + t.Run("json mode wraps env in {\"env\": ...} with trailing newline", func(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, writeEnvOutput(&buf, envVars, false)) + assert.Equal(t, "{\n \"env\": {\n \"DATABRICKS_HOST\": \"https://test.cloud.databricks.com\",\n \"DATABRICKS_TOKEN\": \"secret value\"\n }\n}\n", buf.String()) + }) + + t.Run("empty map", func(t *testing.T) { + var textBuf, jsonBuf bytes.Buffer + require.NoError(t, writeEnvOutput(&textBuf, map[string]string{}, true)) + require.NoError(t, writeEnvOutput(&jsonBuf, map[string]string{}, false)) + assert.Empty(t, textBuf.String()) + assert.Equal(t, "{\n \"env\": {}\n}\n", jsonBuf.String()) + }) +}