diff --git a/cmd/rad/cmd/root.go b/cmd/rad/cmd/root.go index 26712db09d..fc6d0667be 100644 --- a/cmd/rad/cmd/root.go +++ b/cmd/rad/cmd/root.go @@ -32,9 +32,12 @@ import ( "github.com/radius-project/radius/pkg/cli/bicep" "github.com/radius-project/radius/pkg/cli/clierrors" app_delete "github.com/radius-project/radius/pkg/cli/cmd/app/delete" + app_delete_preview "github.com/radius-project/radius/pkg/cli/cmd/app/delete/preview" app_graph "github.com/radius-project/radius/pkg/cli/cmd/app/graph" app_list "github.com/radius-project/radius/pkg/cli/cmd/app/list" + app_list_preview "github.com/radius-project/radius/pkg/cli/cmd/app/list/preview" app_show "github.com/radius-project/radius/pkg/cli/cmd/app/show" + app_show_preview "github.com/radius-project/radius/pkg/cli/cmd/app/show/preview" app_status "github.com/radius-project/radius/pkg/cli/cmd/app/status" bicep_generate_kubernetes_manifest "github.com/radius-project/radius/pkg/cli/cmd/bicep/generatekubernetesmanifest" bicep_publish "github.com/radius-project/radius/pkg/cli/cmd/bicep/publish" @@ -400,12 +403,18 @@ func initSubCommands() { workspaceCmd.AddCommand(workspaceSwitchCmd) appDeleteCmd, _ := app_delete.NewCommand(framework) + previewAppDeleteCmd, _ := app_delete_preview.NewCommand(framework) + wirePreviewSubcommand(appDeleteCmd, previewAppDeleteCmd) applicationCmd.AddCommand(appDeleteCmd) appListCmd, _ := app_list.NewCommand(framework) + previewAppListCmd, _ := app_list_preview.NewCommand(framework) + wirePreviewSubcommand(appListCmd, previewAppListCmd) applicationCmd.AddCommand(appListCmd) appShowCmd, _ := app_show.NewCommand(framework) + previewAppShowCmd, _ := app_show_preview.NewCommand(framework) + wirePreviewSubcommand(appShowCmd, previewAppShowCmd) applicationCmd.AddCommand(appShowCmd) appStatusCmd, _ := app_status.NewCommand(framework) diff --git a/pkg/cli/cmd/app/delete/preview/delete.go b/pkg/cli/cmd/app/delete/preview/delete.go new file mode 100644 index 0000000000..0267ed7c59 --- /dev/null +++ b/pkg/cli/cmd/app/delete/preview/delete.go @@ -0,0 +1,281 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/clients" + generated "github.com/radius-project/radius/pkg/cli/clients_new/generated" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/connections" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" +) + +const ( + msgApplicationDeletedPreview = "Application deleted" + msgDeletingApplicationPreview = "Deleting application %s...\n" + msgDeletingResources = "Deleting %d resource(s) associated with application %s...\n" + bicepWarning = "'%v' is a Bicep filename or path and not the name of a Radius Application. Specify the name of a valid application and try again" +) + +// NewCommand creates an instance of the command and runner for the `rad app delete --preview` command. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete Radius Application (preview)", + Long: `Delete application and its associated resources using the Radius.Core preview API surface.`, + Args: cobra.MaximumNArgs(1), + Example: ` +# Delete current application +rad app delete --preview + +# Delete current application and bypass confirmation prompt +rad app delete --yes --preview + +# Delete specified application +rad app delete my-app --preview +`, + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddResourceGroupFlag(cmd) + commonflags.AddApplicationNameFlag(cmd) + commonflags.AddConfirmationFlag(cmd) + commonflags.AddForceFlag(cmd) + + return cmd, runner +} + +// Runner is the runner implementation for the preview `rad app delete` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + InputPrompter prompt.Interface + ConnectionFactory connections.Factory + Workspace *workspaces.Workspace + RadiusCoreClientFactory *corerpv20250801.ClientFactory + + Confirm bool + Force bool + ApplicationName string +} + +// NewRunner creates a new instance of the preview delete runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + InputPrompter: factory.GetPrompter(), + ConnectionFactory: factory.GetConnectionFactory(), + } +} + +// Validate runs validation for the preview delete command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config) + if err != nil { + return err + } + r.Workspace = workspace + + scope, err := cli.RequireScope(cmd, *r.Workspace) + if err != nil { + return err + } + r.Workspace.Scope = scope + + r.ApplicationName, err = cli.RequireApplicationArgs(cmd, args, *workspace) + if err != nil { + return err + } + + if strings.HasSuffix(r.ApplicationName, ".bicep") { + return clierrors.Message(bicepWarning, r.ApplicationName) + } + + r.Confirm, err = cmd.Flags().GetBool("yes") + if err != nil { + return err + } + + r.Force, err = cmd.Flags().GetBool("force") + if err != nil { + return err + } + + return nil +} + +// Run executes the preview delete command logic. +// +// This discovers resources owned by the application using the management client's +// resource enumeration (ownership-based via properties.application), deletes them +// in parallel, then deletes the application via the Radius.Core preview API. +func (r *Runner) Run(ctx context.Context) error { + if r.RadiusCoreClientFactory == nil { + factory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) + if err != nil { + return err + } + r.RadiusCoreClientFactory = factory + } + + appClient := r.RadiusCoreClientFactory.NewApplicationsClient() + + // Check if the application exists + _, err := appClient.Get(ctx, r.ApplicationName, &corerpv20250801.ApplicationsClientGetOptions{}) + if clients.Is404Error(err) { + r.Output.LogInfo("Application '%s' does not exist or has already been deleted.", r.ApplicationName) + return nil + } else if err != nil { + return err + } + + if !r.Confirm { + promptMsg := fmt.Sprintf("Are you sure you want to delete application '%s'?", r.ApplicationName) + confirmed, err := prompt.YesOrNoPrompt(promptMsg, prompt.ConfirmNo, r.InputPrompter) + if err != nil { + return err + } + if !confirmed { + r.Output.LogInfo("Application %q NOT deleted", r.ApplicationName) + return nil + } + } + + if r.Force { + r.Output.LogInfo("WARNING: Force deleting an application. Resources in non-terminal states may leave orphaned external resources that require manual cleanup.") + } + + // Use the management client to discover and delete owned resources. + // This uses ownership-based filtering (properties.application matches our app ID) + // rather than GetGraph which returns a connectivity graph that may include shared resources. + managementClient, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + + // Build the fully qualified Radius.Core application ID for ownership matching + applicationID := r.Workspace.Scope + "/providers/Radius.Core/applications/" + r.ApplicationName + + resourcesList, err := listResourcesOwnedByApplication(ctx, managementClient, applicationID) + if err != nil && !clients.Is404Error(err) { + return err + } + + // Delete associated resources in parallel + if len(resourcesList) > 0 { + r.Output.LogInfo(msgDeletingResources, len(resourcesList), r.ApplicationName) + + g, groupCtx := errgroup.WithContext(ctx) + for _, resource := range resourcesList { + if resource.ID != nil && resource.Type != nil { + // Log before launching the goroutine; output.Interface implementations + // (including the MockOutput used in tests) are not guaranteed to be + // thread-safe, and ordering the log here keeps output deterministic. + r.Output.LogInfo(" Deleting %s...", *resource.ID) + resourceType := *resource.Type + resourceID := *resource.ID + g.Go(func() error { + _, err := managementClient.DeleteResource(groupCtx, resourceType, resourceID, r.Force) + if err != nil && !clients.Is404Error(err) { + return err + } + return nil + }) + } + } + + if err := g.Wait(); err != nil { + return clierrors.Message("Failed to delete resources for application '%s': %v", r.ApplicationName, err) + } + } + + // Delete the application itself via the preview API. + // Re-check for 404 in case the app was concurrently deleted during resource cleanup. + r.Output.LogInfo(msgDeletingApplicationPreview, r.ApplicationName) + + _, err = appClient.Delete(ctx, r.ApplicationName, &corerpv20250801.ApplicationsClientDeleteOptions{}) + if clients.Is404Error(err) { + r.Output.LogInfo("Application '%s' does not exist or has already been deleted.", r.ApplicationName) + return nil + } else if err != nil { + return err + } + + r.Output.LogInfo(msgApplicationDeletedPreview) + return nil +} + +// listResourcesOwnedByApplication lists resources whose properties.application field +// matches the given application ID. This is an ownership-based query that only returns +// resources explicitly owned by the application, unlike GetGraph which returns a +// connectivity graph that may include shared/environment resources. +func listResourcesOwnedByApplication(ctx context.Context, client clients.ApplicationsManagementClient, applicationID string) ([]generated.GenericResource, error) { + resourceTypesList, err := client.ListAllResourceTypesNames(ctx, "local") + if err != nil { + return nil, err + } + + var results []generated.GenericResource + for _, resourceType := range resourceTypesList { + resources, err := client.ListResourcesOfType(ctx, resourceType) + if err != nil { + return nil, err + } + + for _, resource := range resources { + if isResourceOwnedByApplication(resource, applicationID) { + results = append(results, resource) + } + } + } + + return results, nil +} + +// isResourceOwnedByApplication checks if a resource's properties.application field +// matches the given application ID (case-insensitive). +func isResourceOwnedByApplication(resource generated.GenericResource, applicationID string) bool { + obj, found := resource.Properties["application"] + if !found { + return false + } + + associatedAppID, ok := obj.(string) + if !ok || associatedAppID == "" { + return false + } + + return strings.EqualFold(associatedAppID, applicationID) +} diff --git a/pkg/cli/cmd/app/delete/preview/delete_test.go b/pkg/cli/cmd/app/delete/preview/delete_test.go new file mode 100644 index 0000000000..64d6b829bb --- /dev/null +++ b/pkg/cli/cmd/app/delete/preview/delete_test.go @@ -0,0 +1,508 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/radius-project/radius/pkg/cli/clients" + generated "github.com/radius-project/radius/pkg/cli/clients_new/generated" + "github.com/radius-project/radius/pkg/cli/connections" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/radius-project/radius/pkg/cli/test_client_factory" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview/fake" + "github.com/radius-project/radius/test/radcli" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + + testcases := []radcli.ValidateInput{ + { + Name: "Delete command with flag", + Input: []string{"-a", "test-app"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "Delete command with positional arg", + Input: []string{"test-app"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "Delete command with fallback workspace", + Input: []string{"--application", "test-app", "--group", "test-group"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: radcli.LoadEmptyConfig(t), + }, + }, + { + Name: "Delete command with incorrect args", + Input: []string{"foo", "bar"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "Delete command with Bicep filename", + Input: []string{"app.bicep", "--yes"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + } + + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +// mockManagementClientNoResources returns a mock that has no resources to delete. +func mockManagementClientNoResources(ctrl *gomock.Controller) clients.ApplicationsManagementClient { + mock := clients.NewMockApplicationsManagementClient(ctrl) + mock.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return([]string{}, nil). + AnyTimes() + return mock +} + +// mockManagementClientWithResources returns a mock that has resources owned by the app. +// The force flag controls the expected force argument on DeleteResource. +func mockManagementClientWithResources(ctrl *gomock.Controller, appID string, force bool) clients.ApplicationsManagementClient { + mock := clients.NewMockApplicationsManagementClient(ctrl) + + resourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/my-redis" + resourceType := "Applications.Datastores/redisCaches" + + mock.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return([]string{resourceType}, nil). + Times(1) + mock.EXPECT(). + ListResourcesOfType(gomock.Any(), resourceType). + Return([]generated.GenericResource{ + { + ID: &resourceID, + Type: &resourceType, + Properties: map[string]any{ + "application": appID, + }, + }, + }, nil). + Times(1) + mock.EXPECT(). + DeleteResource(gomock.Any(), resourceType, resourceID, force). + Return(true, nil). + Times(1) + return mock +} + +func Test_Run(t *testing.T) { + workspace := &workspaces.Workspace{ + Name: "test-workspace", + Scope: "/planes/radius/local/resourceGroups/test-group", + } + + t.Run("Success: application deleted (no resources)", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + mockMgmt := mockManagementClientNoResources(ctrl) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: outputSink, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, []any{ + output.LogOutput{ + Format: msgDeletingApplicationPreview, + Params: []any{"test-app"}, + }, + output.LogOutput{ + Format: msgApplicationDeletedPreview, + }, + }, outputSink.Writes) + }) + + t.Run("Success: application deleted with cascade resource deletion", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + appID := workspace.Scope + "/providers/Radius.Core/applications/test-app" + mockMgmt := mockManagementClientWithResources(ctrl, appID, false) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: outputSink, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + + // Verify resource count message and final deleted message are present + require.GreaterOrEqual(t, len(outputSink.Writes), 3) + + // First output should be the resource count message + firstLog, ok := outputSink.Writes[0].(output.LogOutput) + require.True(t, ok) + require.Equal(t, msgDeletingResources, firstLog.Format) + + // Last output should be the application deleted message + lastLog, ok := outputSink.Writes[len(outputSink.Writes)-1].(output.LogOutput) + require.True(t, ok) + require.Equal(t, msgApplicationDeletedPreview, lastLog.Format) + }) + + t.Run("Success: application not found (404)", func(t *testing.T) { + notFoundServer := func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + Get: func( + ctx context.Context, + applicationName string, + options *corerpv20250801.ApplicationsClientGetOptions, + ) (resp azfake.Responder[corerpv20250801.ApplicationsClientGetResponse], errResp azfake.ErrorResponder) { + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return + }, + } + } + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, notFoundServer) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + Output: outputSink, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, []any{ + output.LogOutput{ + Format: "Application '%s' does not exist or has already been deleted.", + Params: []any{"test-app"}, + }, + }, outputSink.Writes) + }) + + t.Run("Success: user declines confirmation", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + promptMock := prompt.NewMockInterface(ctrl) + promptMock.EXPECT(). + GetListInput([]string{prompt.ConfirmNo, prompt.ConfirmYes}, fmt.Sprintf("Are you sure you want to delete application '%s'?", "test-app")). + Return(prompt.ConfirmNo, nil). + Times(1) + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + Output: outputSink, + InputPrompter: promptMock, + ApplicationName: "test-app", + Confirm: false, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, []any{ + output.LogOutput{ + Format: "Application %q NOT deleted", + Params: []any{"test-app"}, + }, + }, outputSink.Writes) + }) + + t.Run("Success: --force passes force=true to child resource deletes", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + appID := workspace.Scope + "/providers/Radius.Core/applications/test-app" + mockMgmt := mockManagementClientWithResources(ctrl, appID, true) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: outputSink, + ApplicationName: "test-app", + Confirm: true, + Force: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + + // First log should be the force warning. + require.NotEmpty(t, outputSink.Writes) + firstLog, ok := outputSink.Writes[0].(output.LogOutput) + require.True(t, ok) + require.Contains(t, firstLog.Format, "Force deleting an application") + }) + + t.Run("Failure: child resource delete failure surfaces error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + appID := workspace.Scope + "/providers/Radius.Core/applications/test-app" + resourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/my-redis" + resourceType := "Applications.Datastores/redisCaches" + + mockMgmt := clients.NewMockApplicationsManagementClient(ctrl) + mockMgmt.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return([]string{resourceType}, nil). + Times(1) + mockMgmt.EXPECT(). + ListResourcesOfType(gomock.Any(), resourceType). + Return([]generated.GenericResource{{ + ID: &resourceID, + Type: &resourceType, + Properties: map[string]any{"application": appID}, + }}, nil). + Times(1) + mockMgmt.EXPECT(). + DeleteResource(gomock.Any(), resourceType, resourceID, false). + Return(false, fmt.Errorf("simulated delete failure")). + Times(1) + + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: &output.MockOutput{}, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "Failed to delete resources for application 'test-app'") + }) + + t.Run("Failure: ListAllResourceTypesNames failure surfaces error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + mockMgmt := clients.NewMockApplicationsManagementClient(ctrl) + mockMgmt.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return(nil, fmt.Errorf("simulated list error")). + Times(1) + + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: &output.MockOutput{}, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "simulated list error") + }) + + t.Run("Success: resources owned by other applications are filtered out", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + appID := workspace.Scope + "/providers/Radius.Core/applications/test-app" + otherAppID := workspace.Scope + "/providers/Radius.Core/applications/other-app" + ownedResourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/owned" + unrelatedResourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/unrelated" + orphanResourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/orphan" + resourceType := "Applications.Datastores/redisCaches" + + mockMgmt := clients.NewMockApplicationsManagementClient(ctrl) + mockMgmt.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return([]string{resourceType}, nil). + Times(1) + mockMgmt.EXPECT(). + ListResourcesOfType(gomock.Any(), resourceType). + Return([]generated.GenericResource{ + // Owned by our app — should be deleted. + {ID: &ownedResourceID, Type: &resourceType, Properties: map[string]any{"application": appID}}, + // Owned by another app — must NOT be deleted. + {ID: &unrelatedResourceID, Type: &resourceType, Properties: map[string]any{"application": otherAppID}}, + // No application property — must NOT be deleted. + {ID: &orphanResourceID, Type: &resourceType, Properties: map[string]any{}}, + }, nil). + Times(1) + // Only the owned resource is deleted. + mockMgmt.EXPECT(). + DeleteResource(gomock.Any(), resourceType, ownedResourceID, false). + Return(true, nil). + Times(1) + + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: &output.MockOutput{}, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + }) + + t.Run("Success: case-insensitive ownership match", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + // Resource records the application ID in a different case from the constructed ID. + ownedAppID := strings.ToUpper(workspace.Scope) + "/providers/Radius.Core/applications/TEST-APP" + resourceID := "/planes/radius/local/resourceGroups/test-group/providers/Applications.Datastores/redisCaches/my-redis" + resourceType := "Applications.Datastores/redisCaches" + + mockMgmt := clients.NewMockApplicationsManagementClient(ctrl) + mockMgmt.EXPECT(). + ListAllResourceTypesNames(gomock.Any(), "local"). + Return([]string{resourceType}, nil). + Times(1) + mockMgmt.EXPECT(). + ListResourcesOfType(gomock.Any(), resourceType). + Return([]generated.GenericResource{{ + ID: &resourceID, + Type: &resourceType, + Properties: map[string]any{"application": ownedAppID}, + }}, nil). + Times(1) + mockMgmt.EXPECT(). + DeleteResource(gomock.Any(), resourceType, resourceID, false). + Return(true, nil). + Times(1) + + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: &output.MockOutput{}, + ApplicationName: "test-app", + Confirm: true, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + }) + + t.Run("Success: user accepts confirmation prompt", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + promptMock := prompt.NewMockInterface(ctrl) + promptMock.EXPECT(). + GetListInput([]string{prompt.ConfirmNo, prompt.ConfirmYes}, fmt.Sprintf("Are you sure you want to delete application '%s'?", "test-app")). + Return(prompt.ConfirmYes, nil). + Times(1) + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + mockMgmt := mockManagementClientNoResources(ctrl) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockMgmt}, + Workspace: workspace, + Output: outputSink, + InputPrompter: promptMock, + ApplicationName: "test-app", + Confirm: false, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + + // Must reach the final "Application deleted" log. + lastLog, ok := outputSink.Writes[len(outputSink.Writes)-1].(output.LogOutput) + require.True(t, ok) + require.Equal(t, msgApplicationDeletedPreview, lastLog.Format) + }) +} diff --git a/pkg/cli/cmd/app/list/preview/list.go b/pkg/cli/cmd/app/list/preview/list.go new file mode 100644 index 0000000000..bae2ec6526 --- /dev/null +++ b/pkg/cli/cmd/app/list/preview/list.go @@ -0,0 +1,117 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/cmd" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/objectformats" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" +) + +// NewCommand creates an instance of the command and runner for the `rad app list` preview command. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "list", + Short: "List Radius Applications (preview)", + Long: `List Radius.Core applications using the preview API surface.`, + Args: cobra.NoArgs, + RunE: framework.RunCommand(runner), + Example: `rad app list`, + } + + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddResourceGroupFlag(cmd) + commonflags.AddOutputFlag(cmd) + + return cmd, runner +} + +// Runner is the runner implementation for the preview `rad app list` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + Format string + RadiusCoreClientFactory *corerpv20250801.ClientFactory +} + +// NewRunner creates a new instance of the preview list runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + } +} + +// Validate runs validation for the preview list command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config) + if err != nil { + return err + } + r.Workspace = workspace + + scope, err := cli.RequireScope(cmd, *r.Workspace) + if err != nil { + return err + } + r.Workspace.Scope = scope + + format, err := cli.RequireOutput(cmd) + if err != nil { + return err + } + r.Format = format + + return nil +} + +// Run executes the preview list command logic. +func (r *Runner) Run(ctx context.Context) error { + if r.RadiusCoreClientFactory == nil { + factory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) + if err != nil { + return err + } + r.RadiusCoreClientFactory = factory + } + + client := r.RadiusCoreClientFactory.NewApplicationsClient() + pager := client.NewListByScopePager(&corerpv20250801.ApplicationsClientListByScopeOptions{}) + + var applications []*corerpv20250801.ApplicationResource + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return err + } + applications = append(applications, page.Value...) + } + + return r.Output.WriteFormatted(r.Format, applications, objectformats.GetResourceTableFormat()) +} diff --git a/pkg/cli/cmd/app/list/preview/list_test.go b/pkg/cli/cmd/app/list/preview/list_test.go new file mode 100644 index 0000000000..adbdcf935d --- /dev/null +++ b/pkg/cli/cmd/app/list/preview/list_test.go @@ -0,0 +1,198 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + "net/http" + "testing" + + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/stretchr/testify/require" + + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/objectformats" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/test_client_factory" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview/fake" + "github.com/radius-project/radius/test/radcli" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + + testcases := []radcli.ValidateInput{ + { + Name: "List command with incorrect args", + Input: []string{"group"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "List command with bad workspace", + Input: []string{"-w", "doesnotexist"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "List command with valid workspace", + Input: []string{"-w", radcli.TestWorkspaceName}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + }, + { + Name: "List command with fallback workspace", + Input: []string{"--group", "test-group"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: radcli.LoadEmptyConfig(t), + }, + }, + } + + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + workspace := &workspaces.Workspace{ + Name: "test-workspace", + Scope: "/planes/radius/local/resourceGroups/test-group", + } + + testcases := []struct { + name string + serverFactory func() fake.ApplicationsServer + expectedOutput []any + expectError bool + }{ + { + name: "applications returned", + serverFactory: test_client_factory.WithApplicationsServerNoError, + expectedOutput: []any{ + output.FormattedOutput{ + Format: "table", + Obj: []*corerpv20250801.ApplicationResource{ + {Name: new("test-app-1")}, + {Name: new("test-app-2")}, + }, + Options: objectformats.GetResourceTableFormat(), + }, + }, + }, + { + name: "empty list", + serverFactory: func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + NewListByScopePager: func(_ *corerpv20250801.ApplicationsClientListByScopeOptions) (resp azfake.PagerResponder[corerpv20250801.ApplicationsClientListByScopeResponse]) { + resp.AddPage(http.StatusOK, corerpv20250801.ApplicationsClientListByScopeResponse{ + ApplicationResourceListResult: corerpv20250801.ApplicationResourceListResult{ + Value: []*corerpv20250801.ApplicationResource{}, + }, + }, nil) + return + }, + } + }, + expectedOutput: []any{ + output.FormattedOutput{ + Format: "table", + Obj: []*corerpv20250801.ApplicationResource(nil), + Options: objectformats.GetResourceTableFormat(), + }, + }, + }, + { + name: "multi-page list", + serverFactory: func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + NewListByScopePager: func(_ *corerpv20250801.ApplicationsClientListByScopeOptions) (resp azfake.PagerResponder[corerpv20250801.ApplicationsClientListByScopeResponse]) { + resp.AddPage(http.StatusOK, corerpv20250801.ApplicationsClientListByScopeResponse{ + ApplicationResourceListResult: corerpv20250801.ApplicationResourceListResult{ + Value: []*corerpv20250801.ApplicationResource{{Name: new("page1-a")}, {Name: new("page1-b")}}, + }, + }, nil) + resp.AddPage(http.StatusOK, corerpv20250801.ApplicationsClientListByScopeResponse{ + ApplicationResourceListResult: corerpv20250801.ApplicationResourceListResult{ + Value: []*corerpv20250801.ApplicationResource{{Name: new("page2-a")}}, + }, + }, nil) + return + }, + } + }, + expectedOutput: []any{ + output.FormattedOutput{ + Format: "table", + Obj: []*corerpv20250801.ApplicationResource{ + {Name: new("page1-a")}, + {Name: new("page1-b")}, + {Name: new("page2-a")}, + }, + Options: objectformats.GetResourceTableFormat(), + }, + }, + }, + { + name: "pager error surfaces", + serverFactory: func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + NewListByScopePager: func(_ *corerpv20250801.ApplicationsClientListByScopeOptions) (resp azfake.PagerResponder[corerpv20250801.ApplicationsClientListByScopeResponse]) { + resp.AddResponseError(http.StatusInternalServerError, "InternalServerError") + return + }, + } + }, + expectError: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, tc.serverFactory) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + Format: "table", + Output: outputSink, + } + + err = runner.Run(context.Background()) + if tc.expectError { + require.Error(t, err) + require.Empty(t, outputSink.Writes) + return + } + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, outputSink.Writes) + }) + } +} diff --git a/pkg/cli/cmd/app/show/preview/show.go b/pkg/cli/cmd/app/show/preview/show.go new file mode 100644 index 0000000000..f1e8523c08 --- /dev/null +++ b/pkg/cli/cmd/app/show/preview/show.go @@ -0,0 +1,130 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/clients" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/objectformats" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" +) + +// NewCommand creates an instance of the command and runner for the `rad app show` preview command. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "show", + Short: "Show Radius Application details (preview)", + Long: `Show Radius.Core application details using the preview API surface.`, + Args: cobra.MaximumNArgs(1), + Example: ` +# Show current application +rad app show + +# Show specified application +rad app show my-app + +# Show specified application in a specified resource group +rad app show my-app --group my-group +`, + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddResourceGroupFlag(cmd) + commonflags.AddApplicationNameFlag(cmd) + commonflags.AddOutputFlag(cmd) + + return cmd, runner +} + +// Runner is the runner implementation for the `rad app show` preview command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + Output output.Interface + Workspace *workspaces.Workspace + ApplicationName string + Format string + RadiusCoreClientFactory *corerpv20250801.ClientFactory +} + +// NewRunner creates a new instance of the `rad app show` preview runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + } +} + +// Validate runs validation for the `rad app show` preview command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config) + if err != nil { + return err + } + r.Workspace = workspace + + r.ApplicationName, err = cli.RequireApplicationArgs(cmd, args, *workspace) + if err != nil { + return err + } + + r.Workspace.Scope, err = cli.RequireScope(cmd, *r.Workspace) + if err != nil { + return err + } + + r.Format, err = cli.RequireOutput(cmd) + if err != nil { + return err + } + + return nil +} + +// Run runs the `rad app show` preview command. +func (r *Runner) Run(ctx context.Context) error { + if r.RadiusCoreClientFactory == nil { + clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) + if err != nil { + return err + } + r.RadiusCoreClientFactory = clientFactory + } + + client := r.RadiusCoreClientFactory.NewApplicationsClient() + + resp, err := client.Get(ctx, r.ApplicationName, &corerpv20250801.ApplicationsClientGetOptions{}) + if clients.Is404Error(err) { + return clierrors.Message("The application %q was not found or has been deleted.", r.ApplicationName) + } else if err != nil { + return err + } + + return r.Output.WriteFormatted(r.Format, resp.ApplicationResource, objectformats.GetResourceTableFormat()) +} diff --git a/pkg/cli/cmd/app/show/preview/show_test.go b/pkg/cli/cmd/app/show/preview/show_test.go new file mode 100644 index 0000000000..9dac08e360 --- /dev/null +++ b/pkg/cli/cmd/app/show/preview/show_test.go @@ -0,0 +1,223 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + "net/http" + "testing" + + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/stretchr/testify/require" + + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/objectformats" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/test_client_factory" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview/fake" + "github.com/radius-project/radius/test/radcli" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + + testcases := []radcli.ValidateInput{ + { + Name: "Show Command with flag", + Input: []string{"-a", "test-app"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Show Command with positional arg", + Input: []string{"test-app"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Show Command with fallback workspace", + Input: []string{"--application", "test-app", "--group", "test-group"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: radcli.LoadEmptyConfig(t), + }, + }, + { + Name: "Show Command with incorrect args", + Input: []string{"foo", "bar"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + } + + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + workspace := &workspaces.Workspace{ + Name: "test-workspace", + Scope: "/planes/radius/local/resourceGroups/test-group", + } + + testcases := []struct { + name string + appFactory func() fake.ApplicationsServer + applicationName string + expectedOutput []any + }{ + { + name: "application found", + appFactory: test_client_factory.WithApplicationsServerNoError, + applicationName: "test-app", + expectedOutput: []any{ + output.FormattedOutput{ + Format: "table", + Obj: corerpv20250801.ApplicationResource{ + Name: new("test-app"), + }, + Options: objectformats.GetResourceTableFormat(), + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, tc.appFactory) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + ApplicationName: tc.applicationName, + Format: "table", + Output: outputSink, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, outputSink.Writes) + }) + } + + t.Run("Error: application not found (404)", func(t *testing.T) { + notFoundServer := func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + Get: func( + ctx context.Context, + applicationName string, + options *corerpv20250801.ApplicationsClientGetOptions, + ) (resp azfake.Responder[corerpv20250801.ApplicationsClientGetResponse], errResp azfake.ErrorResponder) { + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return + }, + } + } + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, notFoundServer) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + ApplicationName: "test-app", + Format: "table", + Output: outputSink, + } + + err = runner.Run(context.Background()) + require.Error(t, err) + require.Equal(t, clierrors.Message("The application %q was not found or has been deleted.", "test-app"), err) + require.Empty(t, outputSink.Writes) + }) + + t.Run("Error: non-404 error is propagated", func(t *testing.T) { + serverError := func() fake.ApplicationsServer { + return fake.ApplicationsServer{ + Get: func( + ctx context.Context, + applicationName string, + options *corerpv20250801.ApplicationsClientGetOptions, + ) (resp azfake.Responder[corerpv20250801.ApplicationsClientGetResponse], errResp azfake.ErrorResponder) { + errResp.SetResponseError(http.StatusInternalServerError, "InternalServerError") + return + }, + } + } + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, serverError) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + ApplicationName: "test-app", + Format: "table", + Output: outputSink, + } + + err = runner.Run(context.Background()) + require.Error(t, err) + // Should NOT be the 404 user-facing message. + require.NotEqual(t, clierrors.Message("The application %q was not found or has been deleted.", "test-app"), err) + require.Empty(t, outputSink.Writes) + }) + + t.Run("Success: json output format", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, nil, nil, test_client_factory.WithApplicationsServerNoError) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + Workspace: workspace, + ApplicationName: "test-app", + Format: "json", + Output: outputSink, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, []any{ + output.FormattedOutput{ + Format: "json", + Obj: corerpv20250801.ApplicationResource{Name: new("test-app")}, + Options: objectformats.GetResourceTableFormat(), + }, + }, outputSink.Writes) + }) +} diff --git a/pkg/cli/test_client_factory/radius_core.go b/pkg/cli/test_client_factory/radius_core.go index 43c86098f4..de3b82ba69 100644 --- a/pkg/cli/test_client_factory/radius_core.go +++ b/pkg/cli/test_client_factory/radius_core.go @@ -30,7 +30,8 @@ import ( ) // NewRadiusCoreTestClientFactory creates a new client factory for testing purposes. -func NewRadiusCoreTestClientFactory(rootScope string, envServer func() corerpfake.EnvironmentsServer, recipepackServer func() corerpfake.RecipePacksServer) (*v20250801preview.ClientFactory, error) { +// applicationsServer is variadic for backwards compatibility; only the first value (if any) is used. +func NewRadiusCoreTestClientFactory(rootScope string, envServer func() corerpfake.EnvironmentsServer, recipepackServer func() corerpfake.RecipePacksServer, applicationsServer ...func() corerpfake.ApplicationsServer) (*v20250801preview.ClientFactory, error) { serverFactory := corerpfake.ServerFactory{} if envServer != nil { serverFactory.EnvironmentsServer = envServer() @@ -44,6 +45,12 @@ func NewRadiusCoreTestClientFactory(rootScope string, envServer func() corerpfak serverFactory.RecipePacksServer = WithRecipePackServerNoError() } + if len(applicationsServer) > 0 && applicationsServer[0] != nil { + serverFactory.ApplicationsServer = applicationsServer[0]() + } else { + serverFactory.ApplicationsServer = WithApplicationsServerNoError() + } + serverFactoryTransport := corerpfake.NewServerFactoryTransport(&serverFactory) clientOptions := &armpolicy.ClientOptions{ @@ -171,6 +178,51 @@ func WithEnvironmentServerNoError() corerpfake.EnvironmentsServer { } } +func WithApplicationsServerNoError() corerpfake.ApplicationsServer { + return corerpfake.ApplicationsServer{ + Get: func( + ctx context.Context, + applicationName string, + options *v20250801preview.ApplicationsClientGetOptions, + ) (resp azfake.Responder[v20250801preview.ApplicationsClientGetResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.ApplicationsClientGetResponse{ + ApplicationResource: v20250801preview.ApplicationResource{ + Name: new(applicationName), + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + Delete: func( + ctx context.Context, + applicationName string, + options *v20250801preview.ApplicationsClientDeleteOptions, + ) (resp azfake.Responder[v20250801preview.ApplicationsClientDeleteResponse], errResp azfake.ErrorResponder) { + resp.SetResponse(http.StatusNoContent, v20250801preview.ApplicationsClientDeleteResponse{}, nil) + return + }, + NewListByScopePager: func(options *v20250801preview.ApplicationsClientListByScopeOptions) (resp azfake.PagerResponder[v20250801preview.ApplicationsClientListByScopeResponse]) { + resp.AddPage( + http.StatusOK, + v20250801preview.ApplicationsClientListByScopeResponse{ + ApplicationResourceListResult: v20250801preview.ApplicationResourceListResult{ + Value: []*v20250801preview.ApplicationResource{ + { + Name: new("test-app-1"), + }, + { + Name: new("test-app-2"), + }, + }, + }, + }, + nil, + ) + return + }, + } +} + // WithEnvironmentServer404OnGet returns an EnvironmentsServer that returns 404 on Get // and success on CreateOrUpdate, simulating a new environment creation scenario. func WithEnvironmentServer404OnGet() corerpfake.EnvironmentsServer {