diff --git a/pkg/cli/cmd/env/create/preview/create.go b/pkg/cli/cmd/env/create/preview/create.go index 4f7927a5328..a6ad474ee07 100644 --- a/pkg/cli/cmd/env/create/preview/create.go +++ b/pkg/cli/cmd/env/create/preview/create.go @@ -18,6 +18,7 @@ package preview import ( "context" + "fmt" "github.com/spf13/cobra" @@ -27,6 +28,7 @@ import ( "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/cmd/group/common" "github.com/radius-project/radius/pkg/cli/connections" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/output" @@ -70,6 +72,9 @@ rad env create myenv --kubernetes-namespace mynamespace ## Create environment with recipe packs (--preview) rad env create myenv --preview --recipe-packs pack1,pack2 + +## Create environment with recipe packs from a different resource group (--preview) +rad env create myenv --preview --recipe-packs pack1 --recipe-pack-group other-group `, RunE: framework.RunCommand(runner), } @@ -88,6 +93,7 @@ rad env create myenv --preview --recipe-packs pack1,pack2 commonflags.MarkNamespaceFlagDeprecated(cmd) cmd.MarkFlagsMutuallyExclusive(commonflags.KubernetesNamespaceFlag, commonflags.NamespaceFlag) cmd.Flags().StringSliceP("recipe-packs", "", []string{}, "Specify recipe packs to assign to the environment (--preview). Accepts comma-separated values.") + cmd.Flags().StringP("recipe-pack-group", "", "", "Specify the resource group containing the recipe packs named in --recipe-packs, if different from the environment's resource group (--preview).") return cmd, runner } @@ -107,8 +113,9 @@ type Runner struct { ConfigFileInterface framework.ConfigFileInterface ConnectionFactory connections.Factory - recipePacks []string - providers *corerpv20250801.Providers + recipePacks []string + recipePackGroup string + providers *corerpv20250801.Providers } // NewRunner creates a new instance of the `rad env create` runner. @@ -237,6 +244,21 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { return clierrors.Message("No valid recipe packs were provided. Specify one or more recipe pack names or IDs with --recipe-packs.") } + r.recipePackGroup, err = cmd.Flags().GetString("recipe-pack-group") + if err != nil { + return err + } + + if r.recipePackGroup != "" && !cmd.Flags().Changed("recipe-packs") { + return clierrors.Message("--recipe-pack-group can only be used together with --recipe-packs.") + } + + if r.recipePackGroup != "" { + if err := common.ValidateResourceGroupName(r.recipePackGroup); err != nil { + return err + } + } + return nil } @@ -320,9 +342,18 @@ func (r *Runner) resolveRecipePacks(ctx context.Context) ([]*string, error) { recipePackClient := r.RadiusCoreClientFactory.NewRecipePacksClient() + recipePackScope := r.Workspace.Scope + if r.recipePackGroup != "" { + workspaceScopeID, err := resources.ParseScope(r.Workspace.Scope) + if err != nil { + return nil, err + } + recipePackScope = fmt.Sprintf("%s/resourceGroups/%s", workspaceScopeID.PlaneScope(), r.recipePackGroup) + } + recipePackIDs := make([]*string, 0, len(r.recipePacks)) for _, recipePack := range r.recipePacks { - recipePackID, isFullID, err := recipepack.ResolveID(recipePack, r.Workspace.Scope) + recipePackID, isFullID, err := recipepack.ResolveID(recipePack, recipePackScope) if err != nil { return nil, err } diff --git a/pkg/cli/cmd/env/create/preview/create_test.go b/pkg/cli/cmd/env/create/preview/create_test.go index e5d81d68d3a..0b6028aec03 100644 --- a/pkg/cli/cmd/env/create/preview/create_test.go +++ b/pkg/cli/cmd/env/create/preview/create_test.go @@ -322,6 +322,43 @@ func Test_Validate(t *testing.T) { expectResourceGroupSuccess(mocks.ApplicationManagementClient, "test-resource-group") }, }, + { + Name: "Create command with --recipe-pack-group flag", + Input: []string{"testingenv", "--recipe-packs", "pack1", "--recipe-pack-group", "other-group"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + expectResourceGroupSuccess(mocks.ApplicationManagementClient, "test-resource-group") + }, + ValidateCallback: func(t *testing.T, runner framework.Runner) { + r := runner.(*Runner) + require.Equal(t, "other-group", r.recipePackGroup) + }, + }, + { + Name: "Create command with --recipe-pack-group flag but no --recipe-packs", + Input: []string{"testingenv", "--recipe-pack-group", "other-group"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + expectResourceGroupSuccess(mocks.ApplicationManagementClient, "test-resource-group") + }, + }, + { + Name: "Create command with invalid --recipe-pack-group value", + Input: []string{"testingenv", "--recipe-packs", "pack1", "--recipe-pack-group", "invalid group name!"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + Config: configWithWorkspace, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + expectResourceGroupSuccess(mocks.ApplicationManagementClient, "test-resource-group") + }, + }, } radcli.SharedValidateValidation(t, NewCommand, testcases) } @@ -683,6 +720,50 @@ func Test_Run(t *testing.T) { require.Contains(t, *referencedByUpdate[0], "Radius.Core/environments/testenv") }) + t.Run("resolves a bare recipe pack name against recipePackGroup", func(t *testing.T) { + var capturedEnv v20250801preview.EnvironmentResource + + capturingEnvServer := func() corerpfake.EnvironmentsServer { + return corerpfake.EnvironmentsServer{ + CreateOrUpdate: func( + ctx context.Context, + rootScope string, + environmentName string, + resource v20250801preview.EnvironmentResource, + options *v20250801preview.EnvironmentsClientCreateOrUpdateOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + capturedEnv = resource + resp.SetResponse(http.StatusOK, v20250801preview.EnvironmentsClientCreateOrUpdateResponse{EnvironmentResource: resource}, nil) + return + }, + } + } + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + workspace.Scope, + capturingEnvServer, + test_client_factory.WithRecipePackServerNoError, + ) + require.NoError(t, err) + + runner := &Runner{ + RadiusCoreClientFactory: factory, + Output: &output.MockOutput{}, + Workspace: workspace, + EnvironmentName: "testenv", + ResourceGroupName: "test-resource-group", + recipePacks: []string{"mypack"}, + recipePackGroup: "other-group", + } + + err = runner.Run(t.Context()) + require.NoError(t, err) + + // The bare name is resolved against recipePackGroup, not the workspace's own resource group. + require.Len(t, capturedEnv.Properties.RecipePacks, 1) + require.Equal(t, "/planes/radius/local/resourceGroups/other-group/providers/Radius.Core/recipePacks/mypack", *capturedEnv.Properties.RecipePacks[0]) + }) + t.Run("returns error when specified recipe pack does not exist", func(t *testing.T) { factory, err := test_client_factory.NewRadiusCoreTestClientFactory( workspace.Scope, diff --git a/pkg/cli/cmd/env/update/preview/update.go b/pkg/cli/cmd/env/update/preview/update.go index 726b386dccf..9d7efc67458 100644 --- a/pkg/cli/cmd/env/update/preview/update.go +++ b/pkg/cli/cmd/env/update/preview/update.go @@ -18,6 +18,7 @@ package preview import ( "context" + "fmt" "github.com/spf13/cobra" @@ -26,6 +27,7 @@ import ( "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/cmd/group/common" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/output" "github.com/radius-project/radius/pkg/cli/recipepack" @@ -86,6 +88,9 @@ rad env update myenv --clear-kubernetes ## Set recipe packs to environment (--preview) rad env update myenv --recipe-packs pack1,pack2 + +## Set recipe packs from a different resource group to environment (--preview) +rad env update myenv --recipe-packs pack1 --recipe-pack-group other-group `, RunE: framework.RunCommand(runner), } @@ -96,6 +101,7 @@ rad env update myenv --recipe-packs pack1,pack2 cmd.Flags().Bool(commonflags.ClearEnvAWSFlag, false, "Specify if aws provider needs to be cleared on env") cmd.Flags().Bool(commonflags.ClearEnvKubernetesFlag, false, "Specify if kubernetes provider needs to be cleared on env (--preview)") cmd.Flags().StringSliceP("recipe-packs", "", []string{}, "Specify recipe packs to replace the environment's recipe pack list (--preview). Accepts comma-separated values.") + cmd.Flags().StringP("recipe-pack-group", "", "", "Specify the resource group containing the recipe packs named in --recipe-packs, if different from the environment's resource group (--preview).") commonflags.AddAzureScopeFlags(cmd) commonflags.AddAWSScopeFlags(cmd) commonflags.AddKubernetesScopeFlags(cmd) @@ -119,6 +125,7 @@ type Runner struct { providers *corerpv20250801.Providers noFlagsSet bool recipePacks []string + recipePackGroup string } // NewRunner creates a new instance of the `rad env update` preview runner. @@ -215,6 +222,27 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { r.recipePacks = recipepack.NormalizeRecipePacks(recipePacks) + // Reject an explicitly provided but effectively empty --recipe-packs value + // (e.g. "," or " ") rather than silently skipping the recipe-pack update. + if cmd.Flags().Changed("recipe-packs") && len(r.recipePacks) == 0 { + return clierrors.Message("No valid recipe packs were provided. Specify one or more recipe pack names or IDs with --recipe-packs.") + } + + r.recipePackGroup, err = cmd.Flags().GetString("recipe-pack-group") + if err != nil { + return err + } + + if r.recipePackGroup != "" && !cmd.Flags().Changed("recipe-packs") { + return clierrors.Message("--recipe-pack-group can only be used together with --recipe-packs.") + } + + if r.recipePackGroup != "" { + if err := common.ValidateResourceGroupName(r.recipePackGroup); err != nil { + return err + } + } + return nil } @@ -341,8 +369,17 @@ func (r *Runner) resolveRecipePacks(ctx context.Context) ([]*string, error) { recipePackClient := r.RadiusCoreClientFactory.NewRecipePacksClient() recipePackIDs := make([]*string, 0, len(r.recipePacks)) + recipePackScope := r.Workspace.Scope + if r.recipePackGroup != "" { + workspaceScopeID, err := resources.ParseScope(r.Workspace.Scope) + if err != nil { + return nil, err + } + recipePackScope = fmt.Sprintf("%s/resourceGroups/%s", workspaceScopeID.PlaneScope(), r.recipePackGroup) + } + for _, recipePack := range r.recipePacks { - recipePackID, isFullID, err := recipepack.ResolveID(recipePack, r.Workspace.Scope) + recipePackID, isFullID, err := recipepack.ResolveID(recipePack, recipePackScope) if err != nil { return nil, err } diff --git a/pkg/cli/cmd/env/update/preview/update_test.go b/pkg/cli/cmd/env/update/preview/update_test.go index 50d4a0b4193..d30188331a7 100644 --- a/pkg/cli/cmd/env/update/preview/update_test.go +++ b/pkg/cli/cmd/env/update/preview/update_test.go @@ -92,6 +92,46 @@ func Test_Validate(t *testing.T) { Config: configWithWorkspace, }, }, + { + Name: "Update Env Command with --recipe-pack-group and --recipe-packs", + Input: []string{"default", "--recipe-packs", "pack1", "--recipe-pack-group", "other-group"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + ValidateCallback: func(t *testing.T, runner framework.Runner) { + r := runner.(*Runner) + require.Equal(t, "other-group", r.recipePackGroup) + }, + }, + { + Name: "Update Env Command with --recipe-pack-group but no --recipe-packs", + Input: []string{"default", "--recipe-pack-group", "other-group"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Update Env Command with empty --recipe-packs value", + Input: []string{"default", "--recipe-packs", " , "}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "Update Env Command with invalid --recipe-pack-group value", + Input: []string{"default", "--recipe-packs", "pack1", "--recipe-pack-group", "invalid group name!"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, } radcli.SharedValidateValidation(t, NewCommand, testcases) @@ -144,6 +184,7 @@ func Test_ValidateRecipePackParsing(t *testing.T) { cmd.Flags().Bool(commonflags.ClearEnvAWSFlag, false, "") cmd.Flags().Bool(commonflags.ClearEnvKubernetesFlag, false, "") cmd.Flags().StringSliceP("recipe-packs", "", []string{}, "Specify recipe packs to be added to the environment (--preview)") + cmd.Flags().StringP("recipe-pack-group", "", "", "Specify the resource group containing the recipe packs (--preview)") // Parse flags err := cmd.Flags().Parse(tc.input) @@ -444,6 +485,87 @@ func Test_Run_RecipePackNotFound(t *testing.T) { } } +// Test_Run_RecipePacksWithGroup verifies that a bare recipe pack name is +// resolved against recipePackGroup, not the environment's own resource group, +// when --recipe-pack-group is specified. +// +// The environment is seeded with the exact recipe pack ID that "mypack" combined +// with recipePackGroup "other-group" should resolve to, so the resolved list is +// unchanged and referencedBy sync (which requires a live connection for a pack +// outside the workspace's own scope) is a no-op. This isolates the scope +// resolution behavior under test from that unrelated, pre-existing code path. +func Test_Run_RecipePacksWithGroup(t *testing.T) { + workspace := &workspaces.Workspace{ + Name: "test-workspace", + Scope: "/planes/radius/local/resourceGroups/test-group", + } + + expectedPackID := "/planes/radius/local/resourceGroups/other-group/providers/Radius.Core/recipePacks/mypack" + + var capturedEnv v20250801preview.EnvironmentResource + + envServer := func() fake.EnvironmentsServer { + return fake.EnvironmentsServer{ + Get: func( + _ context.Context, + _ string, + environmentName string, + _ *v20250801preview.EnvironmentsClientGetOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientGetResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.EnvironmentsClientGetResponse{ + EnvironmentResource: v20250801preview.EnvironmentResource{ + ID: to.Ptr(workspace.Scope + "/providers/Radius.Core/environments/" + environmentName), + Name: to.Ptr(environmentName), + Properties: &v20250801preview.EnvironmentProperties{ + RecipePacks: []*string{to.Ptr(expectedPackID)}, + }, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + CreateOrUpdate: func( + _ context.Context, + _ string, + _ string, + resource v20250801preview.EnvironmentResource, + _ *v20250801preview.EnvironmentsClientCreateOrUpdateOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + capturedEnv = resource + result := v20250801preview.EnvironmentsClientCreateOrUpdateResponse{ + EnvironmentResource: resource, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + } + } + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + workspace.Scope, + envServer, + nil, // uses default RecipePacksServer (accepts any name) + ) + require.NoError(t, err) + + runner := &Runner{ + ConfigHolder: &framework.ConfigHolder{}, + Output: &output.MockOutput{}, + Workspace: workspace, + EnvironmentName: "test-env", + RadiusCoreClientFactory: factory, + recipePacks: []string{"mypack"}, + recipePackGroup: "other-group", + providers: &v20250801preview.Providers{}, + } + + err = runner.Run(t.Context()) + require.NoError(t, err) + + require.Len(t, capturedEnv.Properties.RecipePacks, 1) + require.Equal(t, expectedPackID, *capturedEnv.Properties.RecipePacks[0]) +} + // Test_Run_RecipePackFullResourceID verifies that a recipe pack referenced by full // resource ID is validated and stored as-is rather than being re-scoped. func Test_Run_RecipePackFullResourceID(t *testing.T) {