From 0673055c460f0530335d9d43e550cce562bae108 Mon Sep 17 00:00:00 2001 From: Martin Polden Date: Thu, 7 Apr 2022 14:19:56 +0200 Subject: Add support for local configuration --- client/go/cmd/cert.go | 4 +- client/go/cmd/config.go | 234 ++++++++++++++++++++++++++++------------- client/go/cmd/config_test.go | 99 +++++++++++------ client/go/cmd/deploy.go | 27 +++-- client/go/cmd/deploy_test.go | 2 +- client/go/cmd/prod.go | 11 +- client/go/cmd/root.go | 102 ++++++++++-------- client/go/cmd/status.go | 9 +- client/go/cmd/test.go | 3 +- client/go/cmd/testutil_test.go | 2 - client/go/vespa/application.go | 5 +- 11 files changed, 320 insertions(+), 178 deletions(-) (limited to 'client/go') diff --git a/client/go/cmd/cert.go b/client/go/cmd/cert.go index 11b20603ce8..581c76b8721 100644 --- a/client/go/cmd/cert.go +++ b/client/go/cmd/cert.go @@ -100,7 +100,7 @@ func doCert(cli *CLI, overwriteCertificate, noApplicationPackage bool, args []st } var pkg vespa.ApplicationPackage if !noApplicationPackage { - pkg, err = vespa.FindApplicationPackage(applicationSource(args), false) + pkg, err = cli.applicationPackageFrom(args, false) if err != nil { return err } @@ -168,7 +168,7 @@ func doCertAdd(cli *CLI, overwriteCertificate bool, args []string) error { if err != nil { return err } - pkg, err := vespa.FindApplicationPackage(applicationSource(args), false) + pkg, err := cli.applicationPackageFrom(args, false) if err != nil { return err } diff --git a/client/go/cmd/config.go b/client/go/cmd/config.go index 417dfd77198..e64353fd91d 100644 --- a/client/go/cmd/config.go +++ b/client/go/cmd/config.go @@ -13,9 +13,11 @@ import ( "sort" "strconv" "strings" + "time" "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/spf13/viper" "github.com/vespa-engine/vespa/client/go/auth/auth0" "github.com/vespa-engine/vespa/client/go/util" @@ -38,7 +40,21 @@ future invocations the flag can then be omitted as it is read from the config file instead. Configuration is written to $HOME/.vespa by default. This path can be -overridden by setting the VESPA_CLI_HOME environment variable.`, +overridden by setting the VESPA_CLI_HOME environment variable. + +When configuring a local option, the configuration is written to +[working-directory]/.vespa, where working directory is assumed to be Vespa +application directory. This allows you have separate configuration options per +application. + +Vespa CLI chooses the value for a given option in the following order, from +most to least preferred: + +1. flag value specified on the command line +2. local config value +3. global config value +4. default value +`, DisableAutoGenTag: true, SilenceUsage: false, Args: cobra.MinimumNArgs(1), @@ -49,7 +65,8 @@ overridden by setting the VESPA_CLI_HOME environment variable.`, } func newConfigSetCmd(cli *CLI) *cobra.Command { - return &cobra.Command{ + var localArg bool + cmd := &cobra.Command{ Use: "set option-name value", Short: "Set a configuration option.", Example: `# Set the target to Vespa Cloud @@ -62,57 +79,88 @@ $ vespa config set application my-tenant.my-application $ vespa config set application my-tenant.my-application.my-instance # Set the instance explicitly. This will take precedence over an instance specified as part of the application option. -$ vespa config set instance other-instance`, +$ vespa config set instance other-instance + +# Set an option in local configuration, for the current application only +$ vespa config set --local wait 600 +`, DisableAutoGenTag: true, SilenceUsage: true, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - if err := cli.config.set(args[0], args[1]); err != nil { + config := cli.config + if localArg { + // Need an application package in working directory to allow local configuration + if _, err := cli.applicationPackageFrom(nil, false); err != nil { + return fmt.Errorf("failed to write local configuration: %w", err) + } + if err := cli.config.loadLocalConfigFrom(".", true, true); err != nil { + return fmt.Errorf("failed to create local configuration: %w", err) + } + config = cli.config.local + } + if err := config.set(args[0], args[1]); err != nil { return err } - return cli.config.write() + return config.write() }, } + cmd.Flags().BoolVarP(&localArg, "local", "l", false, "Write option to local configuration, i.e. for the current application") + return cmd } func newConfigGetCmd(cli *CLI) *cobra.Command { - return &cobra.Command{ + var localArg bool + cmd := &cobra.Command{ Use: "get [option-name]", Short: "Show given configuration option, or all configuration options", + Long: `Show given configuration option, or all configuration options. + +By default this command prints the effective configuration for the current +application, i.e. it takes into account any local configuration located in +[working-directory]/.vespa. +`, Example: `$ vespa config get -$ vespa config get target`, +$ vespa config get target +$ vespa config get --local +`, Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { // Print all values - var flags []string - for flag := range cli.config.bindings.flag { - flags = append(flags, flag) + config := cli.config + if localArg { + if err := cli.config.loadLocalConfigFrom(".", false, true); err != nil { + return fmt.Errorf("failed to load local configuration: %w", err) } - sort.Strings(flags) - for _, flag := range flags { - cli.config.printOption(flag) + config = cli.config.local + if config == nil { + cli.printWarning("no local configuration present") + return nil + } + } + if len(args) == 0 { // Print all values + for _, option := range config.list() { + config.printOption(option) } } else { - cli.config.printOption(args[0]) + config.printOption(args[0]) } return nil }, } + cmd.Flags().BoolVarP(&localArg, "local", "l", false, "Show only local configuration, if any") + return cmd } type Config struct { homeDir string cacheDir string environment map[string]string - bindings ConfigBindings - createDirs bool -} + local *Config -type ConfigBindings struct { - flag map[string]*cobra.Command - environment map[string]string + flags *pflag.FlagSet + viper *viper.Viper } type KeyPair struct { @@ -121,43 +169,70 @@ type KeyPair struct { PrivateKeyFile string } -func NewConfigBindings() ConfigBindings { - return ConfigBindings{ - flag: make(map[string]*cobra.Command), - environment: make(map[string]string), - } -} - -func (b *ConfigBindings) bindFlag(name string, command *cobra.Command) { - b.flag[name] = command -} - -func (b *ConfigBindings) bindEnvironment(flagName string, variable string) { - b.environment[flagName] = variable -} - -func loadConfig(environment map[string]string, bindings ConfigBindings) (*Config, error) { +func loadConfig(environment map[string]string, globalFlags *pflag.FlagSet) (*Config, error) { home, err := vespaCliHome(environment) if err != nil { return nil, fmt.Errorf("could not detect config directory: %w", err) } + config, err := loadConfigFrom(home, environment, globalFlags) + if err != nil { + return nil, err + } + // Load local config from working directory by default, if any + if err := config.loadLocalConfigFrom(".", false, false); err != nil { + return nil, err + } + return config, nil +} + +func loadConfigFrom(dir string, environment map[string]string, globalFlags *pflag.FlagSet) (*Config, error) { cacheDir, err := vespaCliCacheDir(environment) if err != nil { return nil, fmt.Errorf("could not detect cache directory: %w", err) } c := &Config{ - homeDir: home, + homeDir: dir, cacheDir: cacheDir, environment: environment, - bindings: bindings, - createDirs: true, - } - if err := c.load(); err != nil { - return nil, fmt.Errorf("could not load config: %w", err) + flags: globalFlags, + } + v := viper.New() + v.SetConfigName(configName) + v.SetConfigType(configType) + v.AddConfigPath(c.homeDir) + v.BindPFlags(globalFlags) + c.viper = v + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, err + } } return c, nil } +func (c *Config) loadLocalConfigFrom(parent string, create, noFlags bool) error { + home := filepath.Join(parent, ".vespa") + _, err := os.Stat(home) + if err != nil { + if !os.IsNotExist(err) { + return err + } + if !create { + return nil + } + } + flags := c.flags + if noFlags { + flags = &pflag.FlagSet{} + } + config, err := loadConfigFrom(home, c.environment, flags) + if err != nil { + return err + } + c.local = config + return nil +} + func (c *Config) write() error { if err := os.MkdirAll(c.homeDir, 0700); err != nil { return err @@ -168,7 +243,8 @@ func (c *Config) write() error { return err } } - return viper.WriteConfig() + err := c.viper.WriteConfig() + return err } func (c *Config) targetType() (string, error) { @@ -179,6 +255,23 @@ func (c *Config) targetType() (string, error) { return targetType, nil } +func (c *Config) timeout() (time.Duration, error) { + wait, ok := c.get(waitFlag) + if !ok { + return 0, nil + } + secs, err := strconv.Atoi(wait) + if err != nil { + return 0, err + } + return time.Duration(secs) * time.Second, nil +} + +func (c *Config) isQuiet() bool { + quiet, _ := c.get(quietFlag) + return quiet == "true" +} + func (c *Config) application() (vespa.ApplicationID, error) { app, ok := c.get(applicationFlag) if !ok { @@ -195,10 +288,11 @@ func (c *Config) application() (vespa.ApplicationID, error) { return application, nil } -func (c *Config) deploymentIn(zoneName string, system vespa.System) (vespa.Deployment, error) { +func (c *Config) deploymentIn(system vespa.System) (vespa.Deployment, error) { zone := system.DefaultZone - var err error - if zoneName != "" { + zoneName, ok := c.get(zoneFlag) + if ok { + var err error zone, err = vespa.ZoneFromString(zoneName) if err != nil { return vespa.Deployment{}, err @@ -321,35 +415,25 @@ func (c *Config) writeSessionID(app vespa.ApplicationID, sessionID int64) error func (c *Config) applicationFilePath(app vespa.ApplicationID, name string) (string, error) { appDir := filepath.Join(c.homeDir, app.String()) - if c.createDirs { - if err := os.MkdirAll(appDir, 0700); err != nil { - return "", err - } + if err := os.MkdirAll(appDir, 0700); err != nil { + return "", err } return filepath.Join(appDir, name), nil } -func (c *Config) load() error { - viper.SetConfigName(configName) - viper.SetConfigType(configType) - viper.AddConfigPath(c.homeDir) - for option, command := range c.bindings.flag { - viper.BindPFlag(option, command.PersistentFlags().Lookup(option)) - } - err := viper.ReadInConfig() - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - return nil - } - return err +func (c *Config) list() []string { + options := c.viper.AllKeys() + sort.Strings(options) + return options } func (c *Config) get(option string) (string, bool) { - if envVar, ok := c.bindings.environment[option]; ok { - if value, ok := c.environment[envVar]; ok { - return value, true + if c.local != nil { + if value, ok := c.local.get(option); ok { + return value, ok } } - value := viper.GetString(option) + value := c.viper.GetString(option) if value == "" { return "", false } @@ -361,11 +445,11 @@ func (c *Config) set(option, value string) error { case targetFlag: switch value { case vespa.TargetLocal, vespa.TargetCloud, vespa.TargetHosted: - viper.Set(option, value) + c.viper.Set(option, value) return nil } if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { - viper.Set(option, value) + c.viper.Set(option, value) return nil } case applicationFlag: @@ -373,31 +457,31 @@ func (c *Config) set(option, value string) error { if err != nil { return err } - viper.Set(option, app.String()) + c.viper.Set(option, app.String()) return nil case instanceFlag: - viper.Set(option, value) + c.viper.Set(option, value) return nil case waitFlag: if n, err := strconv.Atoi(value); err != nil || n < 0 { return fmt.Errorf("%s option must be an integer >= 0, got %q", option, value) } - viper.Set(option, value) + c.viper.Set(option, value) return nil case colorFlag: switch value { case "auto", "never", "always": - viper.Set(option, value) + c.viper.Set(option, value) return nil } case quietFlag: switch value { case "true", "false": - viper.Set(option, value) + c.viper.Set(option, value) return nil } } - return fmt.Errorf("invalid option or value: %q: %q", option, value) + return fmt.Errorf("invalid option or value: %s = %s", option, value) } func (c *Config) printOption(option string) { diff --git a/client/go/cmd/config_test.go b/client/go/cmd/config_test.go index 4fb9ac606cc..7059bfa78cc 100644 --- a/client/go/cmd/config_test.go +++ b/client/go/cmd/config_test.go @@ -8,52 +8,87 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/vespa-engine/vespa/client/go/mock" "github.com/vespa-engine/vespa/client/go/vespa" ) func TestConfig(t *testing.T) { - assertConfigCommandErr(t, "Error: invalid option or value: \"foo\": \"bar\"\n", "config", "set", "foo", "bar") - assertConfigCommand(t, "foo = \n", "config", "get", "foo") - assertConfigCommand(t, "target = local\n", "config", "get", "target") - assertConfigCommand(t, "", "config", "set", "target", "hosted") - assertConfigCommand(t, "target = hosted\n", "config", "get", "target") - assertConfigCommand(t, "", "config", "set", "target", "cloud") - assertConfigCommand(t, "target = cloud\n", "config", "get", "target") - assertConfigCommand(t, "", "config", "set", "target", "http://127.0.0.1:8080") - assertConfigCommand(t, "", "config", "set", "target", "https://127.0.0.1") - assertConfigCommand(t, "target = https://127.0.0.1\n", "config", "get", "target") - - assertConfigCommandErr(t, "Error: invalid application: \"foo\"\n", "config", "set", "application", "foo") - assertConfigCommand(t, "application = \n", "config", "get", "application") - assertConfigCommand(t, "", "config", "set", "application", "t1.a1.i1") - assertConfigCommand(t, "application = t1.a1.i1\n", "config", "get", "application") - - assertConfigCommand(t, "", "config", "set", "wait", "60") - assertConfigCommandErr(t, "Error: wait option must be an integer >= 0, got \"foo\"\n", "config", "set", "wait", "foo") - assertConfigCommand(t, "wait = 60\n", "config", "get", "wait") - - assertConfigCommand(t, "", "config", "set", "quiet", "true") - assertConfigCommand(t, "", "config", "set", "quiet", "false") - - assertConfigCommand(t, "", "config", "set", "instance", "i2") - assertConfigCommand(t, "instance = i2\n", "config", "get", "instance") - - assertConfigCommand(t, "", "config", "set", "application", "t1.a1") - assertConfigCommand(t, "application = t1.a1.default\n", "config", "get", "application") + configHome := t.TempDir() + assertConfigCommandErr(t, configHome, "Error: invalid option or value: foo = bar\n", "config", "set", "foo", "bar") + assertConfigCommand(t, configHome, "foo = \n", "config", "get", "foo") + assertConfigCommand(t, configHome, "target = local\n", "config", "get", "target") + assertConfigCommand(t, configHome, "", "config", "set", "target", "hosted") + assertConfigCommand(t, configHome, "target = hosted\n", "config", "get", "target") + assertConfigCommand(t, configHome, "", "config", "set", "target", "cloud") + assertConfigCommand(t, configHome, "target = cloud\n", "config", "get", "target") + assertConfigCommand(t, configHome, "", "config", "set", "target", "http://127.0.0.1:8080") + assertConfigCommand(t, configHome, "", "config", "set", "target", "https://127.0.0.1") + assertConfigCommand(t, configHome, "target = https://127.0.0.1\n", "config", "get", "target") + + assertConfigCommandErr(t, configHome, "Error: invalid application: \"foo\"\n", "config", "set", "application", "foo") + assertConfigCommand(t, configHome, "application = \n", "config", "get", "application") + assertConfigCommand(t, configHome, "", "config", "set", "application", "t1.a1.i1") + assertConfigCommand(t, configHome, "application = t1.a1.i1\n", "config", "get", "application") + + assertConfigCommand(t, configHome, "", "config", "set", "wait", "60") + assertConfigCommandErr(t, configHome, "Error: wait option must be an integer >= 0, got \"foo\"\n", "config", "set", "wait", "foo") + assertConfigCommand(t, configHome, "wait = 60\n", "config", "get", "wait") + assertConfigCommand(t, configHome, "wait = 30\n", "config", "get", "--wait", "30", "wait") // flag overrides global config + + assertConfigCommand(t, configHome, "", "config", "set", "quiet", "true") + assertConfigCommand(t, configHome, "", "config", "set", "quiet", "false") + + assertConfigCommand(t, configHome, "", "config", "set", "instance", "i2") + assertConfigCommand(t, configHome, "instance = i2\n", "config", "get", "instance") + + assertConfigCommand(t, configHome, "", "config", "set", "application", "t1.a1") + assertConfigCommand(t, configHome, "application = t1.a1.default\n", "config", "get", "application") +} + +func TestLocalConfig(t *testing.T) { + configHome := t.TempDir() + assertConfigCommand(t, configHome, "", "config", "set", "instance", "main") + + // Change directory to an application package and write local configuration + _, rootDir := mock.ApplicationPackageDir(t, false, false) + wd, err := os.Getwd() + require.Nil(t, err) + t.Cleanup(func() { os.Chdir(wd) }) + require.Nil(t, os.Chdir(rootDir)) + assertConfigCommand(t, configHome, "", "config", "set", "--local", "instance", "foo") + assertConfigCommand(t, configHome, "instance = foo\n", "config", "get", "instance") + assertConfigCommand(t, configHome, "instance = bar\n", "config", "get", "--instance", "bar", "instance") // flag overrides local config + + // get --local prints only options set in local config + assertConfigCommand(t, configHome, "", "config", "set", "--local", "target", "hosted") + assertConfigCommand(t, configHome, "instance = foo\ntarget = hosted\n", "config", "get", "--local") + + // only locally set options are written + localConfig, err := os.ReadFile(filepath.Join(rootDir, ".vespa", "config.yaml")) + require.Nil(t, err) + assert.Equal(t, "instance: foo\ntarget: hosted\n", string(localConfig)) + + // Changing back to original directory reads from global config + require.Nil(t, os.Chdir(wd)) + assertConfigCommand(t, configHome, "instance = main\n", "config", "get", "instance") } -func assertConfigCommand(t *testing.T, expected string, args ...string) { - assertEnvConfigCommand(t, expected, nil, args...) +func assertConfigCommand(t *testing.T, configHome, expected string, args ...string) { + t.Helper() + assertEnvConfigCommand(t, configHome, expected, nil, args...) } -func assertEnvConfigCommand(t *testing.T, expected string, env []string, args ...string) { +func assertEnvConfigCommand(t *testing.T, configHome, expected string, env []string, args ...string) { + t.Helper() + env = append(env, "VESPA_CLI_HOME="+configHome) cli, stdout, _ := newTestCLI(t, env...) err := cli.Run(args...) assert.Nil(t, err) assert.Equal(t, expected, stdout.String()) } -func assertConfigCommandErr(t *testing.T, expected string, args ...string) { +func assertConfigCommandErr(t *testing.T, configHome, expected string, args ...string) { + t.Helper() cli, _, stderr := newTestCLI(t) err := cli.Run(args...) assert.NotNil(t, err) diff --git a/client/go/cmd/deploy.go b/client/go/cmd/deploy.go index 16da69fded6..930e25dddc7 100644 --- a/client/go/cmd/deploy.go +++ b/client/go/cmd/deploy.go @@ -48,7 +48,7 @@ $ vespa deploy -t cloud -z perf.aws-us-east-1c`, DisableAutoGenTag: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - pkg, err := vespa.FindApplicationPackage(applicationSource(args), true) + pkg, err := cli.applicationPackageFrom(args, true) if err != nil { return err } @@ -56,7 +56,10 @@ $ vespa deploy -t cloud -z perf.aws-us-east-1c`, if err != nil { return err } - opts := cli.createDeploymentOptions(pkg, target) + opts, err := cli.createDeploymentOptions(pkg, target) + if err != nil { + return err + } if versionArg != "" { version, err := version.Parse(versionArg) if err != nil { @@ -105,7 +108,7 @@ func newPrepareCmd(cli *CLI) *cobra.Command { DisableAutoGenTag: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - pkg, err := vespa.FindApplicationPackage(applicationSource(args), true) + pkg, err := cli.applicationPackageFrom(args, true) if err != nil { return fmt.Errorf("could not find application package: %w", err) } @@ -113,7 +116,10 @@ func newPrepareCmd(cli *CLI) *cobra.Command { if err != nil { return err } - opts := cli.createDeploymentOptions(pkg, target) + opts, err := cli.createDeploymentOptions(pkg, target) + if err != nil { + return err + } var result vespa.PrepareResult err = cli.spinner(cli.Stderr, "Uploading application package ...", func() error { result, err = vespa.Prepare(opts) @@ -139,7 +145,7 @@ func newActivateCmd(cli *CLI) *cobra.Command { DisableAutoGenTag: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - pkg, err := vespa.FindApplicationPackage(applicationSource(args), true) + pkg, err := cli.applicationPackageFrom(args, true) if err != nil { return fmt.Errorf("could not find application package: %w", err) } @@ -151,7 +157,10 @@ func newActivateCmd(cli *CLI) *cobra.Command { if err != nil { return err } - opts := cli.createDeploymentOptions(pkg, target) + opts, err := cli.createDeploymentOptions(pkg, target) + if err != nil { + return err + } err = vespa.Activate(sessionID, opts) if err != nil { return err @@ -163,7 +172,11 @@ func newActivateCmd(cli *CLI) *cobra.Command { } func waitForQueryService(cli *CLI, target vespa.Target, sessionOrRunID int64) error { - if cli.flags.waitSecs > 0 { + timeout, err := cli.config.timeout() + if err != nil { + return err + } + if timeout > 0 { log.Println() _, err := cli.service(target, vespa.QueryService, sessionOrRunID, "") return err diff --git a/client/go/cmd/deploy_test.go b/client/go/cmd/deploy_test.go index fedcfd604f4..fe3e0ca0467 100644 --- a/client/go/cmd/deploy_test.go +++ b/client/go/cmd/deploy_test.go @@ -66,7 +66,7 @@ func TestDeployApplicationDirectoryWithPomAndEmptyTarget(t *testing.T) { cli, _, stderr := newTestCLI(t) assert.NotNil(t, cli.Run("deploy", "testdata/applications/withEmptyTarget")) assert.Equal(t, - "Error: pom.xml exists but no target/application.zip. Run mvn package first\n", + "Error: found pom.xml, but target/application.zip does not exist: run 'mvn package' first\n", stderr.String()) } diff --git a/client/go/cmd/prod.go b/client/go/cmd/prod.go index 5bc4851dfa7..a7e11d3e117 100644 --- a/client/go/cmd/prod.go +++ b/client/go/cmd/prod.go @@ -53,8 +53,7 @@ https://cloud.vespa.ai/en/reference/deployment`, DisableAutoGenTag: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - appSource := applicationSource(args) - pkg, err := vespa.FindApplicationPackage(appSource, false) + pkg, err := cli.applicationPackageFrom(args, false) if err != nil { return err } @@ -135,8 +134,7 @@ $ vespa prod submit`, // TODO: Add support for hosted return fmt.Errorf("prod submit does not support %s target", target.Type()) } - appSource := applicationSource(args) - pkg, err := vespa.FindApplicationPackage(appSource, true) + pkg, err := cli.applicationPackageFrom(args, true) if err != nil { return err } @@ -154,7 +152,10 @@ $ vespa prod submit`, if !cli.isCI() { cli.printWarning("We recommend doing this only from a CD job", "See https://cloud.vespa.ai/en/getting-to-production") } - opts := cli.createDeploymentOptions(pkg, target) + opts, err := cli.createDeploymentOptions(pkg, target) + if err != nil { + return err + } if err := vespa.Submit(opts); err != nil { return fmt.Errorf("could not submit application for deployment: %w", err) } else { diff --git a/client/go/cmd/root.go b/client/go/cmd/root.go index 92ff98d8756..996a86ec90a 100644 --- a/client/go/cmd/root.go +++ b/client/go/cmd/root.go @@ -11,7 +11,6 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "strings" "time" @@ -19,6 +18,7 @@ import ( "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/vespa-engine/vespa/client/go/build" "github.com/vespa-engine/vespa/client/go/util" "github.com/vespa-engine/vespa/client/go/version" @@ -44,7 +44,6 @@ type CLI struct { Stderr io.Writer cmd *cobra.Command - flags *Flags config *Config version version.Version @@ -54,17 +53,6 @@ type CLI struct { spinner func(w io.Writer, message string, fn func() error) error } -// Flags holds the global Flags of Vespa CLI. -type Flags struct { - target string - application string - instance string - zone string - waitSecs int - color string - quiet bool -} - // ErrCLI is an error returned to the user. It wraps an exit status, a regular error and optional hints for resolving // the error. type ErrCLI struct { @@ -136,7 +124,6 @@ Vespa documentation: https://docs.vespa.ai`, exec: &execSubprocess{}, } cli.isTerminal = func() bool { return isTerminal(cli.Stdout) && isTerminal(cli.Stderr) } - cli.configureFlags() if err := cli.loadConfig(); err != nil { return nil, err } @@ -147,15 +134,7 @@ Vespa documentation: https://docs.vespa.ai`, } func (c *CLI) loadConfig() error { - bindings := NewConfigBindings() - bindings.bindFlag(targetFlag, c.cmd) - bindings.bindFlag(applicationFlag, c.cmd) - bindings.bindFlag(instanceFlag, c.cmd) - bindings.bindFlag(zoneFlag, c.cmd) - bindings.bindFlag(waitFlag, c.cmd) - bindings.bindFlag(colorFlag, c.cmd) - bindings.bindFlag(quietFlag, c.cmd) - config, err := loadConfig(c.Environment, bindings) + config, err := loadConfig(c.Environment, c.configureFlags()) if err != nil { return err } @@ -170,7 +149,7 @@ func (c *CLI) configureOutput(cmd *cobra.Command, args []string) error { if f, ok := c.Stderr.(*os.File); ok { c.Stderr = colorable.NewColorable(f) } - if quiet, _ := c.config.get(quietFlag); quiet == "true" { + if c.config.isQuiet() { c.Stdout = io.Discard } log.SetFlags(0) // No timestamps @@ -185,29 +164,37 @@ func (c *CLI) configureOutput(cmd *cobra.Command, args []string) error { colorize = true case "never": default: - return errHint(fmt.Errorf("invalid value for %s option", colorFlag), "Must be \"auto\", \"never\" or \"always\"") + return errHint(fmt.Errorf("invalid value for option %s: %q", colorFlag, colorValue), "Must be \"auto\", \"never\" or \"always\"") } color.NoColor = !colorize return nil } -func (c *CLI) configureFlags() { - flags := Flags{} - c.cmd.PersistentFlags().StringVarP(&flags.target, targetFlag, "t", "local", "The name or URL of the recipient of this command") - c.cmd.PersistentFlags().StringVarP(&flags.application, applicationFlag, "a", "", "The application to manage") - c.cmd.PersistentFlags().StringVarP(&flags.instance, instanceFlag, "i", "", "The instance of the application to manage") - c.cmd.PersistentFlags().StringVarP(&flags.zone, zoneFlag, "z", "", "The zone to use. This defaults to a dev zone") - c.cmd.PersistentFlags().IntVarP(&flags.waitSecs, waitFlag, "w", 0, "Number of seconds to wait for a service to become ready") - c.cmd.PersistentFlags().StringVarP(&flags.color, colorFlag, "c", "auto", "Whether to use colors in output.") - c.cmd.PersistentFlags().BoolVarP(&flags.quiet, quietFlag, "q", false, "Quiet mode. Only errors will be printed") - c.flags = &flags +func (c *CLI) configureFlags() *pflag.FlagSet { + var ( + target string + application string + instance string + zone string + waitSecs int + color string + quiet bool + ) + c.cmd.PersistentFlags().StringVarP(&target, targetFlag, "t", "local", "The name or URL of the recipient of this command") + c.cmd.PersistentFlags().StringVarP(&application, applicationFlag, "a", "", "The application to manage") + c.cmd.PersistentFlags().StringVarP(&instance, instanceFlag, "i", "", "The instance of the application to manage") + c.cmd.PersistentFlags().StringVarP(&zone, zoneFlag, "z", "", "The zone to use. This defaults to a dev zone") + c.cmd.PersistentFlags().IntVarP(&waitSecs, waitFlag, "w", 0, "Number of seconds to wait for a service to become ready") + c.cmd.PersistentFlags().StringVarP(&color, colorFlag, "c", "auto", "Whether to use colors in output.") + c.cmd.PersistentFlags().BoolVarP(&quiet, quietFlag, "q", false, "Quiet mode. Only errors will be printed") + return c.cmd.PersistentFlags() } func (c *CLI) configureSpinner() { // Explicitly disable spinner for Screwdriver. It emulates a tty but // \r result in a newline, and output gets truncated. _, screwdriver := c.Environment["SCREWDRIVER"] - if c.flags.quiet || !c.isTerminal() || screwdriver { + if c.config.isQuiet() || !c.isTerminal() || screwdriver { c.spinner = func(w io.Writer, message string, fn func() error) error { return fn() } @@ -313,7 +300,7 @@ func (c *CLI) createCloudTarget(targetType string, opts targetOptions) (vespa.Ta if err != nil { return nil, err } - deployment, err := c.config.deploymentIn(c.flags.zone, system) + deployment, err := c.config.deploymentIn(system) if err != nil { return nil, err } @@ -403,9 +390,12 @@ func (c *CLI) system(targetType string) (vespa.System, error) { // function blocks according to the wait period configured in this CLI. The parameter sessionOrRunID specifies either // the session ID (local target) or run ID (cloud target) to wait for. func (c *CLI) service(target vespa.Target, name string, sessionOrRunID int64, cluster string) (*vespa.Service, error) { - timeout := time.Duration(c.flags.waitSecs) * time.Second + timeout, err := c.config.timeout() + if err != nil { + return nil, err + } if timeout > 0 { - log.Printf("Waiting up to %s %s for %s service to become available ...", color.CyanString(strconv.Itoa(c.flags.waitSecs)), color.CyanString("seconds"), color.CyanString(name)) + log.Printf("Waiting up to %s for %s service to become available ...", color.CyanString(timeout.String()), color.CyanString(name)) } s, err := target.Service(name, timeout, sessionOrRunID, cluster) if err != nil { @@ -414,13 +404,17 @@ func (c *CLI) service(target vespa.Target, name string, sessionOrRunID int64, cl return s, nil } -func (c *CLI) createDeploymentOptions(pkg vespa.ApplicationPackage, target vespa.Target) vespa.DeploymentOptions { +func (c *CLI) createDeploymentOptions(pkg vespa.ApplicationPackage, target vespa.Target) (vespa.DeploymentOptions, error) { + timeout, err := c.config.timeout() + if err != nil { + return vespa.DeploymentOptions{}, err + } return vespa.DeploymentOptions{ ApplicationPackage: pkg, Target: target, - Timeout: time.Duration(c.flags.waitSecs) * time.Second, + Timeout: timeout, HTTPClient: c.httpClient, - } + }, nil } // isCI returns true if running inside a continuous integration environment. @@ -520,9 +514,25 @@ func athenzKeyPair() (KeyPair, error) { return KeyPair{KeyPair: kp, CertificateFile: certFile, PrivateKeyFile: keyFile}, nil } -func applicationSource(args []string) string { - if len(args) > 0 { - return args[0] +// applicationPackageFrom returns an application loaded from args. If args is empty, the application package is loaded +// from the working directory. If requirePackaging is true, the application package is required to be packaged with mvn +// package. +func (c *CLI) applicationPackageFrom(args []string, requirePackaging bool) (vespa.ApplicationPackage, error) { + path := "." + if len(args) == 1 { + path = args[0] + stat, err := os.Stat(path) + if err != nil { + return vespa.ApplicationPackage{}, err + } + if stat.IsDir() { + // Using an explicit application directory, look for local config in that directory too + if err := c.config.loadLocalConfigFrom(path, false, false); err != nil { + return vespa.ApplicationPackage{}, err + } + } + } else if len(args) > 1 { + return vespa.ApplicationPackage{}, fmt.Errorf("expected 0 or 1 arguments, got %d", len(args)) } - return "." + return vespa.FindApplicationPackage(path, requirePackaging) } diff --git a/client/go/cmd/status.go b/client/go/cmd/status.go index 1eacff56354..65da8f8f1c0 100644 --- a/client/go/cmd/status.go +++ b/client/go/cmd/status.go @@ -7,8 +7,6 @@ package cmd import ( "fmt" "log" - "strconv" - "time" "github.com/fatih/color" "github.com/spf13/cobra" @@ -76,9 +74,12 @@ func printServiceStatus(cli *CLI, name string) error { if err != nil { return err } - timeout := time.Duration(cli.flags.waitSecs) * time.Second + timeout, err := cli.config.timeout() + if err != nil { + return err + } if timeout > 0 { - log.Printf("Waiting up to %s %s for service to become ready ...", color.CyanString(strconv.Itoa(cli.flags.waitSecs)), color.CyanString("seconds")) + log.Printf("Waiting up to %s for service to become ready ...", color.CyanString(timeout.String())) } s, err := t.Service(name, timeout, 0, "") if err != nil { diff --git a/client/go/cmd/test.go b/client/go/cmd/test.go index acb6231623e..1c452acd7f6 100644 --- a/client/go/cmd/test.go +++ b/client/go/cmd/test.go @@ -77,7 +77,7 @@ func runTests(cli *CLI, rootPath string, dryRun bool) (int, []string, error) { if err != nil { return 0, nil, errHint(err, "See https://docs.vespa.ai/en/reference/testing") } - context := testContext{testsPath: rootPath, dryRun: dryRun, cli: cli, zone: cli.flags.zone} + context := testContext{testsPath: rootPath, dryRun: dryRun, cli: cli} previousFailed := false for _, test := range tests { if !test.IsDir() && filepath.Ext(test.Name()) == ".json" { @@ -469,7 +469,6 @@ type response struct { type testContext struct { cli *CLI - zone string lazyTarget vespa.Target testsPath string dryRun bool diff --git a/client/go/cmd/testutil_test.go b/client/go/cmd/testutil_test.go index e5c69e38e93..26d0369a215 100644 --- a/client/go/cmd/testutil_test.go +++ b/client/go/cmd/testutil_test.go @@ -6,12 +6,10 @@ import ( "path/filepath" "testing" - "github.com/spf13/viper" "github.com/vespa-engine/vespa/client/go/mock" ) func newTestCLI(t *testing.T, envVars ...string) (*CLI, *bytes.Buffer, *bytes.Buffer) { - t.Cleanup(viper.Reset) homeDir := filepath.Join(t.TempDir(), ".vespa") cacheDir := filepath.Join(t.TempDir(), ".cache", "vespa") env := []string{"VESPA_CLI_HOME=" + homeDir, "VESPA_CLI_CACHE_DIR=" + cacheDir} diff --git a/client/go/vespa/application.go b/client/go/vespa/application.go index 7bace69ec52..c03f4d6f644 100644 --- a/client/go/vespa/application.go +++ b/client/go/vespa/application.go @@ -196,7 +196,8 @@ func copyFile(src *zip.File, dst string) error { return err } -// FindApplicationPackage finds the path to an application package from the zip file or directory zipOrDir. +// FindApplicationPackage finds the path to an application package from the zip file or directory zipOrDir. If +// requirePackaging is true, the application package is required to be packaged with mvn package. func FindApplicationPackage(zipOrDir string, requirePackaging bool) (ApplicationPackage, error) { if isZip(zipOrDir) { return ApplicationPackage{Path: zipOrDir}, nil @@ -210,7 +211,7 @@ func FindApplicationPackage(zipOrDir string, requirePackaging bool) (Application return ApplicationPackage{Path: zip}, nil } if requirePackaging { - return ApplicationPackage{}, errors.New("pom.xml exists but no target/application.zip. Run mvn package first") + return ApplicationPackage{}, errors.New("found pom.xml, but target/application.zip does not exist: run 'mvn package' first") } } if path := filepath.Join(zipOrDir, "src", "main", "application"); util.PathExists(path) { -- cgit v1.2.3