From 4af26ca7e51de889b09956dad24782c4dfbe9528 Mon Sep 17 00:00:00 2001 From: fangming-ning-sp <42616890+fangming-ning-sp@users.noreply.github.com> Date: Thu, 7 Sep 2023 11:20:40 -0500 Subject: [PATCH] PLTCONN-3577: Link & unlink and instance list --- cmd/connector/conn.go | 1 + .../conn_invoke_change_password_test.go | 6 +- cmd/connector/conn_invoke_test.go | 2 +- cmd/connector/conn_test.go | 2 +- cmd/connector/customizer.go | 2 + cmd/connector/customizer_link.go | 71 +++++++++++++++++++ cmd/connector/customizer_unlink.go | 66 +++++++++++++++++ cmd/connector/instance.go | 25 +++++++ cmd/connector/instance_list.go | 55 ++++++++++++++ cmd/connector/models.go | 12 ++++ internal/client/client.go | 30 ++++++++ internal/mocks/client.go | 15 ++++ internal/mocks/terminal.go | 28 ++++---- 13 files changed, 296 insertions(+), 19 deletions(-) create mode 100644 cmd/connector/customizer_link.go create mode 100644 cmd/connector/customizer_unlink.go create mode 100644 cmd/connector/instance.go create mode 100644 cmd/connector/instance_list.go diff --git a/cmd/connector/conn.go b/cmd/connector/conn.go index 229e1aa..20f7350 100644 --- a/cmd/connector/conn.go +++ b/cmd/connector/conn.go @@ -56,6 +56,7 @@ func NewConnCmd(term terminal.Terminal) *cobra.Command { newConnStatsCmd(Client), newConnDeleteCmd(Client), newConnCustomizersCmd(Client), + newConnInstancesCmd(Client), ) return conn diff --git a/cmd/connector/conn_invoke_change_password_test.go b/cmd/connector/conn_invoke_change_password_test.go index eb5ca01..12a8e4a 100644 --- a/cmd/connector/conn_invoke_change_password_test.go +++ b/cmd/connector/conn_invoke_change_password_test.go @@ -16,7 +16,7 @@ func TestChangePasswordWithoutInput(t *testing.T) { defer ctrl.Finish() client := mocks.NewMockClient(ctrl) - term := mocks.NewMockTerm(ctrl) + term := mocks.NewMockTerminal(ctrl) cmd := newConnInvokeChangePasswordCmd(client, term) addRequiredFlagsFromParentCmd(cmd) @@ -43,7 +43,7 @@ func TestChangePasswordWithIdentityAndPassword(t *testing.T) { Post(gomock.Any(), gomock.Any(), "application/json", bytes.NewReader([]byte(i))). Return(&http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte("{}")))}, nil) - term := mocks.NewMockTerm(ctrl) + term := mocks.NewMockTerminal(ctrl) term.EXPECT(). PromptPassword(gomock.Any()). Return("password", nil) @@ -73,7 +73,7 @@ func TestChangePasswordWithIdentityAndPasswordAndUniqueId(t *testing.T) { Post(gomock.Any(), gomock.Any(), "application/json", bytes.NewReader([]byte(i))). Return(&http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte("{}")))}, nil) - term := mocks.NewMockTerm(ctrl) + term := mocks.NewMockTerminal(ctrl) term.EXPECT(). PromptPassword(gomock.Any()). Return("password", nil) diff --git a/cmd/connector/conn_invoke_test.go b/cmd/connector/conn_invoke_test.go index ac0661d..5641417 100644 --- a/cmd/connector/conn_invoke_test.go +++ b/cmd/connector/conn_invoke_test.go @@ -24,7 +24,7 @@ func TestNewConnInvokeCmd_noArgs(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - cmd := newConnInvokeCmd(mocks.NewMockClient(ctrl), mocks.NewMockTerm(ctrl)) + cmd := newConnInvokeCmd(mocks.NewMockClient(ctrl), mocks.NewMockTerminal(ctrl)) if len(cmd.Commands()) != numConnInvokeSubcommands { t.Fatalf("expected: %d, actual: %d", len(cmd.Commands()), numConnInvokeSubcommands) } diff --git a/cmd/connector/conn_test.go b/cmd/connector/conn_test.go index de67955..01aebd3 100644 --- a/cmd/connector/conn_test.go +++ b/cmd/connector/conn_test.go @@ -33,7 +33,7 @@ func TestNewConnCmd_noArgs(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - cmd := NewConnCmd(mocks.NewMockTerm(ctrl)) + cmd := NewConnCmd(mocks.NewMockTerminal(ctrl)) if len(cmd.Commands()) != numConnSubcommands { t.Fatalf("expected: %d, actual: %d", len(cmd.Commands()), numConnSubcommands) } diff --git a/cmd/connector/customizer.go b/cmd/connector/customizer.go index a879428..3551486 100644 --- a/cmd/connector/customizer.go +++ b/cmd/connector/customizer.go @@ -24,6 +24,8 @@ func newConnCustomizersCmd(client client.Client) *cobra.Command { newCustomizerUpdateCmd(client), newCustomizerDeleteCmd(client), newCustomizerCreateVersionCmd(client), + newCustomizerLinkCmd(client), + newCustomizerUnlinkCmd(client), ) return cmd diff --git a/cmd/connector/customizer_link.go b/cmd/connector/customizer_link.go new file mode 100644 index 0000000..b712db6 --- /dev/null +++ b/cmd/connector/customizer_link.go @@ -0,0 +1,71 @@ +// Copyright (c) 2023, SailPoint Technologies, Inc. All rights reserved. +package connector + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/olekukonko/tablewriter" + "github.com/sailpoint-oss/sailpoint-cli/internal/client" + "github.com/sailpoint-oss/sailpoint-cli/internal/util" + "github.com/spf13/cobra" +) + +func newCustomizerLinkCmd(client client.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "link", + Short: "Link connector customizer to connector instance", + Example: "sail conn customizers link -c 1234 -i 5678", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + customizerID := cmd.Flags().Lookup("id").Value.String() + instanceID := cmd.Flags().Lookup("instance-id").Value.String() + + raw, err := json.Marshal([]interface{}{map[string]interface{}{ + "op": "replace", + "path": "/connectorCustomizerId", + "value": customizerID, + }}) + if err != nil { + return err + } + + resp, err := client.Patch(cmd.Context(), util.ResourceUrl(connectorInstancesEndpoint, instanceID), bytes.NewReader(raw)) + if err != nil { + return err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("link customizer failed. status: %s\nbody: %s", resp.Status, string(body)) + } + + var i instance + err = json.NewDecoder(resp.Body).Decode(&i) + if err != nil { + return err + } + + table := tablewriter.NewWriter(cmd.OutOrStdout()) + table.SetHeader(instanceColumns) + table.Append(i.columns()) + table.Render() + + return nil + }, + } + + cmd.Flags().StringP("id", "c", "", "Connector customizer ID") + _ = cmd.MarkFlagRequired("customizer-id") + + cmd.Flags().StringP("instance-id", "i", "", "Connector instance ID") + _ = cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/cmd/connector/customizer_unlink.go b/cmd/connector/customizer_unlink.go new file mode 100644 index 0000000..b88bc5f --- /dev/null +++ b/cmd/connector/customizer_unlink.go @@ -0,0 +1,66 @@ +// Copyright (c) 2023, SailPoint Technologies, Inc. All rights reserved. +package connector + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/olekukonko/tablewriter" + "github.com/sailpoint-oss/sailpoint-cli/internal/client" + "github.com/sailpoint-oss/sailpoint-cli/internal/util" + "github.com/spf13/cobra" +) + +func newCustomizerUnlinkCmd(client client.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "unlink", + Short: "Unlink connector customizer from connector instance", + Example: "sail conn customizers unlink -i 5678", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + instanceID := cmd.Flags().Lookup("instance-id").Value.String() + + raw, err := json.Marshal([]interface{}{map[string]interface{}{ + "op": "remove", + "path": "/connectorCustomizerId", + }}) + if err != nil { + return err + } + + resp, err := client.Patch(cmd.Context(), util.ResourceUrl(connectorInstancesEndpoint, instanceID), bytes.NewReader(raw)) + if err != nil { + return err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("link customizer failed. status: %s\nbody: %s", resp.Status, string(body)) + } + + var i instance + err = json.NewDecoder(resp.Body).Decode(&i) + if err != nil { + return err + } + + table := tablewriter.NewWriter(cmd.OutOrStdout()) + table.SetHeader(instanceColumns) + table.Append(i.columns()) + table.Render() + + return nil + }, + } + + cmd.Flags().StringP("instance-id", "i", "", "Connector instance ID") + _ = cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/cmd/connector/instance.go b/cmd/connector/instance.go new file mode 100644 index 0000000..38d66b6 --- /dev/null +++ b/cmd/connector/instance.go @@ -0,0 +1,25 @@ +// Copyright (c) 2023, SailPoint Technologies, Inc. All rights reserved. +package connector + +import ( + "fmt" + + "github.com/sailpoint-oss/sailpoint-cli/internal/client" + "github.com/spf13/cobra" +) + +func newConnInstancesCmd(client client.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "instances", + Short: "Manage connector instances", + Run: func(cmd *cobra.Command, args []string) { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), cmd.UsageString()) + }, + } + + cmd.AddCommand( + newInstanceListCmd(client), + ) + + return cmd +} diff --git a/cmd/connector/instance_list.go b/cmd/connector/instance_list.go new file mode 100644 index 0000000..f06e928 --- /dev/null +++ b/cmd/connector/instance_list.go @@ -0,0 +1,55 @@ +// Copyright (c) 2023, SailPoint Technologies, Inc. All rights reserved. +package connector + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/olekukonko/tablewriter" + "github.com/sailpoint-oss/sailpoint-cli/internal/client" + "github.com/sailpoint-oss/sailpoint-cli/internal/util" + "github.com/spf13/cobra" +) + +func newInstanceListCmd(client client.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all connector instances", + Example: "sail conn instances list", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + + resp, err := client.Get(cmd.Context(), util.ResourceUrl(connectorInstancesEndpoint)) + if err != nil { + return err + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("list connector instances failed. status: %s\nbody: %s", resp.Status, string(body)) + } + + var instances []instance + err = json.NewDecoder(resp.Body).Decode(&instances) + if err != nil { + return err + } + + table := tablewriter.NewWriter(cmd.OutOrStdout()) + table.SetHeader(instanceColumns) + for _, c := range instances { + table.Append(c.columns()) + } + table.Render() + + return nil + }, + } + + return cmd +} diff --git a/cmd/connector/models.go b/cmd/connector/models.go index 470a54b..89ad406 100644 --- a/cmd/connector/models.go +++ b/cmd/connector/models.go @@ -54,6 +54,18 @@ type TagUpdate struct { ActiveVersion uint32 `json:"activeVersion"` } +type instance struct { + ID string `json:"id"` + Name string `json:"name"` + CustomizerId string `json:"connectorCustomizerId"` +} + +func (c instance) columns() []string { + return []string{c.ID, c.Name, c.CustomizerId} +} + +var instanceColumns = []string{"ID", "Name", "Customizer ID"} + type customizer struct { ID string `json:"id"` Name string `json:"name"` diff --git a/internal/client/client.go b/internal/client/client.go index 71e9ef4..be4d3f2 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -17,6 +17,7 @@ type Client interface { Delete(ctx context.Context, url string, params map[string]string) (*http.Response, error) Post(ctx context.Context, url string, contentType string, body io.Reader) (*http.Response, error) Put(ctx context.Context, url string, contentType string, body io.Reader) (*http.Response, error) + Patch(ctx context.Context, url string, body io.Reader) (*http.Response, error) } // SpClient provides access to SP APIs. @@ -156,6 +157,35 @@ func (c *SpClient) Put(ctx context.Context, url string, contentType string, body return resp, nil } +func (c *SpClient) Patch(ctx context.Context, url string, body io.Reader) (*http.Response, error) { + if err := c.ensureAccessToken(ctx); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.getUrl(url), body) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+c.accessToken) + + if c.cfg.Debug { + dbg, _ := httputil.DumpRequest(req, true) + fmt.Println(string(dbg)) + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + + if c.cfg.Debug { + dbg, _ := httputil.DumpResponse(resp, true) + fmt.Println(string(dbg)) + } + + return resp, nil +} + func (c *SpClient) ensureAccessToken(ctx context.Context) error { token, err := config.GetAuthToken() if err != nil { diff --git a/internal/mocks/client.go b/internal/mocks/client.go index 5cdcd38..1af5b52 100644 --- a/internal/mocks/client.go +++ b/internal/mocks/client.go @@ -66,6 +66,21 @@ func (mr *MockClientMockRecorder) Get(ctx, url interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClient)(nil).Get), ctx, url) } +// Patch mocks base method. +func (m *MockClient) Patch(ctx context.Context, url string, body io.Reader) (*http.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Patch", ctx, url, body) + ret0, _ := ret[0].(*http.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Patch indicates an expected call of Patch. +func (mr *MockClientMockRecorder) Patch(ctx, url, body interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockClient)(nil).Patch), ctx, url, body) +} + // Post mocks base method. func (m *MockClient) Post(ctx context.Context, url, contentType string, body io.Reader) (*http.Response, error) { m.ctrl.T.Helper() diff --git a/internal/mocks/terminal.go b/internal/mocks/terminal.go index 36b80e1..96f3e05 100644 --- a/internal/mocks/terminal.go +++ b/internal/mocks/terminal.go @@ -10,31 +10,31 @@ import ( gomock "github.com/golang/mock/gomock" ) -// MockTerm is a mock of Term interface. -type MockTerm struct { +// MockTerminal is a mock of Terminal interface. +type MockTerminal struct { ctrl *gomock.Controller - recorder *MockTermMockRecorder + recorder *MockTerminalMockRecorder } -// MockTermMockRecorder is the mock recorder for MockTerm. -type MockTermMockRecorder struct { - mock *MockTerm +// MockTerminalMockRecorder is the mock recorder for MockTerminal. +type MockTerminalMockRecorder struct { + mock *MockTerminal } -// NewMockTerm creates a new mock instance. -func NewMockTerm(ctrl *gomock.Controller) *MockTerm { - mock := &MockTerm{ctrl: ctrl} - mock.recorder = &MockTermMockRecorder{mock} +// NewMockTerminal creates a new mock instance. +func NewMockTerminal(ctrl *gomock.Controller) *MockTerminal { + mock := &MockTerminal{ctrl: ctrl} + mock.recorder = &MockTerminalMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockTerm) EXPECT() *MockTermMockRecorder { +func (m *MockTerminal) EXPECT() *MockTerminalMockRecorder { return m.recorder } // PromptPassword mocks base method. -func (m *MockTerm) PromptPassword(promptMsg string) (string, error) { +func (m *MockTerminal) PromptPassword(promptMsg string) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PromptPassword", promptMsg) ret0, _ := ret[0].(string) @@ -43,7 +43,7 @@ func (m *MockTerm) PromptPassword(promptMsg string) (string, error) { } // PromptPassword indicates an expected call of PromptPassword. -func (mr *MockTermMockRecorder) PromptPassword(promptMsg interface{}) *gomock.Call { +func (mr *MockTerminalMockRecorder) PromptPassword(promptMsg interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PromptPassword", reflect.TypeOf((*MockTerm)(nil).PromptPassword), promptMsg) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PromptPassword", reflect.TypeOf((*MockTerminal)(nil).PromptPassword), promptMsg) }