diff --git a/cmd/influx/config.go b/cmd/influx/config.go new file mode 100644 index 0000000..52dc183 --- /dev/null +++ b/cmd/influx/config.go @@ -0,0 +1,256 @@ +package main + +import ( + "fmt" + "os" + "path" + + "github.com/influxdata/influx-cli/v2/internal/cmd/config" + iconfig "github.com/influxdata/influx-cli/v2/internal/config" + "github.com/urfave/cli/v2" +) + +var configPathAndPrintFlags = append([]cli.Flag{&configPathFlag}, printFlags...) + +func newConfigCmd() *cli.Command { + return &cli.Command{ + Name: "config", + Usage: "Config management commands", + ArgsUsage: "[config name]", + Description: ` +Providing no argument to the config command will print the active configuration. +When an argument is provided, the active config will be switched to the config with +a name matching that of the argument provided. + +Examples: + # show active config + influx config + + # set active config to previously active config + influx config - + + # set active config + influx config $CONFIG_NAME + +The influx config command displays the active InfluxDB connection configuration and +manages multiple connection configurations stored, by default, in ~/.influxdbv2/configs. +Each connection includes a URL, token, associated organization, and active setting. +InfluxDB reads the token from the active connection configuration, so you don't have +to manually enter a token to log into InfluxDB. + +For information about the config command, see +https://docs.influxdata.com/influxdb/latest/reference/cli/influx/config/ +`, + Before: withCli(), + Flags: configPathAndPrintFlags, + Action: func(ctx *cli.Context) error { + prog := path.Base(os.Args[0]) + if ctx.NArg() > 1 { + return fmt.Errorf("usage: %s config [config name]", prog) + } + client := config.Client{CLI: getCLI(ctx)} + if ctx.NArg() == 1 { + return client.SwitchActive(ctx.Args().Get(0)) + } + return client.PrintActive() + }, + Subcommands: []*cli.Command{ + newConfigCreateCmd(), + newConfigDeleteCmd(), + newConfigUpdateCmd(), + newConfigListCmd(), + }, + } +} + +func newConfigCreateCmd() *cli.Command { + var cfg iconfig.Config + return &cli.Command{ + Name: "create", + Usage: "Create config", + Description: ` +The influx config create command creates a new InfluxDB connection configuration +and stores it in the configs file (by default, stored at ~/.influxdbv2/configs). + +Examples: + # create a config and set it active + influx config create -a -n $CFG_NAME -u $HOST_URL -t $TOKEN -o $ORG_NAME + + # create a config and without setting it active + influx config create -n $CFG_NAME -u $HOST_URL -t $TOKEN -o $ORG_NAME + +For information about the config command, see +https://docs.influxdata.com/influxdb/latest/reference/cli/influx/config/ +and +https://docs.influxdata.com/influxdb/latest/reference/cli/influx/config/create/ +`, + Before: withCli(), + Flags: append( + configPathAndPrintFlags, + &cli.StringFlag{ + Name: "config-name", + Usage: "Name for the new config", + Aliases: []string{"n"}, + Required: true, + Destination: &cfg.Name, + }, + &cli.StringFlag{ + Name: "host-url", + Usage: "Base URL of the InfluxDB server the new config should target", + Aliases: []string{"u"}, + Required: true, + Destination: &cfg.Host, + }, + &cli.StringFlag{ + Name: "token", + Usage: "Auth token to use when communicating with the InfluxDB server", + Aliases: []string{"t"}, + Required: true, + Destination: &cfg.Token, + }, + &cli.StringFlag{ + Name: "org", + Usage: "Default organization name to use in the new config", + Aliases: []string{"o"}, + Destination: &cfg.Org, + }, + &cli.BoolFlag{ + Name: "active", + Usage: "Set the new config as active", + Aliases: []string{"a"}, + Destination: &cfg.Active, + }, + ), + Action: func(ctx *cli.Context) error { + client := config.Client{CLI: getCLI(ctx)} + return client.Create(cfg) + }, + } +} + +func newConfigDeleteCmd() *cli.Command { + return &cli.Command{ + Name: "rm", + Aliases: []string{"delete", "remove"}, + Usage: "Delete config", + ArgsUsage: "[cfg_name]...", + Description: ` +The influx config delete command deletes an InfluxDB connection configuration from +the configs file (by default, stored at ~/.influxdbv2/configs). + +Examples: + # delete a config + influx config rm $CFG_NAME + + # delete multiple configs + influx config rm $CFG_NAME_1 $CFG_NAME_2 + +For information about the config command, see +https://docs.influxdata.com/influxdb/latest/reference/cli/influx/config/ +and +https://docs.influxdata.com/influxdb/latest/reference/cli/influx/config/rm/ +`, + Before: withCli(), + Flags: append([]cli.Flag{&configPathFlag}, printFlags...), + Action: func(ctx *cli.Context) error { + client := config.Client{CLI: getCLI(ctx)} + return client.Delete(ctx.Args().Slice()) + }, + } +} + +func newConfigUpdateCmd() *cli.Command { + var cfg iconfig.Config + return &cli.Command{ + Name: "set", + Aliases: []string{"update"}, + Usage: "Update config", + Description: ` +The influx config set command updates information in an InfluxDB connection +configuration in the configs file (by default, stored at ~/.influxdbv2/configs). + +Examples: + # update a config and set active + influx config set -a -n $CFG_NAME -u $HOST_URL -t $TOKEN -o $ORG_NAME + + # update a config and do not set to active + influx config set -n $CFG_NAME -u $HOST_URL -t $TOKEN -o $ORG_NAME + +For information about the config command, see +https://docs.influxdata.com/influxdb/latest/reference/cli/influx/config/ +and +https://docs.influxdata.com/influxdb/latest/reference/cli/influx/config/set/ +`, + Before: withCli(), + Flags: append( + configPathAndPrintFlags, + &cli.StringFlag{ + Name: "config-name", + Usage: "Name of the config to update", + Aliases: []string{"n"}, + Required: true, + Destination: &cfg.Name, + }, + &cli.StringFlag{ + Name: "host-url", + Usage: "New URL to set on the config", + Aliases: []string{"u"}, + Destination: &cfg.Host, + }, + &cli.StringFlag{ + Name: "token", + Usage: "New auth token to set on the config", + Aliases: []string{"t"}, + Destination: &cfg.Token, + }, + &cli.StringFlag{ + Name: "org", + Usage: "New default organization to set on the config", + Aliases: []string{"o"}, + Destination: &cfg.Org, + }, + &cli.BoolFlag{ + Name: "active", + Usage: "Set the config as active", + Aliases: []string{"a"}, + Destination: &cfg.Active, + }, + ), + Action: func(ctx *cli.Context) error { + client := config.Client{CLI: getCLI(ctx)} + return client.Update(cfg) + }, + } +} + +func newConfigListCmd() *cli.Command { + return &cli.Command{ + Name: "ls", + Aliases: []string{"list"}, + Usage: "List configs", + Description: ` +The influx config ls command lists all InfluxDB connection configurations +in the configs file (by default, stored at ~/.influxdbv2/configs). Each +connection configuration includes a URL, authentication token, and active +setting. An asterisk (*) indicates the active configuration. + +Examples: + # list configs + influx config ls + + # list configs with long alias + influx config list + +For information about the config command, see +https://docs.influxdata.com/influxdb/latest/reference/cli/influx/config/ +and +https://docs.influxdata.com/influxdb/latest/reference/cli/influx/config/list/ +`, + Before: withCli(), + Flags: configPathAndPrintFlags, + Action: func(ctx *cli.Context) error { + client := config.Client{CLI: getCLI(ctx)} + return client.List() + }, + } +} diff --git a/cmd/influx/main.go b/cmd/influx/main.go index dd815dc..548c828 100644 --- a/cmd/influx/main.go +++ b/cmd/influx/main.go @@ -35,15 +35,15 @@ func init() { } var ( - tokenFlag = "token" - hostFlag = "host" - skipVerifyFlag = "skip-verify" - traceIdFlag = "trace-debug-id" - configPathFlag = "configs-path" - configNameFlag = "active-config" - httpDebugFlag = "http-debug" - printJsonFlag = "json" - hideHeadersFlag = "hide-headers" + tokenFlagName = "token" + hostFlagName = "host" + skipVerifyFlagName = "skip-verify" + traceIdFlagName = "trace-debug-id" + configPathFlagName = "configs-path" + configNameFlagName = "active-config" + httpDebugFlagName = "http-debug" + printJsonFlagName = "json" + hideHeadersFlagName = "hide-headers" ) // NOTE: urfave/cli has dedicated support for global flags, but it only parses those flags @@ -53,35 +53,37 @@ var ( // // We replicate the pattern from the old CLI so existing scripts and docs stay valid. -// Flags used by all CLI commands. +var configPathFlag = cli.PathFlag{ + Name: configPathFlagName, + Usage: "Path to the influx CLI configurations", + EnvVars: []string{"INFLUX_CLI_CONFIGS_PATH"}, +} + +// Flags used by all CLI commands that make HTTP requests. var coreFlags = []cli.Flag{ &cli.StringFlag{ - Name: hostFlag, + Name: hostFlagName, Usage: "HTTP address of InfluxDB", EnvVars: []string{"INFLUX_HOST"}, }, &cli.BoolFlag{ - Name: skipVerifyFlag, + Name: skipVerifyFlagName, Usage: "Skip TLS certificate chain and host name verification", }, - &cli.PathFlag{ - Name: configPathFlag, - Usage: "Path to the influx CLI configurations", - EnvVars: []string{"INFLUX_CLI_CONFIGS_PATH"}, - }, + &configPathFlag, &cli.StringFlag{ - Name: configNameFlag, + Name: configNameFlagName, Usage: "Config name to use for command", Aliases: []string{"c"}, EnvVars: []string{"INFLUX_ACTIVE_CONFIG"}, }, &cli.StringFlag{ - Name: traceIdFlag, + Name: traceIdFlagName, Hidden: true, EnvVars: []string{"INFLUX_TRACE_DEBUG_ID"}, }, &cli.BoolFlag{ - Name: httpDebugFlag, + Name: httpDebugFlagName, Hidden: true, }, } @@ -89,12 +91,12 @@ var coreFlags = []cli.Flag{ // Flags used by commands that display API resources to the user. var printFlags = []cli.Flag{ &cli.BoolFlag{ - Name: printJsonFlag, + Name: printJsonFlagName, Usage: "Output data as JSON", EnvVars: []string{"INFLUX_OUTPUT_JSON"}, }, &cli.BoolFlag{ - Name: hideHeadersFlag, + Name: hideHeadersFlagName, Usage: "Hide the table headers in output data", EnvVars: []string{"INFLUX_HIDE_HEADERS"}, }, @@ -102,7 +104,7 @@ var printFlags = []cli.Flag{ // Flag used by commands that hit an authenticated API. var commonTokenFlag = cli.StringFlag{ - Name: tokenFlag, + Name: tokenFlagName, Usage: "Authentication token", Aliases: []string{"t"}, EnvVars: []string{"INFLUX_TOKEN"}, @@ -115,7 +117,7 @@ var commonFlags = append(commonFlagsNoToken, &commonTokenFlag) // newCli builds a CLI core that reads from stdin, writes to stdout/stderr, manages a local config store, // and optionally tracks a trace ID specified over the CLI. func newCli(ctx *cli.Context) (cmd.CLI, error) { - configPath := ctx.String(configPathFlag) + configPath := ctx.String(configPathFlagName) var err error if configPath == "" { configPath, err = config.DefaultPath() @@ -125,8 +127,8 @@ func newCli(ctx *cli.Context) (cmd.CLI, error) { } configSvc := config.NewLocalConfigService(configPath) var activeConfig config.Config - if ctx.IsSet(configNameFlag) { - if activeConfig, err = configSvc.SwitchActive(ctx.String(configNameFlag)); err != nil { + if ctx.IsSet(configNameFlagName) { + if activeConfig, err = configSvc.SwitchActive(ctx.String(configNameFlagName)); err != nil { return cmd.CLI{}, err } } else if activeConfig, err = configSvc.Active(); err != nil { @@ -135,8 +137,8 @@ func newCli(ctx *cli.Context) (cmd.CLI, error) { return cmd.CLI{ StdIO: stdio.TerminalStdio, - PrintAsJSON: ctx.Bool(printJsonFlag), - HideTableHeaders: ctx.Bool(hideHeadersFlag), + PrintAsJSON: ctx.Bool(printJsonFlagName), + HideTableHeaders: ctx.Bool(hideHeadersFlagName), ActiveConfig: activeConfig, ConfigService: configSvc, }, nil @@ -149,11 +151,11 @@ func newApiClient(ctx *cli.Context, configSvc config.Service, injectToken bool) if err != nil { return nil, err } - if ctx.IsSet(tokenFlag) { - cfg.Token = ctx.String(tokenFlag) + if ctx.IsSet(tokenFlagName) { + cfg.Token = ctx.String(tokenFlagName) } - if ctx.IsSet(hostFlag) { - cfg.Host = ctx.String(hostFlag) + if ctx.IsSet(hostFlagName) { + cfg.Host = ctx.String(hostFlagName) } parsedHost, err := url.Parse(cfg.Host) @@ -162,7 +164,7 @@ func newApiClient(ctx *cli.Context, configSvc config.Service, injectToken bool) } clientTransport := http.DefaultTransport.(*http.Transport) - clientTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: ctx.Bool(skipVerifyFlag)} + clientTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: ctx.Bool(skipVerifyFlagName)} apiConfig := api.NewConfiguration() apiConfig.Host = parsedHost.Host @@ -172,16 +174,16 @@ func newApiClient(ctx *cli.Context, configSvc config.Service, injectToken bool) if injectToken { apiConfig.DefaultHeader["Authorization"] = fmt.Sprintf("Token %s", cfg.Token) } - if ctx.IsSet(traceIdFlag) { + if ctx.IsSet(traceIdFlagName) { // NOTE: This is circumventing our codegen. If the header we use for tracing ever changes, // we'll need to manually update the string here to match. // // The alternative is to pass the trace ID to the business logic for every CLI command, and // use codegen'd logic to set the header on every HTTP request. Early versions of the CLI // used that technique, and we found it to be error-prone and easy to forget during testing. - apiConfig.DefaultHeader["Zap-Trace-Span"] = ctx.String(traceIdFlag) + apiConfig.DefaultHeader["Zap-Trace-Span"] = ctx.String(traceIdFlagName) } - apiConfig.Debug = ctx.Bool(httpDebugFlag) + apiConfig.Debug = ctx.Bool(httpDebugFlagName) return api.NewAPIClient(apiConfig), nil } @@ -200,6 +202,7 @@ var app = cli.App{ newCompletionCmd(), newBucketSchemaCmd(), newQueryCmd(), + newConfigCmd(), }, } diff --git a/cmd/influx/setup.go b/cmd/influx/setup.go index a5325f1..fee42f5 100644 --- a/cmd/influx/setup.go +++ b/cmd/influx/setup.go @@ -27,7 +27,7 @@ func newSetupCmd() *cli.Command { Destination: ¶ms.Password, }, &cli.StringFlag{ - Name: tokenFlag, + Name: tokenFlagName, Usage: "Auth token to set on the initial user", Aliases: []string{"t"}, EnvVars: []string{"INFLUX_TOKEN"}, diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go new file mode 100644 index 0000000..41815fa --- /dev/null +++ b/internal/cmd/config/config.go @@ -0,0 +1,150 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + + "github.com/influxdata/influx-cli/v2/internal/api" + "github.com/influxdata/influx-cli/v2/internal/cmd" + "github.com/influxdata/influx-cli/v2/internal/config" +) + +var ErrInvalidHostUrlScheme = errors.New("a scheme of http or https must be provided for host url") + +type Client struct { + cmd.CLI +} + +func (c Client) SwitchActive(name string) error { + cfg, err := c.ConfigService.SwitchActive(name) + if err != nil { + return err + } + return c.printConfigs(configPrintOpts{config: &cfg}) +} + +func (c Client) PrintActive() error { + active, err := c.CLI.ConfigService.Active() + if err != nil { + return err + } + return c.printConfigs(configPrintOpts{config: &active}) +} + +func (c Client) Create(cfg config.Config) error { + name := cfg.Name + validated, err := validateHostUrl(cfg.Host) + if err != nil { + return fmt.Errorf("host URL %q is invalid: %w", cfg.Host, err) + } + cfg.Host = validated + + cfg, err = c.ConfigService.CreateConfig(cfg) + if err != nil { + return fmt.Errorf("failed to create config %q: %w", name, err) + } + return c.printConfigs(configPrintOpts{config: &cfg}) +} + +func (c Client) Delete(names []string) error { + deleted := make(config.Configs) + for _, name := range names { + if name == "" { + continue + } + cfg, err := c.ConfigService.DeleteConfig(name) + if apiErr, ok := err.(*api.Error); ok && apiErr.Code == api.ERRORCODE_NOT_FOUND { + continue + } else if err != nil { + return err + } + deleted[name] = cfg + } + return c.printConfigs(configPrintOpts{configs: deleted, deleted: true}) +} + +func (c Client) Update(cfg config.Config) error { + name := cfg.Name + if cfg.Host != "" { + validated, err := validateHostUrl(cfg.Host) + if err != nil { + return fmt.Errorf("host URL %q is invalid: %w", cfg.Host, err) + } + cfg.Host = validated + } + + cfg, err := c.ConfigService.UpdateConfig(cfg) + if err != nil { + return fmt.Errorf("failed to update config %q: %w", name, err) + } + return c.printConfigs(configPrintOpts{config: &cfg}) +} + +func (c Client) List() error { + cfgs, err := c.ConfigService.ListConfigs() + if err != nil { + return err + } + return c.printConfigs(configPrintOpts{configs: cfgs}) +} + +type configPrintOpts struct { + deleted bool + config *config.Config + configs config.Configs +} + +func (c Client) printConfigs(opts configPrintOpts) error { + if c.PrintAsJSON { + var v interface{} + if opts.config != nil { + v = opts.config + } else { + v = opts.configs + } + return c.PrintJSON(v) + } + + headers := []string{"Active", "Name", "URL", "Org"} + if opts.deleted { + headers = append(headers, "Deleted") + } + + if opts.config != nil { + opts.configs = config.Configs{ + opts.config.Name: *opts.config, + } + } + + var rows []map[string]interface{} + for _, c := range opts.configs { + var active string + if c.Active { + active = "*" + } + row := map[string]interface{}{ + "Active": active, + "Name": c.Name, + "URL": c.Host, + "Org": c.Org, + } + if opts.deleted { + row["Deleted"] = true + } + rows = append(rows, row) + } + + return c.PrintTable(headers, rows...) +} + +func validateHostUrl(hostUrl string) (string, error) { + u, err := url.Parse(hostUrl) + if err != nil { + return "", err + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", ErrInvalidHostUrlScheme + } + return u.String(), nil +} diff --git a/internal/cmd/config/config_test.go b/internal/cmd/config/config_test.go new file mode 100644 index 0000000..4bbe293 --- /dev/null +++ b/internal/cmd/config/config_test.go @@ -0,0 +1,247 @@ +package config_test + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/influxdata/influx-cli/v2/internal/api" + "github.com/influxdata/influx-cli/v2/internal/cmd" + "github.com/influxdata/influx-cli/v2/internal/cmd/config" + iconfig "github.com/influxdata/influx-cli/v2/internal/config" + "github.com/influxdata/influx-cli/v2/internal/mock" + "github.com/influxdata/influx-cli/v2/internal/testutils" + "github.com/stretchr/testify/require" +) + +func TestClient_SwitchActive(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + stdio := mock.NewMockStdIO(ctrl) + writtenBytes := bytes.Buffer{} + stdio.EXPECT().Write(gomock.Any()).DoAndReturn(writtenBytes.Write).AnyTimes() + + name := "foo" + cfg := iconfig.Config{ + Name: name, + Active: true, + Host: "http://localhost:8086", + Token: "supersecret", + Org: "me", + } + svc := mock.NewMockConfigService(ctrl) + svc.EXPECT().SwitchActive(gomock.Eq(name)).Return(cfg, nil) + + cli := config.Client{CLI: cmd.CLI{ConfigService: svc, StdIO: stdio}} + require.NoError(t, cli.SwitchActive(name)) + testutils.MatchLines(t, []string{ + `Active\s+Name\s+URL\s+Org`, + fmt.Sprintf(`\*\s+%s\s+%s\s+%s`, cfg.Name, cfg.Host, cfg.Org), + }, strings.Split(writtenBytes.String(), "\n")) +} + +func TestClient_PrintActive(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + stdio := mock.NewMockStdIO(ctrl) + writtenBytes := bytes.Buffer{} + stdio.EXPECT().Write(gomock.Any()).DoAndReturn(writtenBytes.Write).AnyTimes() + + cfg := iconfig.Config{ + Name: "foo", + Active: true, + Host: "http://localhost:8086", + Token: "supersecret", + Org: "me", + } + svc := mock.NewMockConfigService(ctrl) + svc.EXPECT().Active().Return(cfg, nil) + + cli := config.Client{CLI: cmd.CLI{ConfigService: svc, StdIO: stdio}} + require.NoError(t, cli.PrintActive()) + testutils.MatchLines(t, []string{ + `Active\s+Name\s+URL\s+Org`, + fmt.Sprintf(`\*\s+%s\s+%s\s+%s`, cfg.Name, cfg.Host, cfg.Org), + }, strings.Split(writtenBytes.String(), "\n")) +} + +func TestClient_Create(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + stdio := mock.NewMockStdIO(ctrl) + writtenBytes := bytes.Buffer{} + stdio.EXPECT().Write(gomock.Any()).DoAndReturn(writtenBytes.Write).AnyTimes() + + cfg := iconfig.Config{ + Name: "foo", + Active: true, + Host: "http://localhost:8086", + Token: "supersecret", + Org: "me", + } + svc := mock.NewMockConfigService(ctrl) + svc.EXPECT().CreateConfig(cfg).Return(cfg, nil) + + cli := config.Client{CLI: cmd.CLI{ConfigService: svc, StdIO: stdio}} + require.NoError(t, cli.Create(cfg)) + testutils.MatchLines(t, []string{ + `Active\s+Name\s+URL\s+Org`, + fmt.Sprintf(`\*\s+%s\s+%s\s+%s`, cfg.Name, cfg.Host, cfg.Org), + }, strings.Split(writtenBytes.String(), "\n")) +} + +func TestClient_Delete(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + in []string + registerExpectations func(service *mock.MockConfigService) + out []string + }{ + { + name: "empty", + }, + { + name: "one", + in: []string{"foo"}, + registerExpectations: func(svc *mock.MockConfigService) { + svc.EXPECT().DeleteConfig(gomock.Eq("foo")). + Return(iconfig.Config{Name: "foo", Host: "bar", Org: "baz"}, nil) + }, + out: []string{`^\s+foo\s+bar\s+baz\s+true`}, + }, + { + name: "many", + in: []string{"foo", "qux", "wibble"}, + registerExpectations: func(svc *mock.MockConfigService) { + svc.EXPECT().DeleteConfig(gomock.Eq("foo")). + Return(iconfig.Config{Name: "foo", Host: "bar", Org: "baz"}, nil) + svc.EXPECT().DeleteConfig(gomock.Eq("qux")). + Return(iconfig.Config{}, &api.Error{Code: api.ERRORCODE_NOT_FOUND}) + svc.EXPECT().DeleteConfig(gomock.Eq("wibble")). + Return(iconfig.Config{Name: "wibble", Host: "bar", Active: true}, nil) + }, + out: []string{ + `^\s+foo\s+bar\s+baz\s+true`, + `\*\s+wibble\s+bar\s+true`, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + stdio := mock.NewMockStdIO(ctrl) + writtenBytes := bytes.Buffer{} + stdio.EXPECT().Write(gomock.Any()).DoAndReturn(writtenBytes.Write).AnyTimes() + + svc := mock.NewMockConfigService(ctrl) + if tc.registerExpectations != nil { + tc.registerExpectations(svc) + } + + cli := config.Client{CLI: cmd.CLI{ConfigService: svc, StdIO: stdio}} + require.NoError(t, cli.Delete(tc.in)) + testutils.MatchLines(t, + append([]string{`Active\s+Name\s+URL\s+Org\s+Deleted`}, tc.out...), + strings.Split(writtenBytes.String(), "\n")) + }) + } +} + +func TestClient_Update(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + stdio := mock.NewMockStdIO(ctrl) + writtenBytes := bytes.Buffer{} + stdio.EXPECT().Write(gomock.Any()).DoAndReturn(writtenBytes.Write).AnyTimes() + + updates := iconfig.Config{ + Name: "foo", + Active: true, + Token: "doublesecret", + } + cfg := iconfig.Config{ + Name: updates.Name, + Active: updates.Active, + Host: "http://localhost:8086", + Token: updates.Token, + Org: "me", + } + svc := mock.NewMockConfigService(ctrl) + svc.EXPECT().UpdateConfig(updates).Return(cfg, nil) + + cli := config.Client{CLI: cmd.CLI{ConfigService: svc, StdIO: stdio}} + require.NoError(t, cli.Update(updates)) + testutils.MatchLines(t, []string{ + `Active\s+Name\s+URL\s+Org`, + fmt.Sprintf(`\*\s+%s\s+%s\s+%s`, cfg.Name, cfg.Host, cfg.Org), + }, strings.Split(writtenBytes.String(), "\n")) +} + +func TestClient_List(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + cfgs iconfig.Configs + expected []string + }{ + { + name: "empty", + }, + { + name: "one", + cfgs: iconfig.Configs{ + "foo": iconfig.Config{Name: "foo", Host: "bar", Org: "baz"}, + }, + expected: []string{`\s+foo\s+bar\s+baz`}, + }, + { + name: "many", + cfgs: iconfig.Configs{ + "foo": iconfig.Config{Name: "foo", Host: "bar", Org: "baz"}, + "wibble": iconfig.Config{Name: "wibble", Host: "bar", Active: true}, + }, + expected: []string{ + `\s+foo\s+bar\s+baz`, + `\*\s+wibble\s+bar`, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + stdio := mock.NewMockStdIO(ctrl) + writtenBytes := bytes.Buffer{} + stdio.EXPECT().Write(gomock.Any()).DoAndReturn(writtenBytes.Write).AnyTimes() + + svc := mock.NewMockConfigService(ctrl) + svc.EXPECT().ListConfigs().Return(tc.cfgs, nil) + + cli := config.Client{CLI: cmd.CLI{ConfigService: svc, StdIO: stdio}} + require.NoError(t, cli.List()) + + // Can't use our usual 'MatchLines' because list output depends on map iteration, + // so the order isn't well-defined. + out := writtenBytes.String() + for _, l := range append([]string{`Active\s+Name\s+URL\s+Org`}, tc.expected...) { + require.Regexp(t, l, out) + } + }) + } +}