From 8f700a7af12f34e0c3eba96c1912669bffdf629e Mon Sep 17 00:00:00 2001 From: Nicolas Grauss Date: Wed, 26 Jun 2024 21:20:25 +0200 Subject: [PATCH] OSD-24127: New accessrequest subcommand used to manage access requests used by the lockbox feature --- Makefile | 1 + .../accessrequest/accessRequest.go | 26 ++ .../accessrequest/createAccessRequest.go | 134 +++++++ .../accessrequest/expireAccessRequest.go | 57 +++ .../accessrequest/getAccessRequest.go | 54 +++ cmd/ocm-backplane/config/set.go | 6 +- cmd/ocm-backplane/root.go | 2 + go.mod | 3 + go.sum | 10 + pkg/accessrequest/accessRequest.go | 259 +++++++++++++ pkg/accessrequest/accessRequest_test.go | 346 ++++++++++++++++++ pkg/cli/config/config.go | 83 ++++- pkg/cli/globalflags/logs.go | 2 +- pkg/ocm/mocks/ocmWrapperMock.go | 75 +++- pkg/ocm/ocmWrapper.go | 112 ++++++ pkg/utils/jira.go | 125 +++++++ pkg/utils/mocks/jiraMock.go | 114 ++++++ 17 files changed, 1395 insertions(+), 14 deletions(-) create mode 100644 cmd/ocm-backplane/accessrequest/accessRequest.go create mode 100644 cmd/ocm-backplane/accessrequest/createAccessRequest.go create mode 100644 cmd/ocm-backplane/accessrequest/expireAccessRequest.go create mode 100644 cmd/ocm-backplane/accessrequest/getAccessRequest.go create mode 100644 pkg/accessrequest/accessRequest.go create mode 100644 pkg/accessrequest/accessRequest_test.go create mode 100644 pkg/utils/jira.go create mode 100644 pkg/utils/mocks/jiraMock.go diff --git a/Makefile b/Makefile index 3b8470c8..588f2aac 100644 --- a/Makefile +++ b/Makefile @@ -115,6 +115,7 @@ mock-gen: mockgen -destination=./pkg/cli/session/mocks/sessionMock.go -package=mocks github.com/openshift/backplane-cli/pkg/cli/session BackplaneSessionInterface mockgen -destination=./pkg/utils/mocks/shellCheckerMock.go -package=mocks github.com/openshift/backplane-cli/pkg/utils ShellCheckerInterface mockgen -destination=./pkg/pagerduty/mocks/clientMock.go -package=mocks github.com/openshift/backplane-cli/pkg/pagerduty PagerDutyClient + mockgen -destination=./pkg/utils/mocks/jiraMock.go -package=mocks github.com/openshift/backplane-cli/pkg/utils IssueServiceInterface .PHONY: build-image build-image: diff --git a/cmd/ocm-backplane/accessrequest/accessRequest.go b/cmd/ocm-backplane/accessrequest/accessRequest.go new file mode 100644 index 00000000..6bf2e36e --- /dev/null +++ b/cmd/ocm-backplane/accessrequest/accessRequest.go @@ -0,0 +1,26 @@ +package accessrequest + +import ( + "github.com/spf13/cobra" +) + +func NewAccessRequestCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "accessrequest", + Aliases: []string{"accessRequest", "accessrequest", "accessrequests"}, + Short: "Manages access requests for clusters on which access protection is enabled", + SilenceUsage: true, + } + + // cluster-id Flag + cmd.PersistentFlags().StringP("cluster-id", "c", "", "Cluster ID could be cluster name, id or external-id") + + cmd.AddCommand(newCreateAccessRequestCmd()) + cmd.AddCommand(newGetAccessRequestCmd()) + cmd.AddCommand(newExpireAccessRequestCmd()) + + return cmd +} + +func init() { +} diff --git a/cmd/ocm-backplane/accessrequest/createAccessRequest.go b/cmd/ocm-backplane/accessrequest/createAccessRequest.go new file mode 100644 index 00000000..fdff1a25 --- /dev/null +++ b/cmd/ocm-backplane/accessrequest/createAccessRequest.go @@ -0,0 +1,134 @@ +package accessrequest + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/openshift/backplane-cli/pkg/accessrequest" + + ocmcli "github.com/openshift-online/ocm-cli/pkg/ocm" + "github.com/openshift/backplane-cli/pkg/login" + "github.com/openshift/backplane-cli/pkg/utils" + logger "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var ( + options struct { + reason string + notificationIssueID string + pendingDuration time.Duration + approvalDuration time.Duration + } +) + +// newCreateAccessRequestCmd returns cobra command +func newCreateAccessRequestCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new pending access request", + Args: cobra.ExactArgs(0), + SilenceUsage: true, + SilenceErrors: true, + RunE: runCreateAccessRequest, + } + + cmd.Flags().StringVarP( + &options.reason, + "reason", + "r", + "", + "Reason/justification passed through the access request to the customer. "+ + "Reason will be read from the kube context (unless --cluster-id is set) or prompted if the option is not set.") + + cmd.Flags().StringVarP( + &options.notificationIssueID, + "notification-issue", + "n", + "", + "JIRA issue used for notifications when the access request is approved or denied. "+ + "Issue needs to belong to the OHSS project on production and to the SDAINT project for staging & integration. "+ + "Issue will automatically be created in the proper project if the option is not set.") + + cmd.Flags().DurationVarP( + &options.approvalDuration, + "approval-duration", + "d", + 8*time.Hour, + "The maximal period of time during which the access request can stay approved") + + return cmd +} + +func retrieveOrPromptReason(cmd *cobra.Command) string { + if utils.CheckValidPrompt() { + clusterKey, err := cmd.Flags().GetString("cluster-id") + + if err == nil && clusterKey == "" { + config, err := utils.ReadKubeconfigRaw() + + if err == nil { + reasons := login.GetElevateContextReasons(config) + for _, reason := range reasons { + if reason != "" { + fmt.Printf("Reason for elevations read from the kube config: %s\n", reason) + if strings.ToLower(utils.AskQuestionFromPrompt("Do you want to use this as the reason/justification for the access request to create (Y/n)? ")) != "n" { + return reason + } + break + } + } + } else { + logger.Warnf("won't extract the elevation reason from the kube context which failed to be read: %v", err) + } + } + } + + return utils.AskQuestionFromPrompt("Please enter a reason/justification for the access request to create: ") +} + +// runCreateAccessRequest creates access request for the given cluster +func runCreateAccessRequest(cmd *cobra.Command, args []string) error { + clusterID, err := accessrequest.GetClusterID(cmd) + if err != nil { + return fmt.Errorf("failed to compute cluster ID: %v", err) + } + + ocmConnection, err := ocmcli.NewConnection().Build() + if err != nil { + return fmt.Errorf("failed to create OCM connection: %v", err) + } + defer ocmConnection.Close() + + accessRequest, err := accessrequest.GetAccessRequest(ocmConnection, clusterID) + + if err != nil { + return err + } + + if accessRequest != nil { + accessrequest.PrintAccessRequest(clusterID, accessRequest) + + return fmt.Errorf("there is already an active access request for cluster '%s', eventually consider expiring it running 'ocm-backplane accessrequest expire'", clusterID) + } + + reason := options.reason + if reason == "" { + reason = retrieveOrPromptReason(cmd) + if reason == "" { + return errors.New("no reason/justification, consider using the --reason option with a non empty string") + } + } + + accessRequest, err = accessrequest.CreateAccessRequest(ocmConnection, clusterID, reason, options.notificationIssueID, options.approvalDuration) + + if err != nil { + return err + } + + accessrequest.PrintAccessRequest(clusterID, accessRequest) + + return nil +} diff --git a/cmd/ocm-backplane/accessrequest/expireAccessRequest.go b/cmd/ocm-backplane/accessrequest/expireAccessRequest.go new file mode 100644 index 00000000..673bd999 --- /dev/null +++ b/cmd/ocm-backplane/accessrequest/expireAccessRequest.go @@ -0,0 +1,57 @@ +package accessrequest + +import ( + "fmt" + + "github.com/openshift/backplane-cli/pkg/accessrequest" + + ocmcli "github.com/openshift-online/ocm-cli/pkg/ocm" + "github.com/spf13/cobra" +) + +// newExpireAccessRequestCmd returns cobra command +func newExpireAccessRequestCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "expire", + Short: "Expire the active (pending or approved) access request", + Args: cobra.ExactArgs(0), + SilenceUsage: true, + SilenceErrors: true, + RunE: runExpireAccessRequest, + } + + return cmd +} + +// runExpireAccessRequest retrieves the active access request and expire it +func runExpireAccessRequest(cmd *cobra.Command, args []string) error { + clusterID, err := accessrequest.GetClusterID(cmd) + if err != nil { + return fmt.Errorf("failed to compute cluster ID: %v", err) + } + + ocmConnection, err := ocmcli.NewConnection().Build() + if err != nil { + return fmt.Errorf("failed to create OCM connection: %v", err) + } + defer ocmConnection.Close() + + accessRequest, err := accessrequest.GetAccessRequest(ocmConnection, clusterID) + + if err != nil { + return fmt.Errorf("failed to retrieve access request: %v", err) + } + + if accessRequest == nil { + return fmt.Errorf("no pending or approved access request for cluster '%s'", clusterID) + } + + err = accessrequest.ExpireAccessRequest(ocmConnection, accessRequest) + if err != nil { + return err + } + + fmt.Printf("Access request '%s' has been expired\n", accessRequest.HREF()) + + return nil +} diff --git a/cmd/ocm-backplane/accessrequest/getAccessRequest.go b/cmd/ocm-backplane/accessrequest/getAccessRequest.go new file mode 100644 index 00000000..82f3edd5 --- /dev/null +++ b/cmd/ocm-backplane/accessrequest/getAccessRequest.go @@ -0,0 +1,54 @@ +package accessrequest + +import ( + "fmt" + + "github.com/openshift/backplane-cli/pkg/accessrequest" + + ocmcli "github.com/openshift-online/ocm-cli/pkg/ocm" + logger "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +// newGetAccessRequestCmd returns cobra command +func newGetAccessRequestCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get", + Short: "Get the active (pending or approved) access request", + Args: cobra.ExactArgs(0), + SilenceUsage: true, + SilenceErrors: true, + RunE: runGetAccessRequest, + } + + return cmd +} + +// runGetAccessRequest retrieves the active access request and print it +func runGetAccessRequest(cmd *cobra.Command, args []string) error { + clusterID, err := accessrequest.GetClusterID(cmd) + if err != nil { + return fmt.Errorf("failed to compute cluster ID: %v", err) + } + + ocmConnection, err := ocmcli.NewConnection().Build() + if err != nil { + return fmt.Errorf("failed to create OCM connection: %v", err) + } + defer ocmConnection.Close() + + accessRequest, err := accessrequest.GetAccessRequest(ocmConnection, clusterID) + + if err != nil { + return err + } + + if accessRequest == nil { + logger.Warnf("no pending or approved access request for cluster '%s'", clusterID) + fmt.Printf("To get denied or expired access requests, run: ocm get /api/access_transparency/v1/access_requests -p search=\"cluster_id='%s'\"\n", clusterID) + } else { + accessrequest.PrintAccessRequest(clusterID, accessRequest) + } + + return nil +} diff --git a/cmd/ocm-backplane/config/set.go b/cmd/ocm-backplane/config/set.go index 8a2e887b..1fe8f57e 100644 --- a/cmd/ocm-backplane/config/set.go +++ b/cmd/ocm-backplane/config/set.go @@ -53,6 +53,7 @@ func setConfig(cmd *cobra.Command, args []string) error { } bpConfig.SessionDirectory = viper.GetString("session-dir") + bpConfig.JiraToken = viper.GetString(config.JiraTokenViperKey) } // create config directory if it doesn't exist @@ -90,8 +91,10 @@ func setConfig(cmd *cobra.Command, args []string) error { bpConfig.SessionDirectory = args[1] case PagerDutyAPIConfigVar: bpConfig.PagerDutyAPIKey = args[1] + case config.JiraTokenViperKey: + bpConfig.JiraToken = args[1] default: - return fmt.Errorf("supported config variables are %s, %s, %s & %s", URLConfigVar, ProxyURLConfigVar, SessionConfigVar, PagerDutyAPIConfigVar) + return fmt.Errorf("supported config variables are %s, %s, %s, %s & %s", URLConfigVar, ProxyURLConfigVar, SessionConfigVar, PagerDutyAPIConfigVar, config.JiraTokenViperKey) } viper.SetConfigType("json") @@ -99,6 +102,7 @@ func setConfig(cmd *cobra.Command, args []string) error { viper.Set(ProxyURLConfigVar, bpConfig.ProxyURL) viper.Set(SessionConfigVar, bpConfig.SessionDirectory) viper.Set(PagerDutyAPIConfigVar, bpConfig.PagerDutyAPIKey) + viper.Set(config.JiraTokenViperKey, bpConfig.JiraToken) err = viper.WriteConfigAs(configPath) if err != nil { diff --git a/cmd/ocm-backplane/root.go b/cmd/ocm-backplane/root.go index ac287a31..c7173e05 100644 --- a/cmd/ocm-backplane/root.go +++ b/cmd/ocm-backplane/root.go @@ -22,6 +22,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "github.com/openshift/backplane-cli/cmd/ocm-backplane/accessrequest" "github.com/openshift/backplane-cli/cmd/ocm-backplane/cloud" "github.com/openshift/backplane-cli/cmd/ocm-backplane/config" "github.com/openshift/backplane-cli/cmd/ocm-backplane/console" @@ -64,6 +65,7 @@ func init() { globalflags.AddVerbosityFlag(rootCmd) // Register sub-commands + rootCmd.AddCommand(accessrequest.NewAccessRequestCmd()) rootCmd.AddCommand(console.NewConsoleCmd()) rootCmd.AddCommand(config.NewConfigCmd()) rootCmd.AddCommand(cloud.CloudCmd) diff --git a/go.mod b/go.mod index 151da0e6..15fb10e8 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/Masterminds/semver v1.5.0 github.com/PagerDuty/go-pagerduty v1.8.0 + github.com/andygrunwald/go-jira v1.16.0 github.com/aws/aws-sdk-go-v2 v1.30.1 github.com/aws/aws-sdk-go-v2/config v1.27.24 github.com/aws/aws-sdk-go-v2/credentials v1.17.24 @@ -59,6 +60,7 @@ require ( github.com/dvsekhvalnov/jose2go v1.6.0 // indirect github.com/emicklei/go-restful/v3 v3.9.0 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect + github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/getkin/kin-openapi v0.113.0 // indirect github.com/go-errors/errors v1.4.2 // indirect @@ -121,6 +123,7 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/trivago/tgo v1.0.7 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/zalando/go-keyring v0.2.3 // indirect go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect diff --git a/go.sum b/go.sum index cb5f3838..698f0278 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/andygrunwald/go-jira v1.16.0 h1:PU7C7Fkk5L96JvPc6vDVIrd99vdPnYudHu4ju2c2ikQ= +github.com/andygrunwald/go-jira v1.16.0/go.mod h1:UQH4IBVxIYWbgagc0LF/k9FRs9xjIiQ8hIcC6HfLwFU= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.30.1 h1:4y/5Dvfrhd1MxRDD77SrfsDaj8kUkkljU7XE83NPV+o= @@ -122,6 +124,8 @@ github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -169,6 +173,7 @@ github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -220,6 +225,7 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -479,6 +485,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= +github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo= github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= @@ -661,6 +669,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -669,6 +678,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= diff --git a/pkg/accessrequest/accessRequest.go b/pkg/accessrequest/accessRequest.go new file mode 100644 index 00000000..abc8dfe0 --- /dev/null +++ b/pkg/accessrequest/accessRequest.go @@ -0,0 +1,259 @@ +package accessrequest + +import ( + "fmt" + "strings" + "time" + + "github.com/andygrunwald/go-jira" + ocmsdk "github.com/openshift-online/ocm-sdk-go" + acctrspv1 "github.com/openshift-online/ocm-sdk-go/accesstransparency/v1" + "github.com/openshift/backplane-cli/pkg/cli/config" + "github.com/openshift/backplane-cli/pkg/ocm" + "github.com/openshift/backplane-cli/pkg/utils" + logger "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func getJiraBaseURL() string { + bpConfig, err := config.GetBackplaneConfiguration() + if err != nil { + logger.Warnf("failed to load backplane config: %v, defaulting JIRA base URL to '%s'", err, config.JiraBaseURLDefaultValue) + + return config.JiraBaseURLDefaultValue + } + + return bpConfig.JiraBaseURL +} + +func GetClusterID(cmd *cobra.Command) (string, error) { + clusterKey, err := cmd.Flags().GetString("cluster-id") + if err != nil { + return "", err + } + + bpCluster, err := utils.DefaultClusterUtils.GetBackplaneCluster(clusterKey) + if err != nil { + return "", err + } + + return bpCluster.ClusterID, nil +} + +func GetAccessRequest(ocmConnection *ocmsdk.Connection, clusterID string) (*acctrspv1.AccessRequest, error) { + isEnabled, err := ocm.DefaultOCMInterface.IsClusterAccessProtectionEnabled(ocmConnection, clusterID) + if err != nil { + return nil, fmt.Errorf("unable to determine if access protection is enabled or not for cluster '%s': %v", clusterID, err) + } + + if !isEnabled { + return nil, fmt.Errorf("access protection is not enabled for cluster '%s'", clusterID) + } + + return ocm.DefaultOCMInterface.GetClusterActiveAccessRequest(ocmConnection, clusterID) +} + +func PrintAccessRequest(clusterID string, accessRequest *acctrspv1.AccessRequest) { + accessRequestStatus := accessRequest.Status() + accessRequestStatusState := acctrspv1.AccessRequestState("") + + if accessRequestStatus != nil && accessRequestStatus.State() != "" { + accessRequestStatusState = accessRequestStatus.State() + } + + fmt.Printf("Active access request for cluster '%s':\n", clusterID) + fmt.Printf(" Status : %s\n", accessRequestStatusState) + + switch { + case accessRequestStatusState == acctrspv1.AccessRequestStateApproved: + fmt.Printf(" Approval expires at : %s\n", accessRequestStatus.ExpiresAt()) + case accessRequestStatusState == acctrspv1.AccessRequestStatePending: + fmt.Printf(" Expires at : %s\n", accessRequest.DeadlineAt()) + fmt.Printf(" Requested approval duration: %s\n", accessRequest.Duration()) + } + fmt.Printf(" JIRA ticket used for notifs: %s/browse/%s\n", getJiraBaseURL(), accessRequest.InternalSupportCaseId()) + + fmt.Printf(" Created by : %s\n", accessRequest.RequestedBy()) + fmt.Printf(" Reason/justification : %s\n", accessRequest.Justification()) + fmt.Printf(" For more details, run : ocm get %s\n", accessRequest.HREF()) +} + +func verifyAndPossiblyRetrieveIssue(bpConfig *config.BackplaneConfiguration, isProd bool, issueID string) (*jira.Issue, error) { + issuesConfig := &bpConfig.JiraConfigForAccessRequests + issueID = strings.TrimPrefix(issueID, getJiraBaseURL()+"/browse/") + + if isProd { + if !strings.HasPrefix(issueID, issuesConfig.ProdProject+"-") { + return nil, fmt.Errorf("issue does not belong to the %s prod JIRA project", issuesConfig.ProdProject) + } + } else { + var knownProjects string + var isIssueProjectValid = false + + for project := range issuesConfig.ProjectToTransitionsNames { + if project != issuesConfig.ProdProject { + if strings.HasPrefix(issueID, project+"-") { + isIssueProjectValid = true + break + } + if knownProjects != "" { + knownProjects += ", " + } + knownProjects += project + } + } + + if !isIssueProjectValid { + return nil, fmt.Errorf("issue does not belong to one of the '%s' test JIRA projects", knownProjects) + } + } + + if bpConfig.JiraToken == "" { + logger.Warnf("won't verify the validity of the '%s' JIRA issue as no JIRA token is defined, consider defining it running 'ocm-backplane config set %s '", issueID, config.JiraTokenViperKey) + + return nil, nil + } + + issue, _, err := utils.DefaultIssueService.Get(issueID, nil) + if err != nil { + return nil, err + } + + return issue, nil +} + +func createNotificationIssue(bpConfig *config.BackplaneConfiguration, isProd bool, clusterID string) (*jira.Issue, error) { + issuesConfig := &bpConfig.JiraConfigForAccessRequests + issueProject := issuesConfig.DefaultProject + issueType := issuesConfig.DefaultIssueType + + if isProd { + issueProject = issuesConfig.ProdProject + issueType = issuesConfig.ProdIssueType + } + + issue := &jira.Issue{ + Fields: &jira.IssueFields{ + Description: "Access request tracker", + Type: jira.IssueType{ + Name: issueType, + }, + Project: jira.Project{ + Key: issueProject, + }, + Summary: fmt.Sprintf("Access request tracker for cluster '%s'", clusterID), + }, + } + + issue, _, err := utils.DefaultIssueService.Create(issue) + if err != nil { + return nil, err + } + + return issue, nil +} + +func getProjectFromIssueID(issueID string) string { + dashIdx := strings.Index(issueID, "-") + + if dashIdx < 0 { + return "" + } + + return issueID[0:dashIdx] +} + +func transitionIssue(issueID, newTransitionName string) { + possibleTransitions, _, err := utils.DefaultIssueService.GetTransitions(issueID) + if err != nil { + logger.Warnf("won't transition the '%s' JIRA issue to '%s' as it was not possible to retrieve the possible transitions for the issue: %v", issueID, newTransitionName, err) + } else { + transitionID := "" + + for _, v := range possibleTransitions { + if v.Name == newTransitionName { + transitionID = v.ID + break + } + } + + if transitionID == "" { + logger.Warnf("won't transition the '%s' JIRA issue to '%s' as there is no transition named that way", issueID, newTransitionName) + } else { + _, err := utils.DefaultIssueService.DoTransition(issueID, transitionID) + + if err != nil { + logger.Warnf("failed to transition the '%s' JIRA issue to '%s': %v", issueID, newTransitionName, err) + } + } + } +} + +func updateNotificationIssueDescription(issue *jira.Issue, onApprovalTransitionName string, accessRequest *acctrspv1.AccessRequest) { + issue.Fields = &jira.IssueFields{ + Description: fmt.Sprintf("Issue used for notifications purpose only.\n"+ + "Issue will moved in '%s' status when the customer approves the corresponding access request:\n%s", + onApprovalTransitionName, accessRequest.HREF()), + } + + _, _, err := utils.DefaultIssueService.Update(issue) + if err != nil { + logger.Warnf("failed to update the description of the '%s' JIRA issue: %v", issue.Key, err) + } +} + +func CreateAccessRequest(ocmConnection *ocmsdk.Connection, clusterID, justification, notificationIssueID string, approvalDuration time.Duration) (*acctrspv1.AccessRequest, error) { + bpConfig, err := config.GetBackplaneConfiguration() + if err != nil { + return nil, err + } + + ocmEnv, err := ocm.DefaultOCMInterface.GetOCMEnvironment() + if err != nil || ocmEnv == nil { + return nil, err + } + isProd := ocmEnv.Name() == bpConfig.ProdEnvName + + var isOwningNotificationIssue = notificationIssueID == "" + var notificationIssue *jira.Issue + + if isOwningNotificationIssue { + notificationIssue, err = createNotificationIssue(&bpConfig, isProd, clusterID) + if err != nil { + return nil, fmt.Errorf("failed to create the notification issue, consider creating the issue separately and passing it with the --notification-issue option: %v", err) + } + notificationIssueID = notificationIssue.Key + } else { + notificationIssue, err = verifyAndPossiblyRetrieveIssue(&bpConfig, isProd, notificationIssueID) + if err != nil { + return nil, fmt.Errorf("issue '%s' passed with the --notification-issue option is invalid: %v", notificationIssueID, err) + } + } + + transitionsNames := bpConfig.JiraConfigForAccessRequests.ProjectToTransitionsNames[getProjectFromIssueID(notificationIssueID)] + + accessRequest, err := ocm.DefaultOCMInterface.CreateClusterAccessRequest(ocmConnection, clusterID, justification, notificationIssueID, approvalDuration.String()) + if err != nil { + if isOwningNotificationIssue { + transitionIssue(notificationIssueID, transitionsNames.OnError) + } + + return nil, fmt.Errorf("failed to create a new access request for cluster '%s': %v", clusterID, err) + } + + if notificationIssue != nil { + updateNotificationIssueDescription(notificationIssue, transitionsNames.OnApproval, accessRequest) + transitionIssue(notificationIssueID, transitionsNames.OnCreation) + } + + return accessRequest, nil +} + +func ExpireAccessRequest(ocmConnection *ocmsdk.Connection, accessRequest *acctrspv1.AccessRequest) error { + _, err := ocm.DefaultOCMInterface.CreateAccessRequestDecision(ocmConnection, accessRequest, acctrspv1.DecisionDecisionExpired, "proactively expired using 'ocm-backplane accessrequest expire' CLI") + if err != nil { + return fmt.Errorf("failed to create a new decision for access request '%s': %v", accessRequest.HREF(), err) + } + + return nil +} diff --git a/pkg/accessrequest/accessRequest_test.go b/pkg/accessrequest/accessRequest_test.go new file mode 100644 index 00000000..e4e99937 --- /dev/null +++ b/pkg/accessrequest/accessRequest_test.go @@ -0,0 +1,346 @@ +package accessrequest + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/andygrunwald/go-jira" + acctrspv1 "github.com/openshift-online/ocm-sdk-go/accesstransparency/v1" + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + "github.com/openshift/backplane-cli/pkg/backplaneapi" + backplaneapiMock "github.com/openshift/backplane-cli/pkg/backplaneapi/mocks" + "github.com/openshift/backplane-cli/pkg/ocm" + ocmMock "github.com/openshift/backplane-cli/pkg/ocm/mocks" + "github.com/openshift/backplane-cli/pkg/utils" + utilsMocks "github.com/openshift/backplane-cli/pkg/utils/mocks" +) + +const testDesc = "accessrequest package" + +type IssueMatcher struct { + closure func(issue *jira.Issue) +} + +func (matcher IssueMatcher) Matches(x interface{}) bool { + issue := x.(*jira.Issue) + matcher.closure(issue) + + return true +} + +func (matcher IssueMatcher) String() string { + return "IssueMatcher" +} + +func writeConfig(jsonData []byte) { + tempDir := os.TempDir() + bpConfigPath := filepath.Join(tempDir, "mock.json") + tempFile, err := os.Create(bpConfigPath) + Expect(err).To(BeNil()) + + _, err = tempFile.Write(jsonData) + Expect(err).To(BeNil()) + + os.Setenv("BACKPLANE_CONFIG", bpConfigPath) +} + +var _ = Describe(testDesc, func() { + var ( + mockCtrl *gomock.Controller + mockClientUtil *backplaneapiMock.MockClientUtils + mockOcmInterface *ocmMock.MockOCMInterface + mockIssueService *utilsMocks.MockIssueServiceInterface + + clusterID string + ocmEnv *cmv1.Environment + reason string + issueID string + issue *jira.Issue + duration time.Duration + durationStr string + accessRequestID string + accessRequest *acctrspv1.AccessRequest + ) + + BeforeEach(func() { + var err error + + mockCtrl = gomock.NewController(GinkgoT()) + + mockClientUtil = backplaneapiMock.NewMockClientUtils(mockCtrl) + backplaneapi.DefaultClientUtils = mockClientUtil + + mockOcmInterface = ocmMock.NewMockOCMInterface(mockCtrl) + ocm.DefaultOCMInterface = mockOcmInterface + + mockIssueService = utilsMocks.NewMockIssueServiceInterface(mockCtrl) + utils.DefaultIssueService = mockIssueService + + clusterID = "cluster-12345678" + + ocmEnv, _ = cmv1.NewEnvironment().BackplaneURL("https://dummy.api").Build() + + reason = "Some reason" + + issueID = "SDAINT-12345" + + issue = &jira.Issue{ + Fields: &jira.IssueFields{ + Description: "Access request tracker", + Type: jira.IssueType{ + Name: "Story", + }, + Project: jira.Project{ + Key: "SDAINT", + }, + Summary: fmt.Sprintf("Access request tracker for cluster '%s'", clusterID), + }, + Key: issueID, + } + + duration = 1 * time.Hour + + durationStr = "1h0m0s" + + accessRequestID = "req-123" + accessRequestBuilder := acctrspv1.NewAccessRequest().ID(accessRequestID).HREF(accessRequestID).Justification(reason). + InternalSupportCaseId(issueID).Duration(durationStr).Status(acctrspv1.NewAccessRequestStatus().State(acctrspv1.AccessRequestStatePending)) + accessRequest, err = accessRequestBuilder.Build() + + Expect(err).To(BeNil()) + }) + + Context("get access request", func() { + It("should fail when access protection is disabled", func() { + mockOcmInterface.EXPECT().IsClusterAccessProtectionEnabled(nil, clusterID).Return(false, nil).Times(1) + + returnedAccessRequest, err := GetAccessRequest(nil, clusterID) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err.Error()).Should(ContainSubstring("access protection is not enabled")) + }) + + It("should fail when access protection cannot be retrieved from OCM", func() { + mockOcmInterface.EXPECT().IsClusterAccessProtectionEnabled(nil, clusterID).Return(true, errors.New("some error")).Times(1) + + returnedAccessRequest, err := GetAccessRequest(nil, clusterID) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err.Error()).Should(ContainSubstring("unable to determine if access protection is enabled or not")) + }) + + Context("access protection is enabled", func() { + BeforeEach(func() { + mockOcmInterface.EXPECT().IsClusterAccessProtectionEnabled(nil, clusterID).Return(true, nil).Times(1) + }) + + It("should fail when access request cannot be retrieved from OCM", func() { + mockOcmInterface.EXPECT().GetClusterActiveAccessRequest(nil, clusterID).Return(nil, errors.New("some error")).Times(1) + + returnedAccessRequest, err := GetAccessRequest(nil, clusterID) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err.Error()).To(Equal("some error")) + }) + + It("should return nil if there is no access request in OCM", func() { + mockOcmInterface.EXPECT().GetClusterActiveAccessRequest(nil, clusterID).Return(nil, nil).Times(1) + + returnedAccessRequest, err := GetAccessRequest(nil, clusterID) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err).To(BeNil()) + }) + + It("should succeed and return the access request if there is one in OCM", func() { + mockOcmInterface.EXPECT().GetClusterActiveAccessRequest(nil, clusterID).Return(accessRequest, nil).Times(1) + + returnedAccessRequest, err := GetAccessRequest(nil, clusterID) + + Expect(returnedAccessRequest).To(Equal(accessRequest)) + Expect(err).To(BeNil()) + }) + }) + }) + + Context("create access request", func() { + It("should fail when env cannot be retrieved from OCM", func() { + mockOcmInterface.EXPECT().GetOCMEnvironment().Return(nil, errors.New("some error")).Times(1) + + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, issueID, duration) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err.Error()).To(Equal("some error")) + }) + + Context("env successfully retrieved from OCM", func() { + BeforeEach(func() { + mockOcmInterface.EXPECT().GetOCMEnvironment().Return(ocmEnv, nil).AnyTimes() + }) + + It("should fail when the backplane config is invalid", func() { + writeConfig([]byte("Hello World")) + + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, "TST-12345", duration) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err.Error()).Should(ContainSubstring("invalid character 'H' looking for beginning of value")) + }) + + Context("backplane config is valid", func() { + BeforeEach(func() { + writeConfig([]byte("{}")) // Defaults will be used + }) + + Context("notification issue ID passed by the caller", func() { + It("should fail when the issue project is not in the config", func() { + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, "AAA-12345", duration) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err.Error()).Should(ContainSubstring("issue does not belong to one of the 'SDAINT' test JIRA projects")) + }) + + Context("issue project is known from the config", func() { + It("should succeed even if the JIRA token is not defined in the config as the issue existence cannot be disproved", func() { + mockOcmInterface.EXPECT().CreateClusterAccessRequest(nil, clusterID, reason, issueID, durationStr).Return(accessRequest, nil).Times(1) + + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, issueID, duration) + + Expect(returnedAccessRequest).To(Equal(accessRequest)) + Expect(err).To(BeNil()) + }) + + Context("JIRA token is defined in the config", func() { + BeforeEach(func() { + writeConfig([]byte(`{"jira-token": "xxxxxx"}`)) + }) + + It("should fail when the issue cannot be retrieved from JIRA", func() { + mockIssueService.EXPECT().Get(issueID, nil).Return(nil, nil, errors.New("some error")).Times(1) + + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, issueID, duration) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err.Error()).Should(ContainSubstring("some error")) + }) + + Context("issue is defined in JIRA", func() { + BeforeEach(func() { + mockIssueService.EXPECT().Get(issueID, nil).Return(issue, nil, nil).AnyTimes() + }) + + Context("access request can be created in OCM", func() { + BeforeEach(func() { + mockOcmInterface.EXPECT().CreateClusterAccessRequest(nil, clusterID, reason, issueID, durationStr).Return(accessRequest, nil).Times(1) + }) + + It("should succeed and create the access request in OCM even if the issue cannot later be transitioned or updated in JIRA", func() { + mockIssueService.EXPECT().GetTransitions(issueID).Return([]jira.Transition{}, nil, errors.New("some error")).Times(1) + mockIssueService.EXPECT().Update(gomock.Any()).Return(nil, nil, errors.New("some other error")).Times(1) + + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, issueID, duration) + + Expect(returnedAccessRequest).To(Equal(accessRequest)) + Expect(err).To(BeNil()) + }) + }) + }) + }) + }) + }) + + Context("notification issue to be created", func() { + It("should fail when the issue cannot be created in JIRA", func() { + mockIssueService.EXPECT().Create(gomock.Any()).Return(nil, nil, errors.New("some error")).Times(1) + + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, "", duration) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err.Error()).Should(ContainSubstring("some error")) + }) + + Context("issue can be created in JIRA", func() { + BeforeEach(func() { + mockIssueService.EXPECT().Create(IssueMatcher{func(receivedIssue *jira.Issue) { + Expect(receivedIssue.Key).To(Equal("")) + Expect(receivedIssue.Fields).To(Equal(issue.Fields)) + }}).Return(issue, nil, nil).AnyTimes() + }) + + Context("access request cannot be created in OCM", func() { + BeforeEach(func() { + mockOcmInterface.EXPECT().CreateClusterAccessRequest(nil, clusterID, reason, issueID, durationStr).Return(nil, errors.New("some error")).Times(1) + }) + + It("should fail and not close the issue if not possible in JIRA", func() { + mockIssueService.EXPECT().GetTransitions(issueID).Return([]jira.Transition{}, nil, errors.New("some other error")).Times(1) + + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, "", duration) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err.Error()).Should(ContainSubstring("some error")) + Expect(err.Error()).ShouldNot(ContainSubstring("some other error")) + }) + + It("should fail and close the issue if possible in JIRA", func() { + mockIssueService.EXPECT().GetTransitions(issueID).Return([]jira.Transition{{ID: "42", Name: "Closed"}}, nil, nil).Times(1) + mockIssueService.EXPECT().DoTransition(issueID, "42").Return(nil, nil).Times(1) + + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, "", duration) + + Expect(returnedAccessRequest).To(BeNil()) + Expect(err.Error()).Should(ContainSubstring("some error")) + Expect(err.Error()).ShouldNot(ContainSubstring("some other error")) + }) + }) + + Context("access request can be created in OCM", func() { + BeforeEach(func() { + mockOcmInterface.EXPECT().CreateClusterAccessRequest(nil, clusterID, reason, issueID, durationStr).Return(accessRequest, nil).Times(1) + }) + + It("should succeed and create the access request in OCM and not update the issue if not possible in JIRA", func() { + mockIssueService.EXPECT().GetTransitions(issueID).Return([]jira.Transition{}, nil, errors.New("some error")).Times(1) + mockIssueService.EXPECT().Update(gomock.Any()).Return(nil, nil, errors.New("some other error")).Times(1) + + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, "", duration) + + Expect(returnedAccessRequest).To(Equal(accessRequest)) + Expect(err).To(BeNil()) + }) + + It("should succeed and create the access request in OCM and update the issue if possible in JIRA", func() { + mockIssueService.EXPECT().GetTransitions(issueID).Return([]jira.Transition{{ID: "68", Name: "In Progress"}}, nil, nil).Times(1) + mockIssueService.EXPECT().DoTransition(issueID, "68").Return(nil, nil).Times(1) + mockIssueService.EXPECT().Update(IssueMatcher{func(receivedIssue *jira.Issue) { + Expect(receivedIssue.Key).To(Equal(issueID)) + Expect(receivedIssue.Fields).ToNot(BeNil()) + Expect(receivedIssue.Fields.Description).Should(ContainSubstring(accessRequestID)) + Expect(receivedIssue.Fields.Description).Should(ContainSubstring("In Progress")) + }}).Return(issue, nil, nil).Times(1) + + returnedAccessRequest, err := CreateAccessRequest(nil, clusterID, reason, "", duration) + + Expect(returnedAccessRequest).To(Equal(accessRequest)) + Expect(err).To(BeNil()) + }) + }) + }) + }) + }) + }) + }) +}) + +func TestIt(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, testDesc) +} diff --git a/pkg/cli/config/config.go b/pkg/cli/config/config.go index f591be24..97376cd8 100644 --- a/pkg/cli/config/config.go +++ b/pkg/cli/config/config.go @@ -15,13 +15,59 @@ import ( "github.com/openshift/backplane-cli/pkg/ocm" ) +type JiraTransitionsNamesForAccessRequests struct { + OnCreation string `json:"on-creation"` + OnApproval string `json:"on-approval"` + OnError string `json:"on-error"` +} + +type AccessRequestsJiraConfiguration struct { + DefaultProject string `json:"default-project"` + DefaultIssueType string `json:"default-issue-type"` + ProdProject string `json:"prod-project"` + ProdIssueType string `json:"prod-issue-type"` + ProjectToTransitionsNames map[string]JiraTransitionsNamesForAccessRequests `json:"project-to-transitions-names"` +} + // Please update the validateConfig function if there is any required config key added type BackplaneConfiguration struct { - URL string `json:"url"` - ProxyURL *string `json:"proxy-url"` - SessionDirectory string `json:"session-dir"` - AssumeInitialArn string `json:"assume-initial-arn"` - PagerDutyAPIKey string `json:"pd-key"` + URL string `json:"url"` + ProxyURL *string `json:"proxy-url"` + SessionDirectory string `json:"session-dir"` + AssumeInitialArn string `json:"assume-initial-arn"` + ProdEnvName string `json:"prod-env-name"` + PagerDutyAPIKey string `json:"pd-key"` + JiraBaseURL string `json:"jira-base-url"` + JiraToken string `json:"jira-token"` + JiraConfigForAccessRequests AccessRequestsJiraConfiguration `json:"jira-config-for-access-requests"` +} + +const ( + prodEnvNameKey = "prod-env-name" + jiraBaseURLKey = "jira-base-url" + JiraTokenViperKey = "jira-token" + JiraConfigForAccessRequestsKey = "jira-config-for-access-requests" + prodEnvNameDefaultValue = "production" + JiraBaseURLDefaultValue = "https://issues.redhat.com" +) + +var JiraConfigForAccessRequestsDefaultValue = AccessRequestsJiraConfiguration{ + DefaultProject: "SDAINT", + DefaultIssueType: "Story", + ProdProject: "OHSS", + ProdIssueType: "Incident", + ProjectToTransitionsNames: map[string]JiraTransitionsNamesForAccessRequests{ + "SDAINT": JiraTransitionsNamesForAccessRequests{ + OnCreation: "In Progress", + OnApproval: "In Progress", + OnError: "Closed", + }, + "OHSS": JiraTransitionsNamesForAccessRequests{ + OnCreation: "Pending Customer", + OnApproval: "New", + OnError: "Cancelled", + }, + }, } // GetConfigFilePath returns the Backplane CLI configuration filepath @@ -44,6 +90,10 @@ func GetConfigFilePath() (string, error) { // GetBackplaneConfiguration parses and returns the given backplane configuration func GetBackplaneConfiguration() (bpConfig BackplaneConfiguration, err error) { + viper.SetDefault(prodEnvNameKey, prodEnvNameDefaultValue) + viper.SetDefault(jiraBaseURLKey, JiraBaseURLDefaultValue) + viper.SetDefault(JiraConfigForAccessRequestsKey, JiraConfigForAccessRequestsDefaultValue) + filePath, err := GetConfigFilePath() if err != nil { return bpConfig, err @@ -107,6 +157,29 @@ func GetBackplaneConfiguration() (bpConfig BackplaneConfiguration, err error) { } else { logger.Info("No PagerDuty API Key configuration available. This will result in failure of `ocm-backplane login --pd ` command.") } + + // OCM prod env name is optional as there is a default value + bpConfig.ProdEnvName = viper.GetString(prodEnvNameKey) + + // JIRA base URL is optional as there is a default value + bpConfig.JiraBaseURL = viper.GetString(jiraBaseURLKey) + + // JIRA token is optional + bpConfig.JiraToken = viper.GetString(JiraTokenViperKey) + + // JIRA config for access requests is optional as there is a default value + err = viper.UnmarshalKey(JiraConfigForAccessRequestsKey, &bpConfig.JiraConfigForAccessRequests) + + if err != nil { + logger.Warnf("failed to unmarshal '%s' entry as json in '%s' config file: %v", JiraConfigForAccessRequestsKey, filePath, err) + } else { + for _, project := range []string{bpConfig.JiraConfigForAccessRequests.DefaultProject, bpConfig.JiraConfigForAccessRequests.ProdProject} { + if _, isKnownProject := bpConfig.JiraConfigForAccessRequests.ProjectToTransitionsNames[project]; !isKnownProject { + logger.Warnf("content unmarshalled from '%s' in '%s' config file is inconsistent: no transitions defined for project '%s'", JiraConfigForAccessRequestsKey, filePath, project) + } + } + } + return bpConfig, nil } diff --git a/pkg/cli/globalflags/logs.go b/pkg/cli/globalflags/logs.go index cefd7ee1..0851f9e2 100644 --- a/pkg/cli/globalflags/logs.go +++ b/pkg/cli/globalflags/logs.go @@ -43,7 +43,7 @@ func AddVerbosityFlag(cmd *cobra.Command) { &logLevel, "verbosity", "v", - "Verbosity level: panic, fatal, error, warn, info, debug. Providing no verbosity level will default to info.", + "Verbosity level: panic, fatal, error, warn, info, debug", ) logLevelFlag.NoOptDefVal = log.InfoLevel.String() } diff --git a/pkg/ocm/mocks/ocmWrapperMock.go b/pkg/ocm/mocks/ocmWrapperMock.go index 6546d6ee..37848449 100644 --- a/pkg/ocm/mocks/ocmWrapperMock.go +++ b/pkg/ocm/mocks/ocmWrapperMock.go @@ -9,7 +9,8 @@ import ( gomock "github.com/golang/mock/gomock" sdk "github.com/openshift-online/ocm-sdk-go" - v1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + v1 "github.com/openshift-online/ocm-sdk-go/accesstransparency/v1" + v10 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" ) // MockOCMInterface is a mock of OCMInterface interface. @@ -35,11 +36,56 @@ func (m *MockOCMInterface) EXPECT() *MockOCMInterfaceMockRecorder { return m.recorder } +// CreateAccessRequestDecision mocks base method. +func (m *MockOCMInterface) CreateAccessRequestDecision(arg0 *sdk.Connection, arg1 *v1.AccessRequest, arg2 v1.DecisionDecision, arg3 string) (*v1.Decision, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAccessRequestDecision", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*v1.Decision) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateAccessRequestDecision indicates an expected call of CreateAccessRequestDecision. +func (mr *MockOCMInterfaceMockRecorder) CreateAccessRequestDecision(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccessRequestDecision", reflect.TypeOf((*MockOCMInterface)(nil).CreateAccessRequestDecision), arg0, arg1, arg2, arg3) +} + +// CreateClusterAccessRequest mocks base method. +func (m *MockOCMInterface) CreateClusterAccessRequest(arg0 *sdk.Connection, arg1, arg2, arg3, arg4 string) (*v1.AccessRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateClusterAccessRequest", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(*v1.AccessRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateClusterAccessRequest indicates an expected call of CreateClusterAccessRequest. +func (mr *MockOCMInterfaceMockRecorder) CreateClusterAccessRequest(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateClusterAccessRequest", reflect.TypeOf((*MockOCMInterface)(nil).CreateClusterAccessRequest), arg0, arg1, arg2, arg3, arg4) +} + +// GetClusterActiveAccessRequest mocks base method. +func (m *MockOCMInterface) GetClusterActiveAccessRequest(arg0 *sdk.Connection, arg1 string) (*v1.AccessRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusterActiveAccessRequest", arg0, arg1) + ret0, _ := ret[0].(*v1.AccessRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetClusterActiveAccessRequest indicates an expected call of GetClusterActiveAccessRequest. +func (mr *MockOCMInterfaceMockRecorder) GetClusterActiveAccessRequest(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterActiveAccessRequest", reflect.TypeOf((*MockOCMInterface)(nil).GetClusterActiveAccessRequest), arg0, arg1) +} + // GetClusterInfoByID mocks base method. -func (m *MockOCMInterface) GetClusterInfoByID(arg0 string) (*v1.Cluster, error) { +func (m *MockOCMInterface) GetClusterInfoByID(arg0 string) (*v10.Cluster, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetClusterInfoByID", arg0) - ret0, _ := ret[0].(*v1.Cluster) + ret0, _ := ret[0].(*v10.Cluster) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -51,10 +97,10 @@ func (mr *MockOCMInterfaceMockRecorder) GetClusterInfoByID(arg0 interface{}) *go } // GetClusterInfoByIDWithConn mocks base method. -func (m *MockOCMInterface) GetClusterInfoByIDWithConn(arg0 *sdk.Connection, arg1 string) (*v1.Cluster, error) { +func (m *MockOCMInterface) GetClusterInfoByIDWithConn(arg0 *sdk.Connection, arg1 string) (*v10.Cluster, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetClusterInfoByIDWithConn", arg0, arg1) - ret0, _ := ret[0].(*v1.Cluster) + ret0, _ := ret[0].(*v10.Cluster) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -113,10 +159,10 @@ func (mr *MockOCMInterfaceMockRecorder) GetOCMAccessTokenWithConn(arg0 interface } // GetOCMEnvironment mocks base method. -func (m *MockOCMInterface) GetOCMEnvironment() (*v1.Environment, error) { +func (m *MockOCMInterface) GetOCMEnvironment() (*v10.Environment, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetOCMEnvironment") - ret0, _ := ret[0].(*v1.Environment) + ret0, _ := ret[0].(*v10.Environment) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -189,6 +235,21 @@ func (mr *MockOCMInterfaceMockRecorder) GetTargetCluster(arg0 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTargetCluster", reflect.TypeOf((*MockOCMInterface)(nil).GetTargetCluster), arg0) } +// IsClusterAccessProtectionEnabled mocks base method. +func (m *MockOCMInterface) IsClusterAccessProtectionEnabled(arg0 *sdk.Connection, arg1 string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsClusterAccessProtectionEnabled", arg0, arg1) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsClusterAccessProtectionEnabled indicates an expected call of IsClusterAccessProtectionEnabled. +func (mr *MockOCMInterfaceMockRecorder) IsClusterAccessProtectionEnabled(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClusterAccessProtectionEnabled", reflect.TypeOf((*MockOCMInterface)(nil).IsClusterAccessProtectionEnabled), arg0, arg1) +} + // IsClusterHibernating mocks base method. func (m *MockOCMInterface) IsClusterHibernating(arg0 string) (bool, error) { m.ctrl.T.Helper() diff --git a/pkg/ocm/ocmWrapper.go b/pkg/ocm/ocmWrapper.go index 4c33b514..a69b8e79 100644 --- a/pkg/ocm/ocmWrapper.go +++ b/pkg/ocm/ocmWrapper.go @@ -1,12 +1,14 @@ package ocm import ( + "errors" "fmt" "net/http" "strings" "github.com/openshift-online/ocm-cli/pkg/ocm" ocmsdk "github.com/openshift-online/ocm-sdk-go" + acctrspv1 "github.com/openshift-online/ocm-sdk-go/accesstransparency/v1" cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" logger "github.com/sirupsen/logrus" @@ -29,6 +31,10 @@ type OCMInterface interface { GetOCMEnvironment() (*cmv1.Environment, error) GetOCMAccessTokenWithConn(ocmConnection *ocmsdk.Connection) (*string, error) GetClusterInfoByIDWithConn(ocmConnection *ocmsdk.Connection, clusterID string) (*cmv1.Cluster, error) + IsClusterAccessProtectionEnabled(ocmConnection *ocmsdk.Connection, clusterID string) (bool, error) + GetClusterActiveAccessRequest(ocmConnection *ocmsdk.Connection, clusterID string) (*acctrspv1.AccessRequest, error) + CreateClusterAccessRequest(ocmConnection *ocmsdk.Connection, clusterID, reason, jiraIssueID, approvalDuration string) (*acctrspv1.AccessRequest, error) + CreateAccessRequestDecision(ocmConnection *ocmsdk.Connection, accessRequest *acctrspv1.AccessRequest, decision acctrspv1.DecisionDecision, justification string) (*acctrspv1.Decision, error) } const ( @@ -324,6 +330,112 @@ func (o *DefaultOCMInterfaceImpl) GetOCMEnvironment() (*cmv1.Environment, error) return responseEnv.Body(), nil } +func (o *DefaultOCMInterfaceImpl) IsClusterAccessProtectionEnabled(ocmConnection *ocmsdk.Connection, clusterID string) (bool, error) { + getResponse, err := ocmConnection.AccessTransparency().V1().AccessProtection().Get().ClusterId(clusterID).Send() + + if getResponse == nil || err != nil { + return false, err + } + + body := getResponse.Body() + + if body == nil { + return false, errors.New("no body in response to access protection get request") + } + + return body.Enabled(), nil +} + +func (o *DefaultOCMInterfaceImpl) GetClusterActiveAccessRequest(ocmConnection *ocmsdk.Connection, clusterID string) (*acctrspv1.AccessRequest, error) { + search := fmt.Sprintf("cluster_id = '%s' and (status.state = 'Pending' or status.state = 'Approved')", clusterID) + listResponse, err := ocmConnection.AccessTransparency().V1().AccessRequests().List().Search(search).Send() + + if err != nil { + logger.Warnf("failed to list access requests: %v", err) + + return nil, err + } + + accessRequests := listResponse.Items() + + if accessRequests == nil { + return nil, errors.New("no access requests in response to the search") + } + + if accessRequests.Len() > 1 { + logger.Warnf("more than one pending or approved access requests; retaining only the first one") + } + + if accessRequests.Empty() { + return nil, nil + } + + accessRequest := accessRequests.Get(0) + + if accessRequest == nil { + return nil, errors.New("nil access request in response to the search") + } + + return accessRequest, nil +} + +func (o *DefaultOCMInterfaceImpl) CreateClusterAccessRequest(ocmConnection *ocmsdk.Connection, clusterID, justification, jiraIssueID, approvalDuration string) (*acctrspv1.AccessRequest, error) { + requestBuilder := acctrspv1.NewAccessRequestPostRequest(). + ClusterId(clusterID). + Justification(justification). + InternalSupportCaseId(jiraIssueID). + Duration(approvalDuration) + + request, err := requestBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build access request post request: %v", err) + } + + postResponse, err := ocmConnection.AccessTransparency().V1().AccessRequests().Post().Body(request).Send() + if err != nil { + return nil, fmt.Errorf("failed to create access request: %v", err) + } + + if postResponse == nil { + return nil, errors.New("nil response to access request creation") + } + + accessRequest := postResponse.Body() + + if accessRequest == nil { + return nil, errors.New("nil access request in response to the creation") + } + + return accessRequest, nil +} + +func (o *DefaultOCMInterfaceImpl) CreateAccessRequestDecision(ocmConnection *ocmsdk.Connection, accessRequest *acctrspv1.AccessRequest, decision acctrspv1.DecisionDecision, justification string) (*acctrspv1.Decision, error) { + decisionBuilder := acctrspv1.NewDecision().Decision(decision).Justification(justification) + + decisionObj, err := decisionBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build access request decision: %v", err) + } + + addResponse, err := ocmConnection.AccessTransparency().V1().AccessRequests().AccessRequest(accessRequest.ID()).Decisions().Add().Body(decisionObj).Send() + + if err != nil { + return nil, fmt.Errorf("failed to create access request decision: %v", err) + } + + if addResponse == nil { + return nil, errors.New("nil response to access request decision creation") + } + + accessRequestDecision := addResponse.Body() + + if accessRequestDecision == nil { + return nil, errors.New("nil access request decision in response to the creation") + } + + return accessRequestDecision, nil +} + func getClusters(client *cmv1.ClustersClient, clusterKey string) ([]*cmv1.Cluster, error) { var clusters []*cmv1.Cluster diff --git a/pkg/utils/jira.go b/pkg/utils/jira.go new file mode 100644 index 00000000..0ffa84d9 --- /dev/null +++ b/pkg/utils/jira.go @@ -0,0 +1,125 @@ +package utils + +import ( + "errors" + "fmt" + + "github.com/openshift/backplane-cli/pkg/cli/config" + + "github.com/andygrunwald/go-jira" +) + +type IssueServiceInterface interface { + Create(issue *jira.Issue) (*jira.Issue, *jira.Response, error) + Get(issueID string, options *jira.GetQueryOptions) (*jira.Issue, *jira.Response, error) + Update(issue *jira.Issue) (*jira.Issue, *jira.Response, error) + GetTransitions(id string) ([]jira.Transition, *jira.Response, error) + DoTransition(ticketID, transitionID string) (*jira.Response, error) +} + +type IssueServiceGetter interface { + GetIssueService() (*jira.IssueService, error) +} + +type IssueServiceDecorator struct { + Getter IssueServiceGetter +} + +func (decorator *IssueServiceDecorator) Create(issue *jira.Issue) (*jira.Issue, *jira.Response, error) { + issueService, err := decorator.Getter.GetIssueService() + + if err != nil { + return nil, nil, err + } + + return issueService.Create(issue) +} + +func (decorator *IssueServiceDecorator) Get(issueID string, options *jira.GetQueryOptions) (*jira.Issue, *jira.Response, error) { + issueService, err := decorator.Getter.GetIssueService() + + if err != nil { + return nil, nil, err + } + + return issueService.Get(issueID, options) +} + +func (decorator *IssueServiceDecorator) Update(issue *jira.Issue) (*jira.Issue, *jira.Response, error) { + issueService, err := decorator.Getter.GetIssueService() + + if err != nil { + return nil, nil, err + } + + return issueService.Update(issue) +} + +func (decorator *IssueServiceDecorator) GetTransitions(id string) ([]jira.Transition, *jira.Response, error) { + issueService, err := decorator.Getter.GetIssueService() + + if err != nil { + return []jira.Transition{}, nil, err + } + + return issueService.GetTransitions(id) +} + +func (decorator *IssueServiceDecorator) DoTransition(ticketID, transitionID string) (*jira.Response, error) { + issueService, err := decorator.Getter.GetIssueService() + + if err != nil { + return nil, err + } + + return issueService.DoTransition(ticketID, transitionID) +} + +type DefaultIssueServiceGetterImpl struct { + issueService *jira.IssueService +} + +func createIssueService() (*jira.IssueService, error) { + bpConfig, err := config.GetBackplaneConfiguration() + if err != nil { + return nil, fmt.Errorf("failed to load backplane config: %v", err) + } + + if bpConfig.JiraToken == "" { + return nil, fmt.Errorf("JIRA token is not defined, consider defining it running 'ocm-backplane config set %s '", config.JiraTokenViperKey) + } + + transport := jira.PATAuthTransport{ + Token: bpConfig.JiraToken, + } + + jiraClient, err := jira.NewClient(transport.Client(), bpConfig.JiraBaseURL) + + if err != nil || jiraClient == nil { + return nil, fmt.Errorf("failed to create the JIRA client: %v", err) + } + + issueService := jiraClient.Issue + + if issueService == nil { + return nil, errors.New("no issue service in the JIRA client") + } + + return issueService, nil +} + +func (getter *DefaultIssueServiceGetterImpl) GetIssueService() (*jira.IssueService, error) { + if getter.issueService == nil { + issueService, err := createIssueService() + + if err != nil { + return nil, err + } + + getter.issueService = issueService + } + + return getter.issueService, nil +} + +var DefaultIssueService IssueServiceInterface = &IssueServiceDecorator{Getter: &DefaultIssueServiceGetterImpl{}} diff --git a/pkg/utils/mocks/jiraMock.go b/pkg/utils/mocks/jiraMock.go new file mode 100644 index 00000000..c2a975a3 --- /dev/null +++ b/pkg/utils/mocks/jiraMock.go @@ -0,0 +1,114 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/openshift/backplane-cli/pkg/utils (interfaces: IssueServiceInterface) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + jira "github.com/andygrunwald/go-jira" + gomock "github.com/golang/mock/gomock" +) + +// MockIssueServiceInterface is a mock of IssueServiceInterface interface. +type MockIssueServiceInterface struct { + ctrl *gomock.Controller + recorder *MockIssueServiceInterfaceMockRecorder +} + +// MockIssueServiceInterfaceMockRecorder is the mock recorder for MockIssueServiceInterface. +type MockIssueServiceInterfaceMockRecorder struct { + mock *MockIssueServiceInterface +} + +// NewMockIssueServiceInterface creates a new mock instance. +func NewMockIssueServiceInterface(ctrl *gomock.Controller) *MockIssueServiceInterface { + mock := &MockIssueServiceInterface{ctrl: ctrl} + mock.recorder = &MockIssueServiceInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIssueServiceInterface) EXPECT() *MockIssueServiceInterfaceMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockIssueServiceInterface) Create(arg0 *jira.Issue) (*jira.Issue, *jira.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0) + ret0, _ := ret[0].(*jira.Issue) + ret1, _ := ret[1].(*jira.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Create indicates an expected call of Create. +func (mr *MockIssueServiceInterfaceMockRecorder) Create(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockIssueServiceInterface)(nil).Create), arg0) +} + +// DoTransition mocks base method. +func (m *MockIssueServiceInterface) DoTransition(arg0, arg1 string) (*jira.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DoTransition", arg0, arg1) + ret0, _ := ret[0].(*jira.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DoTransition indicates an expected call of DoTransition. +func (mr *MockIssueServiceInterfaceMockRecorder) DoTransition(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DoTransition", reflect.TypeOf((*MockIssueServiceInterface)(nil).DoTransition), arg0, arg1) +} + +// Get mocks base method. +func (m *MockIssueServiceInterface) Get(arg0 string, arg1 *jira.GetQueryOptions) (*jira.Issue, *jira.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(*jira.Issue) + ret1, _ := ret[1].(*jira.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Get indicates an expected call of Get. +func (mr *MockIssueServiceInterfaceMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIssueServiceInterface)(nil).Get), arg0, arg1) +} + +// GetTransitions mocks base method. +func (m *MockIssueServiceInterface) GetTransitions(arg0 string) ([]jira.Transition, *jira.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTransitions", arg0) + ret0, _ := ret[0].([]jira.Transition) + ret1, _ := ret[1].(*jira.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetTransitions indicates an expected call of GetTransitions. +func (mr *MockIssueServiceInterfaceMockRecorder) GetTransitions(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTransitions", reflect.TypeOf((*MockIssueServiceInterface)(nil).GetTransitions), arg0) +} + +// Update mocks base method. +func (m *MockIssueServiceInterface) Update(arg0 *jira.Issue) (*jira.Issue, *jira.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", arg0) + ret0, _ := ret[0].(*jira.Issue) + ret1, _ := ret[1].(*jira.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Update indicates an expected call of Update. +func (mr *MockIssueServiceInterfaceMockRecorder) Update(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockIssueServiceInterface)(nil).Update), arg0) +}