From f69ce91655feddc3399bd6ce39d8484eeef9a2d8 Mon Sep 17 00:00:00 2001 From: Guilherme Vicentin <79584418+gvicentin@users.noreply.github.com> Date: Mon, 8 Apr 2024 13:32:41 -0300 Subject: [PATCH] feature: API Start and Stop mechanism for RpaasInstance. (#150) * Start and stop routes and client command * Update copyright date * Adding missing implementations * Fix linter * Shutdown flag showing in the cli info cmd * Bug fixes and creating innitial testing * Fetching reference after patch * Completed test suite * Showing Shutdown when using kubectl get rpaasinstance * Remove content type * Remove content-type assertions --- api/v1alpha1/rpaasinstance_types.go | 1 + cmd/plugin/rpaasv2/cmd/app.go | 2 + cmd/plugin/rpaasv2/cmd/info.go | 1 + cmd/plugin/rpaasv2/cmd/info_test.go | 6 +- cmd/plugin/rpaasv2/cmd/start.go | 48 ++++++++++++++++ cmd/plugin/rpaasv2/cmd/start_test.go | 34 +++++++++++ cmd/plugin/rpaasv2/cmd/stop.go | 48 ++++++++++++++++ cmd/plugin/rpaasv2/cmd/stop_test.go | 34 +++++++++++ .../extensions.tsuru.io_rpaasinstances.yaml | 3 + internal/pkg/rpaas/fake/manager.go | 16 ++++++ internal/pkg/rpaas/k8s.go | 30 ++++++++++ internal/pkg/rpaas/k8s_test.go | 52 ++++++++++++++++- internal/pkg/rpaas/manager.go | 2 + pkg/rpaas/client/client.go | 2 + pkg/rpaas/client/fake/client.go | 18 ++++++ pkg/rpaas/client/start.go | 34 +++++++++++ pkg/rpaas/client/start_test.go | 57 +++++++++++++++++++ pkg/rpaas/client/stop.go | 34 +++++++++++ pkg/rpaas/client/stop_test.go | 57 +++++++++++++++++++ pkg/rpaas/client/types/types.go | 1 + pkg/web/api.go | 2 + pkg/web/handlers.go | 24 ++++++++ pkg/web/info_test.go | 2 +- 23 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 cmd/plugin/rpaasv2/cmd/start.go create mode 100644 cmd/plugin/rpaasv2/cmd/start_test.go create mode 100644 cmd/plugin/rpaasv2/cmd/stop.go create mode 100644 cmd/plugin/rpaasv2/cmd/stop_test.go create mode 100644 pkg/rpaas/client/start.go create mode 100644 pkg/rpaas/client/start_test.go create mode 100644 pkg/rpaas/client/stop.go create mode 100644 pkg/rpaas/client/stop_test.go diff --git a/api/v1alpha1/rpaasinstance_types.go b/api/v1alpha1/rpaasinstance_types.go index 7cb43c671..6615afb46 100644 --- a/api/v1alpha1/rpaasinstance_types.go +++ b/api/v1alpha1/rpaasinstance_types.go @@ -382,6 +382,7 @@ type RpaasInstanceExternalAddressesStatus struct { // +kubebuilder:subresource:status // +kubebuilder:subresource:scale:specpath=.spec.replicas,statuspath=.status.currentReplicas,selectorpath=.status.podSelector // +kubebuilder:printcolumn:name="Suspended",type=boolean,JSONPath=`.spec.suspend` +// +kubebuilder:printcolumn:name="Shutdown",type=boolean,JSONPath=`.spec.shutdown` // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // +kubebuilder:printcolumn:name="IPs",type=string,JSONPath=`.status.externalAddresses.ips[*]` // +kubebuilder:printcolumn:name="Hostnames",type=string,JSONPath=`.status.externalAddresses.hostnames[*]` diff --git a/cmd/plugin/rpaasv2/cmd/app.go b/cmd/plugin/rpaasv2/cmd/app.go index 676a856a6..61cf20681 100644 --- a/cmd/plugin/rpaasv2/cmd/app.go +++ b/cmd/plugin/rpaasv2/cmd/app.go @@ -34,6 +34,8 @@ func NewApp(o, e io.Writer, client rpaasclient.Client) (app *cli.App) { app.Writer = o app.Commands = []*cli.Command{ NewCmdScale(), + NewCmdStart(), + NewCmdStop(), NewCmdAccessControlList(), NewCmdCertificates(), NewCmdBlocks(), diff --git a/cmd/plugin/rpaasv2/cmd/info.go b/cmd/plugin/rpaasv2/cmd/info.go index d1a7bc48f..7ab522ce2 100644 --- a/cmd/plugin/rpaasv2/cmd/info.go +++ b/cmd/plugin/rpaasv2/cmd/info.go @@ -88,6 +88,7 @@ Tags: {{ join ", " .Tags }} Team owner: {{ .Team }} Plan: {{ .Plan }} Flavors: {{ join ", " .Flavors }} +Shutdown: {{ .Shutdown }} {{- with .Cluster}} Cluster: {{ . }} {{- end }} diff --git a/cmd/plugin/rpaasv2/cmd/info_test.go b/cmd/plugin/rpaasv2/cmd/info_test.go index b05803846..5e4992559 100644 --- a/cmd/plugin/rpaasv2/cmd/info_test.go +++ b/cmd/plugin/rpaasv2/cmd/info_test.go @@ -328,6 +328,7 @@ Tags: tag1, tag2, tag3 Team owner: some-team Plan: basic Flavors: flavor1, flavor2, flavor-N +Shutdown: false Cluster: my-dedicated-cluster Pool: my-pool @@ -585,6 +586,7 @@ Tags: tag1, tag2, tag3 Team owner: some-team Plan: basic Flavors: flavor1, flavor2, flavor-N +Shutdown: false Cluster: my-dedicated-cluster Pods: (current: 2 / desired: 3) @@ -680,6 +682,7 @@ Tags: tag1, tag2, tag3 Team owner: some-team Plan: basic Flavors: flavor1, flavor2, flavor-N +Shutdown: false Cluster: my-dedicated-cluster Pods: (current: 2 / desired: 3) @@ -737,10 +740,11 @@ Pods: (current: 2 / desired: 3) Team: "some team", Description: "some description", Tags: []string{"tag1", "tag2", "tag3"}, + Shutdown: true, }, nil }, }, - expected: "{\n\t\"addresses\": [\n\t\t{\n\t\t\t\"type\": \"cluster-external\",\n\t\t\t\"hostname\": \"some-host\",\n\t\t\t\"ip\": \"0.0.0.0\",\n\t\t\t\"status\": \"ready\"\n\t\t},\n\t\t{\n\t\t\t\"type\": \"cluster-external\",\n\t\t\t\"hostname\": \"some-host2\",\n\t\t\t\"ip\": \"0.0.0.1\",\n\t\t\t\"status\": \"ready\"\n\t\t}\n\t],\n\t\"replicas\": 5,\n\t\"plan\": \"basic\",\n\t\"routes\": [\n\t\t{\n\t\t\t\"path\": \"some-path\",\n\t\t\t\"destination\": \"some-destination\"\n\t\t}\n\t],\n\t\"binds\": [\n\t\t{\n\t\t\t\"name\": \"some-name\",\n\t\t\t\"host\": \"some-host\"\n\t\t},\n\t\t{\n\t\t\t\"name\": \"some-name2\",\n\t\t\t\"host\": \"some-host2\"\n\t\t}\n\t],\n\t\"team\": \"some team\",\n\t\"name\": \"my-instance\",\n\t\"description\": \"some description\",\n\t\"tags\": [\n\t\t\"tag1\",\n\t\t\"tag2\",\n\t\t\"tag3\"\n\t]\n}\n", + expected: "{\n\t\"addresses\": [\n\t\t{\n\t\t\t\"type\": \"cluster-external\",\n\t\t\t\"hostname\": \"some-host\",\n\t\t\t\"ip\": \"0.0.0.0\",\n\t\t\t\"status\": \"ready\"\n\t\t},\n\t\t{\n\t\t\t\"type\": \"cluster-external\",\n\t\t\t\"hostname\": \"some-host2\",\n\t\t\t\"ip\": \"0.0.0.1\",\n\t\t\t\"status\": \"ready\"\n\t\t}\n\t],\n\t\"replicas\": 5,\n\t\"plan\": \"basic\",\n\t\"routes\": [\n\t\t{\n\t\t\t\"path\": \"some-path\",\n\t\t\t\"destination\": \"some-destination\"\n\t\t}\n\t],\n\t\"binds\": [\n\t\t{\n\t\t\t\"name\": \"some-name\",\n\t\t\t\"host\": \"some-host\"\n\t\t},\n\t\t{\n\t\t\t\"name\": \"some-name2\",\n\t\t\t\"host\": \"some-host2\"\n\t\t}\n\t],\n\t\"team\": \"some team\",\n\t\"name\": \"my-instance\",\n\t\"description\": \"some description\",\n\t\"tags\": [\n\t\t\"tag1\",\n\t\t\"tag2\",\n\t\t\"tag3\"\n\t],\n\t\"shutdown\": true\n}\n", }, } diff --git a/cmd/plugin/rpaasv2/cmd/start.go b/cmd/plugin/rpaasv2/cmd/start.go new file mode 100644 index 000000000..d61eafa4e --- /dev/null +++ b/cmd/plugin/rpaasv2/cmd/start.go @@ -0,0 +1,48 @@ +// Copyright 2024 tsuru authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmd + +import ( + "fmt" + + "github.com/urfave/cli/v2" +) + +func NewCmdStart() *cli.Command { + return &cli.Command{ + Name: "start", + Usage: "Starts instance if the current state is shutdown", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "service", + Aliases: []string{"tsuru-service", "s"}, + Usage: "the Tsuru service name", + }, + &cli.StringFlag{ + Name: "instance", + Aliases: []string{"tsuru-service-instance", "i"}, + Usage: "the reverse proxy instance name", + Required: true, + }, + }, + Before: setupClient, + Action: runStart, + } +} + +func runStart(c *cli.Context) error { + client, err := getClient(c) + if err != nil { + return err + } + + err = client.Start(c.Context, c.String("instance")) + if err != nil { + return err + } + + fmt.Fprintf(c.App.Writer, "Started instance %s\n", formatInstanceName(c)) + return nil +} diff --git a/cmd/plugin/rpaasv2/cmd/start_test.go b/cmd/plugin/rpaasv2/cmd/start_test.go new file mode 100644 index 000000000..01d2a0f1a --- /dev/null +++ b/cmd/plugin/rpaasv2/cmd/start_test.go @@ -0,0 +1,34 @@ +// Copyright 2024 tsuru authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmd + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tsuru/rpaas-operator/pkg/rpaas/client/fake" +) + +func TestStart(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + args := []string{"./rpaasv2", "start", "-s", "some-service", "-i", "my-instance"} + + client := &fake.FakeClient{ + FakeStart: func(instance string) error { + require.Equal(t, instance, "my-instance") + return nil + }, + } + + app := NewApp(stdout, stderr, client) + err := app.Run(args) + require.NoError(t, err) + assert.Equal(t, stdout.String(), "Started instance some-service/my-instance\n") +} diff --git a/cmd/plugin/rpaasv2/cmd/stop.go b/cmd/plugin/rpaasv2/cmd/stop.go new file mode 100644 index 000000000..fff232912 --- /dev/null +++ b/cmd/plugin/rpaasv2/cmd/stop.go @@ -0,0 +1,48 @@ +// Copyright 2024 tsuru authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmd + +import ( + "fmt" + + "github.com/urfave/cli/v2" +) + +func NewCmdStop() *cli.Command { + return &cli.Command{ + Name: "stop", + Usage: "Shutdown instance (halt autoscale and scale in all replicas)", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "service", + Aliases: []string{"tsuru-service", "s"}, + Usage: "the Tsuru service name", + }, + &cli.StringFlag{ + Name: "instance", + Aliases: []string{"tsuru-service-instance", "i"}, + Usage: "the reverse proxy instance name", + Required: true, + }, + }, + Before: setupClient, + Action: runStop, + } +} + +func runStop(c *cli.Context) error { + client, err := getClient(c) + if err != nil { + return err + } + + err = client.Stop(c.Context, c.String("instance")) + if err != nil { + return err + } + + fmt.Fprintf(c.App.Writer, "Shutting down instance %s\n", formatInstanceName(c)) + return nil +} diff --git a/cmd/plugin/rpaasv2/cmd/stop_test.go b/cmd/plugin/rpaasv2/cmd/stop_test.go new file mode 100644 index 000000000..b78e56e7e --- /dev/null +++ b/cmd/plugin/rpaasv2/cmd/stop_test.go @@ -0,0 +1,34 @@ +// Copyright 2024 tsuru authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cmd + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tsuru/rpaas-operator/pkg/rpaas/client/fake" +) + +func TestStop(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + args := []string{"./rpaasv2", "stop", "-s", "some-service", "-i", "my-instance"} + + client := &fake.FakeClient{ + FakeStop: func(instance string) error { + require.Equal(t, instance, "my-instance") + return nil + }, + } + + app := NewApp(stdout, stderr, client) + err := app.Run(args) + require.NoError(t, err) + assert.Equal(t, stdout.String(), "Shutting down instance some-service/my-instance\n") +} diff --git a/config/crd/bases/extensions.tsuru.io_rpaasinstances.yaml b/config/crd/bases/extensions.tsuru.io_rpaasinstances.yaml index 22b87130f..5aeb10fa3 100644 --- a/config/crd/bases/extensions.tsuru.io_rpaasinstances.yaml +++ b/config/crd/bases/extensions.tsuru.io_rpaasinstances.yaml @@ -22,6 +22,9 @@ spec: - jsonPath: .spec.suspend name: Suspended type: boolean + - jsonPath: .spec.shutdown + name: Shutdown + type: boolean - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/internal/pkg/rpaas/fake/manager.go b/internal/pkg/rpaas/fake/manager.go index 73d1b5c7d..23007c1b3 100644 --- a/internal/pkg/rpaas/fake/manager.go +++ b/internal/pkg/rpaas/fake/manager.go @@ -32,6 +32,8 @@ type RpaasManager struct { FakeInstanceAddress func(name string) (string, error) FakeInstanceStatus func(name string) (*nginxv1alpha1.Nginx, rpaas.PodStatusMap, error) FakeScale func(instanceName string, replicas int32) error + FakeStart func(instanceName string) error + FakeStop func(instanceName string) error FakeGetPlans func() ([]rpaas.Plan, error) FakeGetFlavors func() ([]rpaas.Flavor, error) FakeCreateExtraFiles func(instanceName string, files ...rpaas.File) error @@ -166,6 +168,20 @@ func (m *RpaasManager) Scale(ctx context.Context, instanceName string, replicas return nil } +func (m *RpaasManager) Start(ctx context.Context, instanceName string) error { + if m.FakeStart != nil { + return m.FakeStart(instanceName) + } + return nil +} + +func (m *RpaasManager) Stop(ctx context.Context, instanceName string) error { + if m.FakeStop != nil { + return m.FakeStop(instanceName) + } + return nil +} + func (m *RpaasManager) GetPlans(ctx context.Context) ([]rpaas.Plan, error) { if m.FakeGetPlans != nil { return m.FakeGetPlans() diff --git a/internal/pkg/rpaas/k8s.go b/internal/pkg/rpaas/k8s.go index 9c4b696c2..b730ec990 100644 --- a/internal/pkg/rpaas/k8s.go +++ b/internal/pkg/rpaas/k8s.go @@ -611,10 +611,39 @@ func (m *k8sRpaasManager) Scale(ctx context.Context, instanceName string, replic if replicas < 0 { return ValidationError{Msg: fmt.Sprintf("invalid replicas number: %d", replicas)} } + + oldReplicas := originalInstance.Spec.Replicas + if replicas > 0 && oldReplicas != nil && *oldReplicas == 0 { + // When scaling out from zero, disable shutdown automatically + instance.Spec.Shutdown = false + } + instance.Spec.Replicas = &replicas return m.patchInstance(ctx, originalInstance, instance) } +func (m *k8sRpaasManager) Start(ctx context.Context, instanceName string) error { + instance, err := m.GetInstance(ctx, instanceName) + if err != nil { + return err + } + + originalInstance := instance.DeepCopy() + instance.Spec.Shutdown = false + return m.patchInstance(ctx, originalInstance, instance) +} + +func (m *k8sRpaasManager) Stop(ctx context.Context, instanceName string) error { + instance, err := m.GetInstance(ctx, instanceName) + if err != nil { + return err + } + + originalInstance := instance.DeepCopy() + instance.Spec.Shutdown = true + return m.patchInstance(ctx, originalInstance, instance) +} + func (m *k8sRpaasManager) GetCertificates(ctx context.Context, instanceName string) ([]CertificateData, error) { instance, err := m.GetInstance(ctx, instanceName) if err != nil { @@ -1631,6 +1660,7 @@ func (m *k8sRpaasManager) GetInstanceInfo(ctx context.Context, instanceName stri Plan: instance.Spec.PlanName, Binds: instance.Spec.Binds, Flavors: instance.Spec.Flavors, + Shutdown: instance.Spec.Shutdown, PlanOverride: instance.Spec.PlanTemplate, Autoscale: m.getAutoscale(instance), } diff --git a/internal/pkg/rpaas/k8s_test.go b/internal/pkg/rpaas/k8s_test.go index 5d63eb4c7..0b4f4c33e 100644 --- a/internal/pkg/rpaas/k8s_test.go +++ b/internal/pkg/rpaas/k8s_test.go @@ -3645,7 +3645,13 @@ func Test_k8sRpaasManager_Scale(t *testing.T) { instance2.Name = "another-instance" instance2.Spec.Autoscale = nil - resources := []runtime.Object{instance1, instance2} + instance3 := newEmptyRpaasInstance() + instance3.Name = "instance-scale-from-zero" + instance3.Spec.Shutdown = true + instance3.Spec.Autoscale = nil + instance3.Spec.Replicas = pointerToInt32(0) + + resources := []runtime.Object{instance1, instance2, instance3} testCases := []struct { instance string @@ -3684,6 +3690,19 @@ func Test_k8sRpaasManager_Scale(t *testing.T) { assert.Equal(t, int32(30), *instance.Spec.Replicas) }, }, + { + instance: "instance-scale-from-zero", + assertion: func(t *testing.T, err error, m *k8sRpaasManager) { + assert.NoError(t, err) + + instance := v1alpha1.RpaasInstance{} + err = m.cli.Get(context.Background(), types.NamespacedName{Name: "instance-scale-from-zero", Namespace: getServiceName()}, &instance) + require.NoError(t, err) + + assert.Equal(t, int32(30), *instance.Spec.Replicas) + assert.False(t, instance.Spec.Shutdown) + }, + }, } for _, tt := range testCases { @@ -3695,6 +3714,37 @@ func Test_k8sRpaasManager_Scale(t *testing.T) { } } +func Test_k8sRpaasManager_Start(t *testing.T) { + scheme := newScheme() + instance := newEmptyRpaasInstance() + instance.Name = "my-instance" + instance.Spec.Shutdown = true + + manager := &k8sRpaasManager{cli: fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(instance).Build()} + err := manager.Start(context.Background(), "my-instance") + require.NoError(t, err) + + err = manager.cli.Get(context.Background(), types.NamespacedName{Name: "my-instance", Namespace: getServiceName()}, instance) + require.NoError(t, err) + + assert.False(t, instance.Spec.Shutdown) +} + +func Test_k8sRpaasManager_Stop(t *testing.T) { + scheme := newScheme() + instance := newEmptyRpaasInstance() + instance.Name = "my-instance" + instance.Spec.Shutdown = false + + manager := &k8sRpaasManager{cli: fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(instance).Build()} + err := manager.Stop(context.Background(), "my-instance") + require.NoError(t, err) + + err = manager.cli.Get(context.Background(), types.NamespacedName{Name: "my-instance", Namespace: getServiceName()}, instance) + require.NoError(t, err) + assert.True(t, instance.Spec.Shutdown) +} + func Test_k8sRpaasManager_GetInstanceInfo(t *testing.T) { cfg := config.Get() defer func() { config.Set(cfg) }() diff --git a/internal/pkg/rpaas/manager.go b/internal/pkg/rpaas/manager.go index 67970d380..3900825a7 100644 --- a/internal/pkg/rpaas/manager.go +++ b/internal/pkg/rpaas/manager.go @@ -264,6 +264,8 @@ type RpaasManager interface { GetInstanceAddress(ctx context.Context, name string) (string, error) GetInstanceStatus(ctx context.Context, name string) (*nginxv1alpha1.Nginx, PodStatusMap, error) Scale(ctx context.Context, name string, replicas int32) error + Start(ctx context.Context, name string) error + Stop(ctx context.Context, name string) error GetPlans(ctx context.Context) ([]Plan, error) GetFlavors(ctx context.Context) ([]Flavor, error) BindApp(ctx context.Context, instanceName string, args BindAppArgs) error diff --git a/pkg/rpaas/client/client.go b/pkg/rpaas/client/client.go index 46ba35ab1..f9fe3531a 100644 --- a/pkg/rpaas/client/client.go +++ b/pkg/rpaas/client/client.go @@ -152,6 +152,8 @@ type Client interface { GetPlans(ctx context.Context, instance string) ([]types.Plan, error) GetFlavors(ctx context.Context, instance string) ([]types.Flavor, error) Scale(ctx context.Context, args ScaleArgs) error + Start(ctx context.Context, instance string) error + Stop(ctx context.Context, instance string) error Info(ctx context.Context, args InfoArgs) (*types.InstanceInfo, error) UpdateCertificate(ctx context.Context, args UpdateCertificateArgs) error DeleteCertificate(ctx context.Context, args DeleteCertificateArgs) error diff --git a/pkg/rpaas/client/fake/client.go b/pkg/rpaas/client/fake/client.go index 1668e8b3d..948074fe9 100644 --- a/pkg/rpaas/client/fake/client.go +++ b/pkg/rpaas/client/fake/client.go @@ -19,6 +19,8 @@ type FakeClient struct { FakeGetPlans func(instance string) ([]types.Plan, error) FakeGetFlavors func(instance string) ([]types.Flavor, error) FakeScale func(args client.ScaleArgs) error + FakeStart func(instance string) error + FakeStop func(instance string) error FakeUpdateCertificate func(args client.UpdateCertificateArgs) error FakeDeleteCertificate func(args client.DeleteCertificateArgs) error FakeUpdateBlock func(args client.UpdateBlockArgs) error @@ -77,6 +79,22 @@ func (f *FakeClient) Scale(ctx context.Context, args client.ScaleArgs) error { return nil } +func (f *FakeClient) Start(ctx context.Context, instance string) error { + if f.FakeStart != nil { + return f.FakeStart(instance) + } + + return nil +} + +func (f *FakeClient) Stop(ctx context.Context, instance string) error { + if f.FakeStop != nil { + return f.FakeStop(instance) + } + + return nil +} + func (f *FakeClient) UpdateCertificate(ctx context.Context, args client.UpdateCertificateArgs) error { if f.FakeUpdateCertificate != nil { return f.FakeUpdateCertificate(args) diff --git a/pkg/rpaas/client/start.go b/pkg/rpaas/client/start.go new file mode 100644 index 000000000..dd18febb0 --- /dev/null +++ b/pkg/rpaas/client/start.go @@ -0,0 +1,34 @@ +// Copyright 2024 tsuru authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package client + +import ( + "context" + "fmt" + "net/http" +) + +func (c *client) Start(ctx context.Context, instance string) error { + if instance == "" { + return ErrMissingInstance + } + + pathName := fmt.Sprintf("/resources/%s/start", instance) + req, err := c.newRequest("POST", pathName, nil, instance) + if err != nil { + return err + } + + response, err := c.do(ctx, req) + if err != nil { + return err + } + + if response.StatusCode != http.StatusOK { + return newErrUnexpectedStatusCodeFromResponse(response) + } + + return nil +} diff --git a/pkg/rpaas/client/start_test.go b/pkg/rpaas/client/start_test.go new file mode 100644 index 000000000..4e9ef9886 --- /dev/null +++ b/pkg/rpaas/client/start_test.go @@ -0,0 +1,57 @@ +// Copyright 2024 tsuru authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package client + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClientThroughTsuru_Start(t *testing.T) { + tests := []struct { + name string + instance string + expectedError string + handler http.HandlerFunc + }{ + { + name: "when server returns an unexpected status code", + instance: "my-instance", + expectedError: "rpaasv2: unexpected status code: 404 Not Found, detail: instance not found", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "instance not found") + }, + }, + { + name: "when server returns the expected response", + instance: "my-instance", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, "POST") + assert.Equal(t, fmt.Sprintf("/services/%s/proxy/%s?callback=%s", FakeTsuruService, "my-instance", "/resources/my-instance/start"), r.URL.RequestURI()) + assert.Equal(t, "Bearer f4k3t0k3n", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, server := newClientThroughTsuru(t, tt.handler) + defer server.Close() + err := client.Start(context.TODO(), tt.instance) + if tt.expectedError == "" { + require.NoError(t, err) + return + } + assert.EqualError(t, err, tt.expectedError) + }) + } +} diff --git a/pkg/rpaas/client/stop.go b/pkg/rpaas/client/stop.go new file mode 100644 index 000000000..201e31657 --- /dev/null +++ b/pkg/rpaas/client/stop.go @@ -0,0 +1,34 @@ +// Copyright 2024 tsuru authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package client + +import ( + "context" + "fmt" + "net/http" +) + +func (c *client) Stop(ctx context.Context, instance string) error { + if instance == "" { + return ErrMissingInstance + } + + pathName := fmt.Sprintf("/resources/%s/stop", instance) + req, err := c.newRequest("POST", pathName, nil, instance) + if err != nil { + return err + } + + response, err := c.do(ctx, req) + if err != nil { + return err + } + + if response.StatusCode != http.StatusOK { + return newErrUnexpectedStatusCodeFromResponse(response) + } + + return nil +} diff --git a/pkg/rpaas/client/stop_test.go b/pkg/rpaas/client/stop_test.go new file mode 100644 index 000000000..5b5f9d6b9 --- /dev/null +++ b/pkg/rpaas/client/stop_test.go @@ -0,0 +1,57 @@ +// Copyright 2024 tsuru authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package client + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClientThroughTsuru_Stop(t *testing.T) { + tests := []struct { + name string + instance string + expectedError string + handler http.HandlerFunc + }{ + { + name: "when server returns an unexpected status code", + instance: "my-instance", + expectedError: "rpaasv2: unexpected status code: 404 Not Found, detail: instance not found", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "instance not found") + }, + }, + { + name: "when server returns the expected response", + instance: "my-instance", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, "POST") + assert.Equal(t, fmt.Sprintf("/services/%s/proxy/%s?callback=%s", FakeTsuruService, "my-instance", "/resources/my-instance/stop"), r.URL.RequestURI()) + assert.Equal(t, "Bearer f4k3t0k3n", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, server := newClientThroughTsuru(t, tt.handler) + defer server.Close() + err := client.Stop(context.TODO(), tt.instance) + if tt.expectedError == "" { + require.NoError(t, err) + return + } + assert.EqualError(t, err, tt.expectedError) + }) + } +} diff --git a/pkg/rpaas/client/types/types.go b/pkg/rpaas/client/types/types.go index 647458484..b5e3d2409 100644 --- a/pkg/rpaas/client/types/types.go +++ b/pkg/rpaas/client/types/types.go @@ -149,6 +149,7 @@ type InstanceInfo struct { Events []Event `json:"events,omitempty"` PlanOverride *v1alpha1.RpaasPlanSpec `json:"planOverride,omitempty"` ExtraFiles []RpaasFile `json:"extraFiles,omitempty"` + Shutdown bool `json:"shutdown"` } type AllowedUpstream struct { diff --git a/pkg/web/api.go b/pkg/web/api.go index a7b7d6da7..cea1b6a9f 100644 --- a/pkg/web/api.go +++ b/pkg/web/api.go @@ -224,6 +224,8 @@ func newEcho(targetFactory target.Factory) *echo.Echo { group.POST("/:instance/bind", serviceBindUnit) group.DELETE("/:instance/bind", serviceUnbindUnit) group.POST("/:instance/scale", scale) + group.POST("/:instance/start", start) + group.POST("/:instance/stop", stop) group.GET("/:instance/info", instanceInfo) group.POST("/:instance/certificate", updateCertificate) group.DELETE("/:instance/certificate/:name", deleteCertificate) diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index aeb479cb6..c2d8f0cd0 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -30,6 +30,30 @@ func scale(c echo.Context) error { return c.NoContent(http.StatusOK) } +func start(c echo.Context) error { + ctx := c.Request().Context() + manager, err := getManager(ctx) + if err != nil { + return err + } + if err = manager.Start(ctx, c.Param("instance")); err != nil { + return err + } + return c.NoContent(http.StatusOK) +} + +func stop(c echo.Context) error { + ctx := c.Request().Context() + manager, err := getManager(ctx) + if err != nil { + return err + } + if err = manager.Stop(ctx, c.Param("instance")); err != nil { + return err + } + return c.NoContent(http.StatusOK) +} + func serviceNodeStatus(c echo.Context) error { ctx := c.Request().Context() manager, err := getManager(ctx) diff --git a/pkg/web/info_test.go b/pkg/web/info_test.go index 318e61e27..2cf5ad1c9 100644 --- a/pkg/web/info_test.go +++ b/pkg/web/info_test.go @@ -90,7 +90,7 @@ func Test_instanceInfo(t *testing.T) { }, }, expectedCode: http.StatusOK, - expectedBody: "{\"addresses\":[{\"type\":\"cluster-external\",\"serviceName\":\"my-instance-service\",\"hostname\":\"some host name\",\"ip\":\"0.0.0.0\",\"status\":\"ready\"},{\"type\":\"cluster-external\",\"serviceName\":\"my-instance-service\",\"hostname\":\"some host name 2\",\"ip\":\"0.0.0.1\",\"status\":\"ready\"}],\"replicas\":5,\"plan\":\"basic\",\"routes\":[{\"path\":\"some location path\",\"destination\":\"some destination\"},{\"path\":\"some location path 2\",\"destination\":\"some destination 2\"}],\"autoscale\":{\"cpu\":70,\"maxReplicas\":3,\"memory\":1024,\"minReplicas\":1},\"binds\":[{\"name\":\"app-default\",\"host\":\"some host ip address\"},{\"name\":\"app-backup\",\"host\":\"some host backup ip address\"}],\"team\":\"some team\",\"name\":\"some rpaas instance name\",\"description\":\"some description\",\"tags\":[\"tag1\",\"tag2\"]}", + expectedBody: "{\"addresses\":[{\"type\":\"cluster-external\",\"serviceName\":\"my-instance-service\",\"hostname\":\"some host name\",\"ip\":\"0.0.0.0\",\"status\":\"ready\"},{\"type\":\"cluster-external\",\"serviceName\":\"my-instance-service\",\"hostname\":\"some host name 2\",\"ip\":\"0.0.0.1\",\"status\":\"ready\"}],\"replicas\":5,\"plan\":\"basic\",\"routes\":[{\"path\":\"some location path\",\"destination\":\"some destination\"},{\"path\":\"some location path 2\",\"destination\":\"some destination 2\"}],\"autoscale\":{\"cpu\":70,\"maxReplicas\":3,\"memory\":1024,\"minReplicas\":1},\"binds\":[{\"name\":\"app-default\",\"host\":\"some host ip address\"},{\"name\":\"app-backup\",\"host\":\"some host backup ip address\"}],\"team\":\"some team\",\"name\":\"some rpaas instance name\",\"description\":\"some description\",\"tags\":[\"tag1\",\"tag2\"],\"shutdown\":false}", }, { name: "when some error occurs while creating the info Payload",