diff --git a/pkg/cli/cmd/deploy/deploy.go b/pkg/cli/cmd/deploy/deploy.go index d8a4f16034..7096121d85 100644 --- a/pkg/cli/cmd/deploy/deploy.go +++ b/pkg/cli/cmd/deploy/deploy.go @@ -34,6 +34,7 @@ import ( "github.com/radius-project/radius/pkg/cli/filesystem" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/recipepack" "github.com/radius-project/radius/pkg/cli/workspaces" "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" @@ -136,6 +137,9 @@ type Runner struct { RadiusCoreClientFactory *v20250801preview.ClientFactory Deploy deploy.Interface Output output.Interface + // DefaultScopeClientFactory is the client factory scoped to the default resource group. + // Recipe packs are always created/queried in the default scope. + DefaultScopeClientFactory *v20250801preview.ClientFactory ApplicationName string EnvironmentNameOrID string @@ -345,6 +349,14 @@ func (r *Runner) Run(ctx context.Context) error { "Deployment In Progress... ", r.FilePath, r.ApplicationName, r.EnvironmentNameOrID, r.Workspace.Name) } + // Before deploying, set up recipe packs for any Radius.Core environments in the + // template. This creates default recipe pack resource if not found and injects its + // ID into the template. + err = r.setupRecipePack(ctx, template) + if err != nil { + return err + } + _, err = r.Deploy.DeployWithProgress(ctx, deploy.Options{ ConnectionFactory: r.ConnectionFactory, Workspace: *r.Workspace, @@ -650,6 +662,157 @@ func (r *Runner) setupCloudProviders(properties any) { } } +// setupRecipePack ensures recipe pack(s) for all Radius.Core/environments resources in the template. +// If a Radius.Core environment resource has no recipe +// packs set by the user, Radius creates(if needed) and fetches the default recipe pack from the default scope and +// injects its ID into the template. If the environment already has any recipe pack +// IDs set (literal or Bicep expression references), no changes are made. +func (r *Runner) setupRecipePack(ctx context.Context, template map[string]any) error { + envResources := findRadiusCoreEnvironmentResources(template) + if len(envResources) == 0 { + return nil + } + + for _, envResource := range envResources { + if err := r.setupRecipePackForEnvironment(ctx, envResource); err != nil { + return err + } + } + + return nil +} + +// setupRecipePackForEnvironment sets up recipe packs for a single Radius.Core/environments resource. +// If the environment already has any recipe packs set (literal IDs or ARM expression references), +// no changes are made. Otherwise, it fetches or creates the default recipe pack from +// the default scope and injects their IDs into the template. +func (r *Runner) setupRecipePackForEnvironment(ctx context.Context, envResource map[string]any) error { + // The compiled ARM template has a double-nested properties structure: + // envResource["properties"]["properties"] is where resource-level fields live. + // Navigate to the inner (resource) properties map. + outerProps, ok := envResource["properties"].(map[string]any) + if !ok { + outerProps = map[string]any{} + envResource["properties"] = outerProps + } + + properties, ok := outerProps["properties"].(map[string]any) + if !ok { + properties = map[string]any{} + outerProps["properties"] = properties + } + + // If the environment already has any recipe packs configured (literal IDs or + // Bicep expression references), leave it as-is — the user is managing packs explicitly. + if hasAnyRecipePacks(properties) { + return nil + } + + // No recipe packs set — provide defaults from the default scope. + + // Ensure the default resource group exists before accessing recipe packs. + mgmtClient, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + if err := recipepack.EnsureDefaultResourceGroup(ctx, mgmtClient.CreateOrUpdateResourceGroup); err != nil { + return err + } + + // Initialize the default scope client factory so we can access default recipe packs. + if r.DefaultScopeClientFactory == nil { + defaultFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) + if err != nil { + return err + } + r.DefaultScopeClientFactory = defaultFactory + } + + recipePackDefaultClient := r.DefaultScopeClientFactory.NewRecipePacksClient() + + // Try to GET the default recipe pack from the default scope. + // If it doesn't exist, create it. + packID, err := getOrCreateDefaultRecipePack(ctx, recipePackDefaultClient) + if err != nil { + return err + } + + // Inject the default recipe pack ID into the template. + properties["recipePacks"] = []any{packID} + + return nil +} + +// hasAnyRecipePacks returns true if the environment properties have any recipe packs +// configured, including both literal string IDs and ARM expression references. +func hasAnyRecipePacks(properties map[string]any) bool { + recipePacks, ok := properties["recipePacks"] + if !ok { + return false + } + packsArray, ok := recipePacks.([]any) + if !ok { + return false + } + return len(packsArray) > 0 +} + +// getOrCreateDefaultRecipePack attempts to GET the default recipe pack from +// the default scope. If it doesn't exist (404), it creates it with all core +// resource type recipes. Returns the full resource ID. +func getOrCreateDefaultRecipePack(ctx context.Context, client *v20250801preview.RecipePacksClient) (string, error) { + _, err := client.Get(ctx, recipepack.DefaultRecipePackResourceName, nil) + if err != nil { + if !clients.Is404Error(err) { + return "", fmt.Errorf("failed to get default recipe pack from default scope: %w", err) + } + // Not found — create the default recipe pack with all core types. + resource := recipepack.NewDefaultRecipePackResource() + _, err = client.CreateOrUpdate(ctx, recipepack.DefaultRecipePackResourceName, resource, nil) + if err != nil { + return "", fmt.Errorf("failed to create default recipe pack: %w", err) + } + } + return recipepack.DefaultRecipePackID(), nil +} + +// findRadiusCoreEnvironmentResources walks the template's resources and returns +// all Radius.Core/environments resources found (as mutable maps). +func findRadiusCoreEnvironmentResources(template map[string]any) []map[string]any { + if template == nil { + return nil + } + + resourcesValue, ok := template["resources"] + if !ok { + return nil + } + + resourcesMap, ok := resourcesValue.(map[string]any) + if !ok { + return nil + } + + var envResources []map[string]any + for _, resourceValue := range resourcesMap { + resource, ok := resourceValue.(map[string]any) + if !ok { + continue + } + + resourceType, ok := resource["type"].(string) + if !ok { + continue + } + + if strings.HasPrefix(strings.ToLower(resourceType), "radius.core/environments") { + envResources = append(envResources, resource) + } + } + + return envResources +} + // configureProviders configures environment and cloud providers based on the environment and provider type func (r *Runner) configureProviders() error { var env any diff --git a/pkg/cli/cmd/deploy/deploy_test.go b/pkg/cli/cmd/deploy/deploy_test.go index 08913c8e92..7d21290922 100644 --- a/pkg/cli/cmd/deploy/deploy_test.go +++ b/pkg/cli/cmd/deploy/deploy_test.go @@ -29,6 +29,7 @@ import ( "github.com/radius-project/radius/pkg/cli/deploy" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/recipepack" "github.com/radius-project/radius/pkg/cli/test_client_factory" "github.com/radius-project/radius/pkg/cli/workspaces" "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" @@ -973,6 +974,261 @@ func Test_Run(t *testing.T) { }) } +func Test_setupRecipePacks(t *testing.T) { + scope := "/planes/radius/local/resourceGroups/test-group" + + t.Run("injects default recipe pack into template", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + + // Default scope factory — GET succeeds (pack already exists). + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: &output.MockOutput{}, + } + + // Template with a Radius.Core/environments resource and no recipe packs. + template := map[string]any{ + "resources": map[string]any{ + "env": map[string]any{ + "type": "Radius.Core/environments@2025-08-01-preview", + "properties": map[string]any{ + "name": "myenv", + "properties": map[string]any{}, + }, + }, + }, + } + + err = runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + + // Verify that the default recipe pack was injected. + envRes := template["resources"].(map[string]any)["env"].(map[string]any) + outerProps := envRes["properties"].(map[string]any) + innerProps := outerProps["properties"].(map[string]any) + packs, ok := innerProps["recipePacks"].([]any) + require.True(t, ok) + require.Len(t, packs, 1, "should have the default recipe pack") + require.Equal(t, recipepack.DefaultRecipePackID(), packs[0]) + }) + + t.Run("skips when environment has existing packs", func(t *testing.T) { + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + Output: &output.MockOutput{}, + } + + existingPackID := scope + "/providers/Radius.Core/recipePacks/custom-pack" + + // Template with a Radius.Core/environments resource that already has one pack. + // Since packs are already set, no changes should be made. + template := map[string]any{ + "resources": map[string]any{ + "env": map[string]any{ + "type": "Radius.Core/environments@2025-08-01-preview", + "properties": map[string]any{ + "name": "myenv", + "properties": map[string]any{ + "recipePacks": []any{existingPackID}, + }, + }, + }, + }, + } + + err := runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + + envRes := template["resources"].(map[string]any)["env"].(map[string]any) + outerProps := envRes["properties"].(map[string]any) + innerProps := outerProps["properties"].(map[string]any) + packs := innerProps["recipePacks"].([]any) + // Only the original pack — no singletons added + require.Len(t, packs, 1) + require.Equal(t, existingPackID, packs[0]) + }) + + t.Run("no-op when template has no environment resource", func(t *testing.T) { + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + Output: &output.MockOutput{}, + } + + template := map[string]any{ + "resources": map[string]any{ + "app": map[string]any{ + "type": "Radius.Core/applications@2025-08-01-preview", + }, + }, + } + + err := runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + }) + + t.Run("no-op for Applications.Core environment", func(t *testing.T) { + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + Output: &output.MockOutput{}, + } + + template := map[string]any{ + "resources": map[string]any{ + "env": map[string]any{ + "type": "Applications.Core/environments@2023-10-01-preview", + }, + }, + } + + // Should be a no-op since we only handle Radius.Core environments + err := runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + }) + + t.Run("injects packs only for environment without packs in mixed template", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + // Only one env needs packs, so only one EnsureDefaultResourceGroup call. + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: &output.MockOutput{}, + } + + existingPackID := scope + "/providers/Radius.Core/recipePacks/custom-pack" + + // Two environments: envWithPacks already has a pack, envWithout has none. + template := map[string]any{ + "resources": map[string]any{ + "envWithPacks": map[string]any{ + "type": "Radius.Core/environments@2025-08-01-preview", + "properties": map[string]any{ + "name": "envWithPacks", + "properties": map[string]any{ + "recipePacks": []any{existingPackID}, + }, + }, + }, + "envWithout": map[string]any{ + "type": "Radius.Core/environments@2025-08-01-preview", + "properties": map[string]any{ + "name": "envWithout", + "properties": map[string]any{}, + }, + }, + }, + } + + err = runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + + // envWithPacks should be untouched — still just 1 pack. + envWithPacks := template["resources"].(map[string]any)["envWithPacks"].(map[string]any) + wpOuterProps := envWithPacks["properties"].(map[string]any) + wpInnerProps := wpOuterProps["properties"].(map[string]any) + wpPacks := wpInnerProps["recipePacks"].([]any) + require.Len(t, wpPacks, 1, "envWithPacks should keep its original pack only") + require.Equal(t, existingPackID, wpPacks[0]) + + // envWithout should have received the default pack. + envWithout := template["resources"].(map[string]any)["envWithout"].(map[string]any) + woOuterProps := envWithout["properties"].(map[string]any) + woInnerProps := woOuterProps["properties"].(map[string]any) + woPacks, ok := woInnerProps["recipePacks"].([]any) + require.True(t, ok, "expected recipePacks on envWithout") + require.Len(t, woPacks, 1, "envWithout should have the default recipe pack") + require.Equal(t, recipepack.DefaultRecipePackID(), woPacks[0]) + }) + + t.Run("creates default pack when not found in default scope", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + + // Default scope factory returns 404 on GET (packs don't exist yet) + // but succeeds on CreateOrUpdate. + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServer404OnGet, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: &output.MockOutput{}, + } + + // Template with a Radius.Core/environments resource and no recipe packs. + template := map[string]any{ + "resources": map[string]any{ + "env": map[string]any{ + "type": "Radius.Core/environments@2025-08-01-preview", + "properties": map[string]any{ + "name": "myenv", + "properties": map[string]any{}, + }, + }, + }, + } + + err = runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + + envRes := template["resources"].(map[string]any)["env"].(map[string]any) + outerProps := envRes["properties"].(map[string]any) + innerProps := outerProps["properties"].(map[string]any) + packs, ok := innerProps["recipePacks"].([]any) + require.True(t, ok) + require.Len(t, packs, 1, "should have the default recipe pack") + require.Equal(t, recipepack.DefaultRecipePackID(), packs[0]) + }) +} + func Test_injectAutomaticParameters(t *testing.T) { template := map[string]any{ "parameters": map[string]any{ diff --git a/pkg/cli/cmd/env/create/preview/create.go b/pkg/cli/cmd/env/create/preview/create.go index ec4a9914c2..cd15cdead7 100644 --- a/pkg/cli/cmd/env/create/preview/create.go +++ b/pkg/cli/cmd/env/create/preview/create.go @@ -30,6 +30,7 @@ import ( "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/recipepack" "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" @@ -70,8 +71,12 @@ type Runner struct { EnvironmentName string ResourceGroupName string RadiusCoreClientFactory *corerpv20250801.ClientFactory - ConfigFileInterface framework.ConfigFileInterface - ConnectionFactory connections.Factory + // DefaultScopeClientFactory is a client factory scoped to the default resource group. + // Singleton recipe packs are always created in this scope. If nil, it will be + // initialized automatically. + DefaultScopeClientFactory *corerpv20250801.ClientFactory + ConfigFileInterface framework.ConfigFileInterface + ConnectionFactory connections.Factory } // NewRunner creates a new instance of the `rad env create` runner. @@ -129,9 +134,9 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { } // Run runs the `rad env create` command. - -// Run creates an environment in the specified resource group using the provided environment name and -// returns an error if unsuccessful. +// +// Run creates a new Radius.Core environment with the default recipe pack +// for core resource types linked to it. func (r *Runner) Run(ctx context.Context) error { if r.RadiusCoreClientFactory == nil { clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) @@ -141,19 +146,46 @@ func (r *Runner) Run(ctx context.Context) error { r.RadiusCoreClientFactory = clientFactory } - r.Output.LogInfo("Creating Radius Core Environment...") + r.Output.LogInfo("Creating Radius Core Environment %q...", r.EnvironmentName) - resource := &corerpv20250801.EnvironmentResource{ - Location: to.Ptr(v1.LocationGlobal), - Properties: &corerpv20250801.EnvironmentProperties{}, + // Ensure the default resource group exists before creating recipe packs in it. + mgmtClient, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + if err := recipepack.EnsureDefaultResourceGroup(ctx, mgmtClient.CreateOrUpdateResourceGroup); err != nil { + return err } - _, err := r.RadiusCoreClientFactory.NewEnvironmentsClient().CreateOrUpdate(ctx, r.EnvironmentName, *resource, &corerpv20250801.EnvironmentsClientCreateOrUpdateOptions{}) + // Create the default recipe pack in the default resource group. + // The default pack lives in the default scope regardless of the current workspace scope. + if r.DefaultScopeClientFactory == nil { + defaultClientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) + if err != nil { + return err + } + r.DefaultScopeClientFactory = defaultClientFactory + } + + defaultPack := recipepack.NewDefaultRecipePackResource() + _, err = r.DefaultScopeClientFactory.NewRecipePacksClient().CreateOrUpdate(ctx, recipepack.DefaultRecipePackResourceName, defaultPack, nil) + if err != nil { + return err + } + + resource := &corerpv20250801.EnvironmentResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &corerpv20250801.EnvironmentProperties{ + RecipePacks: []*string{to.Ptr(recipepack.DefaultRecipePackID())}, + }, + } + envClient := r.RadiusCoreClientFactory.NewEnvironmentsClient() + _, err = envClient.CreateOrUpdate(ctx, r.EnvironmentName, *resource, nil) if err != nil { return err } - r.Output.LogInfo("Successfully created environment %q in resource group %q", r.EnvironmentName, r.ResourceGroupName) + r.Output.LogInfo("Successfully created environment %q in resource group %q with default Kubernetes recipe packs.", r.EnvironmentName, r.ResourceGroupName) return nil } diff --git a/pkg/cli/cmd/env/create/preview/create_test.go b/pkg/cli/cmd/env/create/preview/create_test.go index 5540fa6e92..bf62174406 100644 --- a/pkg/cli/cmd/env/create/preview/create_test.go +++ b/pkg/cli/cmd/env/create/preview/create_test.go @@ -24,8 +24,10 @@ import ( "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/output" + "github.com/radius-project/radius/pkg/cli/recipepack" "github.com/radius-project/radius/pkg/cli/test_client_factory" "github.com/radius-project/radius/pkg/cli/workspaces" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" @@ -144,43 +146,60 @@ func Test_Validate(t *testing.T) { } func Test_Run(t *testing.T) { - t.Run("Success: environment created", func(t *testing.T) { - workspace := &workspaces.Workspace{ - Name: "test-workspace", - Scope: "/planes/radius/local/resourceGroups/test-resource-group", - Connection: map[string]any{ - "kind": "kubernetes", - "context": "kind-kind", - }, - } + workspace := &workspaces.Workspace{ + Name: "test-workspace", + Scope: "/planes/radius/local/resourceGroups/test-resource-group", + Connection: map[string]any{ + "kind": "kubernetes", + "context": "kind-kind", + }, + } + + t.Run("creates environment with default recipe pack", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + workspace.Scope, + test_client_factory.WithEnvironmentServer404OnGet, + nil, + ) + require.NoError(t, err) - factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, test_client_factory.WithEnvironmentServerNoError, nil) + // Default recipe pack is created in the default scope. + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + nil, + ) require.NoError(t, err) + outputSink := &output.MockOutput{} runner := &Runner{ - RadiusCoreClientFactory: factory, - Output: outputSink, - Workspace: workspace, - EnvironmentName: "testenv", - ResourceGroupName: "test-resource-group", - } - - expectedOutput := []any{ - output.LogOutput{ - Format: "Creating Radius Core Environment...", - }, - output.LogOutput{ - Format: "Successfully created environment %q in resource group %q", - Params: []interface{}{ - "testenv", - "test-resource-group", - }, - }, + RadiusCoreClientFactory: factory, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: outputSink, + Workspace: workspace, + EnvironmentName: "testenv", + ResourceGroupName: "test-resource-group", } err = runner.Run(context.Background()) require.NoError(t, err) - require.Equal(t, expectedOutput, outputSink.Writes) + + require.Contains(t, outputSink.Writes, output.LogOutput{ + Format: "Creating Radius Core Environment %q...", + Params: []interface{}{"testenv"}, + }) + require.Contains(t, outputSink.Writes, output.LogOutput{ + Format: "Successfully created environment %q in resource group %q with default Kubernetes recipe packs.", + Params: []interface{}{"testenv", "test-resource-group"}, + }) }) } diff --git a/pkg/cli/cmd/env/update/preview/update.go b/pkg/cli/cmd/env/update/preview/update.go index 0f1d6ac7af..b1cdbdff07 100644 --- a/pkg/cli/cmd/env/update/preview/update.go +++ b/pkg/cli/cmd/env/update/preview/update.go @@ -86,7 +86,7 @@ rad env update myenv --clear-kubernetes cmd.Flags().Bool(commonflags.ClearEnvAzureFlag, false, "Specify if azure provider needs to be cleared on env") 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().StringArrayP("recipe-packs", "", []string{}, "Specify recipe packs to be added to the environment (--preview)") + cmd.Flags().StringSliceP("recipe-packs", "", []string{}, "Specify recipe packs to replace the environment's recipe pack list (--preview). Accepts comma-separated values.") commonflags.AddAzureScopeFlags(cmd) commonflags.AddAWSScopeFlags(cmd) commonflags.AddKubernetesScopeFlags(cmd) @@ -206,7 +206,7 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { return err } - r.recipePacks, err = cmd.Flags().GetStringArray("recipe-packs") + r.recipePacks, err = cmd.Flags().GetStringSlice("recipe-packs") if err != nil { return err } @@ -274,11 +274,10 @@ func (r *Runner) Run(ctx context.Context) error { env.Properties.Providers.Kubernetes = r.providers.Kubernetes } - // add recipe packs if any + // replace recipe packs if any are specified if len(r.recipePacks) > 0 { - if env.Properties.RecipePacks == nil { - env.Properties.RecipePacks = []*string{} - } + // Create a new list to replace the existing recipe packs + newRecipePacks := []*string{} for _, recipePack := range r.recipePacks { ID, err := resources.Parse(recipePack) @@ -304,13 +303,14 @@ func (r *Runner) Run(ctx context.Context) error { cfclient := rClientFactory.NewRecipePacksClient() _, err = cfclient.Get(ctx, ID.Name(), &corerpv20250801.RecipePacksClientGetOptions{}) if err != nil { - return clierrors.Message("Recipe pack %q does not exist. Please provide a valid recipe pack to add to the environment.", recipePack) + return clierrors.Message("Recipe pack %q does not exist. Please provide a valid recipe pack to set on the environment.", recipePack) } - if !recipePackExists(env.Properties.RecipePacks, ID.String()) { - env.Properties.RecipePacks = append(env.Properties.RecipePacks, to.Ptr(ID.String())) - } + newRecipePacks = append(newRecipePacks, to.Ptr(ID.String())) } + + // Replace the entire recipe packs list + env.Properties.RecipePacks = newRecipePacks } r.Output.LogInfo("Updating Environment...") @@ -350,12 +350,3 @@ func (r *Runner) Run(ctx context.Context) error { return nil } - -func recipePackExists(packs []*string, id string) bool { - for _, p := range packs { - if p != nil && *p == id { - return true - } - } - return false -} diff --git a/pkg/cli/cmd/env/update/preview/update_test.go b/pkg/cli/cmd/env/update/preview/update_test.go index 0718ed3f8c..56d193c624 100644 --- a/pkg/cli/cmd/env/update/preview/update_test.go +++ b/pkg/cli/cmd/env/update/preview/update_test.go @@ -115,7 +115,7 @@ func Test_Run(t *testing.T) { Format: "table", Obj: environmentForDisplay{ Name: "test-env", - RecipePacks: 3, + RecipePacks: 2, Providers: 3, }, Options: environmentFormat(), diff --git a/pkg/cli/cmd/radinit/environment.go b/pkg/cli/cmd/radinit/environment.go index e2463c46bb..19e1da15a6 100644 --- a/pkg/cli/cmd/radinit/environment.go +++ b/pkg/cli/cmd/radinit/environment.go @@ -25,8 +25,10 @@ import ( "github.com/radius-project/radius/pkg/cli/clierrors" "github.com/radius-project/radius/pkg/cli/cmd" "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/radius-project/radius/pkg/cli/recipepack" "github.com/radius-project/radius/pkg/cli/workspaces" corerp "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" "github.com/radius-project/radius/pkg/to" ucp "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" ) @@ -34,7 +36,7 @@ import ( const ( selectExistingEnvironmentPrompt = "Select an existing environment or create a new one" selectExistingEnvironmentCreateSentinel = "[create new]" - enterNamespacePrompt = "Enter a namespace name to deploy apps into" + enterNamespacePrompt = "Enter a namespace name to deploy apps into. The namespace must exist in the Kubernetes cluster." enterEnvironmentNamePrompt = "Enter an environment name" defaultEnvironmentName = "default" defaultEnvironmentNamespace = "default" @@ -53,41 +55,76 @@ func (r *Runner) CreateEnvironment(ctx context.Context) error { return clierrors.MessageWithCause(err, "Failed to create a resource group.") } - providerList := []any{} + // Build providers for the new Radius.Core/environments resource type + providers := &corerpv20250801.Providers{} + + if r.Options.Environment.Namespace != "" { + providers.Kubernetes = &corerpv20250801.ProvidersKubernetes{ + Namespace: to.Ptr(r.Options.Environment.Namespace), + } + } + if r.Options.CloudProviders.Azure != nil { - providerList = append(providerList, r.Options.CloudProviders.Azure) + providers.Azure = &corerpv20250801.ProvidersAzure{ + SubscriptionID: to.Ptr(r.Options.CloudProviders.Azure.SubscriptionID), + ResourceGroupName: to.Ptr(r.Options.CloudProviders.Azure.ResourceGroup), + } } + if r.Options.CloudProviders.AWS != nil { - providerList = append(providerList, r.Options.CloudProviders.AWS) + providers.Aws = &corerpv20250801.ProvidersAws{ + AccountID: to.Ptr(r.Options.CloudProviders.AWS.AccountID), + Region: to.Ptr(r.Options.CloudProviders.AWS.Region), + } } - providers, err := cmd.CreateEnvProviders(providerList) - if err != nil { - return err + envProperties := corerpv20250801.EnvironmentProperties{ + Providers: providers, } - var recipes map[string]map[string]corerp.RecipePropertiesClassification - if r.Options.Recipes.DevRecipes { - // Note: To use custom registry for recipes, users need to manually configure - // their environment after initialization or use custom recipe definitions - recipes, err = r.DevRecipeClient.GetDevRecipes(ctx) + // Initialize the Radius.Core client factory if not already set + if r.RadiusCoreClientFactory == nil { + clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) if err != nil { - return err + return clierrors.MessageWithCause(err, "Failed to initialize Radius Core client.") } + r.RadiusCoreClientFactory = clientFactory } - envProperties := corerp.EnvironmentProperties{ - Compute: &corerp.KubernetesCompute{ - Namespace: to.Ptr(r.Options.Environment.Namespace), - }, - Providers: &providers, - Recipes: recipes, + // Ensure the default resource group exists before creating recipe packs in it. + if err := recipepack.EnsureDefaultResourceGroup(ctx, client.CreateOrUpdateResourceGroup); err != nil { + return clierrors.MessageWithCause(err, "Failed to create default resource group for recipe packs.") + } + + // Create the default recipe pack and link it to the environment. + // The default pack lives in the default resource group scope. + // DefaultScopeClientFactory is required in the case rad init runs from a workspace with non-default settings. + if r.DefaultScopeClientFactory == nil { + if r.Workspace.Scope == recipepack.DefaultResourceGroupScope { + r.DefaultScopeClientFactory = r.RadiusCoreClientFactory + } else { + defaultClientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to initialize Radius Core client for default scope.") + } + r.DefaultScopeClientFactory = defaultClientFactory + } } - err = client.CreateOrUpdateEnvironment(ctx, r.Options.Environment.Name, &corerp.EnvironmentResource{ + defaultPack := recipepack.NewDefaultRecipePackResource() + _, err = r.DefaultScopeClientFactory.NewRecipePacksClient().CreateOrUpdate(ctx, recipepack.DefaultRecipePackResourceName, defaultPack, nil) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to create default recipe pack.") + } + + // Link the default recipe pack to the environment. + envProperties.RecipePacks = []*string{to.Ptr(recipepack.DefaultRecipePackID())} + + // Create the Radius.Core/environments resource + _, err = r.RadiusCoreClientFactory.NewEnvironmentsClient().CreateOrUpdate(ctx, r.Options.Environment.Name, corerpv20250801.EnvironmentResource{ Location: to.Ptr(v1.LocationGlobal), Properties: &envProperties, - }) + }, &corerpv20250801.EnvironmentsClientCreateOrUpdateOptions{}) if err != nil { return clierrors.MessageWithCause(err, "Failed to create environment.") } diff --git a/pkg/cli/cmd/radinit/init.go b/pkg/cli/cmd/radinit/init.go index 40983b3fb6..f3d874e6cb 100644 --- a/pkg/cli/cmd/radinit/init.go +++ b/pkg/cli/cmd/radinit/init.go @@ -38,6 +38,7 @@ import ( "github.com/radius-project/radius/pkg/cli/setup" "github.com/radius-project/radius/pkg/cli/workspaces" corerp "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" "github.com/radius-project/radius/pkg/to" ucp "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/spf13/cobra" @@ -136,6 +137,14 @@ type Runner struct { // DevRecipeClient is the interface for the dev recipe client. DevRecipeClient DevRecipeClient + // RadiusCoreClientFactory is the client factory for Radius.Core resources. + // If nil, it will be initialized during Run. + RadiusCoreClientFactory *corerpv20250801.ClientFactory + + // DefaultScopeClientFactory is the client factory scoped to the default resource group. + // Singleton recipe packs are always created/queried in the default scope. + DefaultScopeClientFactory *corerpv20250801.ClientFactory + // Format is the output format. Format string diff --git a/pkg/cli/cmd/radinit/init_test.go b/pkg/cli/cmd/radinit/init_test.go index cc26900c2a..40817c6edb 100644 --- a/pkg/cli/cmd/radinit/init_test.go +++ b/pkg/cli/cmd/radinit/init_test.go @@ -44,6 +44,7 @@ import ( "github.com/radius-project/radius/pkg/cli/kubernetes" "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" corerp "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" "github.com/radius-project/radius/pkg/recipes" @@ -945,30 +946,12 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { appManagementClient.EXPECT(). CreateOrUpdateResourceGroup(context.Background(), "local", "default", gomock.Any()). Return(nil). - Times(1) + Times(2) - devRecipeClient := NewMockDevRecipeClient(ctrl) - if !tc.full { - devRecipeClient.EXPECT(). - GetDevRecipes(context.Background()). - Return(tc.recipes, nil). - Times(1) - } - - testEnvProperties := &corerp.EnvironmentProperties{ - Compute: &corerp.KubernetesCompute{ - Namespace: to.Ptr("defaultNamespace"), - }, - Providers: buildProviders(tc.azureProvider, tc.awsProvider), - Recipes: tc.recipes, - } - appManagementClient.EXPECT(). - CreateOrUpdateEnvironment(context.Background(), "default", &corerp.EnvironmentResource{ - Location: to.Ptr(v1.LocationGlobal), - Properties: testEnvProperties, - }). - Return(nil). - Times(1) + // Create a RadiusCoreClientFactory for testing + rootScope := "/planes/radius/local/resourceGroups/default" + radiusCoreClientFactory, err := test_client_factory.NewRadiusCoreTestClientFactory(rootScope, nil, nil) + require.NoError(t, err) credentialManagementClient := cli_credential.NewMockCredentialManagementClient(ctrl) if tc.azureProvider != nil { @@ -1019,7 +1002,7 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { outputSink := &output.MockOutput{} helmInterface := helm.NewMockInterface(ctrl) - + // Verify that Set and SetFile values are passed to Helm expectedClusterOptions := helm.CLIClusterOptions{ Radius: helm.ChartOptions{ @@ -1027,7 +1010,7 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { SetFileArgs: tc.setFile, }, } - + helmInterface.EXPECT(). InstallRadius(context.Background(), gomock.Any(), "kind-kind"). DoAndReturn(func(ctx context.Context, clusterOptions helm.ClusterOptions, kubeContext string) error { @@ -1068,21 +1051,23 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { ApplicationsManagementClient: appManagementClient, CredentialManagementClient: credentialManagementClient, }, - ConfigFileInterface: configFileInterface, - ConfigHolder: &framework.ConfigHolder{ConfigFilePath: "filePath"}, - HelmInterface: helmInterface, - Output: outputSink, - Prompter: prompter, - DevRecipeClient: devRecipeClient, - Options: &options, + ConfigFileInterface: configFileInterface, + ConfigHolder: &framework.ConfigHolder{ConfigFilePath: "filePath"}, + HelmInterface: helmInterface, + Output: outputSink, + Prompter: prompter, + RadiusCoreClientFactory: radiusCoreClientFactory, + DefaultScopeClientFactory: radiusCoreClientFactory, + Options: &options, Workspace: &workspaces.Workspace{ - Name: "default", + Name: "default", + Scope: "/planes/radius/local/resourceGroups/default", }, Set: tc.set, SetFile: tc.setFile, } - err := runner.Run(context.Background()) + err = runner.Run(context.Background()) require.NoError(t, err) if len(tc.expectedOutput) == 0 { @@ -1094,21 +1079,6 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { } } -func buildProviders(azureProvider *azure.Provider, awsProvider *aws.Provider) *corerp.Providers { - providers := &corerp.Providers{} - if azureProvider != nil { - providers.Azure = &corerp.ProvidersAzure{ - Scope: to.Ptr(fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", azureProvider.SubscriptionID, azureProvider.ResourceGroup)), - } - } - if awsProvider != nil { - providers.Aws = &corerp.ProvidersAws{ - Scope: to.Ptr(fmt.Sprintf("/planes/aws/aws/accounts/%s/regions/%s", awsProvider.AccountID, awsProvider.Region)), - } - } - return providers -} - func initGetKubeContextSuccess(kubernestesMock *kubernetes.MockInterface) { kubernestesMock.EXPECT(). GetKubeContext(). diff --git a/pkg/cli/cmd/radinit/options.go b/pkg/cli/cmd/radinit/options.go index 6c3c981c0a..9b890a3f62 100644 --- a/pkg/cli/cmd/radinit/options.go +++ b/pkg/cli/cmd/radinit/options.go @@ -122,7 +122,7 @@ func (r *Runner) enterInitOptions(ctx context.Context) (*initOptions, *workspace workspace.Name = ws.Name } - workspace.Environment = fmt.Sprintf("/planes/radius/local/resourceGroups/%s/providers/Applications.Core/environments/%s", options.Environment.Name, options.Environment.Name) + workspace.Environment = fmt.Sprintf("/planes/radius/local/resourceGroups/%s/providers/Radius.Core/environments/%s", options.Environment.Name, options.Environment.Name) workspace.Scope = fmt.Sprintf("/planes/radius/local/resourceGroups/%s", options.Environment.Name) return &options, workspace, nil } diff --git a/pkg/cli/cmd/radinit/options_test.go b/pkg/cli/cmd/radinit/options_test.go index cf7d5236d5..64220ce39c 100644 --- a/pkg/cli/cmd/radinit/options_test.go +++ b/pkg/cli/cmd/radinit/options_test.go @@ -53,7 +53,7 @@ func Test_enterInitOptions(t *testing.T) { "context": "kind-kind", "kind": workspaces.KindKubernetes, }, - Environment: "/planes/radius/local/resourceGroups/default/providers/Applications.Core/environments/default", + Environment: "/planes/radius/local/resourceGroups/default/providers/Radius.Core/environments/default", Scope: "/planes/radius/local/resourceGroups/default", } require.Equal(t, expectedWorkspace, *workspace) @@ -101,7 +101,7 @@ func Test_enterInitOptions(t *testing.T) { "context": "kind-kind", "kind": workspaces.KindKubernetes, }, - Environment: "/planes/radius/local/resourceGroups/test-env/providers/Applications.Core/environments/test-env", + Environment: "/planes/radius/local/resourceGroups/test-env/providers/Radius.Core/environments/test-env", Scope: "/planes/radius/local/resourceGroups/test-env", } require.Equal(t, expectedWorkspace, *workspace) @@ -137,7 +137,7 @@ workspaces: kind: kubernetes context: cool-beans scope: /a/b/c - environment: /a/b/c/providers/Applications.Core/environments/ice-cold + environment: /a/b/c/providers/Radius.Core/environments/ice-cold ` v, err := makeConfig(yaml) runner := Runner{Prompter: prompter, KubernetesInterface: k8s, HelmInterface: helm, Full: true, ConfigHolder: &framework.ConfigHolder{Config: v}} @@ -160,7 +160,7 @@ workspaces: "context": "kind-kind", "kind": workspaces.KindKubernetes, }, - Environment: "/planes/radius/local/resourceGroups/test-env/providers/Applications.Core/environments/test-env", + Environment: "/planes/radius/local/resourceGroups/test-env/providers/Radius.Core/environments/test-env", Scope: "/planes/radius/local/resourceGroups/test-env", } require.Equal(t, expectedWorkspace, *workspace) @@ -196,13 +196,13 @@ workspaces: kind: kubernetes context: cool-beans scope: /a/b/c - environment: /a/b/c/providers/Applications.Core/environments/ice-cold + environment: /a/b/c/providers/Radius.Core/environments/ice-cold default: connection: kind: kubernetes context: hot-beans scope: /d/e/f - environment: /a/b/c/providers/Applications.Core/environments/hot-coffee + environment: /a/b/c/providers/Radius.Core/environments/hot-coffee ` v, err := makeConfig(yaml) runner := Runner{Prompter: prompter, KubernetesInterface: k8s, HelmInterface: helm, Full: true, ConfigHolder: &framework.ConfigHolder{Config: v}} @@ -225,7 +225,7 @@ workspaces: "context": "kind-kind", "kind": workspaces.KindKubernetes, }, - Environment: "/planes/radius/local/resourceGroups/test-env/providers/Applications.Core/environments/test-env", + Environment: "/planes/radius/local/resourceGroups/test-env/providers/Radius.Core/environments/test-env", Scope: "/planes/radius/local/resourceGroups/test-env", } require.Equal(t, expectedWorkspace, *workspace) diff --git a/pkg/cli/cmd/utils.go b/pkg/cli/cmd/utils.go index 8ae21dc750..b88b27bc65 100644 --- a/pkg/cli/cmd/utils.go +++ b/pkg/cli/cmd/utils.go @@ -31,6 +31,7 @@ import ( "github.com/radius-project/radius/pkg/sdk" "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/resources" ) // CreateEnvProviders forms the provider scope from the given @@ -135,3 +136,30 @@ func InitializeRadiusCoreClientFactory(ctx context.Context, workspace *workspace return clientFactory, nil } + +// PopulateRecipePackClients adds a RecipePacksClient to clientsByScope for +// every scope referenced by packIDs that is not already in the map. +// Callers seed the map with workspace-scope and default-scope clients before +// calling this function. +func PopulateRecipePackClients( + ctx context.Context, + workspace *workspaces.Workspace, + clientsByScope map[string]*v20250801preview.RecipePacksClient, + packIDs []string, +) error { + for _, packIDStr := range packIDs { + // This is the bicep reference for id, and cannot be invalid. + packID, _ := resources.Parse(packIDStr) + scope := packID.RootScope() + if _, exists := clientsByScope[scope]; exists { + continue + } + factory, err := InitializeRadiusCoreClientFactory(ctx, workspace, scope) + if err != nil { + return err + } + clientsByScope[scope] = factory.NewRecipePacksClient() + } + + return nil +} diff --git a/pkg/cli/cmd/utils_test.go b/pkg/cli/cmd/utils_test.go index 8f1de51d44..574c03ee6e 100644 --- a/pkg/cli/cmd/utils_test.go +++ b/pkg/cli/cmd/utils_test.go @@ -17,13 +17,16 @@ limitations under the License. package cmd import ( + "context" "errors" "testing" "github.com/radius-project/radius/pkg/cli/aws" "github.com/radius-project/radius/pkg/cli/azure" "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/workspaces" corerp "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + v20250801preview "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" "github.com/radius-project/radius/pkg/to" "github.com/stretchr/testify/require" ) @@ -140,3 +143,31 @@ func TestCreateEnvProviders(t *testing.T) { }) } } + +func TestPopulateRecipePackClients(t *testing.T) { + scope := "/planes/radius/local/resourceGroups/test-group" + + t.Run("no-op for empty packIDs", func(t *testing.T) { + clientsByScope := map[string]*v20250801preview.RecipePacksClient{ + scope: {}, // placeholder client + } + + err := PopulateRecipePackClients(context.Background(), &workspaces.Workspace{Scope: scope}, clientsByScope, nil) + require.NoError(t, err) + require.Len(t, clientsByScope, 1) + }) + + t.Run("skips packs whose scope is already in the map", func(t *testing.T) { + clientsByScope := map[string]*v20250801preview.RecipePacksClient{ + scope: {}, + } + packIDs := []string{ + scope + "/providers/Radius.Core/recipePacks/pack1", + scope + "/providers/Radius.Core/recipePacks/pack2", + } + + err := PopulateRecipePackClients(context.Background(), &workspaces.Workspace{Scope: scope}, clientsByScope, packIDs) + require.NoError(t, err) + require.Len(t, clientsByScope, 1, "no new scopes should be added") + }) +} diff --git a/pkg/cli/recipepack/recipepack.go b/pkg/cli/recipepack/recipepack.go new file mode 100644 index 0000000000..c0e6df5715 --- /dev/null +++ b/pkg/cli/recipepack/recipepack.go @@ -0,0 +1,129 @@ +/* +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 recipepack + +import ( + "context" + "fmt" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/to" + ucpv20231001 "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/version" +) + +const ( + // DefaultRecipePackResourceName is the name of the default recipe pack + // resource that contains recipes for all core resource types. + DefaultRecipePackResourceName = "default" + + // DefaultResourceGroupName is the name of the default resource group where + // singleton recipe packs are created and looked up. + DefaultResourceGroupName = "default" + + // DefaultResourceGroupScope is the full scope path for the default resource group. + // Singleton recipe packs that Radius provides by default always live in this scope. + DefaultResourceGroupScope = "/planes/radius/local/resourceGroups/" + DefaultResourceGroupName +) + +// ResourceGroupCreator is a function that creates or updates a Radius resource group. +// This is typically satisfied by ApplicationsManagementClient.CreateOrUpdateResourceGroup. +type ResourceGroupCreator func(ctx context.Context, planeName string, resourceGroupName string, resource *ucpv20231001.ResourceGroupResource) error + +// NewDefaultRecipePackResource creates a RecipePackResource containing recipes +// for all core resource types. This is the default recipe pack that gets injected into +// environments that have no recipe packs configured. +func NewDefaultRecipePackResource() corerpv20250801.RecipePackResource { + bicepKind := corerpv20250801.RecipeKindBicep + recipes := make(map[string]*corerpv20250801.RecipeDefinition) + for _, def := range GetDefaultRecipePackDefinition() { + recipes[def.ResourceType] = &corerpv20250801.RecipeDefinition{ + RecipeKind: &bicepKind, + RecipeLocation: to.Ptr(def.RecipeLocation), + } + } + return corerpv20250801.RecipePackResource{ + Location: to.Ptr("global"), + Properties: &corerpv20250801.RecipePackProperties{ + Recipes: recipes, + }, + } +} + +// DefaultRecipePackID returns the full resource ID of the default recipe pack +// in the default resource group scope. +func DefaultRecipePackID() string { + return fmt.Sprintf("%s/providers/Radius.Core/recipePacks/%s", DefaultResourceGroupScope, DefaultRecipePackResourceName) +} + +// EnsureDefaultResourceGroup creates the default resource group if it does not already exist. +// This must be called before creating singleton recipe packs, because recipe packs are +// stored in the default resource group and the PUT will fail with 404 if the group is missing. +// The group might be missing in a sequence such as below: +// 1. rad install +// 2. rad workspace create kubernetes +// 3. rad group create prod +// 4. rad group switch prod +// 5. .rad deploy