Skip to content
Merged
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
203 changes: 123 additions & 80 deletions cmd/configure/configure.go
Original file line number Diff line number Diff line change
@@ -1,124 +1,167 @@
package configure

import (
"bytes"
"errors"
"context"
"fmt"
"os"
"path/filepath"
"net/url"

"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/databrickscfg"
"github.com/databricks/databricks-sdk-go/config"
"github.com/spf13/cobra"
"gopkg.in/ini.v1"
)

type Configs struct {
Host string `ini:"host"`
Token string `ini:"token,omitempty"`
Profile string `ini:"-"`
func validateHost(s string) error {
u, err := url.Parse(s)
if err != nil {
return err
}
if u.Host == "" || u.Scheme != "https" {
return fmt.Errorf("must start with https://")
}
if u.Path != "" && u.Path != "/" {
return fmt.Errorf("must use empty path")
}
return nil
}

var tokenMode bool
func configureFromFlags(cmd *cobra.Command, ctx context.Context, cfg *config.Config) error {
// Configure profile name if set.
profile, err := cmd.Flags().GetString("profile")
if err != nil {
return fmt.Errorf("read --profile flag: %w", err)
}
if profile != "" {
cfg.Profile = profile
}

func (cfg *Configs) loadNonInteractive(cmd *cobra.Command) error {
// Configure host if set.
host, err := cmd.Flags().GetString("host")
if err != nil || host == "" {
return fmt.Errorf("use --host to specify host in non interactive mode: %w", err)
if err != nil {
return fmt.Errorf("read --host flag: %w", err)
}
if host != "" {
cfg.Host = host
}
cfg.Host = host

if !tokenMode {
return nil
// Validate host if set.
if cfg.Host != "" {
err = validateHost(cfg.Host)
if err != nil {
return err
}
}

n, err := fmt.Scanf("%s\n", &cfg.Token)
return nil
}

func configureInteractive(cmd *cobra.Command, ctx context.Context, cfg *config.Config) error {
err := configureFromFlags(cmd, ctx, cfg)
if err != nil {
return err
}
if n != 1 {
return fmt.Errorf("exactly 1 argument required")
}
return nil
}

var configureCmd = &cobra.Command{
Use: "configure",
Short: "Configure authentication",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
profile, err := cmd.Flags().GetString("profile")
// Ask user to specify the host if not already set.
if cfg.Host == "" {
prompt := cmdio.Prompt(ctx)
prompt.Label = "Databricks Host"
prompt.Default = "https://"
prompt.AllowEdit = true
prompt.Validate = validateHost
out, err := prompt.Run()
if err != nil {
return fmt.Errorf("read --profile flag: %w", err)
return err
}
cfg.Host = out
}

path := os.Getenv("DATABRICKS_CONFIG_FILE")
if path == "" {
path, err = os.UserHomeDir()
if err != nil {
return fmt.Errorf("homedir: %w", err)
}
}
if filepath.Base(path) == ".databrickscfg" {
path = filepath.Dir(path)
}
err = os.MkdirAll(path, os.ModeDir|os.ModePerm)
// Ask user to specify the token is not already set.
if cfg.Token == "" {
prompt := cmdio.Prompt(ctx)
prompt.Label = "Personal Access Token"
prompt.Mask = '*'
out, err := prompt.Run()
if err != nil {
return fmt.Errorf("create config dir: %w", err)
}
cfgPath := filepath.Join(path, ".databrickscfg")
_, err = os.Stat(cfgPath)
if errors.Is(err, os.ErrNotExist) {
file, err := os.Create(cfgPath)
if err != nil {
return fmt.Errorf("create config file: %w", err)
}
file.Close()
} else if err != nil {
return fmt.Errorf("open config file: %w", err)
return err
}
cfg.Token = out
}

ini_cfg, err := ini.Load(cfgPath)
if err != nil {
return fmt.Errorf("load config file: %w", err)
}
cfg := &Configs{"", "", profile}
err = ini_cfg.Section(profile).MapTo(cfg)
if err != nil {
return fmt.Errorf("unmarshal loaded config: %w", err)
}
return nil
}

err = cfg.loadNonInteractive(cmd)
func configureNonInteractive(cmd *cobra.Command, ctx context.Context, cfg *config.Config) error {
err := configureFromFlags(cmd, ctx, cfg)
if err != nil {
return err
}

if cfg.Host == "" {
return fmt.Errorf("host must be set in non-interactive mode")
}

// Read token from stdin if not already set.
if cfg.Token == "" {
_, err := fmt.Fscanf(cmd.InOrStdin(), "%s\n", &cfg.Token)
if err != nil {
return fmt.Errorf("reading configs: %w", err)
return err
}
}

err = ini_cfg.Section(profile).ReflectFrom(cfg)
return nil
}

var configureCmd = &cobra.Command{
Use: "configure",
Short: "Configure authentication",
Long: `Configure authentication.

This command adds a profile to your ~/.databrickscfg file.
You can write to a different file by setting the DATABRICKS_CONFIG_FILE environment variable.

If this command is invoked in non-interactive mode, it will read the token from stdin.
The host must be specified with the --host flag.
`,
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
var cfg config.Config

// Load environment variables, possibly the DEFAULT profile.
err := config.ConfigAttributes.Configure(&cfg)
if err != nil {
return fmt.Errorf("marshall config: %w", err)
return fmt.Errorf("unable to instantiate configuration from environment variables: %w", err)
}

var buffer bytes.Buffer
if ini_cfg.Section("DEFAULT").Body() != "" {
//This configuration makes the ini library write the DEFAULT header explicitly.
//DEFAULT section might be empty
ini.DefaultHeader = true
}
_, err = ini_cfg.WriteTo(&buffer)
if err != nil {
return fmt.Errorf("write config to buffer: %w", err)
ctx := cmd.Context()
interactive := cmdio.IsInTTY(ctx) && cmdio.IsOutTTY(ctx)
var fn func(*cobra.Command, context.Context, *config.Config) error
if interactive {
fn = configureInteractive
} else {
fn = configureNonInteractive
}
err = os.WriteFile(cfgPath, buffer.Bytes(), os.ModePerm)
err = fn(cmd, ctx, &cfg)
if err != nil {
return fmt.Errorf("write congfig to file: %w", err)
return err
}

return nil
// Clear the Databricks CLI path in token mode.
// This is relevant for OAuth only.
cfg.DatabricksCliPath = ""

// Save profile to config file.
return databrickscfg.SaveToProfile(ctx, &cfg)
},
}

func init() {
root.RootCmd.AddCommand(configureCmd)
configureCmd.Flags().BoolVarP(&tokenMode, "token", "t", false, "Configure using Databricks Personal Access Token")
configureCmd.Flags().String("host", "", "Host to connect to.")
configureCmd.Flags().String("profile", "DEFAULT", "CLI connection profile to use.")
configureCmd.Flags().String("host", "", "Databricks workspace host.")
configureCmd.Flags().String("profile", "DEFAULT", "Name for the connection profile to configure.")

// Include token flag for compatibility with the legacy CLI.
// It doesn't actually do anything because we always use PATs.
configureCmd.Flags().BoolP("token", "t", true, "Configure using Databricks Personal Access Token")
configureCmd.Flags().MarkHidden("token")
}
19 changes: 9 additions & 10 deletions cmd/configure/configure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func TestDefaultConfigureNoInteractive(t *testing.T) {
})
os.Stdin = inp

root.RootCmd.SetArgs([]string{"configure", "--token", "--host", "host"})
root.RootCmd.SetArgs([]string{"configure", "--token", "--host", "https://host"})

err := root.RootCmd.ExecuteContext(ctx)
assert.NoError(t, err)
Expand All @@ -67,29 +67,28 @@ func TestDefaultConfigureNoInteractive(t *testing.T) {
defaultSection, err := cfg.GetSection("DEFAULT")
assert.NoError(t, err)

assertKeyValueInSection(t, defaultSection, "host", "host")
assertKeyValueInSection(t, defaultSection, "host", "https://host")
assertKeyValueInSection(t, defaultSection, "token", "token")
}

func TestConfigFileFromEnvNoInteractive(t *testing.T) {
//TODO: Replace with similar test code from go SDK, once we start using it directly
ctx := context.Background()
tempHomeDir := setup(t)
cfgFileDir := filepath.Join(tempHomeDir, "test")
t.Setenv("DATABRICKS_CONFIG_FILE", cfgFileDir)
cfgPath := filepath.Join(tempHomeDir, ".databrickscfg")
t.Setenv("DATABRICKS_CONFIG_FILE", cfgPath)

inp := getTempFileWithContent(t, tempHomeDir, "token\n")
defer inp.Close()
oldStdin := os.Stdin
t.Cleanup(func() { os.Stdin = oldStdin })
os.Stdin = inp

root.RootCmd.SetArgs([]string{"configure", "--token", "--host", "host"})
root.RootCmd.SetArgs([]string{"configure", "--token", "--host", "https://host"})

err := root.RootCmd.ExecuteContext(ctx)
assert.NoError(t, err)

cfgPath := filepath.Join(cfgFileDir, ".databrickscfg")
_, err = os.Stat(cfgPath)
assert.NoError(t, err)

Expand All @@ -99,25 +98,25 @@ func TestConfigFileFromEnvNoInteractive(t *testing.T) {
defaultSection, err := cfg.GetSection("DEFAULT")
assert.NoError(t, err)

assertKeyValueInSection(t, defaultSection, "host", "host")
assertKeyValueInSection(t, defaultSection, "host", "https://host")
assertKeyValueInSection(t, defaultSection, "token", "token")
}

func TestCustomProfileConfigureNoInteractive(t *testing.T) {
ctx := context.Background()
tempHomeDir := setup(t)
cfgPath := filepath.Join(tempHomeDir, ".databrickscfg")
inp := getTempFileWithContent(t, tempHomeDir, "token\n")
defer inp.Close()
oldStdin := os.Stdin
t.Cleanup(func() { os.Stdin = oldStdin })
os.Stdin = inp

root.RootCmd.SetArgs([]string{"configure", "--token", "--host", "host", "--profile", "CUSTOM"})
root.RootCmd.SetArgs([]string{"configure", "--token", "--host", "https://host", "--profile", "CUSTOM"})

err := root.RootCmd.ExecuteContext(ctx)
assert.NoError(t, err)

cfgPath := filepath.Join(tempHomeDir, ".databrickscfg")
_, err = os.Stat(cfgPath)
assert.NoError(t, err)

Expand All @@ -127,6 +126,6 @@ func TestCustomProfileConfigureNoInteractive(t *testing.T) {
defaultSection, err := cfg.GetSection("CUSTOM")
assert.NoError(t, err)

assertKeyValueInSection(t, defaultSection, "host", "host")
assertKeyValueInSection(t, defaultSection, "host", "https://host")
assertKeyValueInSection(t, defaultSection, "token", "token")
}
36 changes: 35 additions & 1 deletion libs/cmdio/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func IsInteractive(ctx context.Context) bool {
}

// IsTTY detects if io.Writer is a terminal.
func IsTTY(w io.Writer) bool {
func IsTTY(w any) bool {
f, ok := w.(*os.File)
if !ok {
return false
Expand All @@ -60,6 +60,24 @@ func IsTTY(w io.Writer) bool {
return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)
}

// IsInTTY detects if the input reader is a terminal.
func IsInTTY(ctx context.Context) bool {
c := fromContext(ctx)
return IsTTY(c.in)
}

// IsOutTTY detects if the output writer is a terminal.
func IsOutTTY(ctx context.Context) bool {
c := fromContext(ctx)
return IsTTY(c.out)
}

// IsErrTTY detects if the error writer is a terminal.
func IsErrTTY(ctx context.Context) bool {
c := fromContext(ctx)
return IsTTY(c.err)
}

// IsTTY detects if stdout is a terminal. It assumes that stderr is terminal as well
func (c *cmdIO) IsTTY() bool {
f, ok := c.out.(*os.File)
Expand Down Expand Up @@ -170,6 +188,22 @@ func Secret(ctx context.Context) (value string, err error) {
return c.Secret()
}

type nopWriteCloser struct {
io.Writer
}

func (nopWriteCloser) Close() error {
return nil
}

func Prompt(ctx context.Context) *promptui.Prompt {
c := fromContext(ctx)
return &promptui.Prompt{
Stdin: io.NopCloser(c.in),
Stdout: nopWriteCloser{c.out},
}
}

func (c *cmdIO) Spinner(ctx context.Context) chan string {
var sp *spinner.Spinner
if c.interactive {
Expand Down