From f2df6859f5022f8bd36391f8c44d38aa83eff2ab Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Mon, 2 Feb 2026 16:16:22 -0800 Subject: [PATCH 01/12] update rad install Signed-off-by: nithyatsu --- pkg/cli/cmd/install/kubernetes/kubernetes.go | 105 +++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/pkg/cli/cmd/install/kubernetes/kubernetes.go b/pkg/cli/cmd/install/kubernetes/kubernetes.go index dbcc977e5a..35b132efbc 100644 --- a/pkg/cli/cmd/install/kubernetes/kubernetes.go +++ b/pkg/cli/cmd/install/kubernetes/kubernetes.go @@ -18,11 +18,18 @@ package kubernetes import ( "context" + "fmt" + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/cli/cmd/commonflags" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/helm" "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/sdk" + "github.com/radius-project/radius/pkg/to" + ucpv20231001 "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/version" "github.com/spf13/cobra" ) @@ -196,5 +203,103 @@ func (r *Runner) Run(ctx context.Context) error { return err } + // Deploy the default recipe pack after successful installation. + if err := deployRecipePack(ctx, r.KubeContext, r.Output); err != nil { + return err + } + return nil } + +const defaultResourceGroupName = "default" + +// deployRecipePack connects to the newly installed Radius and deploys the default recipe pack. +func deployRecipePack(ctx context.Context, kubeContext string, out output.Interface) error { + out.LogInfo("Deploying default recipe pack...") + + ws := &workspaces.Workspace{ + Connection: map[string]any{ + "kind": workspaces.KindKubernetes, + "context": kubeContext, + }, + Scope: fmt.Sprintf("/planes/radius/local/resourceGroups/%s", defaultResourceGroupName), + } + + connection, err := ws.Connect(ctx) + if err != nil { + return fmt.Errorf("failed to connect to Radius: %w", err) + } + + clientOptions := sdk.NewClientOptions(connection) + + // Create the default resource group. + rgClient, err := ucpv20231001.NewResourceGroupsClient(&aztoken.AnonymousCredential{}, clientOptions) + if err != nil { + return fmt.Errorf("failed to create resource group client: %w", err) + } + + _, err = rgClient.CreateOrUpdate(ctx, "local", defaultResourceGroupName, ucpv20231001.ResourceGroupResource{ + Location: to.Ptr("global"), + }, nil) + if err != nil { + return fmt.Errorf("failed to create resource group %q: %w", defaultResourceGroupName, err) + } + + // Create the recipe pack. + rpClient, err := corerpv20250801.NewRecipePacksClient( + fmt.Sprintf("planes/radius/local/resourceGroups/%s", defaultResourceGroupName), + &aztoken.AnonymousCredential{}, + clientOptions, + ) + if err != nil { + return fmt.Errorf("failed to create recipe pack client: %w", err) + } + + _, err = rpClient.CreateOrUpdate(ctx, "kuberecipepack", newDefaultRecipePackResource(), nil) + if err != nil { + return fmt.Errorf("failed to deploy recipe pack: %w", err) + } + + out.LogInfo("Successfully deployed default recipe pack.") + return nil +} + +// newDefaultRecipePackResource builds the default RecipePackResource containing +// Bicep recipes for the built-in Radius resource types. +func newDefaultRecipePackResource() corerpv20250801.RecipePackResource { + bicepKind := corerpv20250801.RecipeKindBicep + plainHTTP := true + + return corerpv20250801.RecipePackResource{ + Location: to.Ptr("global"), + Properties: &corerpv20250801.RecipePackProperties{ + Recipes: map[string]*corerpv20250801.RecipeDefinition{ + "Radius.Compute/containers": { + RecipeKind: &bicepKind, + RecipeLocation: to.Ptr("localhost:5000/radius-recipes/compute/containers/kubernetes/bicep/kubernetes-containers:latest"), + PlainHTTP: &plainHTTP, + }, + "Radius.Compute/persistentVolumes": { + RecipeKind: &bicepKind, + RecipeLocation: to.Ptr("localhost:5000/radius-recipes/compute/persistentvolumes/kubernetes/bicep/kubernetes-volumes:latest"), + PlainHTTP: &plainHTTP, + }, + "Radius.Data/mySqlDatabases": { + RecipeKind: &bicepKind, + RecipeLocation: to.Ptr("localhost:5000/radius-recipes/data/mysqldatabases/kubernetes/bicep/kubernetes-mysql:latest"), + PlainHTTP: &plainHTTP, + }, + "Radius.Data/postgreSqlDatabases": { + RecipeKind: &bicepKind, + RecipeLocation: to.Ptr("localhost:5000/radius-recipes/data/postgresqldatabases/kubernetes/bicep/kubernetes-postgresql:latest"), + PlainHTTP: &plainHTTP, + }, + "Radius.Security/secrets": { + RecipeKind: &bicepKind, + RecipeLocation: to.Ptr("localhost:5000/radius-recipes/security/secrets/kubernetes/bicep/kubernetes-secrets:latest"), + PlainHTTP: &plainHTTP, + }, + }, + }, + } +} From 9ee877ba60f23c544e847f0d5fa56d34ec3f0512 Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Mon, 2 Feb 2026 17:07:31 -0800 Subject: [PATCH 02/12] rad init uses new env Signed-off-by: nithyatsu rad init implementation Signed-off-by: nithyatsu name default pack Signed-off-by: nithyatsu add tests Signed-off-by: nithyatsu wip Signed-off-by: nithyatsu rad init cahnges Signed-off-by: nithyatsu wip Signed-off-by: nithyatsu wip Signed-off-by: nithyatsu wip Signed-off-by: nithyatsu renames Signed-off-by: nithyatsu create upadtes Signed-off-by: nithyatsu update Signed-off-by: nithyatsu wip Signed-off-by: nithyatsu deploy changes Signed-off-by: nithyatsu wip Signed-off-by: nithyatsu wip Signed-off-by: nithyatsu wip Signed-off-by: nithyatsu refactor + comments Signed-off-by: nithyatsu deploy fix Signed-off-by: nithyatsu fix Signed-off-by: nithyatsu remove dev recipe test Signed-off-by: nithyatsu wip Signed-off-by: nithyatsu ci: retrigger ci: retrigger ci: retrigger --- pkg/cli/cmd/deploy/deploy.go | 168 ++++++++++ pkg/cli/cmd/deploy/deploy_test.go | 252 ++++++++++++++ pkg/cli/cmd/env/create/preview/create.go | 58 +++- pkg/cli/cmd/env/create/preview/create_test.go | 68 ++-- pkg/cli/cmd/env/update/preview/update.go | 89 ++++- pkg/cli/cmd/env/update/preview/update_test.go | 28 +- pkg/cli/cmd/install/kubernetes/kubernetes.go | 105 ------ pkg/cli/cmd/radinit/environment.go | 76 +++-- pkg/cli/cmd/radinit/init.go | 9 + pkg/cli/cmd/radinit/init_test.go | 66 +--- pkg/cli/cmd/radinit/options.go | 2 +- pkg/cli/cmd/radinit/options_test.go | 14 +- pkg/cli/recipepack/recipepack.go | 310 ++++++++++++++++++ pkg/cli/recipepack/recipepack_test.go | 271 +++++++++++++++ pkg/cli/test_client_factory/radius_core.go | 233 +++++++++++++ 15 files changed, 1509 insertions(+), 240 deletions(-) create mode 100644 pkg/cli/recipepack/recipepack.go create mode 100644 pkg/cli/recipepack/recipepack_test.go diff --git a/pkg/cli/cmd/deploy/deploy.go b/pkg/cli/cmd/deploy/deploy.go index d8a4f16034..121137ca0c 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. + // Singleton recipe packs are always created/queried in the default scope. + DefaultScopeClientFactory *v20250801preview.ClientFactory ApplicationName string EnvironmentNameOrID string @@ -345,6 +349,15 @@ 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 missing singleton recipe pack resources and injects their + // IDs into the template so that the environment is deployed once with the complete + // set of recipe packs. + err = r.setupRecipePacks(ctx, template) + if err != nil { + return err + } + _, err = r.Deploy.DeployWithProgress(ctx, deploy.Options{ ConnectionFactory: r.ConnectionFactory, Workspace: *r.Workspace, @@ -650,6 +663,161 @@ func (r *Runner) setupCloudProviders(properties any) { } } +// setupRecipePacks finds all Radius.Core/environments resources in the template, inspects +// any recipe packs they reference, validates there are no resource-type conflicts, creates +// missing singleton recipe pack resources, and injects their IDs into the template so each +// environment is deployed with the complete set of recipe packs. +func (r *Runner) setupRecipePacks(ctx context.Context, template map[string]any) error { + envResources := findRadiusCoreEnvironmentResources(template) + if len(envResources) == 0 { + return nil + } + + // Initialize client factory so we can inspect packs and create singletons. + if r.RadiusCoreClientFactory == nil { + clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) + if err != nil { + return err + } + r.RadiusCoreClientFactory = clientFactory + } + + for _, envResource := range envResources { + if err := r.setupRecipePacksForEnvironment(ctx, envResource); err != nil { + return err + } + } + + return nil +} + +// setupRecipePacksForEnvironment sets up recipe packs for a single Radius.Core/environments resource. +func (r *Runner) setupRecipePacksForEnvironment(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 + } + + // Extract existing recipe pack IDs from the template (literal strings only). + var existingPacks []string + if recipePacks, ok := properties["recipePacks"]; ok { + if packsArray, ok := recipePacks.([]any); ok { + for _, p := range packsArray { + if s, ok := p.(string); ok { + existingPacks = append(existingPacks, s) + } + } + } + } + + // Build scope → client map. + // Packs in other scopes (from the template) get a new factory. + clientsByScope := map[string]*v20250801preview.RecipePacksClient{ + r.Workspace.Scope: r.RadiusCoreClientFactory.NewRecipePacksClient(), + } + for _, packIDStr := range existingPacks { + packID, parseErr := resources.Parse(packIDStr) + if parseErr != nil { + continue + } + scope := packID.RootScope() + if _, exists := clientsByScope[scope]; !exists { + clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, scope) + if err != nil { + return err + } + clientsByScope[scope] = clientFactory.NewRecipePacksClient() + } + } + + // Inspect existing packs for resource type coverage and conflicts. + coveredTypes, conflicts, err := recipepack.InspectRecipePacks(ctx, clientsByScope, existingPacks) + if err != nil { + return err + } + if len(conflicts) > 0 { + return recipepack.FormatConflictError(conflicts) + } + + if r.DefaultScopeClientFactory == nil { + defaultFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) + if err != nil { + return err + } + r.DefaultScopeClientFactory = defaultFactory + } + + // Create missing singleton recipe packs for uncovered core resource types and + // append their IDs so the template deploys the environment with full coverage. + // Singletons always live in the default scope. + recipePackDefaultClient := r.DefaultScopeClientFactory.NewRecipePacksClient() + clientsByScope[recipepack.DefaultResourceGroupScope] = recipePackDefaultClient + singletonIDs, err := recipepack.EnsureMissingSingletons(ctx, recipePackDefaultClient, coveredTypes) + if err != nil { + return err + } + + // Write the complete list back into the template so the environment resource + // is deployed with all recipe packs in a single operation. + if len(singletonIDs) > 0 { + existingPacks = append(existingPacks, singletonIDs...) + packsAny := make([]any, len(existingPacks)) + for i, p := range existingPacks { + packsAny[i] = p + } + properties["recipePacks"] = packsAny + } + + return 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..eb886090a5 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,257 @@ func Test_Run(t *testing.T) { }) } +func Test_setupRecipePacks(t *testing.T) { + scope := "/planes/radius/local/resourceGroups/test-group" + + t.Run("injects missing singleton recipe packs into template", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + scope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + // Singleton recipe packs are created in the default scope. + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + RadiusCoreClientFactory: factory, + DefaultScopeClientFactory: defaultScopeFactory, + Output: &output.MockOutput{}, + } + + // Template with a Radius.Core/environments resource and no recipe packs. + // ARM templates use double-nested properties: properties.properties. + 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.setupRecipePacks(context.Background(), template) + require.NoError(t, err) + + // Verify that recipe packs were injected into the inner resource properties. + 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, 4, "should have all 4 singleton recipe packs") + }) + + t.Run("preserves existing packs and adds missing singletons", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + scope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + // Singleton recipe packs are created in the default scope. + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + RadiusCoreClientFactory: factory, + DefaultScopeClientFactory: defaultScopeFactory, + Output: &output.MockOutput{}, + } + + existingPackID := scope + "/providers/Radius.Core/recipePacks/custom-pack" + + // Template with a Radius.Core/environments resource that already has one pack. + // ARM templates use double-nested properties: properties.properties. + 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.setupRecipePacks(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) + // 1 existing + 4 singletons + require.Len(t, packs, 5) + require.Equal(t, existingPackID, packs[0]) + }) + + t.Run("detects recipe pack conflicts", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + scope, + nil, + test_client_factory.WithRecipePackServerConflictingTypes, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + RadiusCoreClientFactory: factory, + Output: &output.MockOutput{}, + } + + // Template with two packs that both provide Radius.Compute/containers. + // ARM templates use double-nested properties: properties.properties. + 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{ + scope + "/providers/Radius.Core/recipePacks/pack1", + scope + "/providers/Radius.Core/recipePacks/pack2", + }, + }, + }, + }, + }, + } + + err = runner.setupRecipePacks(context.Background(), template) + require.Error(t, err) + require.Contains(t, err.Error(), "Recipe pack conflict detected") + require.Contains(t, err.Error(), "Radius.Compute/containers") + }) + + 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.setupRecipePacks(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.setupRecipePacks(context.Background(), template) + require.NoError(t, err) + }) + + t.Run("handles multiple Radius.Core environments", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + scope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + RadiusCoreClientFactory: factory, + DefaultScopeClientFactory: defaultScopeFactory, + Output: &output.MockOutput{}, + } + + // Template with two Radius.Core/environments resources, neither has packs. + // ARM templates use double-nested properties: properties.properties. + template := map[string]any{ + "resources": map[string]any{ + "envDev": map[string]any{ + "type": "Radius.Core/environments@2025-08-01-preview", + "properties": map[string]any{ + "name": "envDev", + "properties": map[string]any{}, + }, + }, + "envProd": map[string]any{ + "type": "Radius.Core/environments@2025-08-01-preview", + "properties": map[string]any{ + "name": "envProd", + "properties": map[string]any{}, + }, + }, + }, + } + + err = runner.setupRecipePacks(context.Background(), template) + require.NoError(t, err) + + // Both environments should have received singleton recipe packs in the inner properties. + for _, key := range []string{"envDev", "envProd"} { + envRes := template["resources"].(map[string]any)[key].(map[string]any) + outerProps := envRes["properties"].(map[string]any) + innerProps := outerProps["properties"].(map[string]any) + packs, ok := innerProps["recipePacks"].([]any) + require.True(t, ok, "expected recipePacks on %s", key) + require.Len(t, packs, 4, "expected 4 singleton recipe packs on %s", key) + } + }) +} + 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..67dac2f681 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,11 @@ 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 implements create-or-update semantics. If the environment does not exist, it creates +// a new one with all singleton recipe packs for core resource types. If the environment +// already exists, it checks the existing recipe packs and fills in any missing singletons +// for core resource types, detecting conflicts along the way. func (r *Runner) Run(ctx context.Context) error { if r.RadiusCoreClientFactory == nil { clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) @@ -141,19 +148,48 @@ func (r *Runner) Run(ctx context.Context) error { r.RadiusCoreClientFactory = clientFactory } - r.Output.LogInfo("Creating Radius Core Environment...") + return r.runCreate(ctx) - resource := &corerpv20250801.EnvironmentResource{ - Location: to.Ptr(v1.LocationGlobal), - Properties: &corerpv20250801.EnvironmentProperties{}, +} + +// runCreate creates a new environment with all singleton recipe packs for core resource types. +func (r *Runner) runCreate(ctx context.Context) error { + r.Output.LogInfo("Creating Radius Core Environment %q...", r.EnvironmentName) + + // Create all singleton recipe packs for core resource types in the default resource group. + // Singletons always live 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 + } + recipePackClient := r.DefaultScopeClientFactory.NewRecipePacksClient() + recipePackIDs, err := recipepack.CreateSingletonRecipePacks(ctx, recipePackClient) + if err != nil { + return err } - _, err := r.RadiusCoreClientFactory.NewEnvironmentsClient().CreateOrUpdate(ctx, r.EnvironmentName, *resource, &corerpv20250801.EnvironmentsClientCreateOrUpdateOptions{}) + // Link all recipe packs to the environment + recipePackPtrs := make([]*string, len(recipePackIDs)) + for i, id := range recipePackIDs { + recipePackPtrs[i] = to.Ptr(id) + } + + resource := &corerpv20250801.EnvironmentResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &corerpv20250801.EnvironmentProperties{ + RecipePacks: recipePackPtrs, + }, + } + 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..17eaae1a75 100644 --- a/pkg/cli/cmd/env/create/preview/create_test.go +++ b/pkg/cli/cmd/env/create/preview/create_test.go @@ -26,6 +26,7 @@ import ( "github.com/radius-project/radius/pkg/cli/clients" "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 +145,52 @@ 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("New environment: all singletons created", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + workspace.Scope, + test_client_factory.WithEnvironmentServer404OnGet, + test_client_factory.WithRecipePackServerCoreTypes, + ) + require.NoError(t, err) - factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, test_client_factory.WithEnvironmentServerNoError, nil) + // Singleton recipe packs are created in the default scope. + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerCoreTypes, + ) 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, + 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..ec9ff34f57 100644 --- a/pkg/cli/cmd/env/update/preview/update.go +++ b/pkg/cli/cmd/env/update/preview/update.go @@ -28,6 +28,7 @@ import ( "github.com/radius-project/radius/pkg/cli/cmd/commonflags" "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" @@ -105,6 +106,10 @@ type Runner struct { Format string 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 + EnvironmentName string clearEnvAzure bool clearEnvAws bool @@ -307,12 +312,85 @@ func (r *Runner) Run(ctx context.Context) error { return clierrors.Message("Recipe pack %q does not exist. Please provide a valid recipe pack to add to the environment.", recipePack) } - if !recipePackExists(env.Properties.RecipePacks, ID.String()) { + if !recipepack.RecipePackIDExists(env.Properties.RecipePacks, ID.String()) { env.Properties.RecipePacks = append(env.Properties.RecipePacks, to.Ptr(ID.String())) } } } + // At this point env.Properties.RecipePacks contains the complete set of recipe packs + // the user wants on this environment. For preview, we now: + // 1. Detect conflicts where the same resource type is provided by multiple packs + // 2. Append singleton packs for any missing core resource types, assuming they already exist. + if env.Properties == nil { + env.Properties = &corerpv20250801.EnvironmentProperties{} + } + if env.Properties.RecipePacks == nil { + env.Properties.RecipePacks = []*string{} + } + + // Convert []*string to []string for the shared utility. + packIDs := make([]string, 0, len(env.Properties.RecipePacks)) + for _, p := range env.Properties.RecipePacks { + if p != nil { + packIDs = append(packIDs, *p) + } + } + + // Build scope → client map for inspecting recipe packs. + // Packs in the default scope use the existing factory; packs in other + // scopes (from user-specified full resource IDs) get a new factory. + clientsByScope := map[string]*corerpv20250801.RecipePacksClient{ + r.Workspace.Scope: r.RadiusCoreClientFactory.NewRecipePacksClient(), + } + for _, packIDStr := range packIDs { + packID, parseErr := resources.Parse(packIDStr) + if parseErr != nil { + continue + } + scope := packID.RootScope() + if _, exists := clientsByScope[scope]; !exists { + clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, scope) + if err != nil { + return err + } + clientsByScope[scope] = clientFactory.NewRecipePacksClient() + } + } + + 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() + + // Ensure the default scope client is also in the map for inspecting singletons. + if _, exists := clientsByScope[recipepack.DefaultResourceGroupScope]; !exists { + clientsByScope[recipepack.DefaultResourceGroupScope] = recipePackDefaultClient + } + + coveredTypes, conflicts, err := recipepack.InspectRecipePacks(ctx, clientsByScope, packIDs) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to inspect recipe packs for environment %q.", r.EnvironmentName) + } + + if len(conflicts) > 0 { + return recipepack.FormatConflictError(conflicts) + } + + singletonIDs, err := recipepack.EnsureMissingSingletons(ctx, recipePackDefaultClient, coveredTypes) + if err != nil { + return err + } + for _, id := range singletonIDs { + if !recipepack.RecipePackIDExists(env.Properties.RecipePacks, id) { + env.Properties.RecipePacks = append(env.Properties.RecipePacks, to.Ptr(id)) + } + } + r.Output.LogInfo("Updating Environment...") _, err = envClient.CreateOrUpdate(ctx, r.EnvironmentName, env, &corerpv20250801.EnvironmentsClientCreateOrUpdateOptions{}) if err != nil { @@ -350,12 +428,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..326c525e19 100644 --- a/pkg/cli/cmd/env/update/preview/update_test.go +++ b/pkg/cli/cmd/env/update/preview/update_test.go @@ -22,6 +22,7 @@ import ( "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/v20250801preview" @@ -114,8 +115,10 @@ func Test_Run(t *testing.T) { output.FormattedOutput{ Format: "table", Obj: environmentForDisplay{ - Name: "test-env", - RecipePacks: 3, + Name: "test-env", + // 1 existing pack from the environment, 2 user-specified + // packs, plus 4 singleton packs for core resource types. + RecipePacks: 7, Providers: 3, }, Options: environmentFormat(), @@ -133,18 +136,27 @@ func Test_Run(t *testing.T) { factory, err := test_client_factory.NewRadiusCoreTestClientFactory( workspace.Scope, tc.serverFactory, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + // Singleton recipe packs are created in the default scope. + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, nil, + test_client_factory.WithRecipePackServerUniqueTypes, ) require.NoError(t, err) outputSink := &output.MockOutput{} runner := &Runner{ - ConfigHolder: &framework.ConfigHolder{}, - Output: outputSink, - Workspace: workspace, - EnvironmentName: tc.envName, - RadiusCoreClientFactory: factory, - recipePacks: []string{"rp1", "rp2"}, + ConfigHolder: &framework.ConfigHolder{}, + Output: outputSink, + Workspace: workspace, + EnvironmentName: tc.envName, + RadiusCoreClientFactory: factory, + DefaultScopeClientFactory: defaultScopeFactory, + recipePacks: []string{"rp1", "rp2"}, providers: &v20250801preview.Providers{ Azure: &v20250801preview.ProvidersAzure{ SubscriptionID: to.Ptr("00000000-0000-0000-0000-000000000000"), diff --git a/pkg/cli/cmd/install/kubernetes/kubernetes.go b/pkg/cli/cmd/install/kubernetes/kubernetes.go index 35b132efbc..dbcc977e5a 100644 --- a/pkg/cli/cmd/install/kubernetes/kubernetes.go +++ b/pkg/cli/cmd/install/kubernetes/kubernetes.go @@ -18,18 +18,11 @@ package kubernetes import ( "context" - "fmt" - aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/cli/cmd/commonflags" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/helm" "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/sdk" - "github.com/radius-project/radius/pkg/to" - ucpv20231001 "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/version" "github.com/spf13/cobra" ) @@ -203,103 +196,5 @@ func (r *Runner) Run(ctx context.Context) error { return err } - // Deploy the default recipe pack after successful installation. - if err := deployRecipePack(ctx, r.KubeContext, r.Output); err != nil { - return err - } - return nil } - -const defaultResourceGroupName = "default" - -// deployRecipePack connects to the newly installed Radius and deploys the default recipe pack. -func deployRecipePack(ctx context.Context, kubeContext string, out output.Interface) error { - out.LogInfo("Deploying default recipe pack...") - - ws := &workspaces.Workspace{ - Connection: map[string]any{ - "kind": workspaces.KindKubernetes, - "context": kubeContext, - }, - Scope: fmt.Sprintf("/planes/radius/local/resourceGroups/%s", defaultResourceGroupName), - } - - connection, err := ws.Connect(ctx) - if err != nil { - return fmt.Errorf("failed to connect to Radius: %w", err) - } - - clientOptions := sdk.NewClientOptions(connection) - - // Create the default resource group. - rgClient, err := ucpv20231001.NewResourceGroupsClient(&aztoken.AnonymousCredential{}, clientOptions) - if err != nil { - return fmt.Errorf("failed to create resource group client: %w", err) - } - - _, err = rgClient.CreateOrUpdate(ctx, "local", defaultResourceGroupName, ucpv20231001.ResourceGroupResource{ - Location: to.Ptr("global"), - }, nil) - if err != nil { - return fmt.Errorf("failed to create resource group %q: %w", defaultResourceGroupName, err) - } - - // Create the recipe pack. - rpClient, err := corerpv20250801.NewRecipePacksClient( - fmt.Sprintf("planes/radius/local/resourceGroups/%s", defaultResourceGroupName), - &aztoken.AnonymousCredential{}, - clientOptions, - ) - if err != nil { - return fmt.Errorf("failed to create recipe pack client: %w", err) - } - - _, err = rpClient.CreateOrUpdate(ctx, "kuberecipepack", newDefaultRecipePackResource(), nil) - if err != nil { - return fmt.Errorf("failed to deploy recipe pack: %w", err) - } - - out.LogInfo("Successfully deployed default recipe pack.") - return nil -} - -// newDefaultRecipePackResource builds the default RecipePackResource containing -// Bicep recipes for the built-in Radius resource types. -func newDefaultRecipePackResource() corerpv20250801.RecipePackResource { - bicepKind := corerpv20250801.RecipeKindBicep - plainHTTP := true - - return corerpv20250801.RecipePackResource{ - Location: to.Ptr("global"), - Properties: &corerpv20250801.RecipePackProperties{ - Recipes: map[string]*corerpv20250801.RecipeDefinition{ - "Radius.Compute/containers": { - RecipeKind: &bicepKind, - RecipeLocation: to.Ptr("localhost:5000/radius-recipes/compute/containers/kubernetes/bicep/kubernetes-containers:latest"), - PlainHTTP: &plainHTTP, - }, - "Radius.Compute/persistentVolumes": { - RecipeKind: &bicepKind, - RecipeLocation: to.Ptr("localhost:5000/radius-recipes/compute/persistentvolumes/kubernetes/bicep/kubernetes-volumes:latest"), - PlainHTTP: &plainHTTP, - }, - "Radius.Data/mySqlDatabases": { - RecipeKind: &bicepKind, - RecipeLocation: to.Ptr("localhost:5000/radius-recipes/data/mysqldatabases/kubernetes/bicep/kubernetes-mysql:latest"), - PlainHTTP: &plainHTTP, - }, - "Radius.Data/postgreSqlDatabases": { - RecipeKind: &bicepKind, - RecipeLocation: to.Ptr("localhost:5000/radius-recipes/data/postgresqldatabases/kubernetes/bicep/kubernetes-postgresql:latest"), - PlainHTTP: &plainHTTP, - }, - "Radius.Security/secrets": { - RecipeKind: &bicepKind, - RecipeLocation: to.Ptr("localhost:5000/radius-recipes/security/secrets/kubernetes/bicep/kubernetes-secrets:latest"), - PlainHTTP: &plainHTTP, - }, - }, - }, - } -} diff --git a/pkg/cli/cmd/radinit/environment.go b/pkg/cli/cmd/radinit/environment.go index e2463c46bb..bcede22555 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,73 @@ 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, + // Create singleton recipe packs (one per resource type) and link them to the environment. + // Singletons always live 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 + } } + recipePackIDs, err := recipepack.CreateSingletonRecipePacks(ctx, r.DefaultScopeClientFactory.NewRecipePacksClient()) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to create recipe packs.") + } + + // Link all recipe packs to the environment + recipePackPtrs := make([]*string, len(recipePackIDs)) + for i, id := range recipePackIDs { + recipePackPtrs[i] = to.Ptr(id) + } + envProperties.RecipePacks = recipePackPtrs - err = client.CreateOrUpdateEnvironment(ctx, r.Options.Environment.Name, &corerp.EnvironmentResource{ + // 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..845dd5e635 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" @@ -947,28 +948,10 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { Return(nil). Times(1) - 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/recipepack/recipepack.go b/pkg/cli/recipepack/recipepack.go new file mode 100644 index 0000000000..9751b39697 --- /dev/null +++ b/pkg/cli/recipepack/recipepack.go @@ -0,0 +1,310 @@ +/* +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" + "strings" + + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/pkg/ucp/resources" +) + +const ( + // DefaultRecipePackName is the name of the default Kubernetes recipe pack. + DefaultRecipePackName = "local-dev" + + // 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 +) + +// SingletonRecipePackDefinition defines a singleton recipe pack for a single resource type. +type SingletonRecipePackDefinition struct { + // Name is the name of the recipe pack (derived from resource type). + Name string + // ResourceType is the full resource type (e.g., "Radius.Compute/containers"). + ResourceType string + // RecipeLocation is the OCI registry location for the recipe. + RecipeLocation string +} + +// GetSingletonRecipePackDefinitions returns the list of singleton recipe pack definitions. +// Each definition represents a single recipe pack containing one recipe for one resource type. +// This list is currently hardcoded, but will be made dynamic in the future. +func GetSingletonRecipePackDefinitions() []SingletonRecipePackDefinition { + return []SingletonRecipePackDefinition{ + { + Name: "containers", + ResourceType: "Radius.Compute/containers", + RecipeLocation: "ghcr.io/radius-project/kube-recipes/containers:latest", + }, + { + Name: "persistentvolumes", + ResourceType: "Radius.Compute/persistentVolumes", + RecipeLocation: "ghcr.io/radius-project/kube-recipes/persistentvolumes:latest", + }, + { + Name: "routes", + ResourceType: "Radius.Compute/routes", + RecipeLocation: "ghcr.io/radius-project/kube-recipes/routes:latest", + }, + { + Name: "secrets", + ResourceType: "Radius.Security/secrets", + RecipeLocation: "ghcr.io/radius-project/kube-recipes/secrets:latest", + }, + } +} + +// NewSingletonRecipePackResource creates a RecipePackResource containing a single recipe for the given resource type. +func NewSingletonRecipePackResource(resourceType, recipeLocation string) corerpv20250801.RecipePackResource { + bicepKind := corerpv20250801.RecipeKindBicep + + return corerpv20250801.RecipePackResource{ + Location: to.Ptr("global"), + Properties: &corerpv20250801.RecipePackProperties{ + Recipes: map[string]*corerpv20250801.RecipeDefinition{ + resourceType: { + RecipeKind: &bicepKind, + RecipeLocation: to.Ptr(recipeLocation), + }, + }, + }, + } +} + +// CreateSingletonRecipePacks creates singleton recipe packs (one per resource type) using a RecipePacksClient. +// The client must be scoped to the default resource group (DefaultResourceGroupScope). +// It returns the list of full resource IDs of the created recipe packs, always in the default scope. +func CreateSingletonRecipePacks(ctx context.Context, client *corerpv20250801.RecipePacksClient) ([]string, error) { + definitions := GetSingletonRecipePackDefinitions() + recipePackIDs := make([]string, 0, len(definitions)) + + for _, def := range definitions { + resource := NewSingletonRecipePackResource(def.ResourceType, def.RecipeLocation) + _, err := client.CreateOrUpdate(ctx, def.Name, resource, nil) + if err != nil { + return nil, fmt.Errorf("failed to create recipe pack %q for resource type %q: %w", def.Name, def.ResourceType, err) + } + + // Return the full resource ID of the created recipe pack in the default scope. + recipePackID := fmt.Sprintf("%s/providers/Radius.Core/recipePacks/%s", DefaultResourceGroupScope, def.Name) + recipePackIDs = append(recipePackIDs, recipePackID) + } + + return recipePackIDs, nil +} + +// GetCoreResourceTypes returns the set of core resource types that require recipe packs. +func GetCoreResourceTypes() map[string]bool { + defs := GetSingletonRecipePackDefinitions() + types := make(map[string]bool, len(defs)) + for _, def := range defs { + types[def.ResourceType] = true + } + return types +} + +// IsSingletonRecipePackName checks if the given name matches a known singleton recipe pack name. +func IsSingletonRecipePackName(name string) bool { + for _, def := range GetSingletonRecipePackDefinitions() { + if def.Name == name { + return true + } + } + return false +} + +// CollectResourceTypesFromRecipePacks queries the recipe packs client for each pack name +// and collects all resource types from their recipes. Returns a map of resource type to pack name. +// func CollectResourceTypesFromRecipePacks(ctx context.Context, client *corerpv20250801.RecipePacksClient, packNames []string) (map[string]string, error) { +// coveredTypes := make(map[string]string) +// for _, name := range packNames { +// resp, err := client.Get(ctx, name, nil) +// if err != nil { +// return nil, fmt.Errorf("failed to get recipe pack %q: %w", name, err) +// } +// if resp.Properties != nil && resp.Properties.Recipes != nil { +// for resourceType := range resp.Properties.Recipes { +// coveredTypes[resourceType] = name +// } +// } +// } +// return coveredTypes, nil +// } + +// DetectResourceTypeConflicts checks if any resource type appears in multiple recipe packs. +// Returns a map of resource type to list of pack names that contain it. +// func DetectResourceTypeConflicts(ctx context.Context, client *corerpv20250801.RecipePacksClient, packNames []string) (map[string][]string, error) { +// typeToPackNames := make(map[string][]string) +// for _, name := range packNames { +// resp, err := client.Get(ctx, name, nil) +// if err != nil { +// return nil, fmt.Errorf("failed to get recipe pack %q: %w", name, err) +// } +// if resp.Properties != nil && resp.Properties.Recipes != nil { +// for resourceType := range resp.Properties.Recipes { +// typeToPackNames[resourceType] = append(typeToPackNames[resourceType], name) +// } +// } +// } +// conflicts := make(map[string][]string) +// for resourceType, packs := range typeToPackNames { +// if len(packs) > 1 { +// conflicts[resourceType] = packs +// } +// } +// return conflicts, nil +// } + +// GetMissingSingletonDefinitions returns singleton definitions for core resource types +// that are not already covered by the existing recipe packs. +func GetMissingSingletonDefinitions(coveredTypes map[string]string) []SingletonRecipePackDefinition { + var missing []SingletonRecipePackDefinition + for _, def := range GetSingletonRecipePackDefinitions() { + if _, covered := coveredTypes[def.ResourceType]; !covered { + missing = append(missing, def) + } + } + return missing +} + +// CreateMissingSingletonRecipePacks creates singleton recipe packs for core resource types +// that are not already covered by existing recipe packs. The client must be scoped to the +// default resource group. Returns the IDs of created packs, always in the default scope. +func CreateMissingSingletonRecipePacks(ctx context.Context, client *corerpv20250801.RecipePacksClient, coveredTypes map[string]string) ([]string, error) { + missing := GetMissingSingletonDefinitions(coveredTypes) + if len(missing) == 0 { + return nil, nil + } + + createdIDs := make([]string, 0, len(missing)) + for _, def := range missing { + resource := NewSingletonRecipePackResource(def.ResourceType, def.RecipeLocation) + _, err := client.CreateOrUpdate(ctx, def.Name, resource, nil) + if err != nil { + return nil, fmt.Errorf("failed to create recipe pack %q for resource type %q: %w", def.Name, def.ResourceType, err) + } + recipePackID := fmt.Sprintf("%s/providers/Radius.Core/recipePacks/%s", DefaultResourceGroupScope, def.Name) + createdIDs = append(createdIDs, recipePackID) + } + + return createdIDs, nil +} + +// --------------------------------------------------------------------------- +// Shared utilities used by env create, env update, and rad deploy commands. +// --------------------------------------------------------------------------- + +// InspectRecipePacks fetches each recipe pack by its full resource ID, +// collects the resource types each pack provides, and detects conflicts where +// the same resource type appears in more than one pack. +// +// clientsByScope maps root scope strings to a RecipePacksClient for that scope. +// Pack IDs whose scope has no matching client, or that cannot be parsed, are +// silently skipped. +func InspectRecipePacks(ctx context.Context, clientsByScope map[string]*corerpv20250801.RecipePacksClient, packIDs []string) (coveredTypes map[string]string, conflicts map[string][]string, err error) { + typeToPacks := make(map[string][]string) + coveredTypes = make(map[string]string) + + for _, packIDStr := range packIDs { + packID, parseErr := resources.Parse(packIDStr) + if parseErr != nil { + continue + } + + client, ok := clientsByScope[packID.RootScope()] + if !ok { + continue + } + + resp, err := client.Get(ctx, packID.Name(), nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to inspect recipe pack %q: %w", packIDStr, err) + } + + if resp.Properties != nil && resp.Properties.Recipes != nil { + for resourceType := range resp.Properties.Recipes { + typeToPacks[resourceType] = append(typeToPacks[resourceType], packID.Name()) + if _, exists := coveredTypes[resourceType]; !exists { + coveredTypes[resourceType] = packID.Name() + } + } + } + } + + conflicts = make(map[string][]string) + for resourceType, packs := range typeToPacks { + if len(packs) > 1 { + conflicts[resourceType] = packs + } + } + + return coveredTypes, conflicts, nil +} + +// FormatConflictError creates a user-friendly error when resource types are +// provided by multiple recipe packs. +func FormatConflictError(conflicts map[string][]string) error { + var b strings.Builder + b.WriteString("Recipe pack conflict detected. The following resource types are provided by multiple recipe packs:\n") + for resourceType, packs := range conflicts { + fmt.Fprintf(&b, " - %s: provided by packs %v\n", resourceType, packs) + } + b.WriteString("\nPlease resolve these conflicts by removing or replacing conflicting recipe packs.") + return fmt.Errorf("%s", b.String()) +} + +// EnsureMissingSingletons creates (or updates, idempotently) singleton recipe +// pack resources for core resource types not already covered by coveredTypes, +// and returns their full resource IDs in the default resource group scope. +// The client must be scoped to DefaultResourceGroupScope. +// Singletons always live in the default scope. +func EnsureMissingSingletons(ctx context.Context, client *corerpv20250801.RecipePacksClient, coveredTypes map[string]string) ([]string, error) { + missing := GetMissingSingletonDefinitions(coveredTypes) + if len(missing) == 0 { + return nil, nil + } + + ids := make([]string, 0, len(missing)) + for _, def := range missing { + resource := NewSingletonRecipePackResource(def.ResourceType, def.RecipeLocation) + _, err := client.CreateOrUpdate(ctx, def.Name, resource, nil) + if err != nil { + return nil, fmt.Errorf("failed to create recipe pack %q for resource type %q: %w", def.Name, def.ResourceType, err) + } + ids = append(ids, fmt.Sprintf("%s/providers/Radius.Core/recipePacks/%s", DefaultResourceGroupScope, def.Name)) + } + return ids, nil +} + +// RecipePackIDExists checks whether id is present in a []*string slice. +func RecipePackIDExists(packs []*string, id string) bool { + for _, p := range packs { + if p != nil && *p == id { + return true + } + } + return false +} diff --git a/pkg/cli/recipepack/recipepack_test.go b/pkg/cli/recipepack/recipepack_test.go new file mode 100644 index 0000000000..3c6d368c21 --- /dev/null +++ b/pkg/cli/recipepack/recipepack_test.go @@ -0,0 +1,271 @@ +/* +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" + "testing" + + "github.com/radius-project/radius/pkg/cli/test_client_factory" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/to" + "github.com/stretchr/testify/require" +) + +func Test_DefaultRecipePackName(t *testing.T) { + require.Equal(t, "local-dev", DefaultRecipePackName) +} + +func Test_GetSingletonRecipePackDefinitions(t *testing.T) { + definitions := GetSingletonRecipePackDefinitions() + + // Verify we have the expected number of definitions + require.Len(t, definitions, 4) + + // Verify expected resource types and names + expectedDefinitions := map[string]string{ + "containers": "Radius.Compute/containers", + "persistentvolumes": "Radius.Compute/persistentVolumes", + "routes": "Radius.Compute/routes", + "secrets": "Radius.Security/secrets", + } + + for _, def := range definitions { + expectedResourceType, exists := expectedDefinitions[def.Name] + require.True(t, exists, "Unexpected definition name: %s", def.Name) + require.Equal(t, expectedResourceType, def.ResourceType, "Resource type mismatch for %s", def.Name) + require.NotEmpty(t, def.RecipeLocation, "RecipeLocation should not be empty for %s", def.Name) + } +} + +func Test_NewSingletonRecipePackResource(t *testing.T) { + resourceType := "Radius.Compute/containers" + recipeLocation := "ghcr.io/radius-project/kube-recipes/containers@latest" + + resource := NewSingletonRecipePackResource(resourceType, recipeLocation) + + // Verify location + require.NotNil(t, resource.Location) + require.Equal(t, "global", *resource.Location) + + // Verify properties exist + require.NotNil(t, resource.Properties) + require.NotNil(t, resource.Properties.Recipes) + + // Verify the resource contains exactly one recipe + require.Len(t, resource.Properties.Recipes, 1) + + // Verify the recipe + recipe, exists := resource.Properties.Recipes[resourceType] + require.True(t, exists, "Expected recipe for resource type %s to exist", resourceType) + require.NotNil(t, recipe.RecipeKind) + require.Equal(t, corerpv20250801.RecipeKindBicep, *recipe.RecipeKind) + require.NotNil(t, recipe.RecipeLocation) + require.Equal(t, recipeLocation, *recipe.RecipeLocation) +} + +func Test_CreateSingletonRecipePacksWithClient(t *testing.T) { + t.Run("Success: creates all singleton recipe packs", func(t *testing.T) { + // The client must be scoped to the default resource group scope. + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(DefaultResourceGroupScope, nil, nil) + require.NoError(t, err) + + recipePackClient := factory.NewRecipePacksClient() + + recipePackIDs, err := CreateSingletonRecipePacks(context.Background(), recipePackClient) + require.NoError(t, err) + + // Verify the correct number of recipe packs were created + definitions := GetSingletonRecipePackDefinitions() + require.Len(t, recipePackIDs, len(definitions)) + + // Verify the IDs are in the default scope + for i, def := range definitions { + expectedID := DefaultResourceGroupScope + "/providers/Radius.Core/recipePacks/" + def.Name + require.Equal(t, expectedID, recipePackIDs[i]) + } + }) +} + +func Test_InspectRecipePacks(t *testing.T) { + scope := "/planes/radius/local/resourceGroups/test-rg" + + t.Run("collects resource types from packs", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(scope, nil, test_client_factory.WithRecipePackServerUniqueTypes) + require.NoError(t, err) + + clientsByScope := map[string]*corerpv20250801.RecipePacksClient{ + scope: factory.NewRecipePacksClient(), + } + packIDs := []string{ + scope + "/providers/Radius.Core/recipePacks/pack-a", + scope + "/providers/Radius.Core/recipePacks/pack-b", + } + + coveredTypes, conflicts, err := InspectRecipePacks(context.Background(), clientsByScope, packIDs) + require.NoError(t, err) + require.Empty(t, conflicts) + // Each pack has a unique type based on its name + require.Len(t, coveredTypes, 2) + require.Equal(t, "pack-a", coveredTypes["Test.Resource/pack-a"]) + require.Equal(t, "pack-b", coveredTypes["Test.Resource/pack-b"]) + }) + + t.Run("detects conflicts", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(scope, nil, test_client_factory.WithRecipePackServerConflictingTypes) + require.NoError(t, err) + + clientsByScope := map[string]*corerpv20250801.RecipePacksClient{ + scope: factory.NewRecipePacksClient(), + } + packIDs := []string{ + scope + "/providers/Radius.Core/recipePacks/pack1", + scope + "/providers/Radius.Core/recipePacks/pack2", + } + + _, conflicts, err := InspectRecipePacks(context.Background(), clientsByScope, packIDs) + require.NoError(t, err) + require.Len(t, conflicts, 1) + require.Contains(t, conflicts, "Radius.Compute/containers") + require.ElementsMatch(t, []string{"pack1", "pack2"}, conflicts["Radius.Compute/containers"]) + }) + + t.Run("skips unparseable IDs", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(scope, nil, test_client_factory.WithRecipePackServerUniqueTypes) + require.NoError(t, err) + + clientsByScope := map[string]*corerpv20250801.RecipePacksClient{ + scope: factory.NewRecipePacksClient(), + } + packIDs := []string{ + "not-a-valid-id", + scope + "/providers/Radius.Core/recipePacks/valid-pack", + } + + coveredTypes, conflicts, err := InspectRecipePacks(context.Background(), clientsByScope, packIDs) + require.NoError(t, err) + require.Empty(t, conflicts) + require.Len(t, coveredTypes, 1) + }) + + t.Run("skips packs with unknown scope", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(scope, nil, test_client_factory.WithRecipePackServerUniqueTypes) + require.NoError(t, err) + + clientsByScope := map[string]*corerpv20250801.RecipePacksClient{ + scope: factory.NewRecipePacksClient(), + } + // This pack is in a different scope not in the map + otherScope := "/planes/radius/local/resourceGroups/other-rg" + packIDs := []string{ + otherScope + "/providers/Radius.Core/recipePacks/remote-pack", + } + + coveredTypes, conflicts, err := InspectRecipePacks(context.Background(), clientsByScope, packIDs) + require.NoError(t, err) + require.Empty(t, conflicts) + require.Empty(t, coveredTypes) + }) + + t.Run("empty pack list", func(t *testing.T) { + clientsByScope := map[string]*corerpv20250801.RecipePacksClient{} + + coveredTypes, conflicts, err := InspectRecipePacks(context.Background(), clientsByScope, nil) + require.NoError(t, err) + require.Empty(t, conflicts) + require.Empty(t, coveredTypes) + }) +} + +func Test_EnsureMissingSingletons(t *testing.T) { + t.Run("creates all singletons when none covered", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(DefaultResourceGroupScope, nil, nil) + require.NoError(t, err) + + client := factory.NewRecipePacksClient() + coveredTypes := map[string]string{} // nothing covered + + ids, err := EnsureMissingSingletons(context.Background(), client, coveredTypes) + require.NoError(t, err) + require.Len(t, ids, 4) + + for _, def := range GetSingletonRecipePackDefinitions() { + expected := DefaultResourceGroupScope + "/providers/Radius.Core/recipePacks/" + def.Name + require.Contains(t, ids, expected) + } + }) + + t.Run("skips already covered types", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(DefaultResourceGroupScope, nil, nil) + require.NoError(t, err) + + client := factory.NewRecipePacksClient() + // Cover 2 of 4 types + coveredTypes := map[string]string{ + "Radius.Compute/containers": "my-containers-pack", + "Radius.Security/secrets": "my-secrets-pack", + } + + ids, err := EnsureMissingSingletons(context.Background(), client, coveredTypes) + require.NoError(t, err) + require.Len(t, ids, 2) + + require.Contains(t, ids, DefaultResourceGroupScope+"/providers/Radius.Core/recipePacks/persistentvolumes") + require.Contains(t, ids, DefaultResourceGroupScope+"/providers/Radius.Core/recipePacks/routes") + }) + + t.Run("returns nil when all types covered", func(t *testing.T) { + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(DefaultResourceGroupScope, nil, nil) + require.NoError(t, err) + + client := factory.NewRecipePacksClient() + coveredTypes := map[string]string{ + "Radius.Compute/containers": "a", + "Radius.Compute/persistentVolumes": "b", + "Radius.Compute/routes": "c", + "Radius.Security/secrets": "d", + } + + ids, err := EnsureMissingSingletons(context.Background(), client, coveredTypes) + require.NoError(t, err) + require.Nil(t, ids) + }) +} + +func Test_FormatConflictError(t *testing.T) { + conflicts := map[string][]string{ + "Radius.Compute/containers": {"pack1", "pack2"}, + } + + err := FormatConflictError(conflicts) + require.Error(t, err) + require.Contains(t, err.Error(), "Recipe pack conflict detected") + require.Contains(t, err.Error(), "Radius.Compute/containers") + require.Contains(t, err.Error(), "pack1") + require.Contains(t, err.Error(), "pack2") +} + +func Test_RecipePackIDExists(t *testing.T) { + packs := []*string{ + to.Ptr("/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/containers"), + to.Ptr("/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/routes"), + } + + require.True(t, RecipePackIDExists(packs, "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/containers")) + require.False(t, RecipePackIDExists(packs, "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/secrets")) + require.False(t, RecipePackIDExists(nil, "anything")) +} diff --git a/pkg/cli/test_client_factory/radius_core.go b/pkg/cli/test_client_factory/radius_core.go index 6dfdea051e..3a5a4f63bb 100644 --- a/pkg/cli/test_client_factory/radius_core.go +++ b/pkg/cli/test_client_factory/radius_core.go @@ -18,6 +18,7 @@ package test_client_factory import ( "context" + "fmt" "net/http" armpolicy "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/policy" @@ -82,6 +83,16 @@ func WithRecipePackServerNoError() corerpfake.RecipePacksServer { resp.SetResponse(http.StatusOK, result, nil) return }, + CreateOrUpdate: func(ctx context.Context, recipePackName string, resource v20250801preview.RecipePackResource, options *v20250801preview.RecipePacksClientCreateOrUpdateOptions) (resp azfake.Responder[v20250801preview.RecipePacksClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.RecipePacksClientCreateOrUpdateResponse{ + RecipePackResource: v20250801preview.RecipePackResource{ + Name: to.Ptr(recipePackName), + Properties: resource.Properties, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, } } @@ -159,3 +170,225 @@ func WithEnvironmentServerNoError() corerpfake.EnvironmentsServer { }, } } + +// WithEnvironmentServer404OnGet returns an EnvironmentsServer that returns 404 on Get +// and success on CreateOrUpdate, simulating a new environment creation scenario. +func WithEnvironmentServer404OnGet() corerpfake.EnvironmentsServer { + return corerpfake.EnvironmentsServer{ + Get: func( + ctx context.Context, + environmentName string, + options *v20250801preview.EnvironmentsClientGetOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientGetResponse], errResp azfake.ErrorResponder) { + errResp.SetError(fmt.Errorf("environment not found")) + errResp.SetResponseError(404, "Not Found") + return + }, + CreateOrUpdate: func( + ctx context.Context, + environmentName string, + resource v20250801preview.EnvironmentResource, + options *v20250801preview.EnvironmentsClientCreateOrUpdateOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.EnvironmentsClientCreateOrUpdateResponse{ + EnvironmentResource: resource, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + } +} + +// WithEnvironmentServerNoRecipePacks returns an EnvironmentsServer that returns an existing +// environment with no recipe packs on Get, and success on CreateOrUpdate. +func WithEnvironmentServerNoRecipePacks() corerpfake.EnvironmentsServer { + return corerpfake.EnvironmentsServer{ + Get: func( + ctx context.Context, + environmentName string, + options *v20250801preview.EnvironmentsClientGetOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientGetResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.EnvironmentsClientGetResponse{ + EnvironmentResource: v20250801preview.EnvironmentResource{ + Name: to.Ptr(environmentName), + Location: to.Ptr("global"), + Properties: &v20250801preview.EnvironmentProperties{ + RecipePacks: []*string{}, + }, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + CreateOrUpdate: func( + ctx context.Context, + environmentName string, + resource v20250801preview.EnvironmentResource, + options *v20250801preview.EnvironmentsClientCreateOrUpdateOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.EnvironmentsClientCreateOrUpdateResponse{ + EnvironmentResource: resource, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + } +} + +// WithEnvironmentServerCustomRecipePacks returns a factory function that creates an EnvironmentsServer +// with the given recipe pack IDs on Get, and success on CreateOrUpdate. +func WithEnvironmentServerCustomRecipePacks(recipePacks []*string) func() corerpfake.EnvironmentsServer { + return func() corerpfake.EnvironmentsServer { + return corerpfake.EnvironmentsServer{ + Get: func( + ctx context.Context, + environmentName string, + options *v20250801preview.EnvironmentsClientGetOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientGetResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.EnvironmentsClientGetResponse{ + EnvironmentResource: v20250801preview.EnvironmentResource{ + Name: to.Ptr(environmentName), + Location: to.Ptr("global"), + Properties: &v20250801preview.EnvironmentProperties{ + RecipePacks: recipePacks, + }, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + CreateOrUpdate: func( + ctx context.Context, + environmentName string, + resource v20250801preview.EnvironmentResource, + options *v20250801preview.EnvironmentsClientCreateOrUpdateOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.EnvironmentsClientCreateOrUpdateResponse{ + EnvironmentResource: resource, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + } + } +} + +// WithRecipePackServerCoreTypes returns a RecipePacksServer that maps singleton pack names +// to their actual core resource types. Non-singleton names get a unique test type. +func WithRecipePackServerCoreTypes() corerpfake.RecipePacksServer { + // Build lookup from singleton definitions. These mirror the core + // singleton recipe packs used by the CLI, but are duplicated here to + // avoid importing the recipepack package and creating an import cycle in + // tests. + singletonTypes := map[string]string{ + "containers": "Radius.Compute/containers", + "persistentvolumes": "Radius.Compute/persistentVolumes", + "routes": "Radius.Compute/routes", + "secrets": "Radius.Security/secrets", + } + + return corerpfake.RecipePacksServer{ + Get: func(ctx context.Context, recipePackName string, options *v20250801preview.RecipePacksClientGetOptions) (resp azfake.Responder[v20250801preview.RecipePacksClientGetResponse], errResp azfake.ErrorResponder) { + resourceType, ok := singletonTypes[recipePackName] + if !ok { + resourceType = "Test.Resource/" + recipePackName + } + bicepKind := v20250801preview.RecipeKindBicep + result := v20250801preview.RecipePacksClientGetResponse{ + RecipePackResource: v20250801preview.RecipePackResource{ + Name: to.Ptr(recipePackName), + Properties: &v20250801preview.RecipePackProperties{ + Recipes: map[string]*v20250801preview.RecipeDefinition{ + resourceType: { + RecipeLocation: to.Ptr("ghcr.io/test/" + recipePackName + ":latest"), + RecipeKind: &bicepKind, + }, + }, + }, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + CreateOrUpdate: func(ctx context.Context, recipePackName string, resource v20250801preview.RecipePackResource, options *v20250801preview.RecipePacksClientCreateOrUpdateOptions) (resp azfake.Responder[v20250801preview.RecipePacksClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.RecipePacksClientCreateOrUpdateResponse{ + RecipePackResource: v20250801preview.RecipePackResource{ + Name: to.Ptr(recipePackName), + Properties: resource.Properties, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + } +} + +// WithRecipePackServerUniqueTypes returns a RecipePacksServer where each pack name +// maps to a unique resource type based on the pack name. +func WithRecipePackServerUniqueTypes() corerpfake.RecipePacksServer { + return corerpfake.RecipePacksServer{ + Get: func(ctx context.Context, recipePackName string, options *v20250801preview.RecipePacksClientGetOptions) (resp azfake.Responder[v20250801preview.RecipePacksClientGetResponse], errResp azfake.ErrorResponder) { + bicepKind := v20250801preview.RecipeKindBicep + result := v20250801preview.RecipePacksClientGetResponse{ + RecipePackResource: v20250801preview.RecipePackResource{ + Name: to.Ptr(recipePackName), + Properties: &v20250801preview.RecipePackProperties{ + Recipes: map[string]*v20250801preview.RecipeDefinition{ + "Test.Resource/" + recipePackName: { + RecipeLocation: to.Ptr("ghcr.io/test/" + recipePackName + ":latest"), + RecipeKind: &bicepKind, + }, + }, + }, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + CreateOrUpdate: func(ctx context.Context, recipePackName string, resource v20250801preview.RecipePackResource, options *v20250801preview.RecipePacksClientCreateOrUpdateOptions) (resp azfake.Responder[v20250801preview.RecipePacksClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.RecipePacksClientCreateOrUpdateResponse{ + RecipePackResource: v20250801preview.RecipePackResource{ + Name: to.Ptr(recipePackName), + Properties: resource.Properties, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + } +} + +// WithRecipePackServerConflictingTypes returns a RecipePacksServer where every pack +// returns the same resource type, simulating a conflict scenario. +func WithRecipePackServerConflictingTypes() corerpfake.RecipePacksServer { + return corerpfake.RecipePacksServer{ + Get: func(ctx context.Context, recipePackName string, options *v20250801preview.RecipePacksClientGetOptions) (resp azfake.Responder[v20250801preview.RecipePacksClientGetResponse], errResp azfake.ErrorResponder) { + bicepKind := v20250801preview.RecipeKindBicep + result := v20250801preview.RecipePacksClientGetResponse{ + RecipePackResource: v20250801preview.RecipePackResource{ + Name: to.Ptr(recipePackName), + Properties: &v20250801preview.RecipePackProperties{ + Recipes: map[string]*v20250801preview.RecipeDefinition{ + "Radius.Compute/containers": { + RecipeLocation: to.Ptr("ghcr.io/test/" + recipePackName + ":latest"), + RecipeKind: &bicepKind, + }, + }, + }, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + CreateOrUpdate: func(ctx context.Context, recipePackName string, resource v20250801preview.RecipePackResource, options *v20250801preview.RecipePacksClientCreateOrUpdateOptions) (resp azfake.Responder[v20250801preview.RecipePacksClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.RecipePacksClientCreateOrUpdateResponse{ + RecipePackResource: v20250801preview.RecipePackResource{ + Name: to.Ptr(recipePackName), + Properties: resource.Properties, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + } +} From d8406245935d7b6e3eebf1173a1b529c838e6c4c Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Tue, 10 Feb 2026 13:12:04 -0800 Subject: [PATCH 03/12] check default rg Signed-off-by: nithyatsu --- pkg/cli/cmd/deploy/deploy.go | 9 +++++++++ pkg/cli/cmd/env/create/preview/create.go | 9 +++++++++ pkg/cli/cmd/env/update/preview/update.go | 17 +++++++++++++++-- pkg/cli/cmd/radinit/environment.go | 5 +++++ pkg/cli/recipepack/recipepack.go | 15 +++++++++++++++ 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/pkg/cli/cmd/deploy/deploy.go b/pkg/cli/cmd/deploy/deploy.go index 121137ca0c..ff60461155 100644 --- a/pkg/cli/cmd/deploy/deploy.go +++ b/pkg/cli/cmd/deploy/deploy.go @@ -757,6 +757,15 @@ func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, envResource r.DefaultScopeClientFactory = defaultFactory } + // 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 + } + // Create missing singleton recipe packs for uncovered core resource types and // append their IDs so the template deploys the environment with full coverage. // Singletons always live in the default scope. diff --git a/pkg/cli/cmd/env/create/preview/create.go b/pkg/cli/cmd/env/create/preview/create.go index 67dac2f681..e12841fde1 100644 --- a/pkg/cli/cmd/env/create/preview/create.go +++ b/pkg/cli/cmd/env/create/preview/create.go @@ -156,6 +156,15 @@ func (r *Runner) Run(ctx context.Context) error { func (r *Runner) runCreate(ctx context.Context) error { r.Output.LogInfo("Creating Radius Core Environment %q...", r.EnvironmentName) + // 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 + } + // Create all singleton recipe packs for core resource types in the default resource group. // Singletons always live in the default scope regardless of the current workspace scope. if r.DefaultScopeClientFactory == nil { diff --git a/pkg/cli/cmd/env/update/preview/update.go b/pkg/cli/cmd/env/update/preview/update.go index ec9ff34f57..8d1f09f393 100644 --- a/pkg/cli/cmd/env/update/preview/update.go +++ b/pkg/cli/cmd/env/update/preview/update.go @@ -26,6 +26,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/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" @@ -105,6 +106,7 @@ type Runner struct { Workspace *workspaces.Workspace Format string RadiusCoreClientFactory *corerpv20250801.ClientFactory + ConnectionFactory connections.Factory // DefaultScopeClientFactory is the client factory scoped to the default resource group. // Singleton recipe packs are always created/queried in the default scope. @@ -122,8 +124,9 @@ type Runner struct { // NewRunner creates a new instance of the `rad env update` preview runner. func NewRunner(factory framework.Factory) *Runner { return &Runner{ - ConfigHolder: factory.GetConfigHolder(), - Output: factory.GetOutput(), + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + ConnectionFactory: factory.GetConnectionFactory(), } } @@ -365,6 +368,16 @@ func (r *Runner) Run(ctx context.Context) error { } r.DefaultScopeClientFactory = defaultFactory } + + // 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 + } + recipePackDefaultClient := r.DefaultScopeClientFactory.NewRecipePacksClient() // Ensure the default scope client is also in the map for inspecting singletons. diff --git a/pkg/cli/cmd/radinit/environment.go b/pkg/cli/cmd/radinit/environment.go index bcede22555..05ac4e97a9 100644 --- a/pkg/cli/cmd/radinit/environment.go +++ b/pkg/cli/cmd/radinit/environment.go @@ -91,6 +91,11 @@ func (r *Runner) CreateEnvironment(ctx context.Context) error { r.RadiusCoreClientFactory = clientFactory } + // 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 singleton recipe packs (one per resource type) and link them to the environment. // Singletons always live in the default resource group scope. // DefaultScopeClientFactory is required in the case rad init runs from a workspace with non-default settings. diff --git a/pkg/cli/recipepack/recipepack.go b/pkg/cli/recipepack/recipepack.go index 9751b39697..81876abd4d 100644 --- a/pkg/cli/recipepack/recipepack.go +++ b/pkg/cli/recipepack/recipepack.go @@ -21,9 +21,11 @@ import ( "fmt" "strings" + 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" "github.com/radius-project/radius/pkg/ucp/resources" + ucpv20231001 "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" ) const ( @@ -39,6 +41,19 @@ const ( 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 + +// 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. +func EnsureDefaultResourceGroup(ctx context.Context, createOrUpdate ResourceGroupCreator) error { + return createOrUpdate(ctx, "local", DefaultResourceGroupName, &ucpv20231001.ResourceGroupResource{ + Location: to.Ptr(v1.LocationGlobal), + }) +} + // SingletonRecipePackDefinition defines a singleton recipe pack for a single resource type. type SingletonRecipePackDefinition struct { // Name is the name of the recipe pack (derived from resource type). From 9223013aa00f533346d345f5157dcd3e8a77ca74 Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Tue, 10 Feb 2026 13:16:41 -0800 Subject: [PATCH 04/12] wip Signed-off-by: nithyatsu --- pkg/cli/cmd/deploy/deploy.go | 2 +- pkg/cli/recipepack/recipepack.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cli/cmd/deploy/deploy.go b/pkg/cli/cmd/deploy/deploy.go index ff60461155..517d90555f 100644 --- a/pkg/cli/cmd/deploy/deploy.go +++ b/pkg/cli/cmd/deploy/deploy.go @@ -137,7 +137,7 @@ type Runner struct { RadiusCoreClientFactory *v20250801preview.ClientFactory Deploy deploy.Interface Output output.Interface -// DefaultScopeClientFactory is the client factory scoped to the default resource group. + // DefaultScopeClientFactory is the client factory scoped to the default resource group. // Singleton recipe packs are always created/queried in the default scope. DefaultScopeClientFactory *v20250801preview.ClientFactory diff --git a/pkg/cli/recipepack/recipepack.go b/pkg/cli/recipepack/recipepack.go index 81876abd4d..054c38a578 100644 --- a/pkg/cli/recipepack/recipepack.go +++ b/pkg/cli/recipepack/recipepack.go @@ -24,8 +24,8 @@ import ( 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" - "github.com/radius-project/radius/pkg/ucp/resources" ucpv20231001 "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/resources" ) const ( From 564ba8c20c50c14c050b6955372444d58258d4bd Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Tue, 10 Feb 2026 13:30:38 -0800 Subject: [PATCH 05/12] update tests Signed-off-by: nithyatsu --- pkg/cli/cmd/deploy/deploy_test.go | 24 +++++++++++++++++++ pkg/cli/cmd/env/create/preview/create_test.go | 9 +++++++ pkg/cli/cmd/env/update/preview/update_test.go | 12 ++++++++++ pkg/cli/cmd/radinit/init_test.go | 2 +- 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/pkg/cli/cmd/deploy/deploy_test.go b/pkg/cli/cmd/deploy/deploy_test.go index eb886090a5..111e5f53b7 100644 --- a/pkg/cli/cmd/deploy/deploy_test.go +++ b/pkg/cli/cmd/deploy/deploy_test.go @@ -978,6 +978,13 @@ func Test_setupRecipePacks(t *testing.T) { scope := "/planes/radius/local/resourceGroups/test-group" t.Run("injects missing singleton recipe packs 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) + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( scope, nil, @@ -999,6 +1006,7 @@ func Test_setupRecipePacks(t *testing.T) { }, RadiusCoreClientFactory: factory, DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, Output: &output.MockOutput{}, } @@ -1029,6 +1037,13 @@ func Test_setupRecipePacks(t *testing.T) { }) t.Run("preserves existing packs and adds missing singletons", 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( scope, nil, @@ -1050,6 +1065,7 @@ func Test_setupRecipePacks(t *testing.T) { }, RadiusCoreClientFactory: factory, DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, Output: &output.MockOutput{}, } @@ -1166,6 +1182,13 @@ func Test_setupRecipePacks(t *testing.T) { }) t.Run("handles multiple Radius.Core environments", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(2) + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( scope, nil, @@ -1186,6 +1209,7 @@ func Test_setupRecipePacks(t *testing.T) { }, RadiusCoreClientFactory: factory, DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, Output: &output.MockOutput{}, } diff --git a/pkg/cli/cmd/env/create/preview/create_test.go b/pkg/cli/cmd/env/create/preview/create_test.go index 17eaae1a75..a5f661edeb 100644 --- a/pkg/cli/cmd/env/create/preview/create_test.go +++ b/pkg/cli/cmd/env/create/preview/create_test.go @@ -24,6 +24,7 @@ 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" @@ -155,6 +156,13 @@ func Test_Run(t *testing.T) { } t.Run("New environment: all singletons created", 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, @@ -174,6 +182,7 @@ func Test_Run(t *testing.T) { runner := &Runner{ RadiusCoreClientFactory: factory, DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, Output: outputSink, Workspace: workspace, EnvironmentName: "testenv", diff --git a/pkg/cli/cmd/env/update/preview/update_test.go b/pkg/cli/cmd/env/update/preview/update_test.go index 326c525e19..e602bba6b0 100644 --- a/pkg/cli/cmd/env/update/preview/update_test.go +++ b/pkg/cli/cmd/env/update/preview/update_test.go @@ -20,6 +20,10 @@ import ( "context" "testing" + "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" @@ -133,6 +137,13 @@ func Test_Run(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, 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, tc.serverFactory, @@ -156,6 +167,7 @@ func Test_Run(t *testing.T) { EnvironmentName: tc.envName, RadiusCoreClientFactory: factory, DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, recipePacks: []string{"rp1", "rp2"}, providers: &v20250801preview.Providers{ Azure: &v20250801preview.ProvidersAzure{ diff --git a/pkg/cli/cmd/radinit/init_test.go b/pkg/cli/cmd/radinit/init_test.go index 845dd5e635..40817c6edb 100644 --- a/pkg/cli/cmd/radinit/init_test.go +++ b/pkg/cli/cmd/radinit/init_test.go @@ -946,7 +946,7 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { appManagementClient.EXPECT(). CreateOrUpdateResourceGroup(context.Background(), "local", "default", gomock.Any()). Return(nil). - Times(1) + Times(2) // Create a RadiusCoreClientFactory for testing rootScope := "/planes/radius/local/resourceGroups/default" From 159868075d04f2b1fcb202df1af13c6ec7ed8a7f Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Tue, 10 Feb 2026 13:59:46 -0800 Subject: [PATCH 06/12] wip Signed-off-by: nithyatsu --- .../cli/noncloud/cli_test.go | 56 ------------------- .../testdata/corerp-recipe-pack-test.bicep | 11 ++-- 2 files changed, 4 insertions(+), 63 deletions(-) diff --git a/test/functional-portable/cli/noncloud/cli_test.go b/test/functional-portable/cli/noncloud/cli_test.go index 9c8b015c34..9b32a93550 100644 --- a/test/functional-portable/cli/noncloud/cli_test.go +++ b/test/functional-portable/cli/noncloud/cli_test.go @@ -36,14 +36,9 @@ import ( "github.com/hashicorp/go-retryablehttp" "github.com/radius-project/radius/pkg/cli/bicep" "github.com/radius-project/radius/pkg/cli/clients" - "github.com/radius-project/radius/pkg/cli/cmd/radinit" - "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/workspaces" "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/resources" - "github.com/radius-project/radius/pkg/version" "github.com/radius-project/radius/test/radcli" "github.com/radius-project/radius/test/rp" @@ -732,57 +727,6 @@ func Test_RecipeCommands(t *testing.T) { test.Test(t) } -// This test creates an environment by directly calling the CreateEnvironment function to test dev recipes. -// After dev recipes are confirmed, the environment is deleted. -func Test_DevRecipes(t *testing.T) { - ctx, cancel := testcontext.NewWithCancel(t) - t.Cleanup(cancel) - - options := rp.NewTestOptions(t) - cli := radcli.NewCLI(t, options.ConfigFilePath) - - envName := "test-dev-recipes" - envNamespace := "test-dev-recipes" - - basicRunner := radinit.NewRunner( - &framework.Impl{ - ConnectionFactory: connections.DefaultFactory, - }, - ) - basicRunner.UpdateEnvironmentOptions(true, envName, envNamespace) - basicRunner.UpdateRecipePackOptions(true) - basicRunner.DevRecipeClient = radinit.NewDevRecipeClient() - basicRunner.Workspace = &workspaces.Workspace{ - Name: envName, - Connection: map[string]any{ - "kind": workspaces.KindKubernetes, - }, - Environment: fmt.Sprintf("/planes/radius/local/resourceGroups/kind-radius/providers/Applications.Core/environments/%s", envName), - Scope: "/planes/radius/local/resourceGroups/kind-radius", - } - - // Create the environment - err := basicRunner.CreateEnvironment(ctx) - require.NoError(t, err) - - output, err := cli.RecipeList(ctx, envName) - require.NoError(t, err) - require.Regexp(t, "default", output) - - tag := version.Channel() - if version.IsEdgeChannel() { - tag = "latest" - } - - for _, devRecipe := range radinit.AvailableDevRecipes() { - require.Regexp(t, devRecipe.ResourceType, output) - require.Regexp(t, devRecipe.RepoPath+":"+tag, output) - } - - err = cli.EnvDelete(ctx, envName) - require.NoError(t, err) -} - // GetAvailablePort attempts to find an available port on the localhost and returns it, or returns an error if it fails. func GetAvailablePort() (int, error) { address, err := net.ResolveTCPAddr("tcp", "localhost:0") 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 index e76bb33075..6855ac091b 100644 --- a/test/functional-portable/cli/noncloud/testdata/corerp-recipe-pack-test.bicep +++ b/test/functional-portable/cli/noncloud/testdata/corerp-recipe-pack-test.bicep @@ -3,16 +3,13 @@ resource computeRecipePack 'Radius.Core/recipePacks@2025-08-01-preview' = { name: 'computeRecipePack' properties: { recipes: { - 'Radius.Compute/containers': { + 'Radius.Networking/gateways': { recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' - parameters: { - allowPlatformOptions: true - } + recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/networking/gateways/kubernetes?ref=v0.48' } - 'Radius.Security/secrets': { + 'Radius.Messaging/queues': { recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' + recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/messaging/queues/kubernetes?ref=v0.48' } 'Radius.Storage/volumes': { recipeKind: 'terraform' From 29acb031169d9a441dd99fb643f7175d8cfc7ade Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Tue, 10 Feb 2026 19:18:39 -0800 Subject: [PATCH 07/12] refactor Signed-off-by: nithyatsu --- pkg/cli/cmd/deploy/deploy.go | 55 ++++++++---------------- pkg/cli/cmd/deploy/deploy_test.go | 12 +++++- pkg/cli/cmd/env/update/preview/update.go | 39 ++++++----------- pkg/cli/cmd/utils.go | 28 ++++++++++++ pkg/cli/cmd/utils_test.go | 31 +++++++++++++ pkg/cli/recipepack/recipepack.go | 23 ++++++++++ pkg/cli/recipepack/recipepack_test.go | 52 ++++++++++++++++++++++ 7 files changed, 174 insertions(+), 66 deletions(-) diff --git a/pkg/cli/cmd/deploy/deploy.go b/pkg/cli/cmd/deploy/deploy.go index 517d90555f..4e1e40e47b 100644 --- a/pkg/cli/cmd/deploy/deploy.go +++ b/pkg/cli/cmd/deploy/deploy.go @@ -709,35 +709,25 @@ func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, envResource } // Extract existing recipe pack IDs from the template (literal strings only). - var existingPacks []string - if recipePacks, ok := properties["recipePacks"]; ok { - if packsArray, ok := recipePacks.([]any); ok { - for _, p := range packsArray { - if s, ok := p.(string); ok { - existingPacks = append(existingPacks, s) - } - } + existingPacks := recipepack.ExtractRecipePackIDs(properties) + + // Ensure the default-scope client factory is initialised. + if r.DefaultScopeClientFactory == nil { + defaultFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) + if err != nil { + return err } + r.DefaultScopeClientFactory = defaultFactory } - // Build scope → client map. - // Packs in other scopes (from the template) get a new factory. + // Build scope → client map covering workspace scope, default scope, and + // every additional scope referenced by the template's recipe pack IDs. clientsByScope := map[string]*v20250801preview.RecipePacksClient{ - r.Workspace.Scope: r.RadiusCoreClientFactory.NewRecipePacksClient(), + r.Workspace.Scope: r.RadiusCoreClientFactory.NewRecipePacksClient(), + recipepack.DefaultResourceGroupScope: r.DefaultScopeClientFactory.NewRecipePacksClient(), } - for _, packIDStr := range existingPacks { - packID, parseErr := resources.Parse(packIDStr) - if parseErr != nil { - continue - } - scope := packID.RootScope() - if _, exists := clientsByScope[scope]; !exists { - clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, scope) - if err != nil { - return err - } - clientsByScope[scope] = clientFactory.NewRecipePacksClient() - } + if err := cmd.PopulateRecipePackClients(ctx, r.Workspace, clientsByScope, existingPacks); err != nil { + return err } // Inspect existing packs for resource type coverage and conflicts. @@ -749,14 +739,6 @@ func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, envResource return recipepack.FormatConflictError(conflicts) } - if r.DefaultScopeClientFactory == nil { - defaultFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) - if err != nil { - return err - } - r.DefaultScopeClientFactory = defaultFactory - } - // Ensure the default resource group exists before creating recipe packs in it. mgmtClient, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) if err != nil { @@ -769,8 +751,7 @@ func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, envResource // Create missing singleton recipe packs for uncovered core resource types and // append their IDs so the template deploys the environment with full coverage. // Singletons always live in the default scope. - recipePackDefaultClient := r.DefaultScopeClientFactory.NewRecipePacksClient() - clientsByScope[recipepack.DefaultResourceGroupScope] = recipePackDefaultClient + recipePackDefaultClient := clientsByScope[recipepack.DefaultResourceGroupScope] singletonIDs, err := recipepack.EnsureMissingSingletons(ctx, recipePackDefaultClient, coveredTypes) if err != nil { return err @@ -780,11 +761,11 @@ func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, envResource // is deployed with all recipe packs in a single operation. if len(singletonIDs) > 0 { existingPacks = append(existingPacks, singletonIDs...) - packsAny := make([]any, len(existingPacks)) + allPacks := make([]any, len(existingPacks)) for i, p := range existingPacks { - packsAny[i] = p + allPacks[i] = p } - properties["recipePacks"] = packsAny + properties["recipePacks"] = allPacks } return nil diff --git a/pkg/cli/cmd/deploy/deploy_test.go b/pkg/cli/cmd/deploy/deploy_test.go index 111e5f53b7..3b33b833ac 100644 --- a/pkg/cli/cmd/deploy/deploy_test.go +++ b/pkg/cli/cmd/deploy/deploy_test.go @@ -1107,12 +1107,20 @@ func Test_setupRecipePacks(t *testing.T) { ) require.NoError(t, err) + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + runner := &Runner{ Workspace: &workspaces.Workspace{ Scope: scope, }, - RadiusCoreClientFactory: factory, - Output: &output.MockOutput{}, + RadiusCoreClientFactory: factory, + DefaultScopeClientFactory: defaultScopeFactory, + Output: &output.MockOutput{}, } // Template with two packs that both provide Radius.Compute/containers. diff --git a/pkg/cli/cmd/env/update/preview/update.go b/pkg/cli/cmd/env/update/preview/update.go index 8d1f09f393..f75496a1aa 100644 --- a/pkg/cli/cmd/env/update/preview/update.go +++ b/pkg/cli/cmd/env/update/preview/update.go @@ -341,26 +341,8 @@ func (r *Runner) Run(ctx context.Context) error { } // Build scope → client map for inspecting recipe packs. - // Packs in the default scope use the existing factory; packs in other - // scopes (from user-specified full resource IDs) get a new factory. - clientsByScope := map[string]*corerpv20250801.RecipePacksClient{ - r.Workspace.Scope: r.RadiusCoreClientFactory.NewRecipePacksClient(), - } - for _, packIDStr := range packIDs { - packID, parseErr := resources.Parse(packIDStr) - if parseErr != nil { - continue - } - scope := packID.RootScope() - if _, exists := clientsByScope[scope]; !exists { - clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, scope) - if err != nil { - return err - } - clientsByScope[scope] = clientFactory.NewRecipePacksClient() - } - } - + // Covers workspace scope, default scope, and every additional scope + // referenced by the user's recipe pack IDs. if r.DefaultScopeClientFactory == nil { defaultFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) if err != nil { @@ -369,6 +351,16 @@ func (r *Runner) Run(ctx context.Context) error { r.DefaultScopeClientFactory = defaultFactory } + recipePackDefaultClient := r.DefaultScopeClientFactory.NewRecipePacksClient() + + clientsByScope := map[string]*corerpv20250801.RecipePacksClient{ + r.Workspace.Scope: r.RadiusCoreClientFactory.NewRecipePacksClient(), + recipepack.DefaultResourceGroupScope: recipePackDefaultClient, + } + if err := cmd.PopulateRecipePackClients(ctx, r.Workspace, clientsByScope, packIDs); err != nil { + return err + } + // Ensure the default resource group exists before creating recipe packs in it. mgmtClient, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) if err != nil { @@ -378,13 +370,6 @@ func (r *Runner) Run(ctx context.Context) error { return err } - recipePackDefaultClient := r.DefaultScopeClientFactory.NewRecipePacksClient() - - // Ensure the default scope client is also in the map for inspecting singletons. - if _, exists := clientsByScope[recipepack.DefaultResourceGroupScope]; !exists { - clientsByScope[recipepack.DefaultResourceGroupScope] = recipePackDefaultClient - } - coveredTypes, conflicts, err := recipepack.InspectRecipePacks(ctx, clientsByScope, packIDs) if err != nil { return clierrors.MessageWithCause(err, "Failed to inspect recipe packs for environment %q.", r.EnvironmentName) 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 index 054c38a578..34a72077dc 100644 --- a/pkg/cli/recipepack/recipepack.go +++ b/pkg/cli/recipepack/recipepack.go @@ -323,3 +323,26 @@ func RecipePackIDExists(packs []*string, id string) bool { } return false } + +// ExtractRecipePackIDs extracts recipe pack IDs from an ARM template's +// properties["recipePacks"] value, which is typed as []any after JSON +// deserialization. Only literal string elements are returned. +func ExtractRecipePackIDs(properties map[string]any) []string { + var ids []string + recipePacks, ok := properties["recipePacks"] + if !ok { + return ids + } + + packsArray, ok := recipePacks.([]any) + if !ok { + return ids + } + + for _, p := range packsArray { + if s, ok := p.(string); ok { + ids = append(ids, s) + } + } + return ids +} diff --git a/pkg/cli/recipepack/recipepack_test.go b/pkg/cli/recipepack/recipepack_test.go index 3c6d368c21..367cd39ad6 100644 --- a/pkg/cli/recipepack/recipepack_test.go +++ b/pkg/cli/recipepack/recipepack_test.go @@ -269,3 +269,55 @@ func Test_RecipePackIDExists(t *testing.T) { require.False(t, RecipePackIDExists(packs, "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/secrets")) require.False(t, RecipePackIDExists(nil, "anything")) } + +func Test_ExtractRecipePackIDs(t *testing.T) { + t.Run("nil properties returns nil", func(t *testing.T) { + ids := ExtractRecipePackIDs(nil) + require.Nil(t, ids) + }) + + t.Run("missing recipePacks key returns nil", func(t *testing.T) { + ids := ExtractRecipePackIDs(map[string]any{"other": "value"}) + require.Nil(t, ids) + }) + + t.Run("recipePacks is not an array returns nil", func(t *testing.T) { + ids := ExtractRecipePackIDs(map[string]any{"recipePacks": "not-an-array"}) + require.Nil(t, ids) + }) + + t.Run("empty array returns nil", func(t *testing.T) { + ids := ExtractRecipePackIDs(map[string]any{"recipePacks": []any{}}) + require.Nil(t, ids) + }) + + t.Run("skips non-string elements", func(t *testing.T) { + ids := ExtractRecipePackIDs(map[string]any{ + "recipePacks": []any{42, true, nil}, + }) + require.Nil(t, ids) + }) + + t.Run("extracts string elements", func(t *testing.T) { + ids := ExtractRecipePackIDs(map[string]any{ + "recipePacks": []any{ + "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/pack1", + "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/pack2", + }, + }) + require.Len(t, ids, 2) + require.Equal(t, "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/pack1", ids[0]) + require.Equal(t, "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/pack2", ids[1]) + }) + + t.Run("mixed types extracts only strings", func(t *testing.T) { + ids := ExtractRecipePackIDs(map[string]any{ + "recipePacks": []any{ + "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/pack1", + 42, + "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/pack2", + }, + }) + require.Len(t, ids, 2) + }) +} From 12eb9dd549dc4f3a47bb6a52a722cad2cb076c5b Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Tue, 10 Feb 2026 19:53:21 -0800 Subject: [PATCH 08/12] fix for recipepack reference from template Signed-off-by: nithyatsu --- pkg/cli/cmd/deploy/deploy.go | 195 +++++++++++++++++++++++--- pkg/cli/cmd/deploy/deploy_test.go | 85 +++++++++++ pkg/cli/recipepack/recipepack.go | 14 +- pkg/cli/recipepack/recipepack_test.go | 12 ++ 4 files changed, 284 insertions(+), 22 deletions(-) diff --git a/pkg/cli/cmd/deploy/deploy.go b/pkg/cli/cmd/deploy/deploy.go index 4e1e40e47b..c96974a0fd 100644 --- a/pkg/cli/cmd/deploy/deploy.go +++ b/pkg/cli/cmd/deploy/deploy.go @@ -673,7 +673,7 @@ func (r *Runner) setupRecipePacks(ctx context.Context, template map[string]any) return nil } - // Initialize client factory so we can inspect packs and create singletons. + // Initialize client factory so we can inspect packs. if r.RadiusCoreClientFactory == nil { clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) if err != nil { @@ -682,8 +682,17 @@ func (r *Runner) setupRecipePacks(ctx context.Context, template map[string]any) r.RadiusCoreClientFactory = clientFactory } + // Initialize the default scope client factory so we can access core type singleton packs + if r.DefaultScopeClientFactory == nil { + defaultFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) + if err != nil { + return err + } + r.DefaultScopeClientFactory = defaultFactory + } + for _, envResource := range envResources { - if err := r.setupRecipePacksForEnvironment(ctx, envResource); err != nil { + if err := r.setupRecipePacksForEnvironment(ctx, template, envResource); err != nil { return err } } @@ -692,7 +701,7 @@ func (r *Runner) setupRecipePacks(ctx context.Context, template map[string]any) } // setupRecipePacksForEnvironment sets up recipe packs for a single Radius.Core/environments resource. -func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, envResource map[string]any) error { +func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, template map[string]any, 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. @@ -711,15 +720,6 @@ func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, envResource // Extract existing recipe pack IDs from the template (literal strings only). existingPacks := recipepack.ExtractRecipePackIDs(properties) - // Ensure the default-scope client factory is initialised. - if r.DefaultScopeClientFactory == nil { - defaultFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) - if err != nil { - return err - } - r.DefaultScopeClientFactory = defaultFactory - } - // Build scope → client map covering workspace scope, default scope, and // every additional scope referenced by the template's recipe pack IDs. clientsByScope := map[string]*v20250801preview.RecipePacksClient{ @@ -730,7 +730,7 @@ func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, envResource return err } - // Inspect existing packs for resource type coverage and conflicts. + // Inspect existing packs (literal IDs) for resource type coverage and conflicts. coveredTypes, conflicts, err := recipepack.InspectRecipePacks(ctx, clientsByScope, existingPacks) if err != nil { return err @@ -739,6 +739,17 @@ func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, envResource return recipepack.FormatConflictError(conflicts) } + // Also check for recipe packs defined in the same template and referenced + // via ARM expressions (e.g. "[reference('hotrp2').id]" from bicep's + // `hotrp2.id`). These packs don't exist on the server yet, so we read + // their covered resource types directly from the template. + templateCoveredTypes := extractCoveredTypesFromTemplate(template, envResource) + for resourceType, packName := range templateCoveredTypes { + if _, exists := coveredTypes[resourceType]; !exists { + coveredTypes[resourceType] = packName + } + } + // Ensure the default resource group exists before creating recipe packs in it. mgmtClient, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) if err != nil { @@ -757,15 +768,14 @@ func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, envResource return err } - // Write the complete list back into the template so the environment resource - // is deployed with all recipe packs in a single operation. + // Append singleton IDs to the existing recipePacks array in the template, + // preserving all original entries (both literal IDs and ARM expressions). if len(singletonIDs) > 0 { - existingPacks = append(existingPacks, singletonIDs...) - allPacks := make([]any, len(existingPacks)) - for i, p := range existingPacks { - allPacks[i] = p + originalPacks, _ := properties["recipePacks"].([]any) + for _, id := range singletonIDs { + originalPacks = append(originalPacks, id) } - properties["recipePacks"] = allPacks + properties["recipePacks"] = originalPacks } return nil @@ -808,6 +818,151 @@ func findRadiusCoreEnvironmentResources(template map[string]any) []map[string]an return envResources } +// extractCoveredTypesFromTemplate returns resource types covered by recipe pack +// resources that are defined in the same ARM template and referenced by the +// given environment resource via ARM expression references. +// +// When bicep compiles `mypack.id`, the recipePacks array contains an ARM +// expression like "[reference('mypack').id]". This function extracts the +// symbolic name from such expressions, looks up the corresponding +// Radius.Core/recipePacks resource in the template, and reads its recipes to +// determine which resource types are covered. +func extractCoveredTypesFromTemplate(template map[string]any, envResource map[string]any) map[string]string { + coveredTypes := map[string]string{} + + resourcesMap, ok := template["resources"].(map[string]any) + if !ok { + return coveredTypes + } + + // Build a set of recipe pack symbolic names that the environment references + // via ARM expressions in its recipePacks array. + referencedPacks := findReferencedRecipePackNames(envResource) + if len(referencedPacks) == 0 { + return coveredTypes + } + + // For each referenced symbolic name, look up the resource in the template + // and extract covered resource types from its recipes. + for symbolicName := range referencedPacks { + resourceValue, ok := resourcesMap[symbolicName] + if !ok { + continue + } + + resource, ok := resourceValue.(map[string]any) + if !ok { + continue + } + + resourceType, _ := resource["type"].(string) + if !strings.HasPrefix(strings.ToLower(resourceType), "radius.core/recipepacks") { + continue + } + + // Navigate to the recipes map: properties.properties.recipes + // (double-nested properties in ARM template format). + outerProps, _ := resource["properties"].(map[string]any) + if outerProps == nil { + continue + } + + innerProps, _ := outerProps["properties"].(map[string]any) + if innerProps == nil { + // Some templates may have a flat properties structure. + innerProps = outerProps + } + + recipes, _ := innerProps["recipes"].(map[string]any) + if recipes == nil { + continue + } + + // The pack's name is in properties.name or innerProps.name. + packName, _ := outerProps["name"].(string) + if packName == "" { + packName = symbolicName + } + + for resourceTypeName := range recipes { + if _, exists := coveredTypes[resourceTypeName]; !exists { + coveredTypes[resourceTypeName] = packName + } + } + } + + return coveredTypes +} + +// findReferencedRecipePackNames extracts symbolic resource names from ARM +// expression references in the environment's recipePacks array. +// +// Bicep compiles `mypack.id` to "[reference('mypack').id]". This function +// parses those expressions and returns the set of symbolic names found. +func findReferencedRecipePackNames(envResource map[string]any) map[string]bool { + names := map[string]bool{} + + outerProps, _ := envResource["properties"].(map[string]any) + if outerProps == nil { + return names + } + + innerProps, _ := outerProps["properties"].(map[string]any) + if innerProps == nil { + return names + } + + packsArray, _ := innerProps["recipePacks"].([]any) + + for _, entry := range packsArray { + s, ok := entry.(string) + if !ok { + continue + } + + // ARM expressions are enclosed in [...]. + if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") { + continue + } + + // Extract symbolic name from patterns like: + // [reference('mypack').id] + // [resourceId('Radius.Core/recipePacks', 'name')] + name := extractSymbolicNameFromExpression(s) + if name != "" { + names[name] = true + } + } + + return names +} + +// extractSymbolicNameFromExpression parses an ARM template expression and +// returns the symbolic resource name if present. +// +// Supported patterns: +// - "[reference('symbolicName').id]" → "symbolicName" +// - "[reference('symbolicName', ...).id]" → "symbolicName" +func extractSymbolicNameFromExpression(expr string) string { + // Strip the surrounding [ and ] + inner := expr[1 : len(expr)-1] + + // Look for reference('name') pattern. + const prefix = "reference('" + idx := strings.Index(inner, prefix) + if idx < 0 { + return "" + } + + rest := inner[idx+len(prefix):] + endQuote := strings.Index(rest, "'") + if endQuote < 0 { + return "" + } + + return rest[:endQuote] +} + // 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 3b33b833ac..b16dbc1713 100644 --- a/pkg/cli/cmd/deploy/deploy_test.go +++ b/pkg/cli/cmd/deploy/deploy_test.go @@ -1255,6 +1255,91 @@ func Test_setupRecipePacks(t *testing.T) { require.Len(t, packs, 4, "expected 4 singleton recipe packs on %s", key) } }) + + t.Run("skips singletons for types covered by template-defined recipe packs via ARM expression", 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( + scope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + RadiusCoreClientFactory: factory, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: &output.MockOutput{}, + } + + // Simulates a compiled bicep template where: + // - A recipe pack "mypack" covers Radius.Compute/containers and Radius.Security/secrets + // - The environment references it via "[reference('mypack').id]" (ARM expression) + template := map[string]any{ + "resources": map[string]any{ + "mypack": map[string]any{ + "type": "Radius.Core/recipePacks@2025-08-01-preview", + "properties": map[string]any{ + "name": "mypack", + "properties": map[string]any{ + "recipes": map[string]any{ + "Radius.Compute/containers": map[string]any{ + "recipeKind": "bicep", + "recipeLocation": "ghcr.io/example/containers:latest", + }, + "Radius.Security/secrets": map[string]any{ + "recipeKind": "bicep", + "recipeLocation": "ghcr.io/example/secrets:latest", + }, + }, + }, + }, + }, + "env": map[string]any{ + "type": "Radius.Core/environments@2025-08-01-preview", + "properties": map[string]any{ + "name": "myenv", + "properties": map[string]any{ + "recipePacks": []any{ + "[reference('mypack').id]", + }, + }, + }, + }, + }, + } + + err = runner.setupRecipePacks(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) + + // The expression reference stays, plus 2 singletons for the 2 types + // NOT covered by mypack (persistentVolumes and routes). + require.Len(t, packs, 3, "1 ARM expression + 2 uncovered singletons") + + // Verify the ARM expression reference is preserved at position 0. + require.Equal(t, "[reference('mypack').id]", packs[0]) + }) } func Test_injectAutomaticParameters(t *testing.T) { diff --git a/pkg/cli/recipepack/recipepack.go b/pkg/cli/recipepack/recipepack.go index 34a72077dc..2fcb317492 100644 --- a/pkg/cli/recipepack/recipepack.go +++ b/pkg/cli/recipepack/recipepack.go @@ -340,9 +340,19 @@ func ExtractRecipePackIDs(properties map[string]any) []string { } for _, p := range packsArray { - if s, ok := p.(string); ok { - ids = append(ids, s) + s, ok := p.(string) + if !ok { + continue + } + + // Skip ARM template expressions like "[reference('mypack').id]". + // These are runtime references to other resources in the template + // and cannot be parsed as resource IDs. + if strings.HasPrefix(s, "[") { + continue } + + ids = append(ids, s) } return ids } diff --git a/pkg/cli/recipepack/recipepack_test.go b/pkg/cli/recipepack/recipepack_test.go index 367cd39ad6..2205d2688c 100644 --- a/pkg/cli/recipepack/recipepack_test.go +++ b/pkg/cli/recipepack/recipepack_test.go @@ -320,4 +320,16 @@ func Test_ExtractRecipePackIDs(t *testing.T) { }) require.Len(t, ids, 2) }) + + t.Run("skips ARM template expressions", func(t *testing.T) { + ids := ExtractRecipePackIDs(map[string]any{ + "recipePacks": []any{ + "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/pack1", + "[reference('mypack').id]", + "[resourceId('Radius.Core/recipePacks', 'pack2')]", + }, + }) + require.Len(t, ids, 1) + require.Equal(t, "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/pack1", ids[0]) + }) } From fb1e47ac1ba1cf23af3479b91425ca78dec0fa38 Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Thu, 19 Feb 2026 15:03:24 -0800 Subject: [PATCH 09/12] Update rad init Signed-off-by: nithyatsu --- bicep-types | 1 + pkg/cli/cmd/deploy/deploy.go | 281 +++++---------------- pkg/cli/cmd/deploy/deploy_test.go | 215 ++++------------ pkg/cli/recipepack/recipepack.go | 43 +++- pkg/cli/recipepack/recipepack_test.go | 39 ++- pkg/cli/test_client_factory/radius_core.go | 23 ++ 6 files changed, 193 insertions(+), 409 deletions(-) create mode 160000 bicep-types diff --git a/bicep-types b/bicep-types new file mode 160000 index 0000000000..556bf5edad --- /dev/null +++ b/bicep-types @@ -0,0 +1 @@ +Subproject commit 556bf5edad58e47ca57c6ddb1af155c3bcfdc5c7 diff --git a/pkg/cli/cmd/deploy/deploy.go b/pkg/cli/cmd/deploy/deploy.go index c96974a0fd..b569116845 100644 --- a/pkg/cli/cmd/deploy/deploy.go +++ b/pkg/cli/cmd/deploy/deploy.go @@ -138,7 +138,7 @@ type Runner struct { Deploy deploy.Interface Output output.Interface // DefaultScopeClientFactory is the client factory scoped to the default resource group. - // Singleton recipe packs are always created/queried in the default scope. + // Recipe packs are always created/queried in the default scope. DefaultScopeClientFactory *v20250801preview.ClientFactory ApplicationName string @@ -350,7 +350,7 @@ func (r *Runner) Run(ctx context.Context) error { } // Before deploying, set up recipe packs for any Radius.Core environments in the - // template. This creates missing singleton recipe pack resources and injects their + // template. This creates missing recipe pack resources and injects their // IDs into the template so that the environment is deployed once with the complete // set of recipe packs. err = r.setupRecipePacks(ctx, template) @@ -663,36 +663,19 @@ func (r *Runner) setupCloudProviders(properties any) { } } -// setupRecipePacks finds all Radius.Core/environments resources in the template, inspects -// any recipe packs they reference, validates there are no resource-type conflicts, creates -// missing singleton recipe pack resources, and injects their IDs into the template so each -// environment is deployed with the complete set of recipe packs. +// setupRecipePacks finds all Radius.Core/environments resources in the template and +// ensures they have recipe packs configured. If an environment resource has no recipe +// packs set, it fetches or creates default recipe packs from the default scope and +// injects their IDs into the template. If the environment already has any recipe pack +// IDs set (literal or ARM expression references), no changes are made. func (r *Runner) setupRecipePacks(ctx context.Context, template map[string]any) error { envResources := findRadiusCoreEnvironmentResources(template) if len(envResources) == 0 { return nil } - // Initialize client factory so we can inspect packs. - if r.RadiusCoreClientFactory == nil { - clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) - if err != nil { - return err - } - r.RadiusCoreClientFactory = clientFactory - } - - // Initialize the default scope client factory so we can access core type singleton packs - if r.DefaultScopeClientFactory == nil { - defaultFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) - if err != nil { - return err - } - r.DefaultScopeClientFactory = defaultFactory - } - for _, envResource := range envResources { - if err := r.setupRecipePacksForEnvironment(ctx, template, envResource); err != nil { + if err := r.setupRecipePacksForEnvironment(ctx, envResource); err != nil { return err } } @@ -701,7 +684,10 @@ func (r *Runner) setupRecipePacks(ctx context.Context, template map[string]any) } // setupRecipePacksForEnvironment sets up recipe packs for a single Radius.Core/environments resource. -func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, template map[string]any, envResource map[string]any) error { +// 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) setupRecipePacksForEnvironment(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. @@ -717,68 +703,78 @@ func (r *Runner) setupRecipePacksForEnvironment(ctx context.Context, template ma outerProps["properties"] = properties } - // Extract existing recipe pack IDs from the template (literal strings only). - existingPacks := recipepack.ExtractRecipePackIDs(properties) - - // Build scope → client map covering workspace scope, default scope, and - // every additional scope referenced by the template's recipe pack IDs. - clientsByScope := map[string]*v20250801preview.RecipePacksClient{ - r.Workspace.Scope: r.RadiusCoreClientFactory.NewRecipePacksClient(), - recipepack.DefaultResourceGroupScope: r.DefaultScopeClientFactory.NewRecipePacksClient(), - } - if err := cmd.PopulateRecipePackClients(ctx, r.Workspace, clientsByScope, existingPacks); err != nil { - return err + // If the environment already has any recipe packs configured (literal IDs or + // ARM expression references), leave it as-is — the user is managing packs explicitly. + if hasAnyRecipePacks(properties) { + return nil } - // Inspect existing packs (literal IDs) for resource type coverage and conflicts. - coveredTypes, conflicts, err := recipepack.InspectRecipePacks(ctx, clientsByScope, existingPacks) + // 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 len(conflicts) > 0 { - return recipepack.FormatConflictError(conflicts) + if err := recipepack.EnsureDefaultResourceGroup(ctx, mgmtClient.CreateOrUpdateResourceGroup); err != nil { + return err } - // Also check for recipe packs defined in the same template and referenced - // via ARM expressions (e.g. "[reference('hotrp2').id]" from bicep's - // `hotrp2.id`). These packs don't exist on the server yet, so we read - // their covered resource types directly from the template. - templateCoveredTypes := extractCoveredTypesFromTemplate(template, envResource) - for resourceType, packName := range templateCoveredTypes { - if _, exists := coveredTypes[resourceType]; !exists { - coveredTypes[resourceType] = packName + // 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 } - // Ensure the default resource group exists before creating recipe packs in it. - mgmtClient, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + 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 } - if err := recipepack.EnsureDefaultResourceGroup(ctx, mgmtClient.CreateOrUpdateResourceGroup); err != nil { - return err - } - // Create missing singleton recipe packs for uncovered core resource types and - // append their IDs so the template deploys the environment with full coverage. - // Singletons always live in the default scope. - recipePackDefaultClient := clientsByScope[recipepack.DefaultResourceGroupScope] - singletonIDs, err := recipepack.EnsureMissingSingletons(ctx, recipePackDefaultClient, coveredTypes) - 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 +} - // Append singleton IDs to the existing recipePacks array in the template, - // preserving all original entries (both literal IDs and ARM expressions). - if len(singletonIDs) > 0 { - originalPacks, _ := properties["recipePacks"].([]any) - for _, id := range singletonIDs { - originalPacks = append(originalPacks, id) +// 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) } - properties["recipePacks"] = originalPacks } - - return nil + return recipepack.DefaultRecipePackID(), nil } // findRadiusCoreEnvironmentResources walks the template's resources and returns @@ -818,151 +814,6 @@ func findRadiusCoreEnvironmentResources(template map[string]any) []map[string]an return envResources } -// extractCoveredTypesFromTemplate returns resource types covered by recipe pack -// resources that are defined in the same ARM template and referenced by the -// given environment resource via ARM expression references. -// -// When bicep compiles `mypack.id`, the recipePacks array contains an ARM -// expression like "[reference('mypack').id]". This function extracts the -// symbolic name from such expressions, looks up the corresponding -// Radius.Core/recipePacks resource in the template, and reads its recipes to -// determine which resource types are covered. -func extractCoveredTypesFromTemplate(template map[string]any, envResource map[string]any) map[string]string { - coveredTypes := map[string]string{} - - resourcesMap, ok := template["resources"].(map[string]any) - if !ok { - return coveredTypes - } - - // Build a set of recipe pack symbolic names that the environment references - // via ARM expressions in its recipePacks array. - referencedPacks := findReferencedRecipePackNames(envResource) - if len(referencedPacks) == 0 { - return coveredTypes - } - - // For each referenced symbolic name, look up the resource in the template - // and extract covered resource types from its recipes. - for symbolicName := range referencedPacks { - resourceValue, ok := resourcesMap[symbolicName] - if !ok { - continue - } - - resource, ok := resourceValue.(map[string]any) - if !ok { - continue - } - - resourceType, _ := resource["type"].(string) - if !strings.HasPrefix(strings.ToLower(resourceType), "radius.core/recipepacks") { - continue - } - - // Navigate to the recipes map: properties.properties.recipes - // (double-nested properties in ARM template format). - outerProps, _ := resource["properties"].(map[string]any) - if outerProps == nil { - continue - } - - innerProps, _ := outerProps["properties"].(map[string]any) - if innerProps == nil { - // Some templates may have a flat properties structure. - innerProps = outerProps - } - - recipes, _ := innerProps["recipes"].(map[string]any) - if recipes == nil { - continue - } - - // The pack's name is in properties.name or innerProps.name. - packName, _ := outerProps["name"].(string) - if packName == "" { - packName = symbolicName - } - - for resourceTypeName := range recipes { - if _, exists := coveredTypes[resourceTypeName]; !exists { - coveredTypes[resourceTypeName] = packName - } - } - } - - return coveredTypes -} - -// findReferencedRecipePackNames extracts symbolic resource names from ARM -// expression references in the environment's recipePacks array. -// -// Bicep compiles `mypack.id` to "[reference('mypack').id]". This function -// parses those expressions and returns the set of symbolic names found. -func findReferencedRecipePackNames(envResource map[string]any) map[string]bool { - names := map[string]bool{} - - outerProps, _ := envResource["properties"].(map[string]any) - if outerProps == nil { - return names - } - - innerProps, _ := outerProps["properties"].(map[string]any) - if innerProps == nil { - return names - } - - packsArray, _ := innerProps["recipePacks"].([]any) - - for _, entry := range packsArray { - s, ok := entry.(string) - if !ok { - continue - } - - // ARM expressions are enclosed in [...]. - if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") { - continue - } - - // Extract symbolic name from patterns like: - // [reference('mypack').id] - // [resourceId('Radius.Core/recipePacks', 'name')] - name := extractSymbolicNameFromExpression(s) - if name != "" { - names[name] = true - } - } - - return names -} - -// extractSymbolicNameFromExpression parses an ARM template expression and -// returns the symbolic resource name if present. -// -// Supported patterns: -// - "[reference('symbolicName').id]" → "symbolicName" -// - "[reference('symbolicName', ...).id]" → "symbolicName" -func extractSymbolicNameFromExpression(expr string) string { - // Strip the surrounding [ and ] - inner := expr[1 : len(expr)-1] - - // Look for reference('name') pattern. - const prefix = "reference('" - idx := strings.Index(inner, prefix) - if idx < 0 { - return "" - } - - rest := inner[idx+len(prefix):] - endQuote := strings.Index(rest, "'") - if endQuote < 0 { - return "" - } - - return rest[:endQuote] -} - // 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 b16dbc1713..0fc0390594 100644 --- a/pkg/cli/cmd/deploy/deploy_test.go +++ b/pkg/cli/cmd/deploy/deploy_test.go @@ -977,7 +977,7 @@ func Test_Run(t *testing.T) { func Test_setupRecipePacks(t *testing.T) { scope := "/planes/radius/local/resourceGroups/test-group" - t.Run("injects missing singleton recipe packs into template", func(t *testing.T) { + t.Run("injects default recipe pack into template", func(t *testing.T) { ctrl := gomock.NewController(t) mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) mockAppClient.EXPECT(). @@ -985,14 +985,7 @@ func Test_setupRecipePacks(t *testing.T) { Return(nil). Times(1) - factory, err := test_client_factory.NewRadiusCoreTestClientFactory( - scope, - nil, - test_client_factory.WithRecipePackServerUniqueTypes, - ) - require.NoError(t, err) - - // Singleton recipe packs are created in the default scope. + // Default scope factory — GET succeeds (pack already exists). defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( recipepack.DefaultResourceGroupScope, nil, @@ -1004,14 +997,12 @@ func Test_setupRecipePacks(t *testing.T) { Workspace: &workspaces.Workspace{ Scope: scope, }, - RadiusCoreClientFactory: factory, DefaultScopeClientFactory: defaultScopeFactory, ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, Output: &output.MockOutput{}, } // Template with a Radius.Core/environments resource and no recipe packs. - // ARM templates use double-nested properties: properties.properties. template := map[string]any{ "resources": map[string]any{ "env": map[string]any{ @@ -1027,52 +1018,28 @@ func Test_setupRecipePacks(t *testing.T) { err = runner.setupRecipePacks(context.Background(), template) require.NoError(t, err) - // Verify that recipe packs were injected into the inner resource properties. + // 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, 4, "should have all 4 singleton recipe packs") + require.Len(t, packs, 1, "should have the default recipe pack") + require.Equal(t, recipepack.DefaultRecipePackID(), packs[0]) }) - t.Run("preserves existing packs and adds missing singletons", 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( - scope, - nil, - test_client_factory.WithRecipePackServerUniqueTypes, - ) - require.NoError(t, err) - - // Singleton recipe packs are created in the default scope. - defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( - recipepack.DefaultResourceGroupScope, - nil, - test_client_factory.WithRecipePackServerUniqueTypes, - ) - require.NoError(t, err) - + t.Run("skips when environment has existing packs", func(t *testing.T) { runner := &Runner{ Workspace: &workspaces.Workspace{ Scope: scope, }, - RadiusCoreClientFactory: factory, - DefaultScopeClientFactory: defaultScopeFactory, - ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, - Output: &output.MockOutput{}, + Output: &output.MockOutput{}, } existingPackID := scope + "/providers/Radius.Core/recipePacks/custom-pack" // Template with a Radius.Core/environments resource that already has one pack. - // ARM templates use double-nested properties: properties.properties. + // Since packs are already set, no changes should be made. template := map[string]any{ "resources": map[string]any{ "env": map[string]any{ @@ -1087,67 +1054,18 @@ func Test_setupRecipePacks(t *testing.T) { }, } - err = runner.setupRecipePacks(context.Background(), template) + err := runner.setupRecipePacks(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) - // 1 existing + 4 singletons - require.Len(t, packs, 5) + // Only the original pack — no singletons added + require.Len(t, packs, 1) require.Equal(t, existingPackID, packs[0]) }) - t.Run("detects recipe pack conflicts", func(t *testing.T) { - factory, err := test_client_factory.NewRadiusCoreTestClientFactory( - scope, - nil, - test_client_factory.WithRecipePackServerConflictingTypes, - ) - require.NoError(t, err) - - defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( - recipepack.DefaultResourceGroupScope, - nil, - test_client_factory.WithRecipePackServerUniqueTypes, - ) - require.NoError(t, err) - - runner := &Runner{ - Workspace: &workspaces.Workspace{ - Scope: scope, - }, - RadiusCoreClientFactory: factory, - DefaultScopeClientFactory: defaultScopeFactory, - Output: &output.MockOutput{}, - } - - // Template with two packs that both provide Radius.Compute/containers. - // ARM templates use double-nested properties: properties.properties. - 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{ - scope + "/providers/Radius.Core/recipePacks/pack1", - scope + "/providers/Radius.Core/recipePacks/pack2", - }, - }, - }, - }, - }, - } - - err = runner.setupRecipePacks(context.Background(), template) - require.Error(t, err) - require.Contains(t, err.Error(), "Recipe pack conflict detected") - require.Contains(t, err.Error(), "Radius.Compute/containers") - }) - t.Run("no-op when template has no environment resource", func(t *testing.T) { runner := &Runner{ Workspace: &workspaces.Workspace{ @@ -1189,20 +1107,14 @@ func Test_setupRecipePacks(t *testing.T) { require.NoError(t, err) }) - t.Run("handles multiple Radius.Core environments", func(t *testing.T) { + 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(2) - - factory, err := test_client_factory.NewRadiusCoreTestClientFactory( - scope, - nil, - test_client_factory.WithRecipePackServerUniqueTypes, - ) - require.NoError(t, err) + Times(1) defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( recipepack.DefaultResourceGroupScope, @@ -1215,27 +1127,29 @@ func Test_setupRecipePacks(t *testing.T) { Workspace: &workspaces.Workspace{ Scope: scope, }, - RadiusCoreClientFactory: factory, DefaultScopeClientFactory: defaultScopeFactory, ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, Output: &output.MockOutput{}, } - // Template with two Radius.Core/environments resources, neither has packs. - // ARM templates use double-nested properties: properties.properties. + 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{ - "envDev": map[string]any{ + "envWithPacks": map[string]any{ "type": "Radius.Core/environments@2025-08-01-preview", "properties": map[string]any{ - "name": "envDev", - "properties": map[string]any{}, + "name": "envWithPacks", + "properties": map[string]any{ + "recipePacks": []any{existingPackID}, + }, }, }, - "envProd": map[string]any{ + "envWithout": map[string]any{ "type": "Radius.Core/environments@2025-08-01-preview", "properties": map[string]any{ - "name": "envProd", + "name": "envWithout", "properties": map[string]any{}, }, }, @@ -1245,18 +1159,25 @@ func Test_setupRecipePacks(t *testing.T) { err = runner.setupRecipePacks(context.Background(), template) require.NoError(t, err) - // Both environments should have received singleton recipe packs in the inner properties. - for _, key := range []string{"envDev", "envProd"} { - envRes := template["resources"].(map[string]any)[key].(map[string]any) - outerProps := envRes["properties"].(map[string]any) - innerProps := outerProps["properties"].(map[string]any) - packs, ok := innerProps["recipePacks"].([]any) - require.True(t, ok, "expected recipePacks on %s", key) - require.Len(t, packs, 4, "expected 4 singleton recipe packs on %s", key) - } + // 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("skips singletons for types covered by template-defined recipe packs via ARM expression", func(t *testing.T) { + 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(). @@ -1264,17 +1185,12 @@ func Test_setupRecipePacks(t *testing.T) { Return(nil). Times(1) - factory, err := test_client_factory.NewRadiusCoreTestClientFactory( - scope, - nil, - test_client_factory.WithRecipePackServerUniqueTypes, - ) - require.NoError(t, err) - + // 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.WithRecipePackServerUniqueTypes, + test_client_factory.WithRecipePackServer404OnGet, ) require.NoError(t, err) @@ -1282,44 +1198,19 @@ func Test_setupRecipePacks(t *testing.T) { Workspace: &workspaces.Workspace{ Scope: scope, }, - RadiusCoreClientFactory: factory, DefaultScopeClientFactory: defaultScopeFactory, ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, Output: &output.MockOutput{}, } - // Simulates a compiled bicep template where: - // - A recipe pack "mypack" covers Radius.Compute/containers and Radius.Security/secrets - // - The environment references it via "[reference('mypack').id]" (ARM expression) + // Template with a Radius.Core/environments resource and no recipe packs. template := map[string]any{ "resources": map[string]any{ - "mypack": map[string]any{ - "type": "Radius.Core/recipePacks@2025-08-01-preview", - "properties": map[string]any{ - "name": "mypack", - "properties": map[string]any{ - "recipes": map[string]any{ - "Radius.Compute/containers": map[string]any{ - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/example/containers:latest", - }, - "Radius.Security/secrets": map[string]any{ - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/example/secrets:latest", - }, - }, - }, - }, - }, "env": map[string]any{ "type": "Radius.Core/environments@2025-08-01-preview", "properties": map[string]any{ - "name": "myenv", - "properties": map[string]any{ - "recipePacks": []any{ - "[reference('mypack').id]", - }, - }, + "name": "myenv", + "properties": map[string]any{}, }, }, }, @@ -1331,14 +1222,10 @@ func Test_setupRecipePacks(t *testing.T) { 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) - - // The expression reference stays, plus 2 singletons for the 2 types - // NOT covered by mypack (persistentVolumes and routes). - require.Len(t, packs, 3, "1 ARM expression + 2 uncovered singletons") - - // Verify the ARM expression reference is preserved at position 0. - require.Equal(t, "[reference('mypack').id]", packs[0]) + 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]) }) } diff --git a/pkg/cli/recipepack/recipepack.go b/pkg/cli/recipepack/recipepack.go index 2fcb317492..d1e391b6f6 100644 --- a/pkg/cli/recipepack/recipepack.go +++ b/pkg/cli/recipepack/recipepack.go @@ -29,8 +29,9 @@ import ( ) const ( - // DefaultRecipePackName is the name of the default Kubernetes recipe pack. - DefaultRecipePackName = "local-dev" + // 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. @@ -45,6 +46,32 @@ const ( // 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. @@ -64,10 +91,10 @@ type SingletonRecipePackDefinition struct { RecipeLocation string } -// GetSingletonRecipePackDefinitions returns the list of singleton recipe pack definitions. +// GetDefaultRecipePackDefinition returns the list of singleton recipe pack definitions. // Each definition represents a single recipe pack containing one recipe for one resource type. // This list is currently hardcoded, but will be made dynamic in the future. -func GetSingletonRecipePackDefinitions() []SingletonRecipePackDefinition { +func GetDefaultRecipePackDefinition() []SingletonRecipePackDefinition { return []SingletonRecipePackDefinition{ { Name: "containers", @@ -113,7 +140,7 @@ func NewSingletonRecipePackResource(resourceType, recipeLocation string) corerpv // The client must be scoped to the default resource group (DefaultResourceGroupScope). // It returns the list of full resource IDs of the created recipe packs, always in the default scope. func CreateSingletonRecipePacks(ctx context.Context, client *corerpv20250801.RecipePacksClient) ([]string, error) { - definitions := GetSingletonRecipePackDefinitions() + definitions := GetDefaultRecipePackDefinition() recipePackIDs := make([]string, 0, len(definitions)) for _, def := range definitions { @@ -133,7 +160,7 @@ func CreateSingletonRecipePacks(ctx context.Context, client *corerpv20250801.Rec // GetCoreResourceTypes returns the set of core resource types that require recipe packs. func GetCoreResourceTypes() map[string]bool { - defs := GetSingletonRecipePackDefinitions() + defs := GetDefaultRecipePackDefinition() types := make(map[string]bool, len(defs)) for _, def := range defs { types[def.ResourceType] = true @@ -143,7 +170,7 @@ func GetCoreResourceTypes() map[string]bool { // IsSingletonRecipePackName checks if the given name matches a known singleton recipe pack name. func IsSingletonRecipePackName(name string) bool { - for _, def := range GetSingletonRecipePackDefinitions() { + for _, def := range GetDefaultRecipePackDefinition() { if def.Name == name { return true } @@ -197,7 +224,7 @@ func IsSingletonRecipePackName(name string) bool { // that are not already covered by the existing recipe packs. func GetMissingSingletonDefinitions(coveredTypes map[string]string) []SingletonRecipePackDefinition { var missing []SingletonRecipePackDefinition - for _, def := range GetSingletonRecipePackDefinitions() { + for _, def := range GetDefaultRecipePackDefinition() { if _, covered := coveredTypes[def.ResourceType]; !covered { missing = append(missing, def) } diff --git a/pkg/cli/recipepack/recipepack_test.go b/pkg/cli/recipepack/recipepack_test.go index 2205d2688c..eaedd95735 100644 --- a/pkg/cli/recipepack/recipepack_test.go +++ b/pkg/cli/recipepack/recipepack_test.go @@ -26,12 +26,8 @@ import ( "github.com/stretchr/testify/require" ) -func Test_DefaultRecipePackName(t *testing.T) { - require.Equal(t, "local-dev", DefaultRecipePackName) -} - -func Test_GetSingletonRecipePackDefinitions(t *testing.T) { - definitions := GetSingletonRecipePackDefinitions() +func Test_GetDefaultRecipePackDefinition(t *testing.T) { + definitions := GetDefaultRecipePackDefinition() // Verify we have the expected number of definitions require.Len(t, definitions, 4) @@ -52,11 +48,8 @@ func Test_GetSingletonRecipePackDefinitions(t *testing.T) { } } -func Test_NewSingletonRecipePackResource(t *testing.T) { - resourceType := "Radius.Compute/containers" - recipeLocation := "ghcr.io/radius-project/kube-recipes/containers@latest" - - resource := NewSingletonRecipePackResource(resourceType, recipeLocation) +func Test_NewDefaultRecipePackResource(t *testing.T) { + resource := NewDefaultRecipePackResource() // Verify location require.NotNil(t, resource.Location) @@ -66,16 +59,18 @@ func Test_NewSingletonRecipePackResource(t *testing.T) { require.NotNil(t, resource.Properties) require.NotNil(t, resource.Properties.Recipes) - // Verify the resource contains exactly one recipe - require.Len(t, resource.Properties.Recipes, 1) + // Verify the resource contains recipes for all core types. + definitions := GetDefaultRecipePackDefinition() + require.Len(t, resource.Properties.Recipes, len(definitions)) - // Verify the recipe - recipe, exists := resource.Properties.Recipes[resourceType] - require.True(t, exists, "Expected recipe for resource type %s to exist", resourceType) - require.NotNil(t, recipe.RecipeKind) - require.Equal(t, corerpv20250801.RecipeKindBicep, *recipe.RecipeKind) - require.NotNil(t, recipe.RecipeLocation) - require.Equal(t, recipeLocation, *recipe.RecipeLocation) + for _, def := range definitions { + recipe, exists := resource.Properties.Recipes[def.ResourceType] + require.True(t, exists, "Expected recipe for resource type %s to exist", def.ResourceType) + require.NotNil(t, recipe.RecipeKind) + require.Equal(t, corerpv20250801.RecipeKindBicep, *recipe.RecipeKind) + require.NotNil(t, recipe.RecipeLocation) + require.Equal(t, def.RecipeLocation, *recipe.RecipeLocation) + } } func Test_CreateSingletonRecipePacksWithClient(t *testing.T) { @@ -90,7 +85,7 @@ func Test_CreateSingletonRecipePacksWithClient(t *testing.T) { require.NoError(t, err) // Verify the correct number of recipe packs were created - definitions := GetSingletonRecipePackDefinitions() + definitions := GetDefaultRecipePackDefinition() require.Len(t, recipePackIDs, len(definitions)) // Verify the IDs are in the default scope @@ -203,7 +198,7 @@ func Test_EnsureMissingSingletons(t *testing.T) { require.NoError(t, err) require.Len(t, ids, 4) - for _, def := range GetSingletonRecipePackDefinitions() { + for _, def := range GetDefaultRecipePackDefinition() { expected := DefaultResourceGroupScope + "/providers/Radius.Core/recipePacks/" + def.Name require.Contains(t, ids, expected) } diff --git a/pkg/cli/test_client_factory/radius_core.go b/pkg/cli/test_client_factory/radius_core.go index 3a5a4f63bb..e83b13980d 100644 --- a/pkg/cli/test_client_factory/radius_core.go +++ b/pkg/cli/test_client_factory/radius_core.go @@ -358,6 +358,29 @@ func WithRecipePackServerUniqueTypes() corerpfake.RecipePacksServer { } } +// WithRecipePackServer404OnGet returns a RecipePacksServer that returns 404 on Get +// and success on CreateOrUpdate, simulating a scenario where recipe packs don't exist +// yet and need to be created. +func WithRecipePackServer404OnGet() corerpfake.RecipePacksServer { + return corerpfake.RecipePacksServer{ + Get: func(ctx context.Context, recipePackName string, options *v20250801preview.RecipePacksClientGetOptions) (resp azfake.Responder[v20250801preview.RecipePacksClientGetResponse], errResp azfake.ErrorResponder) { + errResp.SetError(fmt.Errorf("recipe pack not found")) + errResp.SetResponseError(404, "Not Found") + return + }, + CreateOrUpdate: func(ctx context.Context, recipePackName string, resource v20250801preview.RecipePackResource, options *v20250801preview.RecipePacksClientCreateOrUpdateOptions) (resp azfake.Responder[v20250801preview.RecipePacksClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.RecipePacksClientCreateOrUpdateResponse{ + RecipePackResource: v20250801preview.RecipePackResource{ + Name: to.Ptr(recipePackName), + Properties: resource.Properties, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + } +} + // WithRecipePackServerConflictingTypes returns a RecipePacksServer where every pack // returns the same resource type, simulating a conflict scenario. func WithRecipePackServerConflictingTypes() corerpfake.RecipePacksServer { From 3d0d0cb7d8da3e23221fc3be0cdcef611293cbd3 Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Thu, 19 Feb 2026 15:23:15 -0800 Subject: [PATCH 10/12] remove changes to update Signed-off-by: nithyatsu --- pkg/cli/cmd/env/update/preview/update.go | 91 +++---------------- pkg/cli/cmd/env/update/preview/update_test.go | 40 ++------ 2 files changed, 20 insertions(+), 111 deletions(-) diff --git a/pkg/cli/cmd/env/update/preview/update.go b/pkg/cli/cmd/env/update/preview/update.go index f75496a1aa..0f1d6ac7af 100644 --- a/pkg/cli/cmd/env/update/preview/update.go +++ b/pkg/cli/cmd/env/update/preview/update.go @@ -26,10 +26,8 @@ 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/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" @@ -106,11 +104,6 @@ type Runner struct { Workspace *workspaces.Workspace Format string RadiusCoreClientFactory *corerpv20250801.ClientFactory - ConnectionFactory connections.Factory - - // 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 EnvironmentName string clearEnvAzure bool @@ -124,9 +117,8 @@ type Runner struct { // NewRunner creates a new instance of the `rad env update` preview runner. func NewRunner(factory framework.Factory) *Runner { return &Runner{ - ConfigHolder: factory.GetConfigHolder(), - Output: factory.GetOutput(), - ConnectionFactory: factory.GetConnectionFactory(), + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), } } @@ -315,80 +307,12 @@ func (r *Runner) Run(ctx context.Context) error { return clierrors.Message("Recipe pack %q does not exist. Please provide a valid recipe pack to add to the environment.", recipePack) } - if !recipepack.RecipePackIDExists(env.Properties.RecipePacks, ID.String()) { + if !recipePackExists(env.Properties.RecipePacks, ID.String()) { env.Properties.RecipePacks = append(env.Properties.RecipePacks, to.Ptr(ID.String())) } } } - // At this point env.Properties.RecipePacks contains the complete set of recipe packs - // the user wants on this environment. For preview, we now: - // 1. Detect conflicts where the same resource type is provided by multiple packs - // 2. Append singleton packs for any missing core resource types, assuming they already exist. - if env.Properties == nil { - env.Properties = &corerpv20250801.EnvironmentProperties{} - } - if env.Properties.RecipePacks == nil { - env.Properties.RecipePacks = []*string{} - } - - // Convert []*string to []string for the shared utility. - packIDs := make([]string, 0, len(env.Properties.RecipePacks)) - for _, p := range env.Properties.RecipePacks { - if p != nil { - packIDs = append(packIDs, *p) - } - } - - // Build scope → client map for inspecting recipe packs. - // Covers workspace scope, default scope, and every additional scope - // referenced by the user's recipe pack IDs. - 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() - - clientsByScope := map[string]*corerpv20250801.RecipePacksClient{ - r.Workspace.Scope: r.RadiusCoreClientFactory.NewRecipePacksClient(), - recipepack.DefaultResourceGroupScope: recipePackDefaultClient, - } - if err := cmd.PopulateRecipePackClients(ctx, r.Workspace, clientsByScope, packIDs); err != nil { - return err - } - - // 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 - } - - coveredTypes, conflicts, err := recipepack.InspectRecipePacks(ctx, clientsByScope, packIDs) - if err != nil { - return clierrors.MessageWithCause(err, "Failed to inspect recipe packs for environment %q.", r.EnvironmentName) - } - - if len(conflicts) > 0 { - return recipepack.FormatConflictError(conflicts) - } - - singletonIDs, err := recipepack.EnsureMissingSingletons(ctx, recipePackDefaultClient, coveredTypes) - if err != nil { - return err - } - for _, id := range singletonIDs { - if !recipepack.RecipePackIDExists(env.Properties.RecipePacks, id) { - env.Properties.RecipePacks = append(env.Properties.RecipePacks, to.Ptr(id)) - } - } - r.Output.LogInfo("Updating Environment...") _, err = envClient.CreateOrUpdate(ctx, r.EnvironmentName, env, &corerpv20250801.EnvironmentsClientCreateOrUpdateOptions{}) if err != nil { @@ -426,3 +350,12 @@ 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 e602bba6b0..0718ed3f8c 100644 --- a/pkg/cli/cmd/env/update/preview/update_test.go +++ b/pkg/cli/cmd/env/update/preview/update_test.go @@ -20,13 +20,8 @@ import ( "context" "testing" - "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/corerp/api/v20250801preview" @@ -119,10 +114,8 @@ func Test_Run(t *testing.T) { output.FormattedOutput{ Format: "table", Obj: environmentForDisplay{ - Name: "test-env", - // 1 existing pack from the environment, 2 user-specified - // packs, plus 4 singleton packs for core resource types. - RecipePacks: 7, + Name: "test-env", + RecipePacks: 3, Providers: 3, }, Options: environmentFormat(), @@ -137,38 +130,21 @@ func Test_Run(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, 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, tc.serverFactory, - test_client_factory.WithRecipePackServerUniqueTypes, - ) - require.NoError(t, err) - - // Singleton recipe packs are created in the default scope. - defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( - recipepack.DefaultResourceGroupScope, nil, - test_client_factory.WithRecipePackServerUniqueTypes, ) require.NoError(t, err) outputSink := &output.MockOutput{} runner := &Runner{ - ConfigHolder: &framework.ConfigHolder{}, - Output: outputSink, - Workspace: workspace, - EnvironmentName: tc.envName, - RadiusCoreClientFactory: factory, - DefaultScopeClientFactory: defaultScopeFactory, - ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, - recipePacks: []string{"rp1", "rp2"}, + ConfigHolder: &framework.ConfigHolder{}, + Output: outputSink, + Workspace: workspace, + EnvironmentName: tc.envName, + RadiusCoreClientFactory: factory, + recipePacks: []string{"rp1", "rp2"}, providers: &v20250801preview.Providers{ Azure: &v20250801preview.ProvidersAzure{ SubscriptionID: to.Ptr("00000000-0000-0000-0000-000000000000"), From c0e374e3919c01e0aed4a6b9441f09c4620b2bcb Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Thu, 19 Feb 2026 15:31:53 -0800 Subject: [PATCH 11/12] rad init updates Signed-off-by: nithyatsu --- pkg/cli/cmd/radinit/environment.go | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/pkg/cli/cmd/radinit/environment.go b/pkg/cli/cmd/radinit/environment.go index 05ac4e97a9..19e1da15a6 100644 --- a/pkg/cli/cmd/radinit/environment.go +++ b/pkg/cli/cmd/radinit/environment.go @@ -96,9 +96,9 @@ func (r *Runner) CreateEnvironment(ctx context.Context) error { return clierrors.MessageWithCause(err, "Failed to create default resource group for recipe packs.") } - // Create singleton recipe packs (one per resource type) and link them to the environment. - // Singletons always live in the default resource group scope. - // DefaultScopeClientFactory is required in the case rad init runs from a workspace with non-default settings. + // 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 @@ -110,17 +110,15 @@ func (r *Runner) CreateEnvironment(ctx context.Context) error { r.DefaultScopeClientFactory = defaultClientFactory } } - recipePackIDs, err := recipepack.CreateSingletonRecipePacks(ctx, r.DefaultScopeClientFactory.NewRecipePacksClient()) + + defaultPack := recipepack.NewDefaultRecipePackResource() + _, err = r.DefaultScopeClientFactory.NewRecipePacksClient().CreateOrUpdate(ctx, recipepack.DefaultRecipePackResourceName, defaultPack, nil) if err != nil { - return clierrors.MessageWithCause(err, "Failed to create recipe packs.") + return clierrors.MessageWithCause(err, "Failed to create default recipe pack.") } - // Link all recipe packs to the environment - recipePackPtrs := make([]*string, len(recipePackIDs)) - for i, id := range recipePackIDs { - recipePackPtrs[i] = to.Ptr(id) - } - envProperties.RecipePacks = recipePackPtrs + // 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{ From 0124690a047d24efe816dab535534947666c17db Mon Sep 17 00:00:00 2001 From: nithyatsu Date: Thu, 19 Feb 2026 15:38:12 -0800 Subject: [PATCH 12/12] update env create Signed-off-by: nithyatsu --- pkg/cli/cmd/env/create/preview/create.go | 24 +++++++------------ pkg/cli/cmd/env/create/preview/create_test.go | 8 +++---- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/pkg/cli/cmd/env/create/preview/create.go b/pkg/cli/cmd/env/create/preview/create.go index e12841fde1..c923adf867 100644 --- a/pkg/cli/cmd/env/create/preview/create.go +++ b/pkg/cli/cmd/env/create/preview/create.go @@ -136,9 +136,8 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { // Run runs the `rad env create` command. // // Run implements create-or-update semantics. If the environment does not exist, it creates -// a new one with all singleton recipe packs for core resource types. If the environment -// already exists, it checks the existing recipe packs and fills in any missing singletons -// for core resource types, detecting conflicts along the way. +// a new one with the default recipe pack for core resource types. If the environment +// already exists, it creates the default recipe pack if missing and links it. func (r *Runner) Run(ctx context.Context) error { if r.RadiusCoreClientFactory == nil { clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) @@ -152,7 +151,7 @@ func (r *Runner) Run(ctx context.Context) error { } -// runCreate creates a new environment with all singleton recipe packs for core resource types. +// runCreate creates a new environment with the default recipe pack for core resource types. func (r *Runner) runCreate(ctx context.Context) error { r.Output.LogInfo("Creating Radius Core Environment %q...", r.EnvironmentName) @@ -165,8 +164,8 @@ func (r *Runner) runCreate(ctx context.Context) error { return err } - // Create all singleton recipe packs for core resource types in the default resource group. - // Singletons always live in the default scope regardless of the current workspace scope. + // 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 { @@ -174,22 +173,17 @@ func (r *Runner) runCreate(ctx context.Context) error { } r.DefaultScopeClientFactory = defaultClientFactory } - recipePackClient := r.DefaultScopeClientFactory.NewRecipePacksClient() - recipePackIDs, err := recipepack.CreateSingletonRecipePacks(ctx, recipePackClient) + + defaultPack := recipepack.NewDefaultRecipePackResource() + _, err = r.DefaultScopeClientFactory.NewRecipePacksClient().CreateOrUpdate(ctx, recipepack.DefaultRecipePackResourceName, defaultPack, nil) if err != nil { return err } - // Link all recipe packs to the environment - recipePackPtrs := make([]*string, len(recipePackIDs)) - for i, id := range recipePackIDs { - recipePackPtrs[i] = to.Ptr(id) - } - resource := &corerpv20250801.EnvironmentResource{ Location: to.Ptr(v1.LocationGlobal), Properties: &corerpv20250801.EnvironmentProperties{ - RecipePacks: recipePackPtrs, + RecipePacks: []*string{to.Ptr(recipepack.DefaultRecipePackID())}, }, } diff --git a/pkg/cli/cmd/env/create/preview/create_test.go b/pkg/cli/cmd/env/create/preview/create_test.go index a5f661edeb..bf62174406 100644 --- a/pkg/cli/cmd/env/create/preview/create_test.go +++ b/pkg/cli/cmd/env/create/preview/create_test.go @@ -155,7 +155,7 @@ func Test_Run(t *testing.T) { }, } - t.Run("New environment: all singletons created", func(t *testing.T) { + t.Run("creates environment with default recipe pack", func(t *testing.T) { ctrl := gomock.NewController(t) mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) mockAppClient.EXPECT(). @@ -166,15 +166,15 @@ func Test_Run(t *testing.T) { factory, err := test_client_factory.NewRadiusCoreTestClientFactory( workspace.Scope, test_client_factory.WithEnvironmentServer404OnGet, - test_client_factory.WithRecipePackServerCoreTypes, + nil, ) require.NoError(t, err) - // Singleton recipe packs are created in the default scope. + // Default recipe pack is created in the default scope. defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( recipepack.DefaultResourceGroupScope, nil, - test_client_factory.WithRecipePackServerCoreTypes, + nil, ) require.NoError(t, err)