Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 53 additions & 118 deletions cmd/auth/env.go
Original file line number Diff line number Diff line change
@@ -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, "'", "'\\''") + "'"
}
71 changes: 71 additions & 0 deletions cmd/auth/env_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
Loading