diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 8d05761..a1c4df8 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -14,4 +14,4 @@ echo "Executing golang tests..." cd "$WORK_DIR/api" && go test ./... echo "Executing yarn linting..." -cd "$WORK_DIR/web" && yarn lint +cd "$WORK_DIR/web" && yarn lint && yarn test --watchAll=false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b152701..6ce5f75 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,7 @@ jobs: run: | cd web yarn install + yarn test --watchAll=false yarn build - name: Set up Go 1.x diff --git a/api/actions/instance.go b/api/actions/instance.go new file mode 100644 index 0000000..fec2e0f --- /dev/null +++ b/api/actions/instance.go @@ -0,0 +1,45 @@ +package actions + +import ( + "fmt" + "gnt-cc/rapi_client" + "strconv" + "strings" +) + +type rapiActionToMethodMap map[string](func(string, string, interface{}) (rapi_client.Response, error)) + +type InstanceActions struct { + RAPIClient rapi_client.Client +} + +func (actions *InstanceActions) PerformSimpleInstanceAction(clusterName string, instanceName string, rapiAction string) (int, error) { + rapiActionToMethodMapping := rapiActionToMethodMap{ + "startup": actions.RAPIClient.Put, + "reboot": actions.RAPIClient.Post, + "shutdown": actions.RAPIClient.Put, + "migrate": actions.RAPIClient.Put, + "failover": actions.RAPIClient.Put, + } + + rapiMethod, exists := rapiActionToMethodMapping[rapiAction] + + if !exists { + return 0, fmt.Errorf("cannot find rapiClient function for action '%s'", rapiAction) + } + + slug := fmt.Sprintf("/2/instances/%s/%s", instanceName, rapiAction) + response, err := rapiMethod(clusterName, slug, nil) + + if err != nil { + return 0, err + } + + jobID, err := strconv.Atoi(strings.TrimSpace(response.Body)) + + if err != nil { + return 0, fmt.Errorf("cannot parse RAPI response") + } + + return jobID, nil +} diff --git a/api/actions/instance_test.go b/api/actions/instance_test.go new file mode 100644 index 0000000..474c7cf --- /dev/null +++ b/api/actions/instance_test.go @@ -0,0 +1,57 @@ +package actions_test + +import ( + "errors" + "gnt-cc/actions" + "gnt-cc/mocking" + "gnt-cc/rapi_client" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var rapiActions = []string{ + "startup", + "reboot", + "shutdown", +} + +func TestInstanceMethodReturnsError_WhenRAPIReturnsError(t *testing.T) { + client := mocking.NewRAPIClient() + client.On("Post", "testClusterName", mock.Anything, nil). + Return(rapi_client.Response{}, errors.New("expected error")) + client.On("Put", "testClusterName", mock.Anything, nil). + Return(rapi_client.Response{}, errors.New("expected error")) + + actions := actions.InstanceActions{RAPIClient: client} + + for _, rapiAction := range rapiActions { + _, err := actions.PerformSimpleInstanceAction("testClusterName", "testInstanceName", rapiAction) + assert.EqualError(t, err, "expected error") + } +} + +func TestRAPIEndpointIsCalled_WhenInvokingInstanceMethod(t *testing.T) { + client := mocking.NewRAPIClient() + client.On("Put", "testClusterName", "/2/instances/testInstanceName/startup", nil). + Once().Return(rapi_client.Response{ + Body: "423458", + }, nil) + client.On("Post", "testClusterName", "/2/instances/testInstanceName/reboot", nil). + Once().Return(rapi_client.Response{ + Body: "423458", + }, nil) + client.On("Put", "testClusterName", "/2/instances/testInstanceName/shutdown", nil). + Once().Return(rapi_client.Response{ + Body: "423458", + }, nil) + + actions := actions.InstanceActions{RAPIClient: client} + + for _, rapiAction := range rapiActions { + jobId, err := actions.PerformSimpleInstanceAction("testClusterName", "testInstanceName", rapiAction) + assert.Nil(t, err) + assert.Equal(t, jobId, 423458) + } +} diff --git a/api/controllers/instance.go b/api/controllers/instance.go index 78a1d6e..854bd0e 100644 --- a/api/controllers/instance.go +++ b/api/controllers/instance.go @@ -10,6 +10,7 @@ import ( type InstanceController struct { Repository instanceRepository + Actions instanceActions } // GetAll godoc @@ -37,6 +38,72 @@ func (controller *InstanceController) GetAll(c *gin.Context) { }) } +// Start godoc +// @Summary Start an instance in a given cluster +// @Description ... +// @Produce json +// @Success 200 {object} model.JobIDResponse +// @Router /clusters/{cluster}/instances/{instance}/start [post] +func (controller *InstanceController) Start(c *gin.Context) { + controller.SimpleAction(c, "startup") +} + +// Restart godoc +// @Summary Restart an instance in a given cluster +// @Description ... +// @Produce json +// @Success 200 {object} model.JobIDResponse +// @Router /clusters/{cluster}/instances/{instance}/restart [post] +func (controller *InstanceController) Restart(c *gin.Context) { + controller.SimpleAction(c, "reboot") +} + +// Shutdown godoc +// @Summary Shutdown an instance in a given cluster +// @Description ... +// @Produce json +// @Success 200 {object} model.JobIDResponse +// @Router /clusters/{cluster}/instances/{instance}/shutdown [post] +func (controller *InstanceController) Shutdown(c *gin.Context) { + controller.SimpleAction(c, "shutdown") +} + +// Migrate godoc +// @Summary Migrate an instance in a given cluster +// @Description ... +// @Produce json +// @Success 200 {object} model.JobIDResponse +// @Router /clusters/{cluster}/instances/{instance}/migrate [post] +func (controller *InstanceController) Migrate(c *gin.Context) { + controller.SimpleAction(c, "migrate") +} + +// Failover godoc +// @Summary Failover an instance in a given cluster +// @Description ... +// @Produce json +// @Success 200 {object} model.JobIDResponse +// @Router /clusters/{cluster}/instances/{instance}/failover [post] +func (controller *InstanceController) Failover(c *gin.Context) { + controller.SimpleAction(c, "failover") +} + +func (controller *InstanceController) SimpleAction(c *gin.Context, action string) { + clusterName := c.Param("cluster") + instanceName := c.Param("instance") + + jobID, err := controller.Actions.PerformSimpleInstanceAction(clusterName, instanceName, action) + + if err != nil { + abortWithInternalServerError(c, err) + return + } + + c.JSON(200, model.JobIDResponse{ + JobID: jobID, + }) +} + // Get godoc // @Summary Get an instance in a given cluster // @Description ... diff --git a/api/controllers/types.go b/api/controllers/types.go index bd7e415..10e0fa5 100644 --- a/api/controllers/types.go +++ b/api/controllers/types.go @@ -17,4 +17,8 @@ type ( GetAll(clusterName string) ([]model.GntJob, error) Get(clusterName, jobID string) (model.JobResult, error) } + + instanceActions interface { + PerformSimpleInstanceAction(clusterName string, instanceName string, rapiAction string) (int, error) + } ) diff --git a/api/go.mod b/api/go.mod index fef77c1..7221ab3 100644 --- a/api/go.mod +++ b/api/go.mod @@ -7,14 +7,17 @@ require ( github.com/appleboy/gin-jwt/v2 v2.7.0 github.com/gin-contrib/cors v1.3.1 github.com/gin-gonic/gin v1.7.4 + github.com/gofrs/uuid v4.0.0+incompatible // indirect github.com/gorilla/websocket v1.4.2 + github.com/jarcoal/httpmock v1.0.8 github.com/jtblin/go-ldap-client v0.0.0-20170223121919-b73f66626b33 + github.com/shopspring/decimal v1.2.0 // indirect github.com/sirupsen/logrus v1.8.1 github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.7.0 github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 github.com/swaggo/gin-swagger v1.3.1 - github.com/swaggo/swag v1.7.4 + github.com/swaggo/swag v1.7.9 gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect gopkg.in/ldap.v2 v2.5.1 // indirect ) diff --git a/api/go.sum b/api/go.sum index d715970..cefed90 100644 --- a/api/go.sum +++ b/api/go.sum @@ -49,6 +49,7 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -71,6 +72,7 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/daaku/go.zipexe v1.0.0 h1:VSOgZtH418pH9L16hC/JrgSNJbbAL26pj7lmD1+CGdY= @@ -88,6 +90,7 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/cors v1.3.1 h1:doAsuITavI4IOcd0Y19U4B+O0dNWihRyX//nn4sEmgA= github.com/gin-contrib/cors v1.3.1/go.mod h1:jjEJ4268OPZUcU7k9Pm653S7lXUGcqMADzFA61xsmDk= @@ -112,13 +115,19 @@ github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3Hfo github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM= github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= github.com/go-openapi/spec v0.20.3 h1:uH9RQ6vdyPSs2pSy9fL8QPspDF2AMIMPtmK5coSSjtQ= github.com/go-openapi/spec v0.20.3/go.mod h1:gG4F8wdEDN+YPBMVnzE85Rbhf+Th2DTvA9nFPQ5AYEg= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng= github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= @@ -225,6 +234,8 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jarcoal/httpmock v1.0.8 h1:8kI16SoO6LQKgPE7PvQuV+YuD/inwHd7fOOe2zMbo4k= +github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= @@ -283,6 +294,11 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc= +github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -294,10 +310,12 @@ github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndr github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -333,6 +351,8 @@ github.com/swaggo/gin-swagger v1.3.1/go.mod h1:Z6NtRBK2PRig0EUmy1Xu75CnCEs6vGYu9 github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y= github.com/swaggo/swag v1.7.4 h1:up+ixy8yOqJKiFcuhMgkuYuF4xnevuhnFAXXF8OSfNg= github.com/swaggo/swag v1.7.4/go.mod h1:zD8h6h4SPv7t3l+4BKdRquqW1ASWjKZgT6Qv9z3kNqI= +github.com/swaggo/swag v1.7.9 h1:6vCG5mm43ebDzGlZPMGYrYI4zKFfOr5kicQX8qjeDwc= +github.com/swaggo/swag v1.7.9/go.mod h1:gZ+TJ2w/Ve1RwQsA2IRoSOTidHz6DX+PIG8GWvbnoLU= github.com/tidwall/gjson v1.9.4 h1:oNis7dk9Rs3dKJNNigXZT1MTOiJeBtpurn+IpCB75MY= github.com/tidwall/gjson v1.9.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -346,7 +366,9 @@ github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljT github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.1.13 h1:013LbFhocBoIqgHeIHKlV4JWYhqogATYWZhIcH0WHn4= github.com/ugorji/go/codec v1.1.13/go.mod h1:oNVt3Dq+FO91WNQ/9JnHKQP2QJxTzoN7wCBFCq1OeuU= +github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= @@ -355,6 +377,7 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= @@ -453,6 +476,9 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQcQGzbisXo31MIeenXI= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -520,8 +546,12 @@ golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -531,6 +561,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -588,6 +621,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.2 h1:kRBLX7v7Af8W7Gdbbc908OJcdgtK8bOz9Uaj8/F1ACA= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ= +golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/api/mocking/http_client.go b/api/mocking/http_client.go deleted file mode 100644 index 77f345f..0000000 --- a/api/mocking/http_client.go +++ /dev/null @@ -1,54 +0,0 @@ -package mocking - -import ( - "bytes" - "io" - "io/ioutil" - "net/http" - - "github.com/stretchr/testify/mock" -) - -type httpClient struct { - mock.Mock -} - -func NewHTTPClient() *httpClient { - return new(httpClient) -} - -func (mock *httpClient) Get(url string) (*http.Response, error) { - args := mock.Called(url) - - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*http.Response), args.Error(1) -} - -func (mock *httpClient) Post(url string, contentType string, body io.Reader) (*http.Response, error) { - args := mock.Called(url, contentType, body) - - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*http.Response), args.Error(1) -} - -func MakeSuccessResponse(response string) *http.Response { - return &http.Response{ - StatusCode: 200, - Status: "200 OK", - Body: ioutil.NopCloser(bytes.NewBufferString(response)), - Header: make(http.Header), - } -} - -func MakeNotFoundResponse() *http.Response { - return &http.Response{ - StatusCode: 404, - Status: "404 NOT FOUND", - Body: ioutil.NopCloser(bytes.NewBufferString("Not Found")), - Header: make(http.Header), - } -} diff --git a/api/mocking/rapi_client.go b/api/mocking/rapi_client.go index 9c0ef16..1fafa1c 100644 --- a/api/mocking/rapi_client.go +++ b/api/mocking/rapi_client.go @@ -1,8 +1,9 @@ package mocking import ( - "github.com/stretchr/testify/mock" "gnt-cc/rapi_client" + + "github.com/stretchr/testify/mock" ) type rapiClient struct { @@ -24,3 +25,9 @@ func (mock *rapiClient) Post(clusterName string, slug string, body interface{}) return args.Get(0).(rapi_client.Response), args.Error(1) } + +func (mock *rapiClient) Put(clusterName string, slug string, body interface{}) (rapi_client.Response, error) { + args := mock.Called(clusterName, slug, body) + + return args.Get(0).(rapi_client.Response), args.Error(1) +} diff --git a/api/model/response.go b/api/model/response.go index 0b13289..922434b 100644 --- a/api/model/response.go +++ b/api/model/response.go @@ -43,6 +43,10 @@ type JobResponse struct { Job GntJob `json:"job"` } +type JobIDResponse struct { + JobID int `json:"jobId"` +} + type ErrorResponse struct { Message string `json:"message"` } diff --git a/api/rapi_client/client.go b/api/rapi_client/client.go index 7581176..18ced68 100644 --- a/api/rapi_client/client.go +++ b/api/rapi_client/client.go @@ -4,23 +4,19 @@ import ( "errors" "fmt" "gnt-cc/config" - "io" "net/http" + "time" ) -type HTTPClient interface { - Get(url string) (*http.Response, error) - Post(url string, contentType string, body io.Reader) (*http.Response, error) -} - type Client interface { Get(clusterName string, slug string) (Response, error) Post(clusterName string, slug string, body interface{}) (Response, error) + Put(clusterName string, slug string, body interface{}) (Response, error) } type rapiClient struct { clusterUrls map[string]string - http HTTPClient + http *http.Client } type Response struct { @@ -28,7 +24,7 @@ type Response struct { Body string } -func New(httpClient HTTPClient, clusterConfigs []config.ClusterConfig) (*rapiClient, error) { +func New(clusterConfigs []config.ClusterConfig, transport http.RoundTripper) (*rapiClient, error) { urlMap, err := validateAndCreateClusterUrls(clusterConfigs) if err != nil { @@ -37,7 +33,10 @@ func New(httpClient HTTPClient, clusterConfigs []config.ClusterConfig) (*rapiCli return &rapiClient{ clusterUrls: urlMap, - http: httpClient, + http: &http.Client{ + Timeout: time.Second * 10, + Transport: transport, + }, }, nil } diff --git a/api/rapi_client/client_test.go b/api/rapi_client/client_test.go index 2d971ba..f52d942 100644 --- a/api/rapi_client/client_test.go +++ b/api/rapi_client/client_test.go @@ -3,7 +3,6 @@ package rapi_client_test import ( "fmt" "gnt-cc/config" - "gnt-cc/mocking" "gnt-cc/rapi_client" "io/ioutil" "net/http" @@ -53,35 +52,32 @@ func makeResponseWithBodyReaderReturningAnError() *http.Response { } func TestCreatingRAPIClientShouldNotBePossible_WhenAClusterConfigHasNoName(t *testing.T) { - httpClient := mocking.NewHTTPClient() - client, err := rapi_client.New(httpClient, []config.ClusterConfig{{ + client, err := rapi_client.New([]config.ClusterConfig{{ Hostname: "test", }, { Name: "test", - }}) + }}, nil) assert.NotNil(t, err) assert.Nil(t, client) } func TestCreatingRAPIClientShouldNotBePossible_WhenClusterNamesAreNotUnique(t *testing.T) { - httpClient := mocking.NewHTTPClient() - client, err := rapi_client.New(httpClient, []config.ClusterConfig{{ + client, err := rapi_client.New([]config.ClusterConfig{{ Name: "test", }, { Name: "test", - }}) + }}, nil) assert.NotNil(t, err) assert.Nil(t, client) } func TestCreatingRAPIClientShouldBePossible_WhenAllClusterConfigsAreValid(t *testing.T) { - httpClient := mocking.NewHTTPClient() - client, err := rapi_client.New(httpClient, []config.ClusterConfig{ + client, err := rapi_client.New([]config.ClusterConfig{ createTestConfigNoSSL("test1"), createTestConfigSSL("test2"), - }) + }, nil) assert.Nil(t, err) assert.NotNil(t, client) diff --git a/api/rapi_client/requests.go b/api/rapi_client/requests.go index 8e40937..5504dc3 100644 --- a/api/rapi_client/requests.go +++ b/api/rapi_client/requests.go @@ -24,30 +24,46 @@ func (client rapiClient) Get(clusterName string, slug string) (Response, error) return parseRAPIResponse(httpResponse) } -func (client rapiClient) Post(clusterName string, slug string, postData interface{}) (Response, error) { +func (client rapiClient) Post(clusterName string, slug string, body interface{}) (Response, error) { + return client.modify(clusterName, slug, body, http.MethodPost) +} + +func (client rapiClient) Put(clusterName string, slug string, body interface{}) (Response, error) { + return client.modify(clusterName, slug, body, http.MethodPut) +} + +func (client rapiClient) modify(clusterName string, slug string, body interface{}, method string) (Response, error) { clusterURL, exists := client.clusterUrls[clusterName] if !exists { return Response{}, fmt.Errorf("cluster not found: %s", clusterName) } - jsonBody, err := json.Marshal(postData) + jsonBody, err := json.Marshal(body) if err != nil { return Response{}, fmt.Errorf("could not prepare request: %s", err) } - httpResponse, err := client.http.Post( + request, err := http.NewRequest( + method, clusterURL+slug, - "application/json", bytes.NewBuffer(jsonBody), ) + if err != nil { + return Response{}, err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := client.http.Do(request) + if err != nil { return Response{}, fmt.Errorf("request error: %s", err) } - return parseRAPIResponse(httpResponse) + return parseRAPIResponse(response) } func parseRAPIResponse(httpResponse *http.Response) (Response, error) { diff --git a/api/rapi_client/requests_test.go b/api/rapi_client/requests_test.go index 69c1f06..303c420 100644 --- a/api/rapi_client/requests_test.go +++ b/api/rapi_client/requests_test.go @@ -1,91 +1,73 @@ package rapi_client_test import ( - "bytes" - "fmt" "gnt-cc/config" - "gnt-cc/mocking" "gnt-cc/rapi_client" "testing" + "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" ) -func prepareNonExistingClusterClient() rapi_client.Client { - httpClient := mocking.NewHTTPClient() - client, _ := rapi_client.New(httpClient, getDefaultTestClusters()) - return client -} - -func TestGetFuncReturnsError_WhenANonExistingClusterIsPassedIn(t *testing.T) { - client := prepareNonExistingClusterClient() - _, err := client.Get("test1", "/") - assert.NotNil(t, err) -} - -func TestPostFuncReturnsError_WhenANonExistingClusterIsPassedIn(t *testing.T) { - client := prepareNonExistingClusterClient() - _, err := client.Post("test1", "/", "") - assert.NotNil(t, err) -} - -func getTestClustersWithURLs() (clusters []config.ClusterConfig, urls []string) { +func getTestClusters() []config.ClusterConfig { return []config.ClusterConfig{ - { - Name: "test1", - Hostname: "test1.gnt", - Port: 5080, - Username: "test", - Password: "supersecret", - SSL: true, - }, - { - Name: "test3", - Hostname: "test3.other.gnt", - Port: 5090, - Username: "test", - Password: "supersecret3", - SSL: false, - }, - }, []string{ - "https://test:supersecret@test1.gnt:5080/", - "http://test:supersecret3@test3.other.gnt:5090/", - } + { + Name: "test1", + Hostname: "test1.gnt", + Port: 5080, + Username: "test", + Password: "supersecret", + SSL: true, + }, + { + Name: "test3", + Hostname: "test3.other.gnt", + Port: 5090, + Username: "test", + Password: "supersecret3", + SSL: false, + }, + } } func TestGetMethodCallsCorrectURLs(t *testing.T) { - httpClient := mocking.NewHTTPClient() - clusters, urls := getTestClustersWithURLs() - for _, url := range urls { - httpClient.On("Get", url+"test"). - Once(). - Return(mocking.MakeSuccessResponse("Test"), nil) - } - client, err := rapi_client.New(httpClient, clusters) + mock := httpmock.NewMockTransport() + mock.RegisterResponder("GET", "=~.*", + httpmock.NewStringResponder(200, "Test")) + + clusters := getTestClusters() + client, err := rapi_client.New(clusters, mock) + assert.NoError(t, err) + for _, cluster := range clusters { _, err = client.Get(cluster.Name, "/test") assert.NoError(t, err) } - httpClient.AssertExpectations(t) + + info := mock.GetCallCountInfo() + assert.Equal(t, 1, info["GET https://test:supersecret@test1.gnt:5080/test"]) + assert.Equal(t, 1, info["GET http://test:supersecret3@test3.other.gnt:5090/test"]) } func TestPostMethodCallsCorrectURLs(t *testing.T) { - httpClient := mocking.NewHTTPClient() - clusters, urls := getTestClustersWithURLs() - for _, url := range urls { - httpClient.On("Post", url+"test", mock.Anything, mock.Anything). - Once(). - Return(mocking.MakeSuccessResponse("Test"), nil) - } - client, err := rapi_client.New(httpClient, clusters) + mock := httpmock.NewMockTransport() + mock.RegisterResponder("POST", "=~.*", + httpmock.NewStringResponder(200, "Test")) + + clusters := getTestClusters() + client, err := rapi_client.New(clusters, mock) + assert.NoError(t, err) + for _, cluster := range clusters { _, err = client.Post(cluster.Name, "/test", "") assert.NoError(t, err) } - httpClient.AssertExpectations(t) + + info := mock.GetCallCountInfo() + assert.Equal(t, 1, info["POST https://test:supersecret@test1.gnt:5080/test"]) + assert.Equal(t, 1, info["POST http://test:supersecret3@test3.other.gnt:5090/test"]) } func TestPostMethodWillReturnError_WhenRequestBodyIsInvalid(t *testing.T) { @@ -93,42 +75,23 @@ func TestPostMethodWillReturnError_WhenRequestBodyIsInvalid(t *testing.T) { Test func() `json:"test"` }{Test: func() {}} - httpClient := mocking.NewHTTPClient() - client, err := rapi_client.New(httpClient, []config.ClusterConfig{createTestConfigSSL("test1")}) + mock := httpmock.NewMockTransport() + + client, err := rapi_client.New([]config.ClusterConfig{createTestConfigSSL("test1")}, mock) assert.NoError(t, err) _, err = client.Post("test1", "/test", invalidBody) - httpClient.AssertNotCalled(t, "Post") - assert.NotNil(t, err) -} - -func TestGetMethodWillReturnErrorThrownByHTTPClient(t *testing.T) { - httpClient := mocking.NewHTTPClient() - httpClient.On("Get", mock.Anything). - Once(). - Return(nil, fmt.Errorf("fail")) - client, _ := rapi_client.New(httpClient, getDefaultTestClusters()) - _, err := client.Get("test", "/") - assert.NotNil(t, err) -} - -func TestPostMethodWillReturnErrorThrownByHTTPClient(t *testing.T) { - httpClient := mocking.NewHTTPClient() - httpClient.On("Post", mock.Anything, mock.Anything, mock.Anything). - Once(). - Return(nil, fmt.Errorf("fail")) - client, _ := rapi_client.New(httpClient, getDefaultTestClusters()) - _, err := client.Post("test", "/", "") assert.NotNil(t, err) + info := mock.GetCallCountInfo() + assert.Equal(t, 0, info["POST https://test:supersecret@test1.gnt:5080/test"]) } func TestGetFuncWillCorrectlyParseResponseBody(t *testing.T) { - httpClient := mocking.NewHTTPClient() - httpClient. - On("Get", mock.Anything). - Once(). - Return(mocking.MakeSuccessResponse(`{ "test": "Test" }`), nil) - client, _ := rapi_client.New(httpClient, getDefaultTestClusters()) + mock := httpmock.NewMockTransport() + mock.RegisterResponder("GET", "=~.*", + httpmock.NewStringResponder(200, `{ "test": "Test" }`)) + + client, _ := rapi_client.New(getDefaultTestClusters(), mock) response, err := client.Get("test", "/") assert.NoError(t, err) assert.EqualValues(t, 200, response.Status) @@ -136,72 +99,33 @@ func TestGetFuncWillCorrectlyParseResponseBody(t *testing.T) { } func TestPostFuncWillCorrectlyParseResponseBody(t *testing.T) { - httpClient := mocking.NewHTTPClient() - httpClient. - On("Post", mock.Anything, mock.Anything, mock.Anything). - Once(). - Return(mocking.MakeSuccessResponse(`{ "test": "Test" }`), nil) - client, _ := rapi_client.New(httpClient, getDefaultTestClusters()) + mock := httpmock.NewMockTransport() + mock.RegisterResponder("POST", "=~.*", + httpmock.NewStringResponder(200, `{ "test": "Test" }`)) + + client, _ := rapi_client.New(getDefaultTestClusters(), mock) response, err := client.Post("test", "/", "") assert.NoError(t, err) assert.EqualValues(t, 200, response.Status) assert.EqualValues(t, `{ "test": "Test" }`, response.Body) } -func TestPostFuncWillCorrectlyPrepareRequestBody(t *testing.T) { - httpClient := mocking.NewHTTPClient() - httpClient. - On("Post", mock.Anything, mock.Anything, bytes.NewBufferString(`{"test":"Test"}`)). - Once(). - Return(mocking.MakeSuccessResponse(""), nil) - client, _ := rapi_client.New(httpClient, getDefaultTestClusters()) - body := struct { - Test string `json:"test"` - }{ - Test: "Test", - } - _, _ = client.Post("test", "/", body) - httpClient.AssertExpectations(t) -} - func TestGetFuncWillReturnErrorHTTPStatusCode(t *testing.T) { - httpClient := mocking.NewHTTPClient() - httpClient. - On("Get", mock.Anything). - Once(). - Return(mocking.MakeNotFoundResponse(), nil) - client, _ := rapi_client.New(httpClient, getDefaultTestClusters()) + mock := httpmock.NewMockTransport() + mock.RegisterResponder("GET", "=~.*", + httpmock.NewStringResponder(404, "")) + + client, _ := rapi_client.New(getDefaultTestClusters(), mock) response, _ := client.Get("test", "/") assert.EqualValues(t, 404, response.Status) } func TestPostFuncWillReturnErrorHTTPStatusCode(t *testing.T) { - httpClient := mocking.NewHTTPClient() - httpClient. - On("Post", mock.Anything, mock.Anything, mock.Anything). - Once(). - Return(mocking.MakeNotFoundResponse(), nil) - client, _ := rapi_client.New(httpClient, getDefaultTestClusters()) + mock := httpmock.NewMockTransport() + mock.RegisterResponder("POST", "=~.*", + httpmock.NewStringResponder(404, "")) + + client, _ := rapi_client.New(getDefaultTestClusters(), mock) response, _ := client.Post("test", "/", "") assert.EqualValues(t, 404, response.Status) } - -func TestGetFuncReturnsError_WhenBodyCannotBeRead(t *testing.T) { - httpClient := mocking.NewHTTPClient() - client, _ := rapi_client.New(httpClient, []config.ClusterConfig{createTestConfigSSL("test1")}) - httpClient.On("Get", mock.Anything). - Once(). - Return(makeResponseWithBodyReaderReturningAnError(), nil) - _, err := client.Get("test1", "/") - assert.NotNil(t, err) -} - -func TestPostFuncReturnsError_WhenBodyCannotBeRead(t *testing.T) { - httpClient := mocking.NewHTTPClient() - client, _ := rapi_client.New(httpClient, []config.ClusterConfig{createTestConfigSSL("test1")}) - httpClient.On("Post", mock.Anything, mock.Anything, mock.Anything). - Once(). - Return(makeResponseWithBodyReaderReturningAnError(), nil) - _, err := client.Post("test1", "/", "") - assert.NotNil(t, err) -} diff --git a/api/router/router.go b/api/router/router.go index c9f23d5..71a659b 100644 --- a/api/router/router.go +++ b/api/router/router.go @@ -2,6 +2,7 @@ package router import ( "crypto/tls" + "gnt-cc/actions" auth2 "gnt-cc/auth" "gnt-cc/config" "gnt-cc/controllers" @@ -47,6 +48,7 @@ func New(engine *gin.Engine) *router { } instanceRepository := repository.InstanceRepository{RAPIClient: rapiClient, QueryPerformer: &query.Performer{}} + instanceActions := actions.InstanceActions{RAPIClient: rapiClient} groupRepository := repository.GroupRepository{RAPIClient: rapiClient} nodeRepository := repository.NodeRepository{RAPIClient: rapiClient, GroupRepository: groupRepository} jobRepository := repository.JobRepository{RAPIClient: rapiClient} @@ -58,6 +60,7 @@ func New(engine *gin.Engine) *router { r.clusterController = controllers.ClusterController{} r.instanceController = controllers.InstanceController{ Repository: &instanceRepository, + Actions: &instanceActions, } r.nodeController = controllers.NodeController{ Repository: &nodeRepository, @@ -74,23 +77,18 @@ func New(engine *gin.Engine) *router { return &r } -func createHTTPClient(skipCertificateVerify bool) *http.Client { - transport := &http.Transport{ +func createRAPIClientFromConfig(configs []config.ClusterConfig, rapiConfig config.RapiConfig) (rapi_client.Client, error) { + return rapi_client.New(configs, createHTTPTransport(rapiConfig.SkipCertificateVerify)) +} + +func createHTTPTransport(skipCertificateVerify bool) *http.Transport { + return &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: skipCertificateVerify}, Dial: (&net.Dialer{ Timeout: 5 * time.Second, }).Dial, TLSHandshakeTimeout: 5 * time.Second, } - - return &http.Client{ - Timeout: time.Second * 10, - Transport: transport, - } -} - -func createRAPIClientFromConfig(configs []config.ClusterConfig, rapiConfig config.RapiConfig) (rapi_client.Client, error) { - return rapi_client.New(createHTTPClient(rapiConfig.SkipCertificateVerify), configs) } func (r *router) InitTemplates(box *rice.Box) { @@ -134,6 +132,11 @@ func (r *router) SetupAPIRoutes() { withCluster.GET("/instances", r.instanceController.GetAll) withCluster.GET("/instances/:instance", r.instanceController.Get) withCluster.GET("/instances/:instance/console", r.instanceController.OpenInstanceConsole) + withCluster.POST("/instances/:instance/start", r.instanceController.Start) + withCluster.POST("/instances/:instance/restart", r.instanceController.Restart) + withCluster.POST("/instances/:instance/shutdown", r.instanceController.Shutdown) + withCluster.POST("/instances/:instance/migrate", r.instanceController.Migrate) + withCluster.POST("/instances/:instance/failover", r.instanceController.Failover) withCluster.GET("/statistics", r.statisticsController.Get) withCluster.GET("/jobs", r.jobController.GetAll) withCluster.GET("/jobs/many", r.jobController.GetManyWithLogs) diff --git a/web/package.json b/web/package.json index 0a00ce8..a76d912 100644 --- a/web/package.json +++ b/web/package.json @@ -25,6 +25,7 @@ "eslint-plugin-prettier": "^3.3.1", "eslint-plugin-react": "^7.22.0", "formik": "^2.2.6", + "msw": "^0.36.8", "prettier": "^2.2.1", "react": "^17.0.1", "react-data-table-component": "^6.11.6", diff --git a/web/src/api/models.ts b/web/src/api/models.ts index 03f0c29..a78c3c6 100644 --- a/web/src/api/models.ts +++ b/web/src/api/models.ts @@ -70,3 +70,7 @@ export type GntJobLogEntry = { export type GntJobWithLog = GntJob & { log: GntJobLogEntry[]; }; + +export type JobIdResponse = { + jobId: number; +}; diff --git a/web/src/components/InstanceActionConfirmationModal/InstanceActionConfirmationModal.module.scss b/web/src/components/InstanceActionConfirmationModal/InstanceActionConfirmationModal.module.scss new file mode 100644 index 0000000..8578ffa --- /dev/null +++ b/web/src/components/InstanceActionConfirmationModal/InstanceActionConfirmationModal.module.scss @@ -0,0 +1,5 @@ +.buttons { + display: flex; + gap: 1rem; + margin-top: 2rem; +} diff --git a/web/src/components/InstanceActionConfirmationModal/InstanceActionConfirmationModal.tsx b/web/src/components/InstanceActionConfirmationModal/InstanceActionConfirmationModal.tsx new file mode 100644 index 0000000..6842588 --- /dev/null +++ b/web/src/components/InstanceActionConfirmationModal/InstanceActionConfirmationModal.tsx @@ -0,0 +1,42 @@ +import React, { ReactElement } from "react"; +import Button from "../Button/Button"; +import Modal from "../Modal/Modal"; +import Name from "../Name/Name"; +import styles from "./InstanceActionConfirmationModal.module.scss"; + +type Props = { + isVisible: boolean; + actionName: string; + instanceName: string; + onConfirm: () => void; + onHide: () => void; +}; + +function InstanceActionConfirmationModal({ + isVisible, + actionName, + instanceName, + onConfirm, + onHide, +}: Props): ReactElement { + return ( + +

Are you sure you would like to {actionName}

+ {instanceName} + +
+
+
+ ); +} + +export default InstanceActionConfirmationModal; diff --git a/web/src/components/InstanceActions/InstanceActions.module.scss b/web/src/components/InstanceActions/InstanceActions.module.scss new file mode 100644 index 0000000..e0def2c --- /dev/null +++ b/web/src/components/InstanceActions/InstanceActions.module.scss @@ -0,0 +1,6 @@ +.wrapper { + display: inline-flex; + gap: 0.75rem; + color: inherit; + margin-left: auto; +} diff --git a/web/src/components/InstanceActions/InstanceActions.test.tsx b/web/src/components/InstanceActions/InstanceActions.test.tsx new file mode 100644 index 0000000..b642bb2 --- /dev/null +++ b/web/src/components/InstanceActions/InstanceActions.test.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { + findByText as findByTextGlobal, + fireEvent, + Matcher, + render, + waitFor, +} from "@testing-library/react"; +import each from "jest-each"; +import { rest } from "msw"; +import { setupServer } from "msw/node"; +import InstanceActions from "./InstanceActions"; +import { GntInstance } from "../../api/models"; +import JobWatchContext from "../../contexts/JobWatchContext"; + +type Actions = "failover" | "migrate" | "start" | "restart" | "shutdown"; + +const jobIds = { + failover: 421, + migrate: 422, + start: 423, + restart: 424, + shutdown: 425, +}; + +const server = setupServer( + rest.post( + "/api/v1/clusters/testClusterName/instances/testInstance/:action", + (req, res, ctx) => { + const jobId = jobIds[req.params.action]; + + if (!jobId) { + return res(ctx.status(400), ctx.body("invalid action")); + } + + return res( + ctx.json({ + jobId, + }) + ); + } + ) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +function createMockInstance(overrideParams: Partial): GntInstance { + return { + name: "testInstance", + cpuCount: 1, + disks: [], + isRunning: true, + memoryTotal: 1024, + nics: [], + offersVnc: false, + primaryNode: "", + secondaryNodes: [], + tags: [], + ...overrideParams, + }; +} + +each([ + ["failover", /^failover$/i, createMockInstance({ isRunning: true })], + ["migrate", /^migrate$/i, createMockInstance({ isRunning: true })], + ["start", /^start$/i, createMockInstance({ isRunning: false })], + ["restart", /^restart$/i, createMockInstance({ isRunning: true })], + ["shutdown", /^shutdown$/i, createMockInstance({ isRunning: true })], +]).test( + "%s action button triggers corresponding action", + async (name: Actions, matcher: Matcher, instance: GntInstance) => { + const mockTrackJob = jest.fn(); + + const { findByText, findByRole } = render( + + + + ); + + const button = await findByText(matcher); + + fireEvent.click(button); + + const dialog = await findByRole("dialog"); + const confirmButton = await findByTextGlobal(dialog, matcher); + + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockTrackJob).toHaveBeenCalledWith(jobIds[name]); + }); + } +); diff --git a/web/src/components/InstanceActions/InstanceActions.tsx b/web/src/components/InstanceActions/InstanceActions.tsx new file mode 100644 index 0000000..22a352d --- /dev/null +++ b/web/src/components/InstanceActions/InstanceActions.tsx @@ -0,0 +1,152 @@ +import { faTerminal } from "@fortawesome/free-solid-svg-icons"; +import React, { ReactElement, useContext, useState } from "react"; +import { HttpMethod, useApi } from "../../api"; +import { GntInstance, JobIdResponse } from "../../api/models"; +import JobWatchContext from "../../contexts/JobWatchContext"; +import Button from "../Button/Button"; +import InstanceActionConfirmationModal from "../InstanceActionConfirmationModal/InstanceActionConfirmationModal"; +import PrefixLink from "../PrefixLink"; +import styles from "./InstanceActions.module.scss"; + +type Props = { + clusterName: string; + instance: GntInstance; +}; + +function useInstanceAction( + clusterName: string, + instanceName: string, + action: "start" | "failover" | "migrate" | "restart" | "shutdown" +) { + const [, execute] = useApi( + { + slug: `/clusters/${clusterName}/instances/${instanceName}/${action}`, + method: HttpMethod.Post, + }, + { + manual: true, + } + ); + + return execute; +} + +type ConfirmationState = { + actionName: string; + action: () => Promise; +}; + +function InstanceActions({ clusterName, instance }: Props): ReactElement { + const [ + confirmationState, + setConfirmationState, + ] = useState(null); + + const { trackJob } = useContext(JobWatchContext); + + const executeStart = useInstanceAction(clusterName, instance.name, "start"); + const executeMigrate = useInstanceAction( + clusterName, + instance.name, + "migrate" + ); + const executeFailover = useInstanceAction( + clusterName, + instance.name, + "failover" + ); + const executeRestart = useInstanceAction( + clusterName, + instance.name, + "restart" + ); + const executeShutdown = useInstanceAction( + clusterName, + instance.name, + "shutdown" + ); + + function createExecutor(action: () => Promise) { + return async () => { + const response = await action(); + + if (typeof response === "string") { + alert(`An error occured: ${response}`); + } else { + trackJob(response.jobId); + } + }; + } + + function onStart() { + setConfirmationState({ + actionName: "start", + action: createExecutor(executeStart), + }); + } + + function onMigrate() { + setConfirmationState({ + actionName: "migrate", + action: createExecutor(executeMigrate), + }); + } + + function onFailover() { + setConfirmationState({ + actionName: "failover", + action: createExecutor(executeFailover), + }); + } + + function onRestart() { + setConfirmationState({ + actionName: "restart", + action: createExecutor(executeRestart), + }); + } + + function onShutdown() { + setConfirmationState({ + actionName: "shutdown", + action: createExecutor(executeShutdown), + }); + } + + async function onActionConfirmed() { + if (confirmationState !== null) { + await confirmationState.action(); + } + } + + return ( + <> +
+
+ setConfirmationState(null)} + onConfirm={onActionConfirmed} + actionName={confirmationState?.actionName || ""} + instanceName={instance.name} + /> + + ); +} + +export default InstanceActions; diff --git a/web/src/components/Modal/Modal.tsx b/web/src/components/Modal/Modal.tsx index 1b984cc..8cb39f3 100644 --- a/web/src/components/Modal/Modal.tsx +++ b/web/src/components/Modal/Modal.tsx @@ -37,7 +37,7 @@ function Modal({ } return createPortal( -
+
e.stopPropagation()}> {children}
diff --git a/web/src/components/Name/Name.module.scss b/web/src/components/Name/Name.module.scss new file mode 100644 index 0000000..5cb4f83 --- /dev/null +++ b/web/src/components/Name/Name.module.scss @@ -0,0 +1,6 @@ +.name { + font-size: 1.15rem; + font-family: monospace; + white-space: nowrap; + overflow-x: auto; +} diff --git a/web/src/components/Name/Name.tsx b/web/src/components/Name/Name.tsx new file mode 100644 index 0000000..bad0c11 --- /dev/null +++ b/web/src/components/Name/Name.tsx @@ -0,0 +1,8 @@ +import React, { PropsWithChildren, ReactElement } from "react"; +import styles from "./Name.module.scss"; + +function Name({ children }: PropsWithChildren): ReactElement { + return {children}; +} + +export default Name; diff --git a/web/src/components/VNCCtrlAltDelConfirmModal/VNCCtrlAltDelConfirmModal.module.scss b/web/src/components/VNCCtrlAltDelConfirmModal/VNCCtrlAltDelConfirmModal.module.scss index d7fd581..8578ffa 100644 --- a/web/src/components/VNCCtrlAltDelConfirmModal/VNCCtrlAltDelConfirmModal.module.scss +++ b/web/src/components/VNCCtrlAltDelConfirmModal/VNCCtrlAltDelConfirmModal.module.scss @@ -3,14 +3,3 @@ gap: 1rem; margin-top: 2rem; } - -.serverName { - border: 1px solid var(--colorSeparator); - border-radius: 0.25rem; - overflow-x: auto; - padding: 1rem; - color: var(--colorEmphasisMedium); - white-space: nowrap; - font-size: 1.15rem; - font-weight: bold; -} diff --git a/web/src/components/VNCCtrlAltDelConfirmModal/VNCCtrlAltDelConfirmModal.tsx b/web/src/components/VNCCtrlAltDelConfirmModal/VNCCtrlAltDelConfirmModal.tsx index 025e34b..bcd50d1 100644 --- a/web/src/components/VNCCtrlAltDelConfirmModal/VNCCtrlAltDelConfirmModal.tsx +++ b/web/src/components/VNCCtrlAltDelConfirmModal/VNCCtrlAltDelConfirmModal.tsx @@ -2,6 +2,7 @@ import React from "react"; import { ReactElement } from "react"; import Button from "../Button/Button"; import Modal from "../Modal/Modal"; +import Name from "../Name/Name"; import styles from "./VNCCtrlAltDelConfirmModal.module.scss"; type Props = { @@ -21,9 +22,9 @@ export default function VNCCtrlAltDelConfirmModal({

Are you sure, you would like to send Ctrl + Alt + Del to

-

{instanceName}

+ {instanceName} -

This might trigger a reboot.

+

This might trigger an instance restart.