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