From 35214b187692d3d294ba57cf90691698f2f6915b Mon Sep 17 00:00:00 2001 From: Simon Chan Date: Thu, 9 Apr 2026 16:26:40 +0100 Subject: [PATCH] feat(debug): add prune-sessions command --- pkg/api/deployment.go | 13 +++++ pkg/api/deployment_test.go | 74 +++++++++++++++++++++++++++ pkg/cmdutil/factory.go | 1 + testutil/mocks/factory.go | 42 ++++++++++------ vcr/debug/debug.go | 3 ++ vcr/debug/prune_sessions.go | 46 +++++++++++++++++ vcr/debug/prune_sessions_test.go | 86 ++++++++++++++++++++++++++++++++ 7 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 vcr/debug/prune_sessions.go create mode 100644 vcr/debug/prune_sessions_test.go diff --git a/pkg/api/deployment.go b/pkg/api/deployment.go index 90f792b..f018bda 100644 --- a/pkg/api/deployment.go +++ b/pkg/api/deployment.go @@ -156,6 +156,19 @@ func (c *DeploymentClient) DeleteDebugService(ctx context.Context, serviceName s return nil } +func (c *DeploymentClient) PruneDebugSessions(ctx context.Context) error { + resp, err := c.httpClient.R(). + SetContext(ctx). + Delete(c.baseURL + "/debug/services") + if err != nil { + return fmt.Errorf("%w: trace_id = %s", err, traceIDFromHTTPResponse(resp)) + } + if resp.IsError() { + return NewErrorFromHTTPResponse(resp) + } + return nil +} + type statusResponse struct { Ready bool `json:"ready"` } diff --git a/pkg/api/deployment_test.go b/pkg/api/deployment_test.go index f75568a..0371a89 100644 --- a/pkg/api/deployment_test.go +++ b/pkg/api/deployment_test.go @@ -403,6 +403,80 @@ func TestDeleteDebugService(t *testing.T) { } } +func TestPruneDebugSessions(t *testing.T) { + client := resty.New() + httpmock.ActivateNonDefault(client.GetClient()) + defer httpmock.DeactivateAndReset() + + type mock struct { + mockResponse string + status int + } + + type want struct { + err error + } + + tests := []struct { + name string + mock mock + want want + }{ + { + name: "204-happy-path", + mock: mock{ + mockResponse: "", + status: http.StatusNoContent, + }, + want: want{ + err: nil, + }, + }, + { + name: "404-error", + mock: mock{ + mockResponse: `{"error": {"code": 2003, "message": "not found", "traceId": "n/a", "containerLogs": ""}}`, + status: http.StatusNotFound, + }, + want: want{ + err: errors.New("API Error Encountered: ( HTTP status: 404 Error code: 2003 Detailed message: not found Trace ID: n/a )"), + }, + }, + { + name: "500-error", + mock: mock{ + mockResponse: `{"error": {"code": 1001, "message": "internal server error", "traceId": "n/a", "containerLogs": ""}}`, + status: http.StatusInternalServerError, + }, + want: want{ + err: errors.New("API Error Encountered: ( HTTP status: 500 Error code: 1001 Detailed message: internal server error Trace ID: n/a )"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + httpmock.RegisterResponder("DELETE", "https://example.com/v0.3/debug/services", + func(_ *http.Request) (*http.Response, error) { + resp := httpmock.NewStringResponse(tt.mock.status, tt.mock.mockResponse) + resp.Header.Set("Content-Type", "application/json") + return resp, nil + }) + + deploymentClient := NewDeploymentClient("https://example.com", "v0.3", client, nil) + + err := deploymentClient.PruneDebugSessions(t.Context()) + if tt.want.err != nil { + require.EqualError(t, err, tt.want.err.Error()) + httpmock.Reset() + return + } + require.NoError(t, err) + httpmock.Reset() + }) + } +} + func TestGetServiceReadyStatus(t *testing.T) { client := resty.New() httpmock.ActivateNonDefault(client.GetClient()) diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 43c6ec7..294de8a 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -43,6 +43,7 @@ type DeploymentInterface interface { DeployDebugService(ctx context.Context, region, applicationID, name string, caps api.Capabilities) (api.DeployResponse, error) GetServiceReadyStatus(ctx context.Context, serviceName string) (bool, error) DeleteDebugService(ctx context.Context, serviceName string, preserveData bool) error + PruneDebugSessions(ctx context.Context) error CreatePackage(ctx context.Context, createPackageArgs api.CreatePackageArgs) (api.CreatePackageResponse, error) CreateProject(ctx context.Context, projectName string) (api.CreateProjectResponse, error) DeployInstance(ctx context.Context, deployInstanceArgs api.DeployInstanceArgs) (api.DeployInstanceResponse, error) diff --git a/testutil/mocks/factory.go b/testutil/mocks/factory.go index c7dfdbc..1d5aa57 100644 --- a/testutil/mocks/factory.go +++ b/testutil/mocks/factory.go @@ -471,6 +471,20 @@ func (mr *MockDeploymentInterfaceMockRecorder) ListVonageApplications(ctx, filte return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListVonageApplications", reflect.TypeOf((*MockDeploymentInterface)(nil).ListVonageApplications), ctx, filter) } +// PruneDebugSessions mocks base method. +func (m *MockDeploymentInterface) PruneDebugSessions(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PruneDebugSessions", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// PruneDebugSessions indicates an expected call of PruneDebugSessions. +func (mr *MockDeploymentInterfaceMockRecorder) PruneDebugSessions(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PruneDebugSessions", reflect.TypeOf((*MockDeploymentInterface)(nil).PruneDebugSessions), ctx) +} + // RemoveSecret mocks base method. func (m *MockDeploymentInterface) RemoveSecret(ctx context.Context, name string) error { m.ctrl.T.Helper() @@ -499,34 +513,34 @@ func (mr *MockDeploymentInterfaceMockRecorder) UpdateSecret(ctx, s interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSecret", reflect.TypeOf((*MockDeploymentInterface)(nil).UpdateSecret), ctx, s) } -// ValidateDeployment mocks base method. -func (m *MockDeploymentInterface) ValidateDeployment(ctx context.Context, req api.ValidateDeploymentRequest) (api.ValidateDeploymentResponse, error) { +// UploadTgz mocks base method. +func (m *MockDeploymentInterface) UploadTgz(ctx context.Context, fileBytes []byte) (api.UploadResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ValidateDeployment", ctx, req) - ret0, _ := ret[0].(api.ValidateDeploymentResponse) + ret := m.ctrl.Call(m, "UploadTgz", ctx, fileBytes) + ret0, _ := ret[0].(api.UploadResponse) ret1, _ := ret[1].(error) return ret0, ret1 } -// ValidateDeployment indicates an expected call of ValidateDeployment. -func (mr *MockDeploymentInterfaceMockRecorder) ValidateDeployment(ctx, req interface{}) *gomock.Call { +// UploadTgz indicates an expected call of UploadTgz. +func (mr *MockDeploymentInterfaceMockRecorder) UploadTgz(ctx, fileBytes interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateDeployment", reflect.TypeOf((*MockDeploymentInterface)(nil).ValidateDeployment), ctx, req) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadTgz", reflect.TypeOf((*MockDeploymentInterface)(nil).UploadTgz), ctx, fileBytes) } -// UploadTgz mocks base method. -func (m *MockDeploymentInterface) UploadTgz(ctx context.Context, fileBytes []byte) (api.UploadResponse, error) { +// ValidateDeployment mocks base method. +func (m *MockDeploymentInterface) ValidateDeployment(ctx context.Context, req api.ValidateDeploymentRequest) (api.ValidateDeploymentResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UploadTgz", ctx, fileBytes) - ret0, _ := ret[0].(api.UploadResponse) + ret := m.ctrl.Call(m, "ValidateDeployment", ctx, req) + ret0, _ := ret[0].(api.ValidateDeploymentResponse) ret1, _ := ret[1].(error) return ret0, ret1 } -// UploadTgz indicates an expected call of UploadTgz. -func (mr *MockDeploymentInterfaceMockRecorder) UploadTgz(ctx, fileBytes interface{}) *gomock.Call { +// ValidateDeployment indicates an expected call of ValidateDeployment. +func (mr *MockDeploymentInterfaceMockRecorder) ValidateDeployment(ctx, req interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadTgz", reflect.TypeOf((*MockDeploymentInterface)(nil).UploadTgz), ctx, fileBytes) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateDeployment", reflect.TypeOf((*MockDeploymentInterface)(nil).ValidateDeployment), ctx, req) } // WatchDeployment mocks base method. diff --git a/vcr/debug/debug.go b/vcr/debug/debug.go index 3b7d24d..cc3e3ea 100644 --- a/vcr/debug/debug.go +++ b/vcr/debug/debug.go @@ -157,6 +157,9 @@ func NewCmdDebug(f cmdutil.Factory) *cobra.Command { cmd.Flags().IntVarP(&opts.DebuggerPort, "debugger-port", "d", defaultDebuggerPort, "Local port for debugger proxy server (default: 3001)") cmd.Flags().BoolVarP(&opts.PreserveData, "preserve-data", "", false, "Keep debug session data after stopping (useful for debugging state issues)") cmd.Flags().StringVarP(&opts.ManifestFile, "filename", "f", "", "Path to VCR manifest file (default: vcr.yml in project directory)") + + cmd.AddCommand(NewCmdPruneSessions(f)) + return cmd } diff --git a/vcr/debug/prune_sessions.go b/vcr/debug/prune_sessions.go new file mode 100644 index 0000000..d76aa50 --- /dev/null +++ b/vcr/debug/prune_sessions.go @@ -0,0 +1,46 @@ +package debug + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "vonage-cloud-runtime-cli/pkg/cmdutil" +) + +type PruneSessionsOptions struct { + cmdutil.Factory +} + +func NewCmdPruneSessions(f cmdutil.Factory) *cobra.Command { + opts := &PruneSessionsOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "prune-sessions", + Short: "Remove all active debug sessions", + Long: "Remove all active debug sessions for the configured API key.", + RunE: func(_ *cobra.Command, _ []string) error { + ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) + defer cancel() + return runPruneSessions(ctx, opts) + }, + } + + return cmd +} + +func runPruneSessions(ctx context.Context, opts *PruneSessionsOptions) error { + io := opts.IOStreams() + c := io.ColorScheme() + + spinner := cmdutil.DisplaySpinnerMessageWithHandle(" Pruning debug sessions...") + err := opts.DeploymentClient().PruneDebugSessions(ctx) + spinner.Stop() + if err != nil { + return fmt.Errorf("failed to prune debug sessions: %w", err) + } + + fmt.Fprintf(io.Out, "%s Debug sessions successfully pruned\n", c.SuccessIcon()) + return nil +} diff --git a/vcr/debug/prune_sessions_test.go b/vcr/debug/prune_sessions_test.go new file mode 100644 index 0000000..c72f452 --- /dev/null +++ b/vcr/debug/prune_sessions_test.go @@ -0,0 +1,86 @@ +package debug + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + + "vonage-cloud-runtime-cli/testutil" + "vonage-cloud-runtime-cli/testutil/mocks" +) + +func TestPruneSessions(t *testing.T) { + type mock struct { + pruneDebugSessionsTimes int + pruneDebugSessionsReturnErr error + } + + type want struct { + errMsg string + stdout string + } + + tests := []struct { + name string + mock mock + want want + }{ + { + name: "happy-path", + mock: mock{ + pruneDebugSessionsTimes: 1, + pruneDebugSessionsReturnErr: nil, + }, + want: want{ + stdout: "✓ Debug sessions successfully pruned\n", + }, + }, + { + name: "api-error", + mock: mock{ + pruneDebugSessionsTimes: 1, + pruneDebugSessionsReturnErr: errors.New("api error"), + }, + want: want{ + errMsg: "failed to prune debug sessions: api error", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + deploymentMock := mocks.NewMockDeploymentInterface(ctrl) + + deploymentMock.EXPECT(). + PruneDebugSessions(gomock.Any()). + Times(tt.mock.pruneDebugSessionsTimes). + Return(tt.mock.pruneDebugSessionsReturnErr) + + ios, _, stdout, _ := iostreams.Test() + + f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, deploymentMock, nil, nil) + + cmd := NewCmdPruneSessions(f) + cmd.SetArgs([]string{}) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + if tt.want.errMsg != "" { + require.Error(t, err) + require.Equal(t, tt.want.errMsg, err.Error()) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want.stdout, stdout.String()) + }) + } +}