diff --git a/CHANGELOG.md b/CHANGELOG.md index d159fa2bde..dc6076524c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 1. [16765](https://github.com/influxdata/influxdb/pull/16765): Extend influx cli pkg command with ability to take multiple files and directories 1. [16767](https://github.com/influxdata/influxdb/pull/16767): Extend influx cli pkg command with ability to take multiple urls, files, directories, and stdin at the same time +1. [16786](https://github.com/influxdata/influxdb/pull/16786): influx cli can manage secrets. ### Bug Fixes diff --git a/cmd/influx/bucket.go b/cmd/influx/bucket.go index 0149142c6a..180958f020 100644 --- a/cmd/influx/bucket.go +++ b/cmd/influx/bucket.go @@ -7,7 +7,6 @@ import ( "time" "github.com/influxdata/influxdb" - "github.com/influxdata/influxdb/cmd/influx/internal" "github.com/influxdata/influxdb/http" "github.com/spf13/cobra" ) @@ -108,7 +107,7 @@ func (b *cmdBucketBuilder) cmdCreateRunEFn(*cobra.Command, []string) error { return fmt.Errorf("failed to create bucket: %v", err) } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders("ID", "Name", "Retention", "OrganizationID") w.Write(map[string]interface{}{ "ID": bkt.ID.String(), @@ -152,7 +151,7 @@ func (b *cmdBucketBuilder) cmdDeleteRunEFn(cmd *cobra.Command, args []string) er return fmt.Errorf("failed to delete bucket with id %q: %v", id, err) } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders("ID", "Name", "Retention", "OrganizationID", "Deleted") w.Write(map[string]interface{}{ "ID": bkt.ID.String(), @@ -225,7 +224,7 @@ func (b *cmdBucketBuilder) cmdFindRunEFn(cmd *cobra.Command, args []string) erro return fmt.Errorf("failed to retrieve buckets: %s", err) } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.HideHeaders(!b.headers) w.WriteHeaders("ID", "Name", "Retention", "OrganizationID") for _, b := range buckets { @@ -291,7 +290,7 @@ func (b *cmdBucketBuilder) cmdUpdateRunEFn(cmd *cobra.Command, args []string) er return fmt.Errorf("failed to update bucket: %v", err) } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders("ID", "Name", "Retention", "OrganizationID") w.Write(map[string]interface{}{ "ID": bkt.ID.String(), diff --git a/cmd/influx/internal/tabwriter.go b/cmd/influx/internal/tabwriter.go index a921d28a53..85272b9048 100644 --- a/cmd/influx/internal/tabwriter.go +++ b/cmd/influx/internal/tabwriter.go @@ -9,30 +9,35 @@ import ( platform "github.com/influxdata/influxdb" ) -type tabWriter struct { +// TabWriter wraps tab writer headers logic. +type TabWriter struct { writer *tabwriter.Writer headers []string hideHeaders bool } -func NewTabWriter(w io.Writer) *tabWriter { - return &tabWriter{ +// NewTabWriter creates a new tab writer. +func NewTabWriter(w io.Writer) *TabWriter { + return &TabWriter{ writer: tabwriter.NewWriter(w, 0, 8, 1, '\t', 0), } } -func (w *tabWriter) HideHeaders(b bool) { +// HideHeaders will set the hideHeaders flag. +func (w *TabWriter) HideHeaders(b bool) { w.hideHeaders = b } -func (w *tabWriter) WriteHeaders(h ...string) { +// WriteHeaders will write headers. +func (w *TabWriter) WriteHeaders(h ...string) { w.headers = h if !w.hideHeaders { fmt.Fprintln(w.writer, strings.Join(h, "\t")) } } -func (w *tabWriter) Write(m map[string]interface{}) { +// Write will write the map into embed tab writer. +func (w *TabWriter) Write(m map[string]interface{}) { body := make([]interface{}, len(w.headers)) types := make([]string, len(w.headers)) for i, h := range w.headers { @@ -45,7 +50,11 @@ func (w *tabWriter) Write(m map[string]interface{}) { fmt.Fprintf(w.writer, formatString+"\n", body...) } -func (w *tabWriter) Flush() { +// Flush should be called after the last call to Write to ensure +// that any data buffered in the Writer is written to output. Any +// incomplete escape sequence at the end is considered +// complete for formatting purposes. +func (w *TabWriter) Flush() { w.writer.Flush() } diff --git a/cmd/influx/main.go b/cmd/influx/main.go index e0a0edd2a8..3f3ad99da2 100644 --- a/cmd/influx/main.go +++ b/cmd/influx/main.go @@ -75,6 +75,10 @@ func (o genericCLIOpts) newCmd(use string, runE func(*cobra.Command, []string) e return cmd } +func (o genericCLIOpts) newTabWriter() *internal.TabWriter { + return internal.NewTabWriter(o.w) +} + func in(r io.Reader) genericCLIOptFn { return func(o *genericCLIOpts) { o.in = r @@ -128,6 +132,7 @@ func influxCmd(opts ...genericCLIOptFn) *cobra.Command { cmdQuery(), cmdTranspile(), cmdREPL(), + cmdSecret(runEWrapper), cmdSetup(), cmdTask(), cmdUser(runEWrapper), diff --git a/cmd/influx/organization.go b/cmd/influx/organization.go index 430d6ff27c..ecc7bfeecb 100644 --- a/cmd/influx/organization.go +++ b/cmd/influx/organization.go @@ -9,7 +9,6 @@ import ( "github.com/influxdata/influxdb/http" "github.com/influxdata/influxdb" - "github.com/influxdata/influxdb/cmd/influx/internal" "github.com/spf13/cobra" ) @@ -88,7 +87,7 @@ func (b *cmdOrgBuilder) createRunEFn(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create organization: %v", err) } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders("ID", "Name") w.Write(map[string]interface{}{ "ID": org.ID.String(), @@ -138,7 +137,7 @@ func (b *cmdOrgBuilder) deleteRunEFn(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to delete org with id %q: %v", id, err) } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders("ID", "Name", "Deleted") w.Write(map[string]interface{}{ "ID": o.ID.String(), @@ -199,7 +198,7 @@ func (b *cmdOrgBuilder) findRunEFn(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed find orgs: %v", err) } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders("ID", "Name") for _, o := range orgs { w.Write(map[string]interface{}{ @@ -269,7 +268,7 @@ func (b *cmdOrgBuilder) updateRunEFn(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to update org: %v", err) } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders("ID", "Name") w.Write(map[string]interface{}{ "ID": o.ID.String(), @@ -348,7 +347,7 @@ func (b *cmdOrgBuilder) memberListRunEFn(cmd *cobra.Command, args []string) erro } ctx := context.Background() - return memberList(ctx, b.w, urmSVC, userSVC, influxdb.UserResourceMappingFilter{ + return memberList(ctx, b, urmSVC, userSVC, influxdb.UserResourceMappingFilter{ ResourceType: influxdb.OrgsResourceType, ResourceID: organization.ID, UserType: influxdb.Member, @@ -538,7 +537,7 @@ func newOrganizationService() (influxdb.OrganizationService, error) { }, nil } -func memberList(ctx context.Context, w io.Writer, urmSVC influxdb.UserResourceMappingService, userSVC influxdb.UserService, f influxdb.UserResourceMappingFilter) error { +func memberList(ctx context.Context, b *cmdOrgBuilder, urmSVC influxdb.UserResourceMappingService, userSVC influxdb.UserService, f influxdb.UserResourceMappingFilter) error { mps, _, err := urmSVC.FindUserResourceMappings(ctx, f) if err != nil { return fmt.Errorf("failed to find members: %v", err) @@ -582,7 +581,7 @@ func memberList(ctx context.Context, w io.Writer, urmSVC influxdb.UserResourceMa } } - tw := internal.NewTabWriter(w) + tw := b.newTabWriter() tw.WriteHeaders("ID", "Name", "Status") for _, m := range urs { tw.Write(map[string]interface{}{ diff --git a/cmd/influx/secret.go b/cmd/influx/secret.go new file mode 100644 index 0000000000..3cf4409752 --- /dev/null +++ b/cmd/influx/secret.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/influxdata/influxdb" + "github.com/influxdata/influxdb/http" + "github.com/spf13/cobra" + input "github.com/tcnksm/go-input" +) + +type secretSVCsFn func() (influxdb.SecretService, influxdb.OrganizationService, func(*input.UI) string, error) + +func cmdSecret(opts ...genericCLIOptFn) *cobra.Command { + return newCmdSecretBuilder(newSecretSVCs, opts...).cmd() +} + +type cmdSecretBuilder struct { + genericCLIOpts + + svcFn secretSVCsFn + + key string + org organization +} + +func newCmdSecretBuilder(svcsFn secretSVCsFn, opts ...genericCLIOptFn) *cmdSecretBuilder { + opt := genericCLIOpts{ + in: os.Stdin, + w: os.Stdout, + } + for _, o := range opts { + o(&opt) + } + + return &cmdSecretBuilder{ + genericCLIOpts: opt, + svcFn: svcsFn, + } +} + +func (b *cmdSecretBuilder) cmd() *cobra.Command { + cmd := b.newCmd("secret", nil) + cmd.Short = "Secret management commands" + cmd.Run = seeHelp + cmd.AddCommand( + b.cmdDelete(), + b.cmdFind(), + b.cmdUpdate(), + ) + return cmd +} + +func (b *cmdSecretBuilder) cmdUpdate() *cobra.Command { + cmd := b.newCmd("update", b.cmdUpdateRunEFn) + cmd.Short = "Update secret" + cmd.Flags().StringVarP(&b.key, "key", "k", "", "The secret key (required)") + cmd.MarkFlagRequired("key") + b.org.register(cmd, false) + + return cmd +} + +func (b *cmdSecretBuilder) cmdDelete() *cobra.Command { + cmd := b.newCmd("delete", b.cmdDeleteRunEFn) + cmd.Short = "Delete secret" + + cmd.Flags().StringVarP(&b.key, "key", "k", "", "The secret key (required)") + cmd.MarkFlagRequired("key") + b.org.register(cmd, false) + + return cmd +} + +func (b *cmdSecretBuilder) cmdUpdateRunEFn(cmd *cobra.Command, args []string) error { + scrSVC, orgSVC, getSecretFn, err := b.svcFn() + if err != nil { + return err + } + orgID, err := b.org.getID(orgSVC) + if err != nil { + return err + } + + ctx := context.Background() + + ui := &input.UI{ + Writer: b.genericCLIOpts.w, + Reader: b.genericCLIOpts.in, + } + secret := getSecretFn(ui) + + if err := scrSVC.PatchSecrets(ctx, orgID, map[string]string{b.key: secret}); err != nil { + return fmt.Errorf("failed to update secret with key %q: %v", b.key, err) + } + + w := b.newTabWriter() + w.WriteHeaders("Key", "OrgID", "Updated") + w.Write(map[string]interface{}{ + "Key": b.key, + "OrgID": orgID, + "Updated": true, + }) + w.Flush() + + return nil +} + +func (b *cmdSecretBuilder) cmdDeleteRunEFn(cmd *cobra.Command, args []string) error { + scrSVC, orgSVC, _, err := b.svcFn() + if err != nil { + return err + } + orgID, err := b.org.getID(orgSVC) + if err != nil { + return err + } + + ctx := context.Background() + if err := scrSVC.DeleteSecret(ctx, orgID, b.key); err != nil { + return fmt.Errorf("failed to delete secret with key %q: %v", b.key, err) + } + + w := b.newTabWriter() + w.WriteHeaders("Key", "OrgID", "Deleted") + w.Write(map[string]interface{}{ + "Key": b.key, + "OrgID": orgID, + "Deleted": true, + }) + w.Flush() + + return nil +} + +func (b *cmdSecretBuilder) cmdFind() *cobra.Command { + cmd := b.newCmd("find", b.cmdFindRunEFn) + cmd.Short = "Find secrets" + b.org.register(cmd, false) + + return cmd +} + +func (b *cmdSecretBuilder) cmdFindRunEFn(cmd *cobra.Command, args []string) error { + + scrSVC, orgSVC, _, err := b.svcFn() + if err != nil { + return err + } + + orgID, err := b.org.getID(orgSVC) + if err != nil { + return err + } + + secrets, err := scrSVC.GetSecretKeys(context.Background(), orgID) + if err != nil { + return fmt.Errorf("failed to retrieve secret keys: %s", err) + } + + w := b.newTabWriter() + w.WriteHeaders("Key", "OrganizationID") + for _, s := range secrets { + w.Write(map[string]interface{}{ + "Key": s, + "OrganizationID": orgID, + }) + } + w.Flush() + + return nil +} + +func newSecretSVCs() (influxdb.SecretService, influxdb.OrganizationService, func(*input.UI) string, error) { + httpClient, err := newHTTPClient() + if err != nil { + return nil, nil, nil, err + } + orgSvc := &http.OrganizationService{Client: httpClient} + + return &http.SecretService{Client: httpClient}, orgSvc, getSecret, nil +} diff --git a/cmd/influx/secret_test.go b/cmd/influx/secret_test.go new file mode 100644 index 0000000000..d74f86f8ee --- /dev/null +++ b/cmd/influx/secret_test.go @@ -0,0 +1,196 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "testing" + + "github.com/influxdata/influxdb" + "github.com/influxdata/influxdb/mock" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + input "github.com/tcnksm/go-input" +) + +func TestCmdSecret(t *testing.T) { + setViperOptions() + + orgID := influxdb.ID(9000) + + fakeSVCFn := func(svc influxdb.SecretService, fn func(*input.UI) string) secretSVCsFn { + return func() (influxdb.SecretService, influxdb.OrganizationService, func(*input.UI) string, error) { + return svc, &mock.OrganizationService{ + FindOrganizationF: func(ctx context.Context, filter influxdb.OrganizationFilter) (*influxdb.Organization, error) { + return &influxdb.Organization{ID: orgID, Name: "influxdata"}, nil + }, + }, fn, nil + } + } + + t.Run("find", func(t *testing.T) { + type called []string + tests := []struct { + name string + expected called + flags []string + envVars map[string]string + }{ + { + name: "org id", + flags: []string{"--org-id=" + influxdb.ID(3).String()}, + envVars: envVarsZeroMap, + expected: called{"k1", "k2", "k3"}, + }, + { + name: "org", + flags: []string{"--org=rg"}, + envVars: envVarsZeroMap, + expected: called{"k1", "k2", "k3"}, + }, + { + name: "env vars", + envVars: map[string]string{ + "INFLUX_ORG": "rg", + }, + flags: []string{}, + expected: called{"k1", "k2", "k3"}, + }, + } + + cmdFn := func() (*cobra.Command, *called) { + calls := new(called) + svc := mock.NewSecretService() + svc.GetSecretKeysFn = func(ctx context.Context, organizationID influxdb.ID) ([]string, error) { + if !organizationID.Valid() { + return []string{}, nil + } + *calls = []string{"k1", "k2", "k3"} + return []string{}, nil + } + + builder := newCmdSecretBuilder(fakeSVCFn(svc, nil), in(new(bytes.Buffer)), out(ioutil.Discard)) + cmd := builder.cmdFind() + cmd.RunE = builder.cmdFindRunEFn + return cmd, calls + } + + for _, tt := range tests { + fn := func(t *testing.T) { + defer addEnvVars(t, tt.envVars)() + + cmd, calls := cmdFn() + cmd.SetArgs(tt.flags) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, tt.expected, *calls) + } + + t.Run(tt.name, fn) + } + }) + + t.Run("delete", func(t *testing.T) { + tests := []struct { + name string + expectedKey string + flags []string + }{ + { + name: "with key", + expectedKey: "key1", + flags: []string{ + "--org=org name", "--key=key1", + }, + }, + { + name: "shorts", + expectedKey: "key1", + flags: []string{"-o=" + orgID.String(), "-k=key1"}, + }, + } + + cmdFn := func(expectedKey string) *cobra.Command { + svc := mock.NewSecretService() + svc.DeleteSecretFn = func(ctx context.Context, orgID influxdb.ID, ks ...string) error { + if expectedKey != ks[0] { + return fmt.Errorf("unexpected id:\n\twant= %s\n\tgot= %s", expectedKey, ks[0]) + } + return nil + } + + builder := newCmdSecretBuilder(fakeSVCFn(svc, nil), out(ioutil.Discard)) + cmd := builder.cmdDelete() + cmd.RunE = builder.cmdDeleteRunEFn + return cmd + } + + for _, tt := range tests { + fn := func(t *testing.T) { + cmd := cmdFn(tt.expectedKey) + cmd.SetArgs(tt.flags) + require.NoError(t, cmd.Execute()) + } + + t.Run(tt.name, fn) + } + }) + + t.Run("update", func(t *testing.T) { + tests := []struct { + name string + expectedKey string + flags []string + }{ + { + name: "with key", + expectedKey: "key1", + flags: []string{ + "--org=org name", "--key=key1", + }, + }, + { + name: "shorts", + expectedKey: "key1", + flags: []string{"-o=" + orgID.String(), "-k=key1"}, + }, + } + + cmdFn := func(expectedKey string) *cobra.Command { + svc := mock.NewSecretService() + svc.PatchSecretsFn = func(ctx context.Context, orgID influxdb.ID, m map[string]string) error { + var key string + for k := range m { + key = k + break + } + if expectedKey != key { + return fmt.Errorf("unexpected id:\n\twant= %s\n\tgot= %s", expectedKey, key) + } + return nil + } + + getSctFn := func(*input.UI) string { + return "ss" + } + + builder := newCmdSecretBuilder(fakeSVCFn(svc, getSctFn), out(ioutil.Discard)) + cmd := builder.cmdUpdate() + cmd.RunE = builder.cmdUpdateRunEFn + return cmd + } + + for _, tt := range tests { + fn := func(t *testing.T) { + cmd := cmdFn(tt.expectedKey) + cmd.SetArgs(tt.flags) + require.NoError(t, cmd.Execute()) + } + + t.Run(tt.name, fn) + } + }) + +} diff --git a/cmd/influx/setup.go b/cmd/influx/setup.go index 8d04240e32..a2abc63572 100644 --- a/cmd/influx/setup.go +++ b/cmd/influx/setup.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/influxdata/influxdb" platform "github.com/influxdata/influxdb" "github.com/influxdata/influxdb/cmd/influx/internal" "github.com/influxdata/influxdb/http" @@ -236,28 +237,42 @@ You have entered: } } -var ( - errPasswordIsNotMatch = fmt.Errorf("passwords do not match") - errPasswordIsTooShort = fmt.Errorf("passwords is too short") -) +func errSecretIsNotMatch(title string) error { + return fmt.Errorf(title + "s do not match") +} + +func errSecretIsTooShort(title string) error { + return &influxdb.Error{ + Code: influxdb.EUnprocessableEntity, + Msg: title + " is too short", + } +} + +func getSecret(ui *input.UI) (secret string) { + return getSecretInput(ui, false, "secret") +} func getPassword(ui *input.UI, showNew bool) (password string) { + return getSecretInput(ui, showNew, "password") +} + +func getSecretInput(ui *input.UI, showNew bool, title string) (secret string) { newStr := "" if showNew { newStr = " new" } var err error -enterPasswd: - query := string(promptWithColor("Please type your"+newStr+" password", colorCyan)) +enterSecret: + query := string(promptWithColor("Please type your"+newStr+" "+title, colorCyan)) for { - password, err = ui.Ask(query, &input.Options{ + secret, err = ui.Ask(query, &input.Options{ Required: true, HideOrder: true, Hide: true, Mask: false, ValidateFunc: func(s string) error { if len(s) < 8 { - return errPasswordIsTooShort + return errSecretIsTooShort(title) } return nil }, @@ -265,25 +280,25 @@ enterPasswd: switch err { case input.ErrInterrupted: os.Exit(1) - case errPasswordIsTooShort: - ui.Writer.Write(promptWithColor("Password too short - minimum length is 8 characters!", colorRed)) - goto enterPasswd default: - if password = strings.TrimSpace(password); password == "" { + if influxdb.ErrorCode(err) == influxdb.EUnprocessableEntity { + ui.Writer.Write(promptWithColor(strings.ToTitle(title)+" too short - minimum length is 8 characters!\n\r", colorRed)) + goto enterSecret + } else if secret = strings.TrimSpace(secret); secret == "" { continue } } break } - query = string(promptWithColor("Please type your"+newStr+" password again", colorCyan)) + query = string(promptWithColor("Please type your"+newStr+" "+title+" again", colorCyan)) for { _, err = ui.Ask(query, &input.Options{ Required: true, HideOrder: true, Hide: true, ValidateFunc: func(s string) error { - if s != password { - return errPasswordIsNotMatch + if s != secret { + return errSecretIsNotMatch(title) } return nil }, @@ -294,12 +309,12 @@ enterPasswd: case nil: // Nothing. default: - ui.Writer.Write(promptWithColor("Passwords do not match!\n", colorRed)) - goto enterPasswd + ui.Writer.Write(promptWithColor(strings.ToTitle(title)+"s do not match!\n", colorRed)) + goto enterSecret } break } - return password + return secret } func getInput(ui *input.UI, prompt, defaultValue string) string { diff --git a/cmd/influx/user.go b/cmd/influx/user.go index 927f710c7d..8589b363bf 100644 --- a/cmd/influx/user.go +++ b/cmd/influx/user.go @@ -7,7 +7,6 @@ import ( "os" "github.com/influxdata/influxdb" - "github.com/influxdata/influxdb/cmd/influx/internal" "github.com/influxdata/influxdb/http" "github.com/spf13/cobra" input "github.com/tcnksm/go-input" @@ -184,7 +183,7 @@ func (b *cmdUserBuilder) cmdUpdateRunEFn(cmd *cobra.Command, args []string) erro return err } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders( "ID", "Name", @@ -247,7 +246,7 @@ func (b *cmdUserBuilder) cmdCreateRunEFn(*cobra.Command, []string) error { for i, h := range headers { m[h] = vals[i] } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders(headers...) w.Write(m) w.Flush() @@ -319,7 +318,7 @@ func (b *cmdUserBuilder) cmdFindRunEFn(*cobra.Command, []string) error { return err } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders( "ID", "Name", @@ -366,7 +365,7 @@ func (b *cmdUserBuilder) cmdDeleteRunEFn(cmd *cobra.Command, args []string) erro return err } - w := internal.NewTabWriter(b.w) + w := b.newTabWriter() w.WriteHeaders( "ID", "Name",