diff --git a/.gitignore b/.gitignore index 7957e2b..348b3e0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ lefthook-local.yml # Node modules (for commitlint) node_modules/ + +# Agent working files +docs/superpowers/ diff --git a/pkg/api/deployment.go b/pkg/api/deployment.go index ebbe169..fe19616 100644 --- a/pkg/api/deployment.go +++ b/pkg/api/deployment.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "net/http" + "net/url" "regexp" "strings" @@ -106,6 +107,22 @@ func (c *DeploymentClient) GenerateVonageApplicationKeys(ctx context.Context, ap return nil } +func (c *DeploymentClient) DeleteVonageApplication(ctx context.Context, appID string) error { + resp, err := c.httpClient.R(). + SetContext(ctx). + Delete(c.baseURL + "/applications/" + url.PathEscape(appID)) + if err != nil { + return fmt.Errorf("%w: trace_id = %s", err, traceIDFromHTTPResponse(resp)) + } + if resp.StatusCode() == http.StatusNotFound { + return ErrNotFound + } + if resp.IsError() { + return NewErrorFromHTTPResponse(resp) + } + return nil +} + type deployRequest struct { Runtime string `json:"runtime"` Region string `json:"region"` diff --git a/pkg/api/deployment_test.go b/pkg/api/deployment_test.go index a6d8467..29ac8f9 100644 --- a/pkg/api/deployment_test.go +++ b/pkg/api/deployment_test.go @@ -239,6 +239,80 @@ func TestGenerateVonageApplicationKeys(t *testing.T) { } } +func TestDeleteVonageApplication(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-not-found", + mock: mock{ + mockResponse: "", + status: http.StatusNotFound, + }, + want: want{ + err: ErrNotFound, + }, + }, + { + 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/applications/application-id", + 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.DeleteVonageApplication(t.Context(), "application-id") + if tt.want.err != nil { + require.EqualError(t, err, tt.want.err.Error()) + httpmock.Reset() + return + } + require.NoError(t, err) + httpmock.Reset() + }) + } +} + func TestDeployDebugService(t *testing.T) { client := resty.New() httpmock.ActivateNonDefault(client.GetClient()) diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index c8b76f5..2c10087 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -40,6 +40,7 @@ type DeploymentInterface interface { CreateVonageApplication(ctx context.Context, name string, enableRTC, enableVoice, enableMessages bool) (api.CreateVonageApplicationOutput, error) ListVonageApplications(ctx context.Context, filter string) (api.ListVonageApplicationsOutput, error) GenerateVonageApplicationKeys(ctx context.Context, appID string) error + DeleteVonageApplication(ctx context.Context, appID string) error 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 diff --git a/testutil/mocks/factory.go b/testutil/mocks/factory.go index fe27069..66f6d3d 100644 --- a/testutil/mocks/factory.go +++ b/testutil/mocks/factory.go @@ -367,6 +367,20 @@ func (mr *MockDeploymentInterfaceMockRecorder) DeleteMongoDatabase(ctx, version, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMongoDatabase", reflect.TypeOf((*MockDeploymentInterface)(nil).DeleteMongoDatabase), ctx, version, database) } +// DeleteVonageApplication mocks base method. +func (m *MockDeploymentInterface) DeleteVonageApplication(ctx context.Context, appID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteVonageApplication", ctx, appID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteVonageApplication indicates an expected call of DeleteVonageApplication. +func (mr *MockDeploymentInterfaceMockRecorder) DeleteVonageApplication(ctx, appID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteVonageApplication", reflect.TypeOf((*MockDeploymentInterface)(nil).DeleteVonageApplication), ctx, appID) +} + // DeployDebugService mocks base method. func (m *MockDeploymentInterface) DeployDebugService(ctx context.Context, region, applicationID, name string, caps api.Capabilities) (api.DeployResponse, error) { m.ctrl.T.Helper() @@ -685,34 +699,34 @@ func (mr *MockDatastoreInterfaceMockRecorder) GetRuntimeByName(ctx, name interfa return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRuntimeByName", reflect.TypeOf((*MockDatastoreInterface)(nil).GetRuntimeByName), ctx, name) } -// ListLogsByInstanceID mocks base method. -func (m *MockDatastoreInterface) ListLogsByInstanceID(ctx context.Context, instanceID string, limit int, timestamp time.Time) ([]api.Log, error) { +// ListInstances mocks base method. +func (m *MockDatastoreInterface) ListInstances(ctx context.Context, filter string) ([]api.InstanceListItem, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListLogsByInstanceID", ctx, instanceID, limit, timestamp) - ret0, _ := ret[0].([]api.Log) + ret := m.ctrl.Call(m, "ListInstances", ctx, filter) + ret0, _ := ret[0].([]api.InstanceListItem) ret1, _ := ret[1].(error) return ret0, ret1 } -// ListLogsByInstanceID indicates an expected call of ListLogsByInstanceID. -func (mr *MockDatastoreInterfaceMockRecorder) ListLogsByInstanceID(ctx, instanceID, limit, timestamp interface{}) *gomock.Call { +// ListInstances indicates an expected call of ListInstances. +func (mr *MockDatastoreInterfaceMockRecorder) ListInstances(ctx, filter interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListLogsByInstanceID", reflect.TypeOf((*MockDatastoreInterface)(nil).ListLogsByInstanceID), ctx, instanceID, limit, timestamp) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInstances", reflect.TypeOf((*MockDatastoreInterface)(nil).ListInstances), ctx, filter) } -// ListInstances mocks base method. -func (m *MockDatastoreInterface) ListInstances(ctx context.Context, filter string) ([]api.InstanceListItem, error) { +// ListLogsByInstanceID mocks base method. +func (m *MockDatastoreInterface) ListLogsByInstanceID(ctx context.Context, instanceID string, limit int, timestamp time.Time) ([]api.Log, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListInstances", ctx, filter) - ret0, _ := ret[0].([]api.InstanceListItem) + ret := m.ctrl.Call(m, "ListLogsByInstanceID", ctx, instanceID, limit, timestamp) + ret0, _ := ret[0].([]api.Log) ret1, _ := ret[1].(error) return ret0, ret1 } -// ListInstances indicates an expected call of ListInstances. -func (mr *MockDatastoreInterfaceMockRecorder) ListInstances(ctx, filter interface{}) *gomock.Call { +// ListLogsByInstanceID indicates an expected call of ListLogsByInstanceID. +func (mr *MockDatastoreInterfaceMockRecorder) ListLogsByInstanceID(ctx, instanceID, limit, timestamp interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInstances", reflect.TypeOf((*MockDatastoreInterface)(nil).ListInstances), ctx, filter) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListLogsByInstanceID", reflect.TypeOf((*MockDatastoreInterface)(nil).ListLogsByInstanceID), ctx, instanceID, limit, timestamp) } // ListProducts mocks base method. diff --git a/vcr/app/app.go b/vcr/app/app.go index c72bf6e..2c7d411 100644 --- a/vcr/app/app.go +++ b/vcr/app/app.go @@ -8,6 +8,7 @@ import ( createCmd "vonage-cloud-runtime-cli/vcr/app/create" generatekeysCmd "vonage-cloud-runtime-cli/vcr/app/generatekeys" listCmd "vonage-cloud-runtime-cli/vcr/app/list" + removeCmd "vonage-cloud-runtime-cli/vcr/app/remove" ) func NewCmdApp(f cmdutil.Factory) *cobra.Command { @@ -27,6 +28,7 @@ func NewCmdApp(f cmdutil.Factory) *cobra.Command { AVAILABLE COMMANDS create Create a new Vonage application + remove (rm) Remove a Vonage application list (ls) List all Vonage applications in your account generate-keys Generate new key pairs for an existing application @@ -39,19 +41,23 @@ func NewCmdApp(f cmdutil.Factory) *cobra.Command { # Create a new application with Voice and Messages capabilities $ vcr app create --name my-app --voice --messages + # Remove an application + $ vcr app remove 12345678-1234-1234-1234-123456789abc + + # Generate new keys for an existing application + $ vcr app generate-keys --app-id 12345678-1234-1234-1234-123456789abc + # List all applications $ vcr app list # List applications filtered by name $ vcr app list --filter "production" - - # Generate new keys for an existing application - $ vcr app generate-keys --app-id 12345678-1234-1234-1234-123456789abc `), } - cmd.AddCommand(listCmd.NewCmdAppList(f)) cmd.AddCommand(createCmd.NewCmdAppCreate(f)) cmd.AddCommand(generatekeysCmd.NewCmdAppGenerateKeys(f)) + cmd.AddCommand(listCmd.NewCmdAppList(f)) + cmd.AddCommand(removeCmd.NewCmdAppRemove(f)) return cmd } diff --git a/vcr/app/remove/remove.go b/vcr/app/remove/remove.go new file mode 100644 index 0000000..fc2543f --- /dev/null +++ b/vcr/app/remove/remove.go @@ -0,0 +1,90 @@ +package remove + +import ( + "context" + "errors" + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "vonage-cloud-runtime-cli/pkg/api" + "vonage-cloud-runtime-cli/pkg/cmdutil" +) + +type Options struct { + cmdutil.Factory + + ApplicationID string + SkipPrompts bool +} + +func NewCmdAppRemove(f cmdutil.Factory) *cobra.Command { + opts := Options{ + Factory: f, + } + + cmd := &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm"}, + Short: "Remove a Vonage application", + Long: heredoc.Doc(`Remove a Vonage application from your account. + + This command permanently removes a Vonage application and its associated + credentials. Any VCR instances linked to this application will lose their + authentication credentials on next restart. + + WARNING: This action is irreversible. Make sure no running instances + depend on this application before removing it. + `), + Example: heredoc.Doc(` + # Remove an application (will prompt for confirmation) + $ vcr app remove 12345678-1234-1234-1234-123456789abc + + # Remove without confirmation prompt (useful for CI/CD) + $ vcr app remove 12345678-1234-1234-1234-123456789abc --yes + + # Using the short alias + $ vcr app rm 12345678-1234-1234-1234-123456789abc --yes + `), + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + opts.ApplicationID = args[0] + + ctx, cancel := context.WithDeadline(context.Background(), opts.Deadline()) + defer cancel() + + return runRemove(ctx, &opts) + }, + } + + cmd.Flags().BoolVarP(&opts.SkipPrompts, "yes", "y", false, "Skip confirmation prompt (use with caution)") + + return cmd +} + +func runRemove(ctx context.Context, opts *Options) error { + io := opts.IOStreams() + c := io.ColorScheme() + + if io.CanPrompt() && !opts.SkipPrompts { + if !opts.Survey().AskYesNo(fmt.Sprintf("Are you sure you want to remove application %q?", opts.ApplicationID)) { + fmt.Fprintf(io.ErrOut, "%s Application removal aborted\n", c.WarningIcon()) + return nil + } + } + + spinner := cmdutil.DisplaySpinnerMessageWithHandle(fmt.Sprintf(" Removing application %q...", opts.ApplicationID)) + err := opts.DeploymentClient().DeleteVonageApplication(ctx, opts.ApplicationID) + spinner.Stop() + if err != nil { + if errors.Is(err, api.ErrNotFound) { + return fmt.Errorf("application %q could not be found or may have already been deleted", opts.ApplicationID) + } + return fmt.Errorf("failed to remove application: %w", err) + } + + fmt.Fprintf(io.Out, "%s Application %q successfully removed\n", c.SuccessIcon(), opts.ApplicationID) + + return nil +} diff --git a/vcr/app/remove/remove_test.go b/vcr/app/remove/remove_test.go new file mode 100644 index 0000000..c7a411a --- /dev/null +++ b/vcr/app/remove/remove_test.go @@ -0,0 +1,169 @@ +package remove + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/golang/mock/gomock" + "github.com/google/shlex" + "github.com/stretchr/testify/require" + + "vonage-cloud-runtime-cli/pkg/api" + "vonage-cloud-runtime-cli/testutil" + "vonage-cloud-runtime-cli/testutil/mocks" +) + +func TestAppRemove(t *testing.T) { + const appID = "12345678-1234-1234-1234-123456789abc" + + type mock struct { + DeleteTimes int + DeleteReturnErr error + AskYesNoTimes int + AskYesNoReturn bool + } + type want struct { + errMsg string + stdout string + stderr string + } + + tests := []struct { + name string + cli string + mock mock + want want + }{ + { + name: "happy-path-with-yes-flag", + cli: appID + " --yes", + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: nil, + AskYesNoTimes: 0, + }, + want: want{ + stdout: "✓ Application \"" + appID + "\" successfully removed\n", + }, + }, + { + name: "happy-path-confirm-prompt", + cli: appID, + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: nil, + AskYesNoTimes: 1, + AskYesNoReturn: true, + }, + want: want{ + stdout: "✓ Application \"" + appID + "\" successfully removed\n", + }, + }, + { + name: "user-aborts-prompt", + cli: appID, + mock: mock{ + DeleteTimes: 0, + AskYesNoTimes: 1, + AskYesNoReturn: false, + }, + want: want{ + stderr: "! Application removal aborted\n", + }, + }, + { + name: "missing-application-id", + cli: "", + mock: mock{ + DeleteTimes: 0, + AskYesNoTimes: 0, + }, + want: want{ + errMsg: "accepts 1 arg(s), received 0", + }, + }, + { + name: "not-found", + cli: appID + " --yes", + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: api.ErrNotFound, + }, + want: want{ + errMsg: "application \"" + appID + "\" could not be found or may have already been deleted", + }, + }, + { + name: "api-error", + cli: appID + " --yes", + mock: mock{ + DeleteTimes: 1, + DeleteReturnErr: errors.New("internal server error"), + }, + want: want{ + errMsg: "failed to remove application: internal server error", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + + deploymentMock := mocks.NewMockDeploymentInterface(ctrl) + if tt.mock.DeleteTimes > 0 { + deploymentMock.EXPECT(). + DeleteVonageApplication(gomock.Any(), appID). + Times(tt.mock.DeleteTimes). + Return(tt.mock.DeleteReturnErr) + } + + surveyMock := mocks.NewMockSurveyInterface(ctrl) + if tt.mock.AskYesNoTimes > 0 { + surveyMock.EXPECT(). + AskYesNo(gomock.Any()). + Times(tt.mock.AskYesNoTimes). + Return(tt.mock.AskYesNoReturn) + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + + argv, err := shlex.Split(tt.cli) + if err != nil { + t.Fatal(err) + } + + f := testutil.DefaultFactoryMock(t, ios, nil, nil, nil, deploymentMock, surveyMock, nil) + + cmd := NewCmdAppRemove(f) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, cmdErr := cmd.ExecuteC() + if cmdErr != nil && tt.want.errMsg != "" { + require.Error(t, cmdErr, "should throw error") + require.Equal(t, tt.want.errMsg, cmdErr.Error()) + return + } + + cmdOut := &testutil.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + } + + if tt.want.stderr != "" { + require.Equal(t, tt.want.stderr, cmdOut.Stderr()) + return + } + require.NoError(t, cmdErr, "should not throw error") + require.Equal(t, tt.want.stdout, cmdOut.String()) + }) + } +}