diff --git a/cmd/rad/cmd/recipepack.go b/cmd/rad/cmd/recipepack.go new file mode 100644 index 0000000000..b8634e724c --- /dev/null +++ b/cmd/rad/cmd/recipepack.go @@ -0,0 +1,35 @@ +/* +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 cmd + +import ( + "github.com/spf13/cobra" +) + +func init() { + RootCmd.AddCommand(recipePackCmd) + recipePackCmd.PersistentFlags().StringP("workspace", "w", "", "The workspace name") +} + +func NewRecipePackCommand() *cobra.Command { + return &cobra.Command{ + Use: "recipe-pack", + Short: "Manage recipe-packs", + Long: `Manage recipe-packs + Recipe-packs automate the deployment of infrastructure and configuration of radius resources.`, + } +} diff --git a/cmd/rad/cmd/root.go b/cmd/rad/cmd/root.go index e48b598333..5fb2e68551 100644 --- a/cmd/rad/cmd/root.go +++ b/cmd/rad/cmd/root.go @@ -55,6 +55,8 @@ import ( recipe_register "github.com/radius-project/radius/pkg/cli/cmd/recipe/register" recipe_show "github.com/radius-project/radius/pkg/cli/cmd/recipe/show" recipe_unregister "github.com/radius-project/radius/pkg/cli/cmd/recipe/unregister" + recipe_pack_list "github.com/radius-project/radius/pkg/cli/cmd/recipepack/list" + recipe_pack_show "github.com/radius-project/radius/pkg/cli/cmd/recipepack/show" resource_create "github.com/radius-project/radius/pkg/cli/cmd/resource/create" resource_delete "github.com/radius-project/radius/pkg/cli/cmd/resource/delete" resource_list "github.com/radius-project/radius/pkg/cli/cmd/resource/list" @@ -124,6 +126,7 @@ var resourceCmd = NewResourceCommand() var resourceProviderCmd = NewResourceProviderCommand() var resourceTypeCmd = NewResourceTypeCommand() var recipeCmd = NewRecipeCommand() +var recipePackCmd = NewRecipePackCommand() var envCmd = NewEnvironmentCommand() var workspaceCmd = NewWorkspaceCommand() @@ -303,6 +306,12 @@ func initSubCommands() { unregisterRecipeCmd, _ := recipe_unregister.NewCommand(framework) recipeCmd.AddCommand(unregisterRecipeCmd) + listRecipePackCmd, _ := recipe_pack_list.NewCommand(framework) + recipePackCmd.AddCommand(listRecipePackCmd) + + showRecipePackCmd, _ := recipe_pack_show.NewCommand(framework) + recipePackCmd.AddCommand(showRecipePackCmd) + providerCmd := credential.NewCommand(framework) RootCmd.AddCommand(providerCmd) diff --git a/pkg/cli/clients/clients.go b/pkg/cli/clients/clients.go index ac0cc3c1f9..a8937d6a96 100644 --- a/pkg/cli/clients/clients.go +++ b/pkg/cli/clients/clients.go @@ -23,6 +23,7 @@ import ( "github.com/radius-project/radius/pkg/cli/clients_new/generated" corerp "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + radiuscore "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" ucp_v20231001preview "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" ucpresources "github.com/radius-project/radius/pkg/ucp/resources" ) @@ -201,6 +202,15 @@ type ApplicationsManagementClient interface { // ListEnvironmentsAll lists all environments across resource groups. ListEnvironmentsAll(ctx context.Context) ([]corerp.EnvironmentResource, error) + // ListRecipePacksInResourceGroup lists all recipe packs in the configured scope (assumes configured scope is a resource group). + ListRecipePacksInResourceGroup(ctx context.Context) ([]radiuscore.RecipePackResource, error) + + // ListRecipePacks lists all recipe packs in all resource groups. + ListRecipePacks(ctx context.Context) ([]radiuscore.RecipePackResource, error) + + // GetRecipePack retrieves a recipe pack by its name (in the configured scope) or resource ID. + GetRecipePack(ctx context.Context, recipePackNameOrID string) (radiuscore.RecipePackResource, error) + // GetEnvironment retrieves an environment by its name (in the configured scope) or resource ID. GetEnvironment(ctx context.Context, environmentNameOrID string) (corerp.EnvironmentResource, error) diff --git a/pkg/cli/clients/management.go b/pkg/cli/clients/management.go index 4b9324b239..efcdff3b4f 100644 --- a/pkg/cli/clients/management.go +++ b/pkg/cli/clients/management.go @@ -32,6 +32,7 @@ import ( aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/cli/clients_new/generated" corerpv20231001 "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" ucpv20231001 "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/resources" resources_radius "github.com/radius-project/radius/pkg/ucp/resources/radius" @@ -43,6 +44,7 @@ type UCPApplicationsManagementClient struct { genericResourceClientFactory func(scope string, resourceType string) (genericResourceClient, error) applicationResourceClientFactory func(scope string) (applicationResourceClient, error) environmentResourceClientFactory func(scope string) (environmentResourceClient, error) + recipePackResourceClientFactory func(scope string) (recipePackResourceClient, error) resourceGroupClientFactory func() (resourceGroupClient, error) resourceProviderClientFactory func() (resourceProviderClient, error) resourceTypeClientFactory func() (resourceTypeClient, error) @@ -402,6 +404,63 @@ func (amc *UCPApplicationsManagementClient) DeleteApplication(ctx context.Contex return response.StatusCode != 204, nil } +// ListRecipePacksInResourceGroup lists all recipe packs in the configured scope (assumes configured scope is a resource group). +func (amc *UCPApplicationsManagementClient) ListRecipePacksInResourceGroup(ctx context.Context) ([]corerpv20250801.RecipePackResource, error) { + client, err := amc.createRecipePackClient(amc.RootScope) + if err != nil { + return nil, err + } + + result := []corerpv20250801.RecipePackResource{} + pager := client.NewListByScopePager(&corerpv20250801.RecipePacksClientListByScopeOptions{}) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, err + } + for _, rp := range page.RecipePackResourceListResult.Value { + result = append(result, *rp) + } + } + + return result, nil +} + +// ListRecipePacks lists all recipe packs in all resource groups. +func (amc *UCPApplicationsManagementClient) ListRecipePacks(ctx context.Context) ([]corerpv20250801.RecipePackResource, error) { + scope, err := resources.ParseScope(amc.RootScope) + if err != nil { + return []corerpv20250801.RecipePackResource{}, err + } + + // Query at plane scope, not resource group scope. We don't enforce the exact structure of the scope, so handle both cases. + // + // - /planes/radius/local + // - /planes/radius/local/resourceGroups/my-group + if scope.FindScope(resources_radius.ScopeResourceGroups) != "" { + scope = scope.Truncate() + } + + client, err := amc.createRecipePackClient(scope.String()) + if err != nil { + return nil, err + } + + result := []corerpv20250801.RecipePackResource{} + pager := client.NewListByScopePager(&corerpv20250801.RecipePacksClientListByScopeOptions{}) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, err + } + for _, rp := range page.RecipePackResourceListResult.Value { + result = append(result, *rp) + } + } + + return result, nil +} + // ListEnvironments lists all environments in the configured scope (assumes configured scope is a resource group). func (amc *UCPApplicationsManagementClient) ListEnvironments(ctx context.Context) ([]corerpv20231001.EnvironmentResource, error) { client, err := amc.createEnvironmentClient(amc.RootScope) @@ -481,6 +540,26 @@ func (amc *UCPApplicationsManagementClient) GetEnvironment(ctx context.Context, return response.EnvironmentResource, nil } +// GetRecipePack retrieves a recipe pack by name (in the configured scope) or full resource ID. +func (amc *UCPApplicationsManagementClient) GetRecipePack(ctx context.Context, recipePackNameOrID string) (corerpv20250801.RecipePackResource, error) { + scope, name, err := amc.extractScopeAndName(recipePackNameOrID) + if err != nil { + return corerpv20250801.RecipePackResource{}, err + } + + client, err := amc.createRecipePackClient(scope) + if err != nil { + return corerpv20250801.RecipePackResource{}, err + } + + resp, err := client.Get(ctx, name, &corerpv20250801.RecipePacksClientGetOptions{}) + if err != nil { + return corerpv20250801.RecipePackResource{}, err + } + + return resp.RecipePackResource, nil +} + // GetRecipeMetadata shows recipe details including list of all parameters for a given recipe registered to an environment. func (amc *UCPApplicationsManagementClient) GetRecipeMetadata(ctx context.Context, environmentNameOrID string, recipeMetadata corerpv20231001.RecipeGetMetadata) (corerpv20231001.RecipeGetMetadataResponse, error) { scope, name, err := amc.extractScopeAndName(environmentNameOrID) @@ -1107,6 +1186,13 @@ func (amc *UCPApplicationsManagementClient) createApplicationClient(scope string return amc.applicationResourceClientFactory(scope) } +func (amc *UCPApplicationsManagementClient) createRecipePackClient(scope string) (recipePackResourceClient, error) { + if amc.recipePackResourceClientFactory == nil { + return corerpv20250801.NewRecipePacksClient(strings.TrimPrefix(scope, resources.SegmentSeparator), &aztoken.AnonymousCredential{}, amc.ClientOptions) + } + return amc.recipePackResourceClientFactory(scope) +} + func (amc *UCPApplicationsManagementClient) createEnvironmentClient(scope string) (environmentResourceClient, error) { if amc.environmentResourceClientFactory == nil { // Generated client doesn't like the leading '/' in the scope. diff --git a/pkg/cli/clients/management_mocks.go b/pkg/cli/clients/management_mocks.go index 8a71c5fc1c..a235dbf354 100644 --- a/pkg/cli/clients/management_mocks.go +++ b/pkg/cli/clients/management_mocks.go @@ -22,6 +22,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/radius-project/radius/pkg/cli/clients_new/generated" corerpv20231001 "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" ucpv20231001 "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" ) @@ -34,7 +35,7 @@ import ( // Because these interfaces are non-exported, they MUST be defined in their own file // and we MUST use -source on mockgen to generate mocks for them. -//go:generate mockgen -typed -source=./management_mocks.go -destination=./mock_management_wrapped_clients.go -package=clients -self_package github.com/radius-project/radius/pkg/cli/clients github.com/radius-project/radius/pkg/cli/clients genericResourceClient,applicationResourceClient,environmentResourceClient,resourceGroupClient,resourceProviderClient,resourceTypeClient,apiVersonClient,locationClient +//go:generate mockgen -typed -source=./management_mocks.go -destination=./mock_management_wrapped_clients.go -package=clients -self_package github.com/radius-project/radius/pkg/cli/clients github.com/radius-project/radius/pkg/cli/clients genericResourceClient,applicationResourceClient,environmentResourceClient,resourceGroupClient,resourceProviderClient,resourceTypeClient,apiVersonClient,locationClient,recipePackResourceClient // genericResourceClient is an interface for mocking the generated SDK client for any resource. type genericResourceClient interface { @@ -97,3 +98,11 @@ type apiVersionClient interface { type locationClient interface { BeginCreateOrUpdate(ctx context.Context, planeName string, resourceProviderName string, locationName string, resource ucpv20231001.LocationResource, options *ucpv20231001.LocationsClientBeginCreateOrUpdateOptions) (*runtime.Poller[ucpv20231001.LocationsClientCreateOrUpdateResponse], error) } + +// recipePackResourceClient is an interface for mocking the generated SDK client for recipePack resources. +type recipePackResourceClient interface { + CreateOrUpdate(ctx context.Context, recipePackName string, resource corerpv20250801.RecipePackResource, options *corerpv20250801.RecipePacksClientCreateOrUpdateOptions) (corerpv20250801.RecipePacksClientCreateOrUpdateResponse, error) + Delete(ctx context.Context, recipePackName string, options *corerpv20250801.RecipePacksClientDeleteOptions) (corerpv20250801.RecipePacksClientDeleteResponse, error) + Get(ctx context.Context, recipePackName string, options *corerpv20250801.RecipePacksClientGetOptions) (corerpv20250801.RecipePacksClientGetResponse, error) + NewListByScopePager(options *corerpv20250801.RecipePacksClientListByScopeOptions) *runtime.Pager[corerpv20250801.RecipePacksClientListByScopeResponse] +} diff --git a/pkg/cli/clients/mock_applicationsclient.go b/pkg/cli/clients/mock_applicationsclient.go index 4cd8d72cd9..d249ea38bb 100644 --- a/pkg/cli/clients/mock_applicationsclient.go +++ b/pkg/cli/clients/mock_applicationsclient.go @@ -15,6 +15,7 @@ import ( generated "github.com/radius-project/radius/pkg/cli/clients_new/generated" v20231001preview "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + v20250801preview "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" v20231001preview0 "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" gomock "go.uber.org/mock/gomock" ) @@ -779,6 +780,45 @@ func (c *MockApplicationsManagementClientGetRecipeMetadataCall) DoAndReturn(f fu return c } +// GetRecipePack mocks base method. +func (m *MockApplicationsManagementClient) GetRecipePack(arg0 context.Context, arg1 string) (v20250801preview.RecipePackResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRecipePack", arg0, arg1) + ret0, _ := ret[0].(v20250801preview.RecipePackResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRecipePack indicates an expected call of GetRecipePack. +func (mr *MockApplicationsManagementClientMockRecorder) GetRecipePack(arg0, arg1 any) *MockApplicationsManagementClientGetRecipePackCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRecipePack", reflect.TypeOf((*MockApplicationsManagementClient)(nil).GetRecipePack), arg0, arg1) + return &MockApplicationsManagementClientGetRecipePackCall{Call: call} +} + +// MockApplicationsManagementClientGetRecipePackCall wrap *gomock.Call +type MockApplicationsManagementClientGetRecipePackCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockApplicationsManagementClientGetRecipePackCall) Return(arg0 v20250801preview.RecipePackResource, arg1 error) *MockApplicationsManagementClientGetRecipePackCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockApplicationsManagementClientGetRecipePackCall) Do(f func(context.Context, string) (v20250801preview.RecipePackResource, error)) *MockApplicationsManagementClientGetRecipePackCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockApplicationsManagementClientGetRecipePackCall) DoAndReturn(f func(context.Context, string) (v20250801preview.RecipePackResource, error)) *MockApplicationsManagementClientGetRecipePackCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // GetResource mocks base method. func (m *MockApplicationsManagementClient) GetResource(arg0 context.Context, arg1, arg2 string) (generated.GenericResource, error) { m.ctrl.T.Helper() @@ -1091,6 +1131,84 @@ func (c *MockApplicationsManagementClientListEnvironmentsAllCall) DoAndReturn(f return c } +// ListRecipePacks mocks base method. +func (m *MockApplicationsManagementClient) ListRecipePacks(arg0 context.Context) ([]v20250801preview.RecipePackResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRecipePacks", arg0) + ret0, _ := ret[0].([]v20250801preview.RecipePackResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRecipePacks indicates an expected call of ListRecipePacks. +func (mr *MockApplicationsManagementClientMockRecorder) ListRecipePacks(arg0 any) *MockApplicationsManagementClientListRecipePacksCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRecipePacks", reflect.TypeOf((*MockApplicationsManagementClient)(nil).ListRecipePacks), arg0) + return &MockApplicationsManagementClientListRecipePacksCall{Call: call} +} + +// MockApplicationsManagementClientListRecipePacksCall wrap *gomock.Call +type MockApplicationsManagementClientListRecipePacksCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockApplicationsManagementClientListRecipePacksCall) Return(arg0 []v20250801preview.RecipePackResource, arg1 error) *MockApplicationsManagementClientListRecipePacksCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockApplicationsManagementClientListRecipePacksCall) Do(f func(context.Context) ([]v20250801preview.RecipePackResource, error)) *MockApplicationsManagementClientListRecipePacksCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockApplicationsManagementClientListRecipePacksCall) DoAndReturn(f func(context.Context) ([]v20250801preview.RecipePackResource, error)) *MockApplicationsManagementClientListRecipePacksCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListRecipePacksInResourceGroup mocks base method. +func (m *MockApplicationsManagementClient) ListRecipePacksInResourceGroup(arg0 context.Context) ([]v20250801preview.RecipePackResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRecipePacksInResourceGroup", arg0) + ret0, _ := ret[0].([]v20250801preview.RecipePackResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRecipePacksInResourceGroup indicates an expected call of ListRecipePacksInResourceGroup. +func (mr *MockApplicationsManagementClientMockRecorder) ListRecipePacksInResourceGroup(arg0 any) *MockApplicationsManagementClientListRecipePacksInResourceGroupCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRecipePacksInResourceGroup", reflect.TypeOf((*MockApplicationsManagementClient)(nil).ListRecipePacksInResourceGroup), arg0) + return &MockApplicationsManagementClientListRecipePacksInResourceGroupCall{Call: call} +} + +// MockApplicationsManagementClientListRecipePacksInResourceGroupCall wrap *gomock.Call +type MockApplicationsManagementClientListRecipePacksInResourceGroupCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockApplicationsManagementClientListRecipePacksInResourceGroupCall) Return(arg0 []v20250801preview.RecipePackResource, arg1 error) *MockApplicationsManagementClientListRecipePacksInResourceGroupCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockApplicationsManagementClientListRecipePacksInResourceGroupCall) Do(f func(context.Context) ([]v20250801preview.RecipePackResource, error)) *MockApplicationsManagementClientListRecipePacksInResourceGroupCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockApplicationsManagementClientListRecipePacksInResourceGroupCall) DoAndReturn(f func(context.Context) ([]v20250801preview.RecipePackResource, error)) *MockApplicationsManagementClientListRecipePacksInResourceGroupCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // ListResourceGroups mocks base method. func (m *MockApplicationsManagementClient) ListResourceGroups(arg0 context.Context, arg1 string) ([]v20231001preview0.ResourceGroupResource, error) { m.ctrl.T.Helper() diff --git a/pkg/cli/clients/mock_management_wrapped_clients.go b/pkg/cli/clients/mock_management_wrapped_clients.go index 14992544ef..256c8c0c96 100644 --- a/pkg/cli/clients/mock_management_wrapped_clients.go +++ b/pkg/cli/clients/mock_management_wrapped_clients.go @@ -3,7 +3,7 @@ // // Generated by this command: // -// mockgen -typed -source=./management_mocks.go -destination=./mock_management_wrapped_clients.go -package=clients -self_package github.com/radius-project/radius/pkg/cli/clients github.com/radius-project/radius/pkg/cli/clients genericResourceClient,applicationResourceClient,environmentResourceClient,resourceGroupClient,resourceProviderClient,resourceTypeClient,apiVersonClient,locationClient +// mockgen -typed -source=./management_mocks.go -destination=./mock_management_wrapped_clients.go -package=clients -self_package github.com/radius-project/radius/pkg/cli/clients github.com/radius-project/radius/pkg/cli/clients genericResourceClient,applicationResourceClient,environmentResourceClient,resourceGroupClient,resourceProviderClient,resourceTypeClient,apiVersonClient,locationClient,recipePackResourceClient // // Package clients is a generated GoMock package. @@ -16,6 +16,7 @@ import ( runtime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" generated "github.com/radius-project/radius/pkg/cli/clients_new/generated" v20231001preview "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + v20250801preview "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" v20231001preview0 "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" gomock "go.uber.org/mock/gomock" ) @@ -1289,3 +1290,181 @@ func (c *MocklocationClientBeginCreateOrUpdateCall) DoAndReturn(f func(context.C c.Call = c.Call.DoAndReturn(f) return c } + +// MockrecipePackResourceClient is a mock of recipePackResourceClient interface. +type MockrecipePackResourceClient struct { + ctrl *gomock.Controller + recorder *MockrecipePackResourceClientMockRecorder +} + +// MockrecipePackResourceClientMockRecorder is the mock recorder for MockrecipePackResourceClient. +type MockrecipePackResourceClientMockRecorder struct { + mock *MockrecipePackResourceClient +} + +// NewMockrecipePackResourceClient creates a new mock instance. +func NewMockrecipePackResourceClient(ctrl *gomock.Controller) *MockrecipePackResourceClient { + mock := &MockrecipePackResourceClient{ctrl: ctrl} + mock.recorder = &MockrecipePackResourceClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockrecipePackResourceClient) EXPECT() *MockrecipePackResourceClientMockRecorder { + return m.recorder +} + +// CreateOrUpdate mocks base method. +func (m *MockrecipePackResourceClient) CreateOrUpdate(ctx context.Context, recipePackName string, resource v20250801preview.RecipePackResource, options *v20250801preview.RecipePacksClientCreateOrUpdateOptions) (v20250801preview.RecipePacksClientCreateOrUpdateResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateOrUpdate", ctx, recipePackName, resource, options) + ret0, _ := ret[0].(v20250801preview.RecipePacksClientCreateOrUpdateResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateOrUpdate indicates an expected call of CreateOrUpdate. +func (mr *MockrecipePackResourceClientMockRecorder) CreateOrUpdate(ctx, recipePackName, resource, options any) *MockrecipePackResourceClientCreateOrUpdateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrUpdate", reflect.TypeOf((*MockrecipePackResourceClient)(nil).CreateOrUpdate), ctx, recipePackName, resource, options) + return &MockrecipePackResourceClientCreateOrUpdateCall{Call: call} +} + +// MockrecipePackResourceClientCreateOrUpdateCall wrap *gomock.Call +type MockrecipePackResourceClientCreateOrUpdateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockrecipePackResourceClientCreateOrUpdateCall) Return(arg0 v20250801preview.RecipePacksClientCreateOrUpdateResponse, arg1 error) *MockrecipePackResourceClientCreateOrUpdateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockrecipePackResourceClientCreateOrUpdateCall) Do(f func(context.Context, string, v20250801preview.RecipePackResource, *v20250801preview.RecipePacksClientCreateOrUpdateOptions) (v20250801preview.RecipePacksClientCreateOrUpdateResponse, error)) *MockrecipePackResourceClientCreateOrUpdateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockrecipePackResourceClientCreateOrUpdateCall) DoAndReturn(f func(context.Context, string, v20250801preview.RecipePackResource, *v20250801preview.RecipePacksClientCreateOrUpdateOptions) (v20250801preview.RecipePacksClientCreateOrUpdateResponse, error)) *MockrecipePackResourceClientCreateOrUpdateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Delete mocks base method. +func (m *MockrecipePackResourceClient) Delete(ctx context.Context, recipePackName string, options *v20250801preview.RecipePacksClientDeleteOptions) (v20250801preview.RecipePacksClientDeleteResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, recipePackName, options) + ret0, _ := ret[0].(v20250801preview.RecipePacksClientDeleteResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Delete indicates an expected call of Delete. +func (mr *MockrecipePackResourceClientMockRecorder) Delete(ctx, recipePackName, options any) *MockrecipePackResourceClientDeleteCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockrecipePackResourceClient)(nil).Delete), ctx, recipePackName, options) + return &MockrecipePackResourceClientDeleteCall{Call: call} +} + +// MockrecipePackResourceClientDeleteCall wrap *gomock.Call +type MockrecipePackResourceClientDeleteCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockrecipePackResourceClientDeleteCall) Return(arg0 v20250801preview.RecipePacksClientDeleteResponse, arg1 error) *MockrecipePackResourceClientDeleteCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockrecipePackResourceClientDeleteCall) Do(f func(context.Context, string, *v20250801preview.RecipePacksClientDeleteOptions) (v20250801preview.RecipePacksClientDeleteResponse, error)) *MockrecipePackResourceClientDeleteCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockrecipePackResourceClientDeleteCall) DoAndReturn(f func(context.Context, string, *v20250801preview.RecipePacksClientDeleteOptions) (v20250801preview.RecipePacksClientDeleteResponse, error)) *MockrecipePackResourceClientDeleteCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Get mocks base method. +func (m *MockrecipePackResourceClient) Get(ctx context.Context, recipePackName string, options *v20250801preview.RecipePacksClientGetOptions) (v20250801preview.RecipePacksClientGetResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, recipePackName, options) + ret0, _ := ret[0].(v20250801preview.RecipePacksClientGetResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockrecipePackResourceClientMockRecorder) Get(ctx, recipePackName, options any) *MockrecipePackResourceClientGetCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockrecipePackResourceClient)(nil).Get), ctx, recipePackName, options) + return &MockrecipePackResourceClientGetCall{Call: call} +} + +// MockrecipePackResourceClientGetCall wrap *gomock.Call +type MockrecipePackResourceClientGetCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockrecipePackResourceClientGetCall) Return(arg0 v20250801preview.RecipePacksClientGetResponse, arg1 error) *MockrecipePackResourceClientGetCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockrecipePackResourceClientGetCall) Do(f func(context.Context, string, *v20250801preview.RecipePacksClientGetOptions) (v20250801preview.RecipePacksClientGetResponse, error)) *MockrecipePackResourceClientGetCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockrecipePackResourceClientGetCall) DoAndReturn(f func(context.Context, string, *v20250801preview.RecipePacksClientGetOptions) (v20250801preview.RecipePacksClientGetResponse, error)) *MockrecipePackResourceClientGetCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// NewListByScopePager mocks base method. +func (m *MockrecipePackResourceClient) NewListByScopePager(options *v20250801preview.RecipePacksClientListByScopeOptions) *runtime.Pager[v20250801preview.RecipePacksClientListByScopeResponse] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewListByScopePager", options) + ret0, _ := ret[0].(*runtime.Pager[v20250801preview.RecipePacksClientListByScopeResponse]) + return ret0 +} + +// NewListByScopePager indicates an expected call of NewListByScopePager. +func (mr *MockrecipePackResourceClientMockRecorder) NewListByScopePager(options any) *MockrecipePackResourceClientNewListByScopePagerCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByScopePager", reflect.TypeOf((*MockrecipePackResourceClient)(nil).NewListByScopePager), options) + return &MockrecipePackResourceClientNewListByScopePagerCall{Call: call} +} + +// MockrecipePackResourceClientNewListByScopePagerCall wrap *gomock.Call +type MockrecipePackResourceClientNewListByScopePagerCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockrecipePackResourceClientNewListByScopePagerCall) Return(arg0 *runtime.Pager[v20250801preview.RecipePacksClientListByScopeResponse]) *MockrecipePackResourceClientNewListByScopePagerCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockrecipePackResourceClientNewListByScopePagerCall) Do(f func(*v20250801preview.RecipePacksClientListByScopeOptions) *runtime.Pager[v20250801preview.RecipePacksClientListByScopeResponse]) *MockrecipePackResourceClientNewListByScopePagerCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockrecipePackResourceClientNewListByScopePagerCall) DoAndReturn(f func(*v20250801preview.RecipePacksClientListByScopeOptions) *runtime.Pager[v20250801preview.RecipePacksClientListByScopeResponse]) *MockrecipePackResourceClientNewListByScopePagerCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/pkg/cli/clivalidation.go b/pkg/cli/clivalidation.go index 5f85d2d798..f49de4d353 100644 --- a/pkg/cli/clivalidation.go +++ b/pkg/cli/clivalidation.go @@ -616,6 +616,16 @@ func RequireRecipeNameArgs(cmd *cobra.Command, args []string) (string, error) { return args[0], nil } +// RequireRecipePackNameArgs checks if the provided arguments contain at least one string, and if not, returns an error. If the +// +// arguments contain a string, it is returned. +func RequireRecipePackNameArgs(cmd *cobra.Command, args []string) (string, error) { + if len(args) < 1 { + return "", errors.New("no recipe pack name provided") + } + return args[0], nil +} + // GetResourceType retrieves the resource type flag from the given command and returns it, or an error if the flag is not set. func GetResourceType(cmd *cobra.Command) (string, error) { resourceType, err := cmd.Flags().GetString(ResourceTypeFlag) diff --git a/pkg/cli/cmd/commonflags/flags.go b/pkg/cli/cmd/commonflags/flags.go index d461cef6db..487039e814 100644 --- a/pkg/cli/cmd/commonflags/flags.go +++ b/pkg/cli/cmd/commonflags/flags.go @@ -45,6 +45,11 @@ func AddOutputFlag(cmd *cobra.Command) { cmd.Flags().StringP("output", "o", output.DefaultFormat, description) } +func AddOutputFlagWithPlainText(cmd *cobra.Command) { + description := fmt.Sprintf("output format (supported formats are %s)", strings.Join([]string{output.FormatPlainText, output.FormatJson}, ", ")) + cmd.Flags().StringP("output", "o", output.FormatPlainText, description) +} + // AddWorkspaceFlag adds a flag to the given command that allows the user to specify a workspace name. func AddWorkspaceFlag(cmd *cobra.Command) { cmd.Flags().StringP("workspace", "w", "", "The workspace name") diff --git a/pkg/cli/cmd/recipepack/list/list.go b/pkg/cli/cmd/recipepack/list/list.go new file mode 100644 index 0000000000..bec791125e --- /dev/null +++ b/pkg/cli/cmd/recipepack/list/list.go @@ -0,0 +1,132 @@ +/* +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 list + +import ( + "context" + + "github.com/radius-project/radius/pkg/cli" + "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/objectformats" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/spf13/cobra" +) + +// NewCommand creates an instance of the command and runner for the `rad recipe-pack list` command. +// + +// NewCommand creates a new Cobra command and a Runner object, configures the command with flags and arguments, and +// returns the command and the runner. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "list", + Short: "List recipe packs", + Long: "Lists all recipe packs in all scopes", + Example: `rad recipe-packs list`, + RunE: framework.RunCommand(runner), + Args: cobra.ExactArgs(0), + } + + commonflags.AddOutputFlag(cmd) + commonflags.AddWorkspaceFlag(cmd) + commonflags.AddResourceGroupFlag(cmd) + + return cmd, runner +} + +// Runner is the runner implementation for the `rad recipe list` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + ConnectionFactory connections.Factory + Output output.Interface + Workspace *workspaces.Workspace + Format string + Group string +} + +// NewRunner creates a new instance of the `rad recipe list` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + ConnectionFactory: factory.GetConnectionFactory(), + Output: factory.GetOutput(), + } +} + +// Validate runs validation for the `rad recipe-pack list` command. +// + +// Validate checks the command line arguments for a workspace, environment name, and output format, and sets the corresponding +// fields in the Runner struct if they are valid. If any of the arguments are invalid, an error is returned. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + // Validate command line args + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config, r.ConfigHolder.DirectoryConfig) + if err != nil { + return err + } + r.Workspace = workspace + + r.Group, err = cmd.Flags().GetString("group") + if err != nil { + return err + } + + // Allow '--group' to override scope + 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 runs the `rad recipe-pack list` command. +// +// Run retrieves recipe packs from the given workspace and writes them to the output in the specified format. +// It returns an error if the connection to the workspace fails or if there is an error writing to the output. +func (r *Runner) Run(ctx context.Context) error { + client, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + + var recipePacks []v20250801preview.RecipePackResource + if r.Group != "" { + recipePacks, err = client.ListRecipePacksInResourceGroup(ctx) + } else { + recipePacks, err = client.ListRecipePacks(ctx) + } + + if err != nil { + return err + } + + return r.Output.WriteFormatted(r.Format, recipePacks, objectformats.GetRecipePackTableFormat()) +} diff --git a/pkg/cli/cmd/recipepack/list/list_test.go b/pkg/cli/cmd/recipepack/list/list_test.go new file mode 100644 index 0000000000..22690ce7ba --- /dev/null +++ b/pkg/cli/cmd/recipepack/list/list_test.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 list + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/radius-project/radius/pkg/cli/clients" + "github.com/radius-project/radius/pkg/cli/connections" + "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" + "github.com/radius-project/radius/pkg/to" + "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{"too-many"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "List Command with workspace flag pointing to missing workspace", + Input: []string{"-w", "doesnotexist"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "List Command with valid workspace specified", + Input: []string{"-w", radcli.TestWorkspaceName}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "List Command with fallback workspace", + Input: []string{"--group", "test-group"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: radcli.LoadEmptyConfig(t), + }, + }, + } + + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + recipePacks := []corerpv20250801.RecipePackResource{ + {Name: to.Ptr("pack-a")}, + {Name: to.Ptr("pack-b")}, + } + + appMgmtClient := clients.NewMockApplicationsManagementClient(ctrl) + appMgmtClient.EXPECT(). + ListRecipePacks(gomock.Any()). + Return(recipePacks, nil). + Times(1) + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": "kubernetes", + "context": "kind-kind", + }, + Name: "kind-kind", + Scope: "/planes/radius/local/resourceGroups/test-group", + } + + outputSink := &output.MockOutput{} + runner := &Runner{ + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: appMgmtClient}, + Workspace: workspace, + Format: "table", + Output: outputSink, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + expected := []any{ + output.FormattedOutput{ + Format: "table", + Obj: recipePacks, + Options: objectformats.GetRecipePackTableFormat(), + }, + } + + require.Equal(t, expected, outputSink.Writes) +} diff --git a/pkg/cli/cmd/recipepack/show/display.go b/pkg/cli/cmd/recipepack/show/display.go new file mode 100644 index 0000000000..5f6d949d64 --- /dev/null +++ b/pkg/cli/cmd/recipepack/show/display.go @@ -0,0 +1,116 @@ +/* +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 show + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "sigs.k8s.io/yaml" +) + +func (r *Runner) display(recipePack v20250801preview.RecipePackResource) error { + if recipePack.Properties == nil || recipePack.Properties.Recipes == nil || len(recipePack.Properties.Recipes) == 0 { + r.Output.LogInfo("\nRECIPES: none") + return nil + } + + r.Output.LogInfo("\nRECIPES:") + + resourceTypes := make([]string, 0, len(recipePack.Properties.Recipes)) + for resourceType := range recipePack.Properties.Recipes { + resourceTypes = append(resourceTypes, resourceType) + } + sort.Strings(resourceTypes) + + for idx, resourceType := range resourceTypes { + definition := recipePack.Properties.Recipes[resourceType] + if definition == nil { + continue + } + + kind := "unknown" + if definition.RecipeKind != nil { + kind = string(*definition.RecipeKind) + } + + location := "" + if definition.RecipeLocation != nil { + location = *definition.RecipeLocation + } + + r.Output.LogInfo("%s", resourceType) + r.Output.LogInfo(" Kind: %s", kind) + r.Output.LogInfo(" Location: %s", location) + + if len(definition.Parameters) > 0 { + formatted, err := formatRecipeParameters(definition.Parameters) + if err != nil { + return fmt.Errorf("format recipe parameters: %w", err) + } + if formatted != "" { + r.Output.LogInfo(" Parameters:") + r.Output.LogInfo("%s", indentLines(formatted, " ")) + } + } + + if idx < len(resourceTypes)-1 { + r.Output.LogInfo("") + } + } + + return nil +} + +// formatRecipeParameters renders the parameters map without JSON braces/quotes. +func formatRecipeParameters(params map[string]any) (string, error) { + raw, err := json.Marshal(params) + if err != nil { + return "", err + } + + var normalized map[string]any + if err := json.Unmarshal(raw, &normalized); err != nil { + return "", err + } + if len(normalized) == 0 { + return "", nil + } + + out, err := yaml.Marshal(normalized) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(out)), nil +} + +// indentLines prefixes each line with the provided indent string. +func indentLines(text, indent string) string { + if text == "" { + return "" + } + + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = indent + line + } + return strings.Join(lines, "\n") +} diff --git a/pkg/cli/cmd/recipepack/show/show.go b/pkg/cli/cmd/recipepack/show/show.go new file mode 100644 index 0000000000..5c498a26cf --- /dev/null +++ b/pkg/cli/cmd/recipepack/show/show.go @@ -0,0 +1,150 @@ +/* +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 show + +import ( + "context" + + "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/commonflags" + "github.com/radius-project/radius/pkg/cli/connections" + "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" + "github.com/spf13/cobra" +) + +// NewCommand creates a new Cobra command and a Runner object to show recipe pack details, with flags for workspace, +// resource group and output. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "show", + Short: "Show recipe pack details", + Long: `Show recipe pack details`, + Args: cobra.ExactArgs(1), + Example: ` + +# Show specified recipe pack +rad recipe-show show my-recipe-pack + +# Show specified recipe pack in a specified resource group +rad recipe-show show my-recipe-pack --group my-group +`, + RunE: framework.RunCommand(runner), + } + + commonflags.AddOutputFlagWithPlainText(cmd) + commonflags.AddResourceGroupFlag(cmd) + commonflags.AddWorkspaceFlag(cmd) + + return cmd, runner +} + +// Runner is the runner implementation for the `rad recipe-pack show` command. +type Runner struct { + ConfigHolder *framework.ConfigHolder + ConnectionFactory connections.Factory + Workspace *workspaces.Workspace + Output output.Interface + Format string + RecipePackName string +} + +// NewRunner creates a new instance of the `rad recipe-pack show` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConnectionFactory: factory.GetConnectionFactory(), + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + } +} + +// Validate runs validation for the `rad recipe-pack show` command. +// +// Validate checks the request object for a workspace, scope, recipe pack name, and output format, and sets the +// corresponding fields in the Runner struct if they are found. If any of these fields are not found, an error is returned. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + workspace, err := cli.RequireWorkspace(cmd, r.ConfigHolder.Config, r.ConfigHolder.DirectoryConfig) + if err != nil { + return err + } + r.Workspace = workspace + + // Allow '--group' to override scope + scope, err := cli.RequireScope(cmd, *r.Workspace) + if err != nil { + return err + } + r.Workspace.Scope = scope + + recipePackName, err := cli.RequireRecipePackNameArgs(cmd, args) + if err != nil { + return err + } + r.RecipePackName = recipePackName + + format, err := cli.RequireOutput(cmd) + if err != nil { + return err + } + + r.Format = format + + return nil +} + +// Run runs the `rad recipe-pack show` command. +// + +// Run attempts to retrieve recipe pack details from an ApplicationsManagementClient and write the details to an +// output in a specified format, returning an error if any of these operations fail. +func (r *Runner) Run(ctx context.Context) error { + client, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + + recipePack, err := client.GetRecipePack(ctx, r.RecipePackName) + if clients.Is404Error(err) { + return clierrors.Message("The recipe pack %q was not found or has been deleted.", r.RecipePackName) + } else if err != nil { + return err + } + + if r.Format != "json" { + err = r.Output.WriteFormatted(output.FormatTable, recipePack, objectformats.GetRecipePackTableFormat()) + if err != nil { + return err + } + err = r.display(recipePack) + if err != nil { + return err + } + } else { + err = r.Output.WriteFormatted(r.Format, recipePack, objectformats.GetRecipePackTableFormat()) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/cli/cmd/recipepack/show/show_test.go b/pkg/cli/cmd/recipepack/show/show_test.go new file mode 100644 index 0000000000..c992be240c --- /dev/null +++ b/pkg/cli/cmd/recipepack/show/show_test.go @@ -0,0 +1,140 @@ +/* +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 show + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/radius-project/radius/pkg/cli/clients" + "github.com/radius-project/radius/pkg/cli/connections" + "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" + corerpv20250801preview "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/to" + "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: "missing recipe pack name", + Input: []string{}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "invalid workspace reference", + Input: []string{"my-pack", "-w", "doesnotexist"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "valid with workspace flag", + Input: []string{"my-pack", "-w", radcli.TestWorkspaceName}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "valid with fallback workspace", + Input: []string{"my-pack", "--group", "test-group"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: radcli.LoadEmptyConfig(t), + }, + }, + } + + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + recipePack := corerpv20250801preview.RecipePackResource{ + Name: to.Ptr("sample-pack"), + Properties: &corerpv20250801preview.RecipePackProperties{ + Recipes: map[string]*corerpv20250801preview.RecipeDefinition{ + "Radius.Core/example": { + RecipeKind: to.Ptr(corerpv20250801preview.RecipeKindTerraform), + RecipeLocation: to.Ptr("https://github.com/radius-project/example"), + Parameters: map[string]any{ + "foo": "bar", + }, + }, + }, + }, + } + + appMgmtClient := clients.NewMockApplicationsManagementClient(ctrl) + appMgmtClient.EXPECT(). + GetRecipePack(gomock.Any(), "sample-pack"). + Return(recipePack, nil). + Times(1) + + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": "kubernetes", + "context": "kind-kind", + }, + Name: "kind-kind", + Scope: "/planes/radius/local/resourceGroups/test-group", + } + + outputSink := &output.MockOutput{} + runner := &Runner{ + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: appMgmtClient}, + Workspace: workspace, + Output: outputSink, + RecipePackName: "sample-pack", + Format: "json", + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + expected := []any{ + output.FormattedOutput{ + Format: "json", + Obj: recipePack, + Options: objectformats.GetRecipePackTableFormat(), + }, + } + + require.Equal(t, expected, outputSink.Writes) +} diff --git a/pkg/cli/objectformats/objectformats.go b/pkg/cli/objectformats/objectformats.go index 6735457982..8727ed195d 100644 --- a/pkg/cli/objectformats/objectformats.go +++ b/pkg/cli/objectformats/objectformats.go @@ -46,6 +46,60 @@ func GetResourceTableFormat() output.FormatterOptions { } } +func GetRecipePackTableFormat() output.FormatterOptions { + return output.FormatterOptions{ + Columns: []output.Column{ + { + Heading: "RECIPE PACK", + JSONPath: "{ .Name }", + }, + { + Heading: "GROUP", + JSONPath: "{ .ID }", + Transformer: &ResourceIDToResourceGroupNameTransformer{}, + }, + }, + } +} + +func GetRecipeFormat() output.FormatterOptions { + return output.FormatterOptions{ + Columns: []output.Column{ + { + Heading: "RESOURCE TYPE", + JSONPath: "{ .ResourceType }", + }, + { + Heading: "RECIPE KIND", + JSONPath: "{ .RecipeKind }", + }, + { + Heading: "RECIPE LOCATION", + JSONPath: "{ .RecipeLocation }", + }, + }, + } +} + +func GetRecipeFormatWithoutHeadings() output.FormatterOptions { + return output.FormatterOptions{ + Columns: []output.Column{ + { + Heading: "", + JSONPath: "{ .ResourceType }", + }, + { + Heading: "", + JSONPath: "{ .RecipeKind }", + }, + { + Heading: "", + JSONPath: "{ .RecipeLocation }", + }, + }, + } +} + // GetGenericResourceTableFormat returns the fields to output from a generic resource object. // This function should be used with the Go type GenericResource. // The difference between this function and the GetResourceTableFormat function above is that diff --git a/pkg/cli/output/formats.go b/pkg/cli/output/formats.go index 7d4fbe5aee..07b7ca12fe 100644 --- a/pkg/cli/output/formats.go +++ b/pkg/cli/output/formats.go @@ -17,9 +17,10 @@ limitations under the License. package output const ( - FormatJson = "json" - FormatTable = "table" - DefaultFormat = FormatTable + FormatJson = "json" + FormatTable = "table" + FormatPlainText = "plain-text" + DefaultFormat = FormatTable ) // SupportedFormats returns a slice of strings containing the supported formats for a request. diff --git a/test/functional-portable/cli/noncloud/cli_test.go b/test/functional-portable/cli/noncloud/cli_test.go index 0a396fa48d..40a2dbfa54 100644 --- a/test/functional-portable/cli/noncloud/cli_test.go +++ b/test/functional-portable/cli/noncloud/cli_test.go @@ -817,3 +817,31 @@ func generateUniqueTag() string { tag := fmt.Sprintf("test-%d-%d", timestamp, random) return tag } + +func Test_RecipePack(t *testing.T) { + ctx, cancel := testcontext.NewWithCancel(t) + t.Cleanup(cancel) + + options := rp.NewRPTestOptions(t) + cli := radcli.NewCLI(t, options.ConfigFilePath) + + packName := "computeRecipePack" + templatePath := "./testdata/corerp-recipe-pack-test.bicep" + + t.Run("deploy recipe pack", func(t *testing.T) { + err := cli.Deploy(ctx, templatePath, "", "") + require.NoError(t, err) + }) + + t.Run("verify recipe pack listed", func(t *testing.T) { + output, err := cli.RecipePackList(ctx, "") + require.NoError(t, err) + require.Contains(t, output, packName) + }) + + t.Run("verify recipe pack show", func(t *testing.T) { + output, err := cli.RecipePackShow(ctx, packName, "") + require.NoError(t, err) + require.Contains(t, output, packName) + }) +} diff --git a/test/functional-portable/cli/noncloud/testdata/corerp-recipe-pack-test.bicep b/test/functional-portable/cli/noncloud/testdata/corerp-recipe-pack-test.bicep new file mode 100644 index 0000000000..e76bb33075 --- /dev/null +++ b/test/functional-portable/cli/noncloud/testdata/corerp-recipe-pack-test.bicep @@ -0,0 +1,23 @@ +extension radius +resource computeRecipePack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'computeRecipePack' + properties: { + recipes: { + 'Radius.Compute/containers': { + recipeKind: 'terraform' + recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' + parameters: { + allowPlatformOptions: true + } + } + 'Radius.Security/secrets': { + recipeKind: 'terraform' + recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' + } + 'Radius.Storage/volumes': { + recipeKind: 'terraform' + recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' + } + } + } +} diff --git a/test/radcli/cli.go b/test/radcli/cli.go index 03b696b2d8..aead3bb08f 100644 --- a/test/radcli/cli.go +++ b/test/radcli/cli.go @@ -147,7 +147,7 @@ func (cli *CLI) deployInternal(ctx context.Context, templateFilePath string, env type ShowOptions struct { Group string // The resource group name Workspace string // The workspace name - Output string // Output format (json, table) + Output string // Output format (json, table, plain-text) Application string // Application name (for resource show) } @@ -466,6 +466,33 @@ func (cli *CLI) RecipeList(ctx context.Context, envName string) (string, error) return cli.RunCommand(ctx, args) } +// RecipePackList runs the "recipe-pack list" command with the given environment name and returns the output as a string, returning +// an error if the command fails. +func (cli *CLI) RecipePackList(ctx context.Context, groupName string) (string, error) { + args := []string{ + "recipe-pack", + "list", + } + if groupName != "" { + args = append(args, "--group", groupName) + } + return cli.RunCommand(ctx, args) +} + +// RecipePackShow runs the "recipe-pack show" command with the given recipe pack name and returns the output as a string, returning +// an error if the command fails. +func (cli *CLI) RecipePackShow(ctx context.Context, recipepackName, groupName string) (string, error) { + args := []string{ + "recipe-pack", + "show", + recipepackName, + } + if groupName != "" { + args = append(args, "--group", groupName) + } + return cli.RunCommand(ctx, args) +} + // RecipeRegister runs a command to register a recipe with the given environment, template kind, template path and // resource type, and returns the output string or an error. func (cli *CLI) RecipeRegister(ctx context.Context, envName, recipeName, templateKind, templatePath, resourceType string, plainHTTP bool) (string, error) {