From e1975c5844001374785b87bcc2d85e09f8b9cb91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:40:46 +0000 Subject: [PATCH 01/18] Initial plan From c56df7863d6751a6b72a9933c6703907329b5d32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:50:18 +0000 Subject: [PATCH 02/18] Standardize recipe parameter naming: rename Parameters to RecipeParameters in RecipeDefinition Agent-Logs-Url: https://github.com/radius-project/radius/sessions/31e3b677-4d45-408c-9577-3b2566d6fdbd Co-authored-by: Reshrahim <61033581+Reshrahim@users.noreply.github.com> --- .../recipes/2025-08-recipe-packs.md | 24 +++++++++---------- pkg/cli/cmd/recipepack/show/display.go | 4 ++-- pkg/cli/cmd/recipepack/show/show_test.go | 2 +- .../v20250801preview/recipepack_conversion.go | 16 ++++++------- .../testdata/recipepackresource.json | 4 ++-- .../testdata/recipepackresourcedatamodel.json | 4 ++-- .../v20250801preview/zz_generated_models.go | 2 +- .../zz_generated_models_serde.go | 6 ++--- .../converter/recipepack_converter_test.go | 14 +++++------ pkg/corerp/datamodel/recipepack.go | 4 ++-- .../createorupdaterecipepack_test.go | 6 ++--- pkg/recipes/configloader/environment.go | 4 ++-- pkg/recipes/types.go | 4 ++-- typespec/Radius.Core/recipePacks.tsp | 2 +- 14 files changed, 48 insertions(+), 48 deletions(-) diff --git a/eng/design-notes/recipes/2025-08-recipe-packs.md b/eng/design-notes/recipes/2025-08-recipe-packs.md index d59e6df360..c017527562 100644 --- a/eng/design-notes/recipes/2025-08-recipe-packs.md +++ b/eng/design-notes/recipes/2025-08-recipe-packs.md @@ -104,7 +104,7 @@ recipes: - resourceType: "Radius.Compute/containers@2025-05-01-preview" recipeKind: "bicep" recipeLocation: "oci://ghcr.io/my-org/recipes/core/aci-container:1.2.0" - parameters: + recipeParameters: cpu: "1.0" memoryInGB: "2.0" environmentVariables: @@ -114,12 +114,12 @@ recipes: - resourceType: "Radius.Compute/gateways@2025-05-01-preview" recipeKind: "bicep" recipeLocation: "oci://ghcr.io/my-org/recipes/core/aci-gateway:1.1.0" - parameters: + recipeParameters: sku: "Standard_v2" - resourceType: "Radius.Security/secrets@2025-05-01-preview" recipeKind: "bicep" recipeLocation: "oci://ghcr.io/my-org/recipes/azure/keyvault-secretstore:1.0.0" - parameters: + recipeParameters: skuName: "premium" ``` @@ -209,7 +209,7 @@ recipeLocation: string; recipeDigest?: string; @doc("Parameters to pass to the recipe") -parameters?: Record; +recipeParameters?: Record; } @doc("The type of recipe") @@ -281,7 +281,7 @@ resource computeRecipePack 'Radius.Core/recipePacks@2026-01-01-preview' = { recipeKind: 'terraform' recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' recipeDigest: 'sha256:4g5h6i7j8k9l0m1n2o3p4q5r6s7t8u9v0w1x2y3z4a5b6c7d8e9f0g1h2i3j4k5' - parameters: { + recipeParameters: { allowPlatformOptions: true anIntegerParam: 1 } @@ -327,7 +327,7 @@ curl -X PUT \ "Applications.Datastores/sqlDatabases": { "recipeKind": "terraform", "recipeLocation": "https://github.com/example/recipes/sql-database", - "parameters": { + "recipeParameters": { "size": "small", "backup": false } @@ -335,7 +335,7 @@ curl -X PUT \ "Applications.Datastores/redisCaches": { "recipeKind": "bicep", "recipeLocation": "https://github.com/example/recipes/redis-cache.bicep", - "parameters": { + "recipeParameters": { "tier": "basic" } } @@ -356,14 +356,14 @@ CREATE response: "provisioningState": "Succeeded", "recipes": { "Applications.Datastores/redisCaches": { - "parameters": { + "recipeParameters": { "tier": "basic" }, "recipeKind": "bicep", "recipeLocation": "https://github.com/example/recipes/redis-cache.bicep" }, "Applications.Datastores/sqlDatabases": { - "parameters": { + "recipeParameters": { "backup": false, "size": "small" }, @@ -405,14 +405,14 @@ READ response: "provisioningState": "Succeeded", "recipes": { "Applications.Datastores/redisCaches": { - "parameters": { + "recipeParameters": { "tier": "basic" }, "recipeKind": "bicep", "recipeLocation": "https://github.com/example/recipes/redis-cache.bicep" }, "Applications.Datastores/sqlDatabases": { - "parameters": { + "recipeParameters": { "backup": false, "size": "small" }, @@ -526,7 +526,7 @@ resource computeRecipePack 'Radius.Core/recipePacks@2025-05-01-preview' = { 'Radius.Compute/containers': { recipeKind: 'terraform' recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' - parameters: { + recipeParameters: { allowPlatformOptions: true } } diff --git a/pkg/cli/cmd/recipepack/show/display.go b/pkg/cli/cmd/recipepack/show/display.go index 5f6d949d64..9217e5abbc 100644 --- a/pkg/cli/cmd/recipepack/show/display.go +++ b/pkg/cli/cmd/recipepack/show/display.go @@ -60,8 +60,8 @@ func (r *Runner) display(recipePack v20250801preview.RecipePackResource) error { r.Output.LogInfo(" Kind: %s", kind) r.Output.LogInfo(" Location: %s", location) - if len(definition.Parameters) > 0 { - formatted, err := formatRecipeParameters(definition.Parameters) + if len(definition.RecipeParameters) > 0 { + formatted, err := formatRecipeParameters(definition.RecipeParameters) if err != nil { return fmt.Errorf("format recipe parameters: %w", err) } diff --git a/pkg/cli/cmd/recipepack/show/show_test.go b/pkg/cli/cmd/recipepack/show/show_test.go index 224b4ebfc4..f278b39743 100644 --- a/pkg/cli/cmd/recipepack/show/show_test.go +++ b/pkg/cli/cmd/recipepack/show/show_test.go @@ -90,7 +90,7 @@ func Test_Run(t *testing.T) { "Radius.Core/example": { RecipeKind: to.Ptr(corerpv20250801preview.RecipeKindTerraform), RecipeLocation: new("https://github.com/radius-project/example"), - Parameters: map[string]any{ + RecipeParameters: map[string]any{ "foo": "bar", }, }, diff --git a/pkg/corerp/api/v20250801preview/recipepack_conversion.go b/pkg/corerp/api/v20250801preview/recipepack_conversion.go index a740e48111..4171c45c66 100644 --- a/pkg/corerp/api/v20250801preview/recipepack_conversion.go +++ b/pkg/corerp/api/v20250801preview/recipepack_conversion.go @@ -95,10 +95,10 @@ func toRecipesDataModel(recipes map[string]*RecipeDefinition) map[string]*datamo for key, recipe := range recipes { if recipe != nil { result[key] = &datamodel.RecipeDefinition{ - RecipeKind: toRecipeKindDataModel(recipe.RecipeKind), - RecipeLocation: to.String(recipe.RecipeLocation), - Parameters: recipe.Parameters, - PlainHTTP: to.Bool(recipe.PlainHTTP), + RecipeKind: toRecipeKindDataModel(recipe.RecipeKind), + RecipeLocation: to.String(recipe.RecipeLocation), + RecipeParameters: recipe.RecipeParameters, + PlainHTTP: to.Bool(recipe.PlainHTTP), } } } @@ -114,10 +114,10 @@ func fromRecipesDataModel(recipes map[string]*datamodel.RecipeDefinition) map[st for key, recipe := range recipes { if recipe != nil { result[key] = &RecipeDefinition{ - RecipeKind: fromRecipeKindDataModel(recipe.RecipeKind), - RecipeLocation: new(recipe.RecipeLocation), - Parameters: recipe.Parameters, - PlainHTTP: new(recipe.PlainHTTP), + RecipeKind: fromRecipeKindDataModel(recipe.RecipeKind), + RecipeLocation: new(recipe.RecipeLocation), + RecipeParameters: recipe.RecipeParameters, + PlainHTTP: new(recipe.PlainHTTP), } } } diff --git a/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json b/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json index ec91d1f7b9..0e74168d1b 100644 --- a/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json +++ b/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json @@ -17,7 +17,7 @@ "Applications.Core/containers": { "recipeKind": "Bicep", "recipeLocation": "br:ghcr.io/radius-project/recipes/kubernetes-container:latest", - "parameters": { + "recipeParameters": { "port": 8080, "replicas": 3 }, @@ -26,7 +26,7 @@ "Applications.Dapr/stateStores": { "recipeKind": "Terraform", "recipeLocation": "oci://ghcr.io/radius-project/recipes/terraform/redis:latest", - "parameters": { + "recipeParameters": { "size": "small" }, "plainHTTP": true diff --git a/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json b/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json index 30526b1ad2..bbe341cd44 100644 --- a/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json +++ b/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json @@ -29,7 +29,7 @@ "Applications.Core/containers": { "recipeKind": "Bicep", "recipeLocation": "br:ghcr.io/radius-project/recipes/kubernetes-container:latest", - "parameters": { + "recipeParameters": { "port": 8080, "replicas": 3 }, @@ -38,7 +38,7 @@ "Applications.Dapr/stateStores": { "recipeKind": "Terraform", "recipeLocation": "oci://ghcr.io/radius-project/recipes/terraform/redis:latest", - "parameters": { + "recipeParameters": { "size": "small" }, "plainHTTP": true diff --git a/pkg/corerp/api/v20250801preview/zz_generated_models.go b/pkg/corerp/api/v20250801preview/zz_generated_models.go index 6cc3a1a5c5..be016129c8 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_models.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_models.go @@ -427,7 +427,7 @@ type RecipeDefinition struct { RecipeLocation *string // Parameters to pass to the recipe - Parameters map[string]any + RecipeParameters map[string]any // Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for // example in a locally hosted registry for Bicep recipes. Defaults to false (use diff --git a/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go b/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go index 56b3d37efb..a2d237ef6b 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go @@ -1054,7 +1054,7 @@ func (p *ProvidersKubernetes) UnmarshalJSON(data []byte) error { // MarshalJSON implements the json.Marshaller interface for type RecipeDefinition. func (r RecipeDefinition) MarshalJSON() ([]byte, error) { objectMap := make(map[string]any) - populate(objectMap, "parameters", r.Parameters) + populate(objectMap, "recipeParameters", r.RecipeParameters) populate(objectMap, "plainHttp", r.PlainHTTP) populate(objectMap, "recipeKind", r.RecipeKind) populate(objectMap, "recipeLocation", r.RecipeLocation) @@ -1070,8 +1070,8 @@ func (r *RecipeDefinition) UnmarshalJSON(data []byte) error { for key, val := range rawMsg { var err error switch key { - case "parameters": - err = unpopulate(val, "Parameters", &r.Parameters) + case "recipeParameters": + err = unpopulate(val, "RecipeParameters", &r.RecipeParameters) delete(rawMsg, key) case "plainHttp": err = unpopulate(val, "PlainHTTP", &r.PlainHTTP) diff --git a/pkg/corerp/datamodel/converter/recipepack_converter_test.go b/pkg/corerp/datamodel/converter/recipepack_converter_test.go index 3bf535cde4..253df645ca 100644 --- a/pkg/corerp/datamodel/converter/recipepack_converter_test.go +++ b/pkg/corerp/datamodel/converter/recipepack_converter_test.go @@ -60,7 +60,7 @@ func TestRecipePackDataModelToVersioned(t *testing.T) { "Applications.Core/containers": { RecipeKind: "bicep", RecipeLocation: "br:myregistry.azurecr.io/recipes/container:1.0", - Parameters: map[string]any{ + RecipeParameters: map[string]any{ "param1": "value1", }, PlainHTTP: false, @@ -164,7 +164,7 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { "Applications.Core/containers": { "recipeKind": "bicep", "recipeLocation": "br:myregistry.azurecr.io/recipes/container:1.0", - "parameters": { + "recipeParameters": { "param1": "value1" }, "plainHTTP": false @@ -205,7 +205,7 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { "Applications.Core/containers": { RecipeKind: "bicep", RecipeLocation: "br:myregistry.azurecr.io/recipes/container:1.0", - Parameters: map[string]any{ + RecipeParameters: map[string]any{ "param1": "value1", }, PlainHTTP: false, @@ -394,8 +394,8 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { require.Equal(t, expectedRecipe.PlainHTTP, actualRecipe.PlainHTTP, "PlainHTTP for recipe %s should match. Expected: %v, Actual: %v", key, expectedRecipe.PlainHTTP, actualRecipe.PlainHTTP) // Note: JSON unmarshaling can change the type of parameters, especially for numbers - if expectedRecipe.Parameters != nil { - require.Equal(t, expectedRecipe.Parameters, actualRecipe.Parameters) + if expectedRecipe.RecipeParameters != nil { + require.Equal(t, expectedRecipe.RecipeParameters, actualRecipe.RecipeParameters) } } } @@ -428,7 +428,7 @@ func TestRecipePackRoundTripConversion(t *testing.T) { "Applications.Core/containers": { RecipeKind: "bicep", RecipeLocation: "br:test.azurecr.io/recipes/container:latest", - Parameters: map[string]any{ + RecipeParameters: map[string]any{ "cpu": "0.5", "memory": "1Gi", }, @@ -471,7 +471,7 @@ func TestRecipePackRoundTripConversion(t *testing.T) { require.Equal(t, originalRecipe.RecipeKind, resultRecipe.RecipeKind) require.Equal(t, originalRecipe.RecipeLocation, resultRecipe.RecipeLocation) require.Equal(t, originalRecipe.PlainHTTP, resultRecipe.PlainHTTP) - require.Equal(t, originalRecipe.Parameters, resultRecipe.Parameters) + require.Equal(t, originalRecipe.RecipeParameters, resultRecipe.RecipeParameters) } } diff --git a/pkg/corerp/datamodel/recipepack.go b/pkg/corerp/datamodel/recipepack.go index 7ab6c4c89a..6f92716b47 100644 --- a/pkg/corerp/datamodel/recipepack.go +++ b/pkg/corerp/datamodel/recipepack.go @@ -55,8 +55,8 @@ type RecipeDefinition struct { // RecipeLocation is the URL or path to the recipe source. RecipeLocation string `json:"recipeLocation"` - // Parameters to pass to the recipe. - Parameters map[string]any `json:"parameters,omitempty"` + // RecipeParameters to pass to the recipe. + RecipeParameters map[string]any `json:"recipeParameters,omitempty"` // PlainHTTP connects to the location using HTTP (not-HTTPS). PlainHTTP bool `json:"plainHTTP,omitempty"` diff --git a/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go b/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go index f34b1073e4..cf746c2400 100644 --- a/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go +++ b/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go @@ -168,7 +168,7 @@ func getTestModels() (*v20250801preview.RecipePackResource, *datamodel.RecipePac "Applications.Datastores/redisCaches": { RecipeKind: to.Ptr(v20250801preview.RecipeKindBicep), RecipeLocation: new("https://github.com/example/recipes/redis-cache.bicep"), - Parameters: map[string]any{ + RecipeParameters: map[string]any{ "tier": "basic", }, }, @@ -198,7 +198,7 @@ func getTestModels() (*v20250801preview.RecipePackResource, *datamodel.RecipePac "Applications.Datastores/redisCaches": { RecipeKind: "bicep", RecipeLocation: "https://github.com/example/recipes/redis-cache.bicep", - Parameters: map[string]any{ + RecipeParameters: map[string]any{ "tier": "basic", }, }, @@ -227,7 +227,7 @@ func getTestModels() (*v20250801preview.RecipePackResource, *datamodel.RecipePac "Applications.Datastores/redisCaches": { RecipeKind: to.Ptr(v20250801preview.RecipeKindBicep), RecipeLocation: new("https://github.com/example/recipes/redis-cache.bicep"), - Parameters: map[string]any{ + RecipeParameters: map[string]any{ "tier": "basic", }, PlainHTTP: new(false), diff --git a/pkg/recipes/configloader/environment.go b/pkg/recipes/configloader/environment.go index 61e3a2d50a..f0537f7597 100644 --- a/pkg/recipes/configloader/environment.go +++ b/pkg/recipes/configloader/environment.go @@ -282,7 +282,7 @@ func getRecipeDefinitionFromEnvironmentV20250801(ctx context.Context, environmen } // Reconcile parameters from recipe pack and environment-level recipe parameters - parameters := reconcileRecipeParameters(recipeDefinition.Parameters, envDatamodel.Properties.RecipeParameters, resource.Type()) + parameters := reconcileRecipeParameters(recipeDefinition.RecipeParameters, envDatamodel.Properties.RecipeParameters, resource.Type()) // TODO: For now, we can set "Name" to default as recipe packs don't have named recipes. // We will remove this field from EnvironmentDefinition once we deprecate Applications.Core. @@ -328,7 +328,7 @@ func fetchRecipeDefinition(ctx context.Context, recipePackIDs []string, armOptio return &recipes.RecipeDefinition{ RecipeKind: string(*definition.RecipeKind), RecipeLocation: string(*definition.RecipeLocation), - Parameters: definition.Parameters, + RecipeParameters: definition.RecipeParameters, PlainHTTP: plainHTTP, }, nil } diff --git a/pkg/recipes/types.go b/pkg/recipes/types.go index 61d512487b..c43ab22477 100644 --- a/pkg/recipes/types.go +++ b/pkg/recipes/types.go @@ -156,8 +156,8 @@ type RecipeDefinition struct { RecipeKind string // RecipeLocation represents URL or path to the recipe source RecipeLocation string - // Parameters represents parameters to pass to the recipe - Parameters map[string]any + // RecipeParameters represents parameters to pass to the recipe + RecipeParameters map[string]any // PlainHTTP connects to the location using HTTP (not-HTTPS) PlainHTTP bool } diff --git a/typespec/Radius.Core/recipePacks.tsp b/typespec/Radius.Core/recipePacks.tsp index 2b9b494024..fd23c56360 100644 --- a/typespec/Radius.Core/recipePacks.tsp +++ b/typespec/Radius.Core/recipePacks.tsp @@ -67,7 +67,7 @@ model RecipeDefinition { recipeLocation: string; @doc("Parameters to pass to the recipe") - parameters?: Record; + recipeParameters?: Record; } @doc("The type of recipe") From 0ef75a0187e0c8ebec1e64e69f56df1bd511b505 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 17:24:27 +0000 Subject: [PATCH 03/18] Remove 'recipe' prefix from kind, location, and parameters fields in RecipeDefinition Agent-Logs-Url: https://github.com/radius-project/radius/sessions/0ce85ee7-0a61-465f-a997-c744c2fa54b9 Co-authored-by: Reshrahim <61033581+Reshrahim@users.noreply.github.com> --- .../recipes/2025-08-recipe-packs.md | 88 +++++++++---------- pkg/cli/cmd/env/show/preview/show.go | 4 +- pkg/cli/cmd/env/show/preview/show_test.go | 16 ++-- pkg/cli/cmd/recipepack/show/display.go | 12 +-- pkg/cli/cmd/recipepack/show/show_test.go | 6 +- pkg/cli/test_client_factory/radius_core.go | 8 +- .../v20250801preview/recipepack_conversion.go | 16 ++-- .../testdata/recipepackresource.json | 12 +-- .../testdata/recipepackresourcedatamodel.json | 12 +-- .../v20250801preview/zz_generated_models.go | 6 +- .../zz_generated_models_serde.go | 18 ++-- .../converter/recipepack_converter_test.go | 30 +++---- pkg/corerp/datamodel/recipepack.go | 8 +- .../createorupdaterecipepack_test.go | 30 +++---- pkg/recipes/configloader/environment.go | 8 +- pkg/recipes/types.go | 4 +- typespec/Radius.Core/recipePacks.tsp | 6 +- 17 files changed, 142 insertions(+), 142 deletions(-) diff --git a/eng/design-notes/recipes/2025-08-recipe-packs.md b/eng/design-notes/recipes/2025-08-recipe-packs.md index c017527562..769ad821c2 100644 --- a/eng/design-notes/recipes/2025-08-recipe-packs.md +++ b/eng/design-notes/recipes/2025-08-recipe-packs.md @@ -102,9 +102,9 @@ version: 1.0.0 description: "Recipe Pack for deploying to ACI in production." recipes: - resourceType: "Radius.Compute/containers@2025-05-01-preview" - recipeKind: "bicep" - recipeLocation: "oci://ghcr.io/my-org/recipes/core/aci-container:1.2.0" - recipeParameters: + kind: "bicep" + location: "oci://ghcr.io/my-org/recipes/core/aci-container:1.2.0" + parameters: cpu: "1.0" memoryInGB: "2.0" environmentVariables: @@ -112,14 +112,14 @@ recipes: # Optional: allow platform-specific options like containerGroupProfile for ACI allowPlatformOptions: true - resourceType: "Radius.Compute/gateways@2025-05-01-preview" - recipeKind: "bicep" - recipeLocation: "oci://ghcr.io/my-org/recipes/core/aci-gateway:1.1.0" - recipeParameters: + kind: "bicep" + location: "oci://ghcr.io/my-org/recipes/core/aci-gateway:1.1.0" + parameters: sku: "Standard_v2" - resourceType: "Radius.Security/secrets@2025-05-01-preview" - recipeKind: "bicep" - recipeLocation: "oci://ghcr.io/my-org/recipes/azure/keyvault-secretstore:1.0.0" - recipeParameters: + kind: "bicep" + location: "oci://ghcr.io/my-org/recipes/azure/keyvault-secretstore:1.0.0" + parameters: skuName: "premium" ``` @@ -197,19 +197,19 @@ recipes: Record; @doc("Recipe definition for a specific resource type") model RecipeDefinition { @doc("The type of recipe (e.g., terraform, bicep)") -recipeKind: RecipeKind; +kind: RecipeKind; @doc("Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for example in a locally hosted registry for Bicep recipes. Defaults to false (use HTTPS/TLS)") plainHttp?: boolean; @doc("URL or path to the recipe source") -recipeLocation: string; +location: string; @doc("recipe digest in the format algorithm:digest_value") recipeDigest?: string; @doc("Parameters to pass to the recipe") -recipeParameters?: Record; +parameters?: Record; } @doc("The type of recipe") @@ -278,21 +278,21 @@ resource computeRecipePack 'Radius.Core/recipePacks@2026-01-01-preview' = { properties: { recipes: { 'Radius.Compute/containers': { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' recipeDigest: 'sha256:4g5h6i7j8k9l0m1n2o3p4q5r6s7t8u9v0w1x2y3z4a5b6c7d8e9f0g1h2i3j4k5' - recipeParameters: { + parameters: { allowPlatformOptions: true anIntegerParam: 1 } } 'Radius.Security/secrets': { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' } 'Radius.Storage/volumes': { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' } } } @@ -325,17 +325,17 @@ curl -X PUT \ "description": "Test recipe pack with sample recipes", "recipes": { "Applications.Datastores/sqlDatabases": { - "recipeKind": "terraform", - "recipeLocation": "https://github.com/example/recipes/sql-database", - "recipeParameters": { + "kind": "terraform", + "location": "https://github.com/example/recipes/sql-database", + "parameters": { "size": "small", "backup": false } }, "Applications.Datastores/redisCaches": { - "recipeKind": "bicep", - "recipeLocation": "https://github.com/example/recipes/redis-cache.bicep", - "recipeParameters": { + "kind": "bicep", + "location": "https://github.com/example/recipes/redis-cache.bicep", + "parameters": { "tier": "basic" } } @@ -356,19 +356,19 @@ CREATE response: "provisioningState": "Succeeded", "recipes": { "Applications.Datastores/redisCaches": { - "recipeParameters": { + "parameters": { "tier": "basic" }, - "recipeKind": "bicep", - "recipeLocation": "https://github.com/example/recipes/redis-cache.bicep" + "kind": "bicep", + "location": "https://github.com/example/recipes/redis-cache.bicep" }, "Applications.Datastores/sqlDatabases": { - "recipeParameters": { + "parameters": { "backup": false, "size": "small" }, - "recipeKind": "terraform", - "recipeLocation": "https://github.com/example/recipes/sql-database" + "kind": "terraform", + "location": "https://github.com/example/recipes/sql-database" } } }, @@ -405,19 +405,19 @@ READ response: "provisioningState": "Succeeded", "recipes": { "Applications.Datastores/redisCaches": { - "recipeParameters": { + "parameters": { "tier": "basic" }, - "recipeKind": "bicep", - "recipeLocation": "https://github.com/example/recipes/redis-cache.bicep" + "kind": "bicep", + "location": "https://github.com/example/recipes/redis-cache.bicep" }, "Applications.Datastores/sqlDatabases": { - "recipeParameters": { + "parameters": { "backup": false, "size": "small" }, - "recipeKind": "terraform", - "recipeLocation": "https://github.com/example/recipes/sql-database" + "kind": "terraform", + "location": "https://github.com/example/recipes/sql-database" } } }, @@ -524,19 +524,19 @@ resource computeRecipePack 'Radius.Core/recipePacks@2025-05-01-preview' = { properties: { recipes: { 'Radius.Compute/containers': { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' - recipeParameters: { + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' + parameters: { allowPlatformOptions: true } } 'Radius.Security/secrets': { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' } 'Radius.Storage/volumes': { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' } } } diff --git a/pkg/cli/cmd/env/show/preview/show.go b/pkg/cli/cmd/env/show/preview/show.go index 23785dbd3f..e7072d8b2e 100644 --- a/pkg/cli/cmd/env/show/preview/show.go +++ b/pkg/cli/cmd/env/show/preview/show.go @@ -191,8 +191,8 @@ func (r *Runner) Run(ctx context.Context) error { envRecipes = append(envRecipes, EnvRecipes{ RecipePack: ID.Name(), ResourceType: resourceType, - RecipeKind: string(*recipe.RecipeKind), - RecipeLocation: *recipe.RecipeLocation, + RecipeKind: string(*recipe.Kind), + RecipeLocation: *recipe.Location, }) } } diff --git a/pkg/cli/cmd/env/show/preview/show_test.go b/pkg/cli/cmd/env/show/preview/show_test.go index 66df4adb05..0eabf5977f 100644 --- a/pkg/cli/cmd/env/show/preview/show_test.go +++ b/pkg/cli/cmd/env/show/preview/show_test.go @@ -243,23 +243,23 @@ func Test_Run_RecipeSortOrder(t *testing.T) { if recipePackName == "pack-a" { recipes = map[string]*corerpv20250801.RecipeDefinition{ "Applications.Datastores/sqlDatabases": { - RecipeLocation: new("ghcr.io/radius-project/recipes/sql"), - RecipeKind: to.Ptr(corerpv20250801.RecipeKindTerraform), + Location: new("ghcr.io/radius-project/recipes/sql"), + Kind: to.Ptr(corerpv20250801.RecipeKindTerraform), }, "Applications.Datastores/redisCaches": { - RecipeLocation: new("ghcr.io/radius-project/recipes/redis"), - RecipeKind: to.Ptr(corerpv20250801.RecipeKindTerraform), + Location: new("ghcr.io/radius-project/recipes/redis"), + Kind: to.Ptr(corerpv20250801.RecipeKindTerraform), }, } } else { recipes = map[string]*corerpv20250801.RecipeDefinition{ "Applications.Messaging/rabbitMQQueues": { - RecipeLocation: new("ghcr.io/radius-project/recipes/rabbitmq"), - RecipeKind: to.Ptr(corerpv20250801.RecipeKindBicep), + Location: new("ghcr.io/radius-project/recipes/rabbitmq"), + Kind: to.Ptr(corerpv20250801.RecipeKindBicep), }, "Applications.Dapr/stateStores": { - RecipeLocation: new("ghcr.io/radius-project/recipes/dapr-state"), - RecipeKind: to.Ptr(corerpv20250801.RecipeKindBicep), + Location: new("ghcr.io/radius-project/recipes/dapr-state"), + Kind: to.Ptr(corerpv20250801.RecipeKindBicep), }, } } diff --git a/pkg/cli/cmd/recipepack/show/display.go b/pkg/cli/cmd/recipepack/show/display.go index 9217e5abbc..f8b7c132d7 100644 --- a/pkg/cli/cmd/recipepack/show/display.go +++ b/pkg/cli/cmd/recipepack/show/display.go @@ -47,21 +47,21 @@ func (r *Runner) display(recipePack v20250801preview.RecipePackResource) error { } kind := "unknown" - if definition.RecipeKind != nil { - kind = string(*definition.RecipeKind) + if definition.Kind != nil { + kind = string(*definition.Kind) } location := "" - if definition.RecipeLocation != nil { - location = *definition.RecipeLocation + if definition.Location != nil { + location = *definition.Location } r.Output.LogInfo("%s", resourceType) r.Output.LogInfo(" Kind: %s", kind) r.Output.LogInfo(" Location: %s", location) - if len(definition.RecipeParameters) > 0 { - formatted, err := formatRecipeParameters(definition.RecipeParameters) + if len(definition.Parameters) > 0 { + formatted, err := formatRecipeParameters(definition.Parameters) if err != nil { return fmt.Errorf("format recipe parameters: %w", err) } diff --git a/pkg/cli/cmd/recipepack/show/show_test.go b/pkg/cli/cmd/recipepack/show/show_test.go index f278b39743..b653c61d6d 100644 --- a/pkg/cli/cmd/recipepack/show/show_test.go +++ b/pkg/cli/cmd/recipepack/show/show_test.go @@ -88,9 +88,9 @@ func Test_Run(t *testing.T) { Properties: &corerpv20250801preview.RecipePackProperties{ Recipes: map[string]*corerpv20250801preview.RecipeDefinition{ "Radius.Core/example": { - RecipeKind: to.Ptr(corerpv20250801preview.RecipeKindTerraform), - RecipeLocation: new("https://github.com/radius-project/example"), - RecipeParameters: map[string]any{ + Kind: to.Ptr(corerpv20250801preview.RecipeKindTerraform), + Location: new("https://github.com/radius-project/example"), + Parameters: map[string]any{ "foo": "bar", }, }, diff --git a/pkg/cli/test_client_factory/radius_core.go b/pkg/cli/test_client_factory/radius_core.go index 03aa2446ab..624bda32f5 100644 --- a/pkg/cli/test_client_factory/radius_core.go +++ b/pkg/cli/test_client_factory/radius_core.go @@ -68,12 +68,12 @@ func WithRecipePackServerNoError() corerpfake.RecipePacksServer { Properties: &v20250801preview.RecipePackProperties{ Recipes: map[string]*v20250801preview.RecipeDefinition{ "test-recipe1": { - RecipeLocation: new("https://example.com/recipe1?ref=v0.1"), - RecipeKind: to.Ptr(v20250801preview.RecipeKindTerraform), + Location: new("https://example.com/recipe1?ref=v0.1"), + Kind: to.Ptr(v20250801preview.RecipeKindTerraform), }, "test-recipe2": { - RecipeLocation: new("https://example.com/recipe2?ref=v0.1"), - RecipeKind: to.Ptr(v20250801preview.RecipeKindTerraform), + Location: new("https://example.com/recipe2?ref=v0.1"), + Kind: to.Ptr(v20250801preview.RecipeKindTerraform), }, }, }, diff --git a/pkg/corerp/api/v20250801preview/recipepack_conversion.go b/pkg/corerp/api/v20250801preview/recipepack_conversion.go index 4171c45c66..41421605ac 100644 --- a/pkg/corerp/api/v20250801preview/recipepack_conversion.go +++ b/pkg/corerp/api/v20250801preview/recipepack_conversion.go @@ -95,10 +95,10 @@ func toRecipesDataModel(recipes map[string]*RecipeDefinition) map[string]*datamo for key, recipe := range recipes { if recipe != nil { result[key] = &datamodel.RecipeDefinition{ - RecipeKind: toRecipeKindDataModel(recipe.RecipeKind), - RecipeLocation: to.String(recipe.RecipeLocation), - RecipeParameters: recipe.RecipeParameters, - PlainHTTP: to.Bool(recipe.PlainHTTP), + RecipeKind: toRecipeKindDataModel(recipe.Kind), + RecipeLocation: to.String(recipe.Location), + Parameters: recipe.Parameters, + PlainHTTP: to.Bool(recipe.PlainHTTP), } } } @@ -114,10 +114,10 @@ func fromRecipesDataModel(recipes map[string]*datamodel.RecipeDefinition) map[st for key, recipe := range recipes { if recipe != nil { result[key] = &RecipeDefinition{ - RecipeKind: fromRecipeKindDataModel(recipe.RecipeKind), - RecipeLocation: new(recipe.RecipeLocation), - RecipeParameters: recipe.RecipeParameters, - PlainHTTP: new(recipe.PlainHTTP), + Kind: fromRecipeKindDataModel(recipe.RecipeKind), + Location: new(recipe.RecipeLocation), + Parameters: recipe.Parameters, + PlainHTTP: new(recipe.PlainHTTP), } } } diff --git a/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json b/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json index 0e74168d1b..2d32584a04 100644 --- a/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json +++ b/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json @@ -15,18 +15,18 @@ ], "recipes": { "Applications.Core/containers": { - "recipeKind": "Bicep", - "recipeLocation": "br:ghcr.io/radius-project/recipes/kubernetes-container:latest", - "recipeParameters": { + "kind": "Bicep", + "location": "br:ghcr.io/radius-project/recipes/kubernetes-container:latest", + "parameters": { "port": 8080, "replicas": 3 }, "plainHTTP": false }, "Applications.Dapr/stateStores": { - "recipeKind": "Terraform", - "recipeLocation": "oci://ghcr.io/radius-project/recipes/terraform/redis:latest", - "recipeParameters": { + "kind": "Terraform", + "location": "oci://ghcr.io/radius-project/recipes/terraform/redis:latest", + "parameters": { "size": "small" }, "plainHTTP": true diff --git a/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json b/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json index bbe341cd44..502972e236 100644 --- a/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json +++ b/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json @@ -27,18 +27,18 @@ ], "recipes": { "Applications.Core/containers": { - "recipeKind": "Bicep", - "recipeLocation": "br:ghcr.io/radius-project/recipes/kubernetes-container:latest", - "recipeParameters": { + "kind": "Bicep", + "location": "br:ghcr.io/radius-project/recipes/kubernetes-container:latest", + "parameters": { "port": 8080, "replicas": 3 }, "plainHTTP": false }, "Applications.Dapr/stateStores": { - "recipeKind": "Terraform", - "recipeLocation": "oci://ghcr.io/radius-project/recipes/terraform/redis:latest", - "recipeParameters": { + "kind": "Terraform", + "location": "oci://ghcr.io/radius-project/recipes/terraform/redis:latest", + "parameters": { "size": "small" }, "plainHTTP": true diff --git a/pkg/corerp/api/v20250801preview/zz_generated_models.go b/pkg/corerp/api/v20250801preview/zz_generated_models.go index be016129c8..8bf2fd0d72 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_models.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_models.go @@ -421,13 +421,13 @@ type ProvidersKubernetes struct { // RecipeDefinition - Recipe definition for a specific resource type type RecipeDefinition struct { // REQUIRED; The type of recipe (e.g., Terraform, Bicep) - RecipeKind *RecipeKind + Kind *RecipeKind // REQUIRED; URL path to the recipe - RecipeLocation *string + Location *string // Parameters to pass to the recipe - RecipeParameters map[string]any + Parameters map[string]any // Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for // example in a locally hosted registry for Bicep recipes. Defaults to false (use diff --git a/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go b/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go index a2d237ef6b..21bef053cf 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go @@ -1054,10 +1054,10 @@ func (p *ProvidersKubernetes) UnmarshalJSON(data []byte) error { // MarshalJSON implements the json.Marshaller interface for type RecipeDefinition. func (r RecipeDefinition) MarshalJSON() ([]byte, error) { objectMap := make(map[string]any) - populate(objectMap, "recipeParameters", r.RecipeParameters) + populate(objectMap, "parameters", r.Parameters) populate(objectMap, "plainHttp", r.PlainHTTP) - populate(objectMap, "recipeKind", r.RecipeKind) - populate(objectMap, "recipeLocation", r.RecipeLocation) + populate(objectMap, "kind", r.Kind) + populate(objectMap, "location", r.Location) return json.Marshal(objectMap) } @@ -1070,17 +1070,17 @@ func (r *RecipeDefinition) UnmarshalJSON(data []byte) error { for key, val := range rawMsg { var err error switch key { - case "recipeParameters": - err = unpopulate(val, "RecipeParameters", &r.RecipeParameters) + case "parameters": + err = unpopulate(val, "Parameters", &r.Parameters) delete(rawMsg, key) case "plainHttp": err = unpopulate(val, "PlainHTTP", &r.PlainHTTP) delete(rawMsg, key) - case "recipeKind": - err = unpopulate(val, "RecipeKind", &r.RecipeKind) + case "kind": + err = unpopulate(val, "Kind", &r.Kind) delete(rawMsg, key) - case "recipeLocation": - err = unpopulate(val, "RecipeLocation", &r.RecipeLocation) + case "location": + err = unpopulate(val, "Location", &r.Location) delete(rawMsg, key) } if err != nil { diff --git a/pkg/corerp/datamodel/converter/recipepack_converter_test.go b/pkg/corerp/datamodel/converter/recipepack_converter_test.go index 253df645ca..cc710c293e 100644 --- a/pkg/corerp/datamodel/converter/recipepack_converter_test.go +++ b/pkg/corerp/datamodel/converter/recipepack_converter_test.go @@ -60,7 +60,7 @@ func TestRecipePackDataModelToVersioned(t *testing.T) { "Applications.Core/containers": { RecipeKind: "bicep", RecipeLocation: "br:myregistry.azurecr.io/recipes/container:1.0", - RecipeParameters: map[string]any{ + Parameters: map[string]any{ "param1": "value1", }, PlainHTTP: false, @@ -162,16 +162,16 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { "properties": { "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "br:myregistry.azurecr.io/recipes/container:1.0", - "recipeParameters": { + "kind": "bicep", + "location": "br:myregistry.azurecr.io/recipes/container:1.0", + "parameters": { "param1": "value1" }, "plainHTTP": false }, "Applications.Datastores/sqlDatabases": { - "recipeKind": "terraform", - "recipeLocation": "https://github.com/radius-project/recipes.git//terraform/modules/sql" + "kind": "terraform", + "location": "https://github.com/radius-project/recipes.git//terraform/modules/sql" } }, "referencedBy": [ @@ -205,7 +205,7 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { "Applications.Core/containers": { RecipeKind: "bicep", RecipeLocation: "br:myregistry.azurecr.io/recipes/container:1.0", - RecipeParameters: map[string]any{ + Parameters: map[string]any{ "param1": "value1", }, PlainHTTP: false, @@ -260,8 +260,8 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { "properties": { "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "br:myregistry.azurecr.io/recipes/container:1.0" + "kind": "bicep", + "location": "br:myregistry.azurecr.io/recipes/container:1.0" } } } @@ -302,8 +302,8 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { "properties": { "recipes": { "Applications.Datastores/sqlDatabases": { - "recipeKind": "terraform", - "recipeLocation": "http://insecure-registry.example.com/recipes/sql", + "kind": "terraform", + "location": "http://insecure-registry.example.com/recipes/sql", "plainHttp": true } } @@ -394,8 +394,8 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { require.Equal(t, expectedRecipe.PlainHTTP, actualRecipe.PlainHTTP, "PlainHTTP for recipe %s should match. Expected: %v, Actual: %v", key, expectedRecipe.PlainHTTP, actualRecipe.PlainHTTP) // Note: JSON unmarshaling can change the type of parameters, especially for numbers - if expectedRecipe.RecipeParameters != nil { - require.Equal(t, expectedRecipe.RecipeParameters, actualRecipe.RecipeParameters) + if expectedRecipe.Parameters != nil { + require.Equal(t, expectedRecipe.Parameters, actualRecipe.Parameters) } } } @@ -428,7 +428,7 @@ func TestRecipePackRoundTripConversion(t *testing.T) { "Applications.Core/containers": { RecipeKind: "bicep", RecipeLocation: "br:test.azurecr.io/recipes/container:latest", - RecipeParameters: map[string]any{ + Parameters: map[string]any{ "cpu": "0.5", "memory": "1Gi", }, @@ -471,7 +471,7 @@ func TestRecipePackRoundTripConversion(t *testing.T) { require.Equal(t, originalRecipe.RecipeKind, resultRecipe.RecipeKind) require.Equal(t, originalRecipe.RecipeLocation, resultRecipe.RecipeLocation) require.Equal(t, originalRecipe.PlainHTTP, resultRecipe.PlainHTTP) - require.Equal(t, originalRecipe.RecipeParameters, resultRecipe.RecipeParameters) + require.Equal(t, originalRecipe.Parameters, resultRecipe.Parameters) } } diff --git a/pkg/corerp/datamodel/recipepack.go b/pkg/corerp/datamodel/recipepack.go index 6f92716b47..bb13c7d0b2 100644 --- a/pkg/corerp/datamodel/recipepack.go +++ b/pkg/corerp/datamodel/recipepack.go @@ -50,13 +50,13 @@ type RecipePackProperties struct { // RecipeDefinition represents a recipe definition in the datamodel. type RecipeDefinition struct { // RecipeKind is the type of recipe (e.g., terraform, bicep). - RecipeKind string `json:"recipeKind"` + RecipeKind string `json:"kind"` // RecipeLocation is the URL or path to the recipe source. - RecipeLocation string `json:"recipeLocation"` + RecipeLocation string `json:"location"` - // RecipeParameters to pass to the recipe. - RecipeParameters map[string]any `json:"recipeParameters,omitempty"` + // Parameters to pass to the recipe. + Parameters map[string]any `json:"parameters,omitempty"` // PlainHTTP connects to the location using HTTP (not-HTTPS). PlainHTTP bool `json:"plainHTTP,omitempty"` diff --git a/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go b/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go index cf746c2400..96a17f0f4e 100644 --- a/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go +++ b/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go @@ -158,17 +158,17 @@ func getTestModels() (*v20250801preview.RecipePackResource, *datamodel.RecipePac Properties: &v20250801preview.RecipePackProperties{ Recipes: map[string]*v20250801preview.RecipeDefinition{ "Applications.Core/extenders": { - RecipeKind: to.Ptr(v20250801preview.RecipeKindBicep), - RecipeLocation: new("ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0"), + Kind: to.Ptr(v20250801preview.RecipeKindBicep), + Location: new("ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0"), }, "Radius.Resources/postgreSQL": { - RecipeKind: to.Ptr(v20250801preview.RecipeKindBicep), - RecipeLocation: new("ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0"), + Kind: to.Ptr(v20250801preview.RecipeKindBicep), + Location: new("ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0"), }, "Applications.Datastores/redisCaches": { - RecipeKind: to.Ptr(v20250801preview.RecipeKindBicep), - RecipeLocation: new("https://github.com/example/recipes/redis-cache.bicep"), - RecipeParameters: map[string]any{ + Kind: to.Ptr(v20250801preview.RecipeKindBicep), + Location: new("https://github.com/example/recipes/redis-cache.bicep"), + Parameters: map[string]any{ "tier": "basic", }, }, @@ -198,7 +198,7 @@ func getTestModels() (*v20250801preview.RecipePackResource, *datamodel.RecipePac "Applications.Datastores/redisCaches": { RecipeKind: "bicep", RecipeLocation: "https://github.com/example/recipes/redis-cache.bicep", - RecipeParameters: map[string]any{ + Parameters: map[string]any{ "tier": "basic", }, }, @@ -215,19 +215,19 @@ func getTestModels() (*v20250801preview.RecipePackResource, *datamodel.RecipePac ProvisioningState: to.Ptr(v20250801preview.ProvisioningStateSucceeded), Recipes: map[string]*v20250801preview.RecipeDefinition{ "Applications.Core/extenders": { - RecipeKind: to.Ptr(v20250801preview.RecipeKindBicep), - RecipeLocation: new("ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0"), + Kind: to.Ptr(v20250801preview.RecipeKindBicep), + Location: new("ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0"), PlainHTTP: new(false), }, "Radius.Resources/postgreSQL": { - RecipeKind: to.Ptr(v20250801preview.RecipeKindBicep), - RecipeLocation: new("ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0"), + Kind: to.Ptr(v20250801preview.RecipeKindBicep), + Location: new("ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0"), PlainHTTP: new(false), }, "Applications.Datastores/redisCaches": { - RecipeKind: to.Ptr(v20250801preview.RecipeKindBicep), - RecipeLocation: new("https://github.com/example/recipes/redis-cache.bicep"), - RecipeParameters: map[string]any{ + Kind: to.Ptr(v20250801preview.RecipeKindBicep), + Location: new("https://github.com/example/recipes/redis-cache.bicep"), + Parameters: map[string]any{ "tier": "basic", }, PlainHTTP: new(false), diff --git a/pkg/recipes/configloader/environment.go b/pkg/recipes/configloader/environment.go index f0537f7597..c49482690b 100644 --- a/pkg/recipes/configloader/environment.go +++ b/pkg/recipes/configloader/environment.go @@ -282,7 +282,7 @@ func getRecipeDefinitionFromEnvironmentV20250801(ctx context.Context, environmen } // Reconcile parameters from recipe pack and environment-level recipe parameters - parameters := reconcileRecipeParameters(recipeDefinition.RecipeParameters, envDatamodel.Properties.RecipeParameters, resource.Type()) + parameters := reconcileRecipeParameters(recipeDefinition.Parameters, envDatamodel.Properties.RecipeParameters, resource.Type()) // TODO: For now, we can set "Name" to default as recipe packs don't have named recipes. // We will remove this field from EnvironmentDefinition once we deprecate Applications.Core. @@ -326,9 +326,9 @@ func fetchRecipeDefinition(ctx context.Context, recipePackIDs []string, armOptio plainHTTP = *definition.PlainHTTP } return &recipes.RecipeDefinition{ - RecipeKind: string(*definition.RecipeKind), - RecipeLocation: string(*definition.RecipeLocation), - RecipeParameters: definition.RecipeParameters, + RecipeKind: string(*definition.Kind), + RecipeLocation: string(*definition.Location), + Parameters: definition.Parameters, PlainHTTP: plainHTTP, }, nil } diff --git a/pkg/recipes/types.go b/pkg/recipes/types.go index c43ab22477..61d512487b 100644 --- a/pkg/recipes/types.go +++ b/pkg/recipes/types.go @@ -156,8 +156,8 @@ type RecipeDefinition struct { RecipeKind string // RecipeLocation represents URL or path to the recipe source RecipeLocation string - // RecipeParameters represents parameters to pass to the recipe - RecipeParameters map[string]any + // Parameters represents parameters to pass to the recipe + Parameters map[string]any // PlainHTTP connects to the location using HTTP (not-HTTPS) PlainHTTP bool } diff --git a/typespec/Radius.Core/recipePacks.tsp b/typespec/Radius.Core/recipePacks.tsp index fd23c56360..5e3c788d7f 100644 --- a/typespec/Radius.Core/recipePacks.tsp +++ b/typespec/Radius.Core/recipePacks.tsp @@ -58,16 +58,16 @@ model RecipePackProperties { @doc("Recipe definition for a specific resource type") model RecipeDefinition { @doc("The type of recipe (e.g., Terraform, Bicep)") - recipeKind: RecipeKind; + kind: RecipeKind; @doc("Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for example in a locally hosted registry for Bicep recipes. Defaults to false (use HTTPS/TLS)") plainHttp?: boolean; @doc("URL path to the recipe") - recipeLocation: string; + location: string; @doc("Parameters to pass to the recipe") - recipeParameters?: Record; + parameters?: Record; } @doc("The type of recipe") From 3b302df3692a68a9afb4177ca737db3849913794 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 17:35:29 +0000 Subject: [PATCH 04/18] Update remaining recipeKind/recipeLocation in TypeSpec examples and test data Agent-Logs-Url: https://github.com/radius-project/radius/sessions/a15d6709-011a-41b9-80e1-bb019519928d Co-authored-by: Reshrahim <61033581+Reshrahim@users.noreply.github.com> --- .../testdata/recipepack_datamodel.json | 12 ++++++------ .../RecipePacks_CreateOrUpdate.json | 16 ++++++++-------- .../2025-08-01-preview/RecipePacks_List.json | 8 ++++---- .../RecipePacks_ListByScope.json | 8 ++++---- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/pkg/corerp/frontend/controller/recipepacks/testdata/recipepack_datamodel.json b/pkg/corerp/frontend/controller/recipepacks/testdata/recipepack_datamodel.json index 05a1a4d708..959bafebe8 100644 --- a/pkg/corerp/frontend/controller/recipepacks/testdata/recipepack_datamodel.json +++ b/pkg/corerp/frontend/controller/recipepacks/testdata/recipepack_datamodel.json @@ -14,16 +14,16 @@ "properties": { "recipes": { "Applications.Core/extenders": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0" + "kind": "bicep", + "location": "ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0" }, "Radius.Resources/postgreSQL": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0" + "kind": "bicep", + "location": "ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0" }, "Applications.Datastores/redisCaches": { - "recipeKind": "bicep", - "recipeLocation": "https://github.com/example/recipes/redis-cache.bicep", + "kind": "bicep", + "location": "https://github.com/example/recipes/redis-cache.bicep", "parameters": { "tier": "basic" } diff --git a/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_CreateOrUpdate.json b/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_CreateOrUpdate.json index c803d5088b..04fe84fd0c 100644 --- a/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_CreateOrUpdate.json +++ b/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_CreateOrUpdate.json @@ -10,16 +10,16 @@ "properties": { "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/radius-project/recipes/azure-container-apps:latest", + "kind": "bicep", + "location": "ghcr.io/radius-project/recipes/azure-container-apps:latest", "parameters": { "cpu": "1.0", "memory": "2Gi" } }, "Applications.Dapr/stateStores": { - "recipeKind": "bicep", - "recipeLocation": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", + "kind": "bicep", + "location": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", "parameters": { "databaseName": "radius-db", "throughput": 400 @@ -43,16 +43,16 @@ ], "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/radius-project/recipes/azure-container-apps:latest", + "kind": "bicep", + "location": "ghcr.io/radius-project/recipes/azure-container-apps:latest", "parameters": { "cpu": "1.0", "memory": "2Gi" } }, "Applications.Dapr/stateStores": { - "recipeKind": "bicep", - "recipeLocation": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", + "kind": "bicep", + "location": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", "parameters": { "databaseName": "radius-db", "throughput": 400 diff --git a/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_List.json b/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_List.json index e38bbd79b1..9656c379ab 100644 --- a/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_List.json +++ b/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_List.json @@ -20,8 +20,8 @@ ], "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/radius-project/recipes/azure-container-apps:latest" + "kind": "bicep", + "location": "ghcr.io/radius-project/recipes/azure-container-apps:latest" } } } @@ -36,8 +36,8 @@ "referencedBy": [], "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/myregistry/bicep/recipes/myrecipe:v1.0" + "kind": "bicep", + "location": "ghcr.io/myregistry/bicep/recipes/myrecipe:v1.0" } } } diff --git a/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_ListByScope.json b/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_ListByScope.json index 5f0e6e532c..36bbae58ae 100644 --- a/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_ListByScope.json +++ b/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_ListByScope.json @@ -21,8 +21,8 @@ ], "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/myregistry/bicep/recipes/myrecipe:v1.0" + "kind": "bicep", + "location": "ghcr.io/myregistry/bicep/recipes/myrecipe:v1.0" } } } @@ -37,8 +37,8 @@ "referencedBy": [], "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/myregistry/bicep/recipes/myrecipe:v1.0" + "kind": "bicep", + "location": "ghcr.io/myregistry/bicep/recipes/myrecipe:v1.0" } } } From 073ee92d9262ae5ec4e466070de92759bd61cd67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 17:48:10 +0000 Subject: [PATCH 05/18] =?UTF-8?q?Fix=20RecipeKind/RecipeLocation=20field?= =?UTF-8?q?=20refs=20in=20test=5Fclient=5Ffactory=20and=20plainHTTP?= =?UTF-8?q?=E2=86=92plainHttp=20in=20converter=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/radius-project/radius/sessions/410491d9-ec66-4c56-a4bd-c827e37e10b0 Co-authored-by: Reshrahim <61033581+Reshrahim@users.noreply.github.com> --- pkg/cli/test_client_factory/radius_core.go | 12 ++++++------ .../datamodel/converter/recipepack_converter_test.go | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/cli/test_client_factory/radius_core.go b/pkg/cli/test_client_factory/radius_core.go index c35fe6584d..f35e3d3a61 100644 --- a/pkg/cli/test_client_factory/radius_core.go +++ b/pkg/cli/test_client_factory/radius_core.go @@ -300,8 +300,8 @@ func WithRecipePackServerCoreTypes() corerpfake.RecipePacksServer { Properties: &v20250801preview.RecipePackProperties{ Recipes: map[string]*v20250801preview.RecipeDefinition{ resourceType: { - RecipeLocation: to.Ptr("ghcr.io/test/" + recipePackName + ":latest"), - RecipeKind: &bicepKind, + Location: to.Ptr("ghcr.io/test/" + recipePackName + ":latest"), + Kind: &bicepKind, }, }, }, @@ -335,8 +335,8 @@ func WithRecipePackServerUniqueTypes() corerpfake.RecipePacksServer { Properties: &v20250801preview.RecipePackProperties{ Recipes: map[string]*v20250801preview.RecipeDefinition{ "Test.Resource/" + recipePackName: { - RecipeLocation: to.Ptr("ghcr.io/test/" + recipePackName + ":latest"), - RecipeKind: &bicepKind, + Location: to.Ptr("ghcr.io/test/" + recipePackName + ":latest"), + Kind: &bicepKind, }, }, }, @@ -393,8 +393,8 @@ func WithRecipePackServerConflictingTypes() corerpfake.RecipePacksServer { Properties: &v20250801preview.RecipePackProperties{ Recipes: map[string]*v20250801preview.RecipeDefinition{ "Radius.Compute/containers": { - RecipeLocation: to.Ptr("ghcr.io/test/" + recipePackName + ":latest"), - RecipeKind: &bicepKind, + Location: to.Ptr("ghcr.io/test/" + recipePackName + ":latest"), + Kind: &bicepKind, }, }, }, diff --git a/pkg/corerp/datamodel/converter/recipepack_converter_test.go b/pkg/corerp/datamodel/converter/recipepack_converter_test.go index cc710c293e..33719670df 100644 --- a/pkg/corerp/datamodel/converter/recipepack_converter_test.go +++ b/pkg/corerp/datamodel/converter/recipepack_converter_test.go @@ -167,7 +167,7 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { "parameters": { "param1": "value1" }, - "plainHTTP": false + "plainHttp": false }, "Applications.Datastores/sqlDatabases": { "kind": "terraform", From 33147f32dcc5cd1c67c97d99db436b76da2940ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 18:10:49 +0000 Subject: [PATCH 06/18] Fix remaining RecipeKind/RecipeLocation refs in recipepack.go and update swagger/typespec/bicep-types JSON files Agent-Logs-Url: https://github.com/radius-project/radius/sessions/087df2c3-04e0-472e-b069-2843e058fa1c Co-authored-by: Reshrahim <61033581+Reshrahim@users.noreply.github.com> --- .../radius.core/2025-08-01-preview/types.json | 4 ++-- pkg/cli/recipepack/recipepack.go | 4 ++-- pkg/cli/recipepack/recipepack_test.go | 8 ++++---- .../examples/RecipePacks_CreateOrUpdate.json | 16 ++++++++-------- .../examples/RecipePacks_Get.json | 8 ++++---- .../examples/RecipePacks_ListByScope.json | 8 ++++---- .../preview/2025-08-01-preview/openapi.json | 8 ++++---- .../2025-08-01-preview/RecipePacks_Get.json | 8 ++++---- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json b/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json index 796354f14f..e314a4c545 100644 --- a/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json +++ b/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json @@ -1278,7 +1278,7 @@ "$type": "ObjectType", "name": "RecipeDefinition", "properties": { - "recipeKind": { + "kind": { "type": { "$ref": "#/107" }, @@ -1292,7 +1292,7 @@ "flags": 0, "description": "Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for example in a locally hosted registry for Bicep recipes. Defaults to false (use HTTPS/TLS)" }, - "recipeLocation": { + "location": { "type": { "$ref": "#/0" }, diff --git a/pkg/cli/recipepack/recipepack.go b/pkg/cli/recipepack/recipepack.go index 9d6ecea7d4..efe236af54 100644 --- a/pkg/cli/recipepack/recipepack.go +++ b/pkg/cli/recipepack/recipepack.go @@ -54,8 +54,8 @@ func NewDefaultRecipePackResource() corerpv20250801.RecipePackResource { recipes := make(map[string]*corerpv20250801.RecipeDefinition) for _, def := range GetCoreTypesRecipeInfo() { recipes[def.ResourceType] = &corerpv20250801.RecipeDefinition{ - RecipeKind: &bicepKind, - RecipeLocation: to.Ptr(def.RecipeLocation), + Kind: &bicepKind, + Location: to.Ptr(def.RecipeLocation), } } return corerpv20250801.RecipePackResource{ diff --git a/pkg/cli/recipepack/recipepack_test.go b/pkg/cli/recipepack/recipepack_test.go index 465a6eb51c..e6da9c51ed 100644 --- a/pkg/cli/recipepack/recipepack_test.go +++ b/pkg/cli/recipepack/recipepack_test.go @@ -75,9 +75,9 @@ func Test_NewDefaultRecipePackResource(t *testing.T) { 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) + require.NotNil(t, recipe.Kind) + require.Equal(t, corerpv20250801.RecipeKindBicep, *recipe.Kind) + require.NotNil(t, recipe.Location) + require.Equal(t, def.RecipeLocation, *recipe.Location) } } diff --git a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_CreateOrUpdate.json b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_CreateOrUpdate.json index c803d5088b..04fe84fd0c 100644 --- a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_CreateOrUpdate.json +++ b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_CreateOrUpdate.json @@ -10,16 +10,16 @@ "properties": { "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/radius-project/recipes/azure-container-apps:latest", + "kind": "bicep", + "location": "ghcr.io/radius-project/recipes/azure-container-apps:latest", "parameters": { "cpu": "1.0", "memory": "2Gi" } }, "Applications.Dapr/stateStores": { - "recipeKind": "bicep", - "recipeLocation": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", + "kind": "bicep", + "location": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", "parameters": { "databaseName": "radius-db", "throughput": 400 @@ -43,16 +43,16 @@ ], "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/radius-project/recipes/azure-container-apps:latest", + "kind": "bicep", + "location": "ghcr.io/radius-project/recipes/azure-container-apps:latest", "parameters": { "cpu": "1.0", "memory": "2Gi" } }, "Applications.Dapr/stateStores": { - "recipeKind": "bicep", - "recipeLocation": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", + "kind": "bicep", + "location": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", "parameters": { "databaseName": "radius-db", "throughput": 400 diff --git a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_Get.json b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_Get.json index e5c6b50be1..89580801d4 100644 --- a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_Get.json +++ b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_Get.json @@ -20,16 +20,16 @@ ], "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/radius-project/recipes/azure-container-apps:latest", + "kind": "bicep", + "location": "ghcr.io/radius-project/recipes/azure-container-apps:latest", "parameters": { "cpu": "1.0", "memory": "2Gi" } }, "Applications.Dapr/stateStores": { - "recipeKind": "bicep", - "recipeLocation": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", + "kind": "bicep", + "location": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", "parameters": { "databaseName": "radius-db", "throughput": 400 diff --git a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_ListByScope.json b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_ListByScope.json index 5f0e6e532c..36bbae58ae 100644 --- a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_ListByScope.json +++ b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/examples/RecipePacks_ListByScope.json @@ -21,8 +21,8 @@ ], "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/myregistry/bicep/recipes/myrecipe:v1.0" + "kind": "bicep", + "location": "ghcr.io/myregistry/bicep/recipes/myrecipe:v1.0" } } } @@ -37,8 +37,8 @@ "referencedBy": [], "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/myregistry/bicep/recipes/myrecipe:v1.0" + "kind": "bicep", + "location": "ghcr.io/myregistry/bicep/recipes/myrecipe:v1.0" } } } diff --git a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json index 4fcd687754..2d884f48bd 100644 --- a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json +++ b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json @@ -1989,7 +1989,7 @@ "type": "object", "description": "Recipe definition for a specific resource type", "properties": { - "recipeKind": { + "kind": { "$ref": "#/definitions/RecipeKind", "description": "The type of recipe (e.g., Terraform, Bicep)" }, @@ -1997,7 +1997,7 @@ "type": "boolean", "description": "Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for example in a locally hosted registry for Bicep recipes. Defaults to false (use HTTPS/TLS)" }, - "recipeLocation": { + "location": { "type": "string", "description": "URL path to the recipe" }, @@ -2008,8 +2008,8 @@ } }, "required": [ - "recipeKind", - "recipeLocation" + "kind", + "location" ] }, "RecipeKind": { diff --git a/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_Get.json b/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_Get.json index e5c6b50be1..89580801d4 100644 --- a/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_Get.json +++ b/typespec/Radius.Core/examples/2025-08-01-preview/RecipePacks_Get.json @@ -20,16 +20,16 @@ ], "recipes": { "Applications.Core/containers": { - "recipeKind": "bicep", - "recipeLocation": "ghcr.io/radius-project/recipes/azure-container-apps:latest", + "kind": "bicep", + "location": "ghcr.io/radius-project/recipes/azure-container-apps:latest", "parameters": { "cpu": "1.0", "memory": "2Gi" } }, "Applications.Dapr/stateStores": { - "recipeKind": "bicep", - "recipeLocation": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", + "kind": "bicep", + "location": "br:myregistry.azurecr.io/bicep/recipes/cosmosdb:v1.0", "parameters": { "databaseName": "radius-db", "throughput": 400 From af7e7c3decb78b1e16bc22356c1ca611b9901c00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:30:24 +0000 Subject: [PATCH 07/18] =?UTF-8?q?Fix=20Bicep=20test=20files=20and=20design?= =?UTF-8?q?=20notes:=20recipeKind=E2=86=92kind,=20recipeLocation=E2=86=92l?= =?UTF-8?q?ocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/radius-project/radius/sessions/8181cd38-c77d-4c4e-892d-7a26a14f85b0 Co-authored-by: Reshrahim <61033581+Reshrahim@users.noreply.github.com> --- ...5-06-compute-extensibility-feature-spec.md | 36 +++++++++---------- .../testdata/corerp-recipe-pack-test.bicep | 12 +++---- .../recipepacks-test-no-provider.bicep | 4 +-- .../resources/testdata/recipepacks-test.bicep | 8 ++--- .../testdata/terraformconfig-redis-test.bicep | 4 +-- .../testdata/tfbicep-combined-test.bicep | 4 +-- 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/eng/design-notes/extensibility/2025-06-compute-extensibility-feature-spec.md b/eng/design-notes/extensibility/2025-06-compute-extensibility-feature-spec.md index 87fbb33241..6c5ea242cd 100644 --- a/eng/design-notes/extensibility/2025-06-compute-extensibility-feature-spec.md +++ b/eng/design-notes/extensibility/2025-06-compute-extensibility-feature-spec.md @@ -126,7 +126,7 @@ Step 2 #### User Story 1: As a Platform Engineer, I want to create a Recipe Pack that bundles multiple recipes for core resource types, so that I can easily register and manage them in a Radius environment: 1. **Define a Recipe Pack**: - * A platform engineer creates a Radius Recipe Pack resource definition that specifies a collection of Recipes. It would list each core resource type (e.g., `Radius.Compute/containers@2025-05-01-preview`, `Radius.Compute/routes@2025-05-01-preview`, `Radius.Security/secrets@2025-05-01-preview`) and associate it with a specific Recipe (recipeKind and recipeLocation) and its default parameters: + * A platform engineer creates a Radius Recipe Pack resource definition that specifies a collection of Recipes. It would list each core resource type (e.g., `Radius.Compute/containers@2025-05-01-preview`, `Radius.Compute/routes@2025-05-01-preview`, `Radius.Security/secrets@2025-05-01-preview`) and associate it with a specific Recipe (kind and location) and its default parameters: > Note that Recipe Packs are modeled as a new resource type called `Config`, which was introduced in [this feature spec](https://github.com/radius-project/design-notes/blob/main/features/2025-07-23-radius-configuration-ux.md) * e.g. `computeRecipePack.bicep`: ```bicep @@ -136,19 +136,19 @@ Step 2 properties: { recipes: [ Radius.Compute/container: { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' parameters: { allowPlatformOptions: true } } Radius.Security/secrets: { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' } Radius.Compute/persistentVolumes: { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' } ] } @@ -162,8 +162,8 @@ Step 2 properties: { recipes: [ Radius.Data/redisCaches: { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/data/redis-caches/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/data/redis-caches/kubernetes?ref=v0.48' } ] } @@ -875,8 +875,8 @@ Given: my platform engineer has set up a Radius environment with recipes registe * The `compute.namespace` property is moved to `providers.kubernetes.namespace` in the `Radius.Core/environments` resource type. * The `properties.providers.azure.scope` property in the `environments` resource type is changed to an object with the subscriptionId and resourceGroupName properties separated. `subscriptionId` (required) can be overridden by the Recipe if it provides a `subscriptionId`. `resourceGroup` (optional) can be set in the Environment or by the Recipe (value set in the Recipe overrides the value set in the Environment). If the neither the Environment nor Recipe specifies a `resourceGroup` (optional) and the resource to be deployed needs to be scoped to a resource group, then the deployment would fail. * The following [Recipe properties](https://docs.radapp.io/reference/resource-schema/core-schema/environment-schema/#recipe-properties) are renamed for better clarity going forward: - * `templateKind` -> `recipeKind` - * `templatePath` -> `recipeLocation` + * `templateKind` -> `kind` + * `templatePath` -> `location` * Recipes accept a new `allowPlatformOptions` parameter that determines whether platform-specific configurations via their own schemas (e.g., ACI containerGroupProfile, Kubernetes podSpec) are allowed in the resource type definition. * Ability to package and register sets of related recipes as "Recipe Packs" to simplify distribution and management. Environment definitions can reference these packs, which will include the necessary recipes for core types. * Core types (`Applications.Core/containers`, `Applications.Core/gateways`, `Applications.Core/secrets`, `Applications.Core/volumes`) are re-implemented as Radius Resource Types (RRT) with new, versioned resource type names (e.g., `Radius.Compute/containers@2025-05-01-preview`). @@ -1094,8 +1094,8 @@ Recipe packs being defined in a yaml manifest that bundles individual Recipes ca description: "Recipe Pack for deploying to ACI in production." recipes: - resourceType: "Radius.Compute/containers@2025-05-01-preview" - recipeKind: "bicep" - recipeLocation: "oci://ghcr.io/my-org/recipes/core/aci-container:1.2.0" + kind: "bicep" + location: "oci://ghcr.io/my-org/recipes/core/aci-container:1.2.0" parameters: cpu: "1.0" memoryInGB: "2.0" @@ -1104,17 +1104,17 @@ Recipe packs being defined in a yaml manifest that bundles individual Recipes ca # Optional: allow platform-specific options like containerGroupProfile for ACI allowPlatformOptions: true - resourceType: "Radius.Compute/routes@2025-05-01-preview" - recipeKind: "bicep" - recipeLocation: "oci://ghcr.io/my-org/recipes/core/aci-gateway:1.1.0" + kind: "bicep" + location: "oci://ghcr.io/my-org/recipes/core/aci-gateway:1.1.0" parameters: sku: "Standard_v2" - resourceType: "Radius.Security/secrets@2025-05-01-preview" - recipeKind: "bicep" - recipeLocation: "oci://ghcr.io/my-org/recipes/azure/keyvault-secretstore:1.0.0" + kind: "bicep" + location: "oci://ghcr.io/my-org/recipes/azure/keyvault-secretstore:1.0.0" parameters: skuName: "premium" ``` - > Note: `templateKind` is changed to `recipeKind` and `templatePath` is changed to `recipeLocation` + > Note: `templateKind` is changed to `kind` and `templatePath` is changed to `location` 1. **Add the Recipe Pack to an Environment**: * The platform engineer uses a new CLI command to add the entire pack to a Radius environment. 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..a68ef38b96 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 @@ -4,19 +4,19 @@ resource computeRecipePack 'Radius.Core/recipePacks@2025-08-01-preview' = { properties: { recipes: { 'Radius.Compute/containers': { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' parameters: { allowPlatformOptions: true } } 'Radius.Security/secrets': { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' } 'Radius.Storage/volumes': { - recipeKind: 'terraform' - recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' + kind: 'terraform' + location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' } } } diff --git a/test/functional-portable/dynamicrp/noncloud/resources/testdata/recipepacks-test-no-provider.bicep b/test/functional-portable/dynamicrp/noncloud/resources/testdata/recipepacks-test-no-provider.bicep index 9dcd18ad76..6ebcae4949 100644 --- a/test/functional-portable/dynamicrp/noncloud/resources/testdata/recipepacks-test-no-provider.bicep +++ b/test/functional-portable/dynamicrp/noncloud/resources/testdata/recipepacks-test-no-provider.bicep @@ -14,8 +14,8 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { properties: { recipes: { 'Test.Resources/userTypeAlpha': { - recipeKind: 'bicep' - recipeLocation: '${registry}/test/testrecipes/test-bicep-recipes/dynamicrp_recipe:${version}' + kind: 'bicep' + location: '${registry}/test/testrecipes/test-bicep-recipes/dynamicrp_recipe:${version}' parameters: { port: port } diff --git a/test/functional-portable/dynamicrp/noncloud/resources/testdata/recipepacks-test.bicep b/test/functional-portable/dynamicrp/noncloud/resources/testdata/recipepacks-test.bicep index 891d8e9001..2b77af0272 100644 --- a/test/functional-portable/dynamicrp/noncloud/resources/testdata/recipepacks-test.bicep +++ b/test/functional-portable/dynamicrp/noncloud/resources/testdata/recipepacks-test.bicep @@ -18,16 +18,16 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { properties: { recipes: { 'Test.Resources/userTypeAlpha': { - recipeKind: 'bicep' - recipeLocation: '${registry}/test/testrecipes/test-bicep-recipes/dynamicrp_recipe:${version}' + kind: 'bicep' + location: '${registry}/test/testrecipes/test-bicep-recipes/dynamicrp_recipe:${version}' parameters: { port: port } } 'Test.Resources/postgres': { - recipeKind: 'bicep' + kind: 'bicep' // update the sha sum after making changes to the recipe - recipeLocation: '${registry}/test/testrecipes/test-bicep-recipes/dynamicrp_postgress_recipe@sha256:40d079856c2b7cf4df146c0726b31b8bea6a82ef1eb7fa9bc9e00498367f2a4d' + location: '${registry}/test/testrecipes/test-bicep-recipes/dynamicrp_postgress_recipe@sha256:40d079856c2b7cf4df146c0726b31b8bea6a82ef1eb7fa9bc9e00498367f2a4d' } } } diff --git a/test/functional-portable/dynamicrp/noncloud/resources/testdata/terraformconfig-redis-test.bicep b/test/functional-portable/dynamicrp/noncloud/resources/testdata/terraformconfig-redis-test.bicep index 22c4795807..a4c06f348c 100644 --- a/test/functional-portable/dynamicrp/noncloud/resources/testdata/terraformconfig-redis-test.bicep +++ b/test/functional-portable/dynamicrp/noncloud/resources/testdata/terraformconfig-redis-test.bicep @@ -30,8 +30,8 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { properties: { recipes: { 'Applications.Core/extenders': { - recipeKind: 'terraform' - recipeLocation: '${moduleServer}/kubernetes-redis.zip//modules' + kind: 'terraform' + location: '${moduleServer}/kubernetes-redis.zip//modules' } } } diff --git a/test/functional-portable/dynamicrp/noncloud/resources/testdata/tfbicep-combined-test.bicep b/test/functional-portable/dynamicrp/noncloud/resources/testdata/tfbicep-combined-test.bicep index 58e6408f97..5fc948ede7 100644 --- a/test/functional-portable/dynamicrp/noncloud/resources/testdata/tfbicep-combined-test.bicep +++ b/test/functional-portable/dynamicrp/noncloud/resources/testdata/tfbicep-combined-test.bicep @@ -73,8 +73,8 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { properties: { recipes: { 'Applications.Core/extenders': { - recipeKind: 'terraform' - recipeLocation: '${moduleServer}/kubernetes-redis.zip//modules' + kind: 'terraform' + location: '${moduleServer}/kubernetes-redis.zip//modules' } } } From 647345c740dcaca8414f7aec3ea926b88e18c035 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:50:52 +0000 Subject: [PATCH 08/18] Revert out-of-scope extensibility design notes change and fix serde field ordering Agent-Logs-Url: https://github.com/radius-project/radius/sessions/64db5ed2-8d6a-471b-88fa-3f5d850e789e Co-authored-by: Reshrahim <61033581+Reshrahim@users.noreply.github.com> --- ...5-06-compute-extensibility-feature-spec.md | 36 +++++++++---------- .../zz_generated_models_serde.go | 16 ++++----- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/eng/design-notes/extensibility/2025-06-compute-extensibility-feature-spec.md b/eng/design-notes/extensibility/2025-06-compute-extensibility-feature-spec.md index 6c5ea242cd..87fbb33241 100644 --- a/eng/design-notes/extensibility/2025-06-compute-extensibility-feature-spec.md +++ b/eng/design-notes/extensibility/2025-06-compute-extensibility-feature-spec.md @@ -126,7 +126,7 @@ Step 2 #### User Story 1: As a Platform Engineer, I want to create a Recipe Pack that bundles multiple recipes for core resource types, so that I can easily register and manage them in a Radius environment: 1. **Define a Recipe Pack**: - * A platform engineer creates a Radius Recipe Pack resource definition that specifies a collection of Recipes. It would list each core resource type (e.g., `Radius.Compute/containers@2025-05-01-preview`, `Radius.Compute/routes@2025-05-01-preview`, `Radius.Security/secrets@2025-05-01-preview`) and associate it with a specific Recipe (kind and location) and its default parameters: + * A platform engineer creates a Radius Recipe Pack resource definition that specifies a collection of Recipes. It would list each core resource type (e.g., `Radius.Compute/containers@2025-05-01-preview`, `Radius.Compute/routes@2025-05-01-preview`, `Radius.Security/secrets@2025-05-01-preview`) and associate it with a specific Recipe (recipeKind and recipeLocation) and its default parameters: > Note that Recipe Packs are modeled as a new resource type called `Config`, which was introduced in [this feature spec](https://github.com/radius-project/design-notes/blob/main/features/2025-07-23-radius-configuration-ux.md) * e.g. `computeRecipePack.bicep`: ```bicep @@ -136,19 +136,19 @@ Step 2 properties: { recipes: [ Radius.Compute/container: { - kind: 'terraform' - location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' + recipeKind: 'terraform' + recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/compute/containers/kubernetes?ref=v0.48' parameters: { allowPlatformOptions: true } } Radius.Security/secrets: { - kind: 'terraform' - location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' + recipeKind: 'terraform' + recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/security/secrets/kubernetes?ref=v0.48' } Radius.Compute/persistentVolumes: { - kind: 'terraform' - location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' + recipeKind: 'terraform' + recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/storage/volumes/kubernetes?ref=v0.48' } ] } @@ -162,8 +162,8 @@ Step 2 properties: { recipes: [ Radius.Data/redisCaches: { - kind: 'terraform' - location: 'https://github.com/project-radius/resource-types-contrib.git//recipes/data/redis-caches/kubernetes?ref=v0.48' + recipeKind: 'terraform' + recipeLocation: 'https://github.com/project-radius/resource-types-contrib.git//recipes/data/redis-caches/kubernetes?ref=v0.48' } ] } @@ -875,8 +875,8 @@ Given: my platform engineer has set up a Radius environment with recipes registe * The `compute.namespace` property is moved to `providers.kubernetes.namespace` in the `Radius.Core/environments` resource type. * The `properties.providers.azure.scope` property in the `environments` resource type is changed to an object with the subscriptionId and resourceGroupName properties separated. `subscriptionId` (required) can be overridden by the Recipe if it provides a `subscriptionId`. `resourceGroup` (optional) can be set in the Environment or by the Recipe (value set in the Recipe overrides the value set in the Environment). If the neither the Environment nor Recipe specifies a `resourceGroup` (optional) and the resource to be deployed needs to be scoped to a resource group, then the deployment would fail. * The following [Recipe properties](https://docs.radapp.io/reference/resource-schema/core-schema/environment-schema/#recipe-properties) are renamed for better clarity going forward: - * `templateKind` -> `kind` - * `templatePath` -> `location` + * `templateKind` -> `recipeKind` + * `templatePath` -> `recipeLocation` * Recipes accept a new `allowPlatformOptions` parameter that determines whether platform-specific configurations via their own schemas (e.g., ACI containerGroupProfile, Kubernetes podSpec) are allowed in the resource type definition. * Ability to package and register sets of related recipes as "Recipe Packs" to simplify distribution and management. Environment definitions can reference these packs, which will include the necessary recipes for core types. * Core types (`Applications.Core/containers`, `Applications.Core/gateways`, `Applications.Core/secrets`, `Applications.Core/volumes`) are re-implemented as Radius Resource Types (RRT) with new, versioned resource type names (e.g., `Radius.Compute/containers@2025-05-01-preview`). @@ -1094,8 +1094,8 @@ Recipe packs being defined in a yaml manifest that bundles individual Recipes ca description: "Recipe Pack for deploying to ACI in production." recipes: - resourceType: "Radius.Compute/containers@2025-05-01-preview" - kind: "bicep" - location: "oci://ghcr.io/my-org/recipes/core/aci-container:1.2.0" + recipeKind: "bicep" + recipeLocation: "oci://ghcr.io/my-org/recipes/core/aci-container:1.2.0" parameters: cpu: "1.0" memoryInGB: "2.0" @@ -1104,17 +1104,17 @@ Recipe packs being defined in a yaml manifest that bundles individual Recipes ca # Optional: allow platform-specific options like containerGroupProfile for ACI allowPlatformOptions: true - resourceType: "Radius.Compute/routes@2025-05-01-preview" - kind: "bicep" - location: "oci://ghcr.io/my-org/recipes/core/aci-gateway:1.1.0" + recipeKind: "bicep" + recipeLocation: "oci://ghcr.io/my-org/recipes/core/aci-gateway:1.1.0" parameters: sku: "Standard_v2" - resourceType: "Radius.Security/secrets@2025-05-01-preview" - kind: "bicep" - location: "oci://ghcr.io/my-org/recipes/azure/keyvault-secretstore:1.0.0" + recipeKind: "bicep" + recipeLocation: "oci://ghcr.io/my-org/recipes/azure/keyvault-secretstore:1.0.0" parameters: skuName: "premium" ``` - > Note: `templateKind` is changed to `kind` and `templatePath` is changed to `location` + > Note: `templateKind` is changed to `recipeKind` and `templatePath` is changed to `recipeLocation` 1. **Add the Recipe Pack to an Environment**: * The platform engineer uses a new CLI command to add the entire pack to a Radius environment. diff --git a/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go b/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go index a4bc1d9058..363fc8dc69 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go @@ -1265,10 +1265,10 @@ func (p *ProvidersKubernetes) UnmarshalJSON(data []byte) error { // MarshalJSON implements the json.Marshaller interface for type RecipeDefinition. func (r RecipeDefinition) MarshalJSON() ([]byte, error) { objectMap := make(map[string]any) - populate(objectMap, "parameters", r.Parameters) - populate(objectMap, "plainHttp", r.PlainHTTP) populate(objectMap, "kind", r.Kind) populate(objectMap, "location", r.Location) + populate(objectMap, "parameters", r.Parameters) + populate(objectMap, "plainHttp", r.PlainHTTP) return json.Marshal(objectMap) } @@ -1281,18 +1281,18 @@ func (r *RecipeDefinition) UnmarshalJSON(data []byte) error { for key, val := range rawMsg { var err error switch key { - case "parameters": - err = unpopulate(val, "Parameters", &r.Parameters) - delete(rawMsg, key) - case "plainHttp": - err = unpopulate(val, "PlainHTTP", &r.PlainHTTP) - delete(rawMsg, key) case "kind": err = unpopulate(val, "Kind", &r.Kind) delete(rawMsg, key) case "location": err = unpopulate(val, "Location", &r.Location) delete(rawMsg, key) + case "parameters": + err = unpopulate(val, "Parameters", &r.Parameters) + delete(rawMsg, key) + case "plainHttp": + err = unpopulate(val, "PlainHTTP", &r.PlainHTTP) + delete(rawMsg, key) } if err != nil { return fmt.Errorf("unmarshalling type %T: %v", r, err) From 515219571cc092327bd5e34eddda0fdbdeff8ca0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 22:20:27 +0000 Subject: [PATCH 09/18] Add test coverage for renamed Kind/Location fields in test helpers and fetchRecipeDefinition Agent-Logs-Url: https://github.com/radius-project/radius/sessions/0899830b-632d-4a0e-8f45-65d39a9f85e6 Co-authored-by: Reshrahim <61033581+Reshrahim@users.noreply.github.com> --- .../test_client_factory/radius_core_test.go | 93 +++++++++++++++++++ .../environment_v20250801_bridge_test.go | 47 ++++++++++ 2 files changed, 140 insertions(+) create mode 100644 pkg/cli/test_client_factory/radius_core_test.go diff --git a/pkg/cli/test_client_factory/radius_core_test.go b/pkg/cli/test_client_factory/radius_core_test.go new file mode 100644 index 0000000000..87de66a456 --- /dev/null +++ b/pkg/cli/test_client_factory/radius_core_test.go @@ -0,0 +1,93 @@ +/* +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 test_client_factory + +import ( + "context" + "testing" + + armpolicy "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + corerpfake "github.com/radius-project/radius/pkg/corerp/api/v20250801preview/fake" + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" + "github.com/stretchr/testify/require" +) + +func newTestClient(t *testing.T, rpSrv corerpfake.RecipePacksServer) *v20250801preview.RecipePacksClient { + t.Helper() + opts := &armpolicy.ClientOptions{ + ClientOptions: policy.ClientOptions{ + Transport: corerpfake.NewServerFactoryTransport(&corerpfake.ServerFactory{ + RecipePacksServer: rpSrv, + }), + }, + } + client, err := v20250801preview.NewRecipePacksClient("/planes/radius/local/resourceGroups/test-rg", &aztoken.AnonymousCredential{}, opts) + require.NoError(t, err) + return client +} + +func TestWithRecipePackServerNoError_Get(t *testing.T) { + client := newTestClient(t, WithRecipePackServerNoError()) + + resp, err := client.Get(context.Background(), "my-pack", nil) + require.NoError(t, err) + require.Equal(t, "my-pack", *resp.Name) + require.Len(t, resp.Properties.Recipes, 2) + require.Equal(t, v20250801preview.RecipeKindTerraform, *resp.Properties.Recipes["test-recipe1"].Kind) + require.Equal(t, "https://example.com/recipe1?ref=v0.1", *resp.Properties.Recipes["test-recipe1"].Location) + require.Equal(t, v20250801preview.RecipeKindTerraform, *resp.Properties.Recipes["test-recipe2"].Kind) + require.Equal(t, "https://example.com/recipe2?ref=v0.1", *resp.Properties.Recipes["test-recipe2"].Location) +} + +func TestWithRecipePackServerCoreTypes_Get(t *testing.T) { + client := newTestClient(t, WithRecipePackServerCoreTypes()) + + resp, err := client.Get(context.Background(), "containers", nil) + require.NoError(t, err) + require.Equal(t, "containers", *resp.Name) + + recipe := resp.Properties.Recipes["Radius.Compute/containers"] + require.NotNil(t, recipe) + require.Equal(t, v20250801preview.RecipeKindBicep, *recipe.Kind) + require.Equal(t, "ghcr.io/test/containers:latest", *recipe.Location) +} + +func TestWithRecipePackServerUniqueTypes_Get(t *testing.T) { + client := newTestClient(t, WithRecipePackServerUniqueTypes()) + + resp, err := client.Get(context.Background(), "mypack", nil) + require.NoError(t, err) + + recipe := resp.Properties.Recipes["Test.Resource/mypack"] + require.NotNil(t, recipe) + require.Equal(t, v20250801preview.RecipeKindBicep, *recipe.Kind) + require.Equal(t, "ghcr.io/test/mypack:latest", *recipe.Location) +} + +func TestWithRecipePackServerConflictingTypes_Get(t *testing.T) { + client := newTestClient(t, WithRecipePackServerConflictingTypes()) + + resp, err := client.Get(context.Background(), "pack-a", nil) + require.NoError(t, err) + + recipe := resp.Properties.Recipes["Radius.Compute/containers"] + require.NotNil(t, recipe) + require.Equal(t, v20250801preview.RecipeKindBicep, *recipe.Kind) + require.Equal(t, "ghcr.io/test/pack-a:latest", *recipe.Location) +} diff --git a/pkg/recipes/configloader/environment_v20250801_bridge_test.go b/pkg/recipes/configloader/environment_v20250801_bridge_test.go index 6dcd1e810f..b638e7a5fc 100644 --- a/pkg/recipes/configloader/environment_v20250801_bridge_test.go +++ b/pkg/recipes/configloader/environment_v20250801_bridge_test.go @@ -261,6 +261,53 @@ func TestGetConfigurationV20250801_BicepAllEntriesSkipped_LeavesAuthNil(t *testi require.Empty(t, cfg.RecipeConfig.Bicep.Authentication, "expected no auth map when no usable entries") } +func TestFetchRecipeDefinition_Success(t *testing.T) { + recipePackName := "kubernetes-pack" + recipePackID := "/planes/radius/local/resourceGroups/rg/providers/Radius.Core/recipePacks/" + recipePackName + resourceType := "Radius.Compute/containers" + expectedLocation := "ghcr.io/test/containers:latest" + bicepKind := v20250801.RecipeKindBicep + + rpSrv := fake.RecipePacksServer{ + Get: func(ctx context.Context, name string, opts *v20250801.RecipePacksClientGetOptions) (resp azfake.Responder[v20250801.RecipePacksClientGetResponse], errResp azfake.ErrorResponder) { + require.Equal(t, recipePackName, name) + resp.SetResponse(http.StatusOK, v20250801.RecipePacksClientGetResponse{ + RecipePackResource: v20250801.RecipePackResource{ + Name: to.Ptr(recipePackName), + Properties: &v20250801.RecipePackProperties{ + Recipes: map[string]*v20250801.RecipeDefinition{ + resourceType: { + Kind: &bicepKind, + Location: to.Ptr(expectedLocation), + Parameters: map[string]any{ + "tier": "basic", + }, + }, + }, + }, + }, + }, nil) + return + }, + } + + armOpts := &armpolicy.ClientOptions{ + ClientOptions: policy.ClientOptions{ + Transport: fake.NewServerFactoryTransport(&fake.ServerFactory{ + RecipePacksServer: rpSrv, + }), + }, + } + + def, err := fetchRecipeDefinition(context.Background(), []string{recipePackID}, armOpts, resourceType) + require.NoError(t, err) + require.NotNil(t, def) + require.Equal(t, string(bicepKind), def.RecipeKind) + require.Equal(t, expectedLocation, def.RecipeLocation) + require.Equal(t, map[string]any{"tier": "basic"}, def.Parameters) + require.False(t, def.PlainHTTP) +} + func TestGetConfigurationV20250801_TerraformFetchError_IsWrapped(t *testing.T) { tfSrv := fake.TerraformConfigsServer{ Get: func(ctx context.Context, name string, opts *v20250801.TerraformConfigsClientGetOptions) (resp azfake.Responder[v20250801.TerraformConfigsClientGetResponse], errResp azfake.ErrorResponder) { From 79613b439d084d0819c34731e7b17fd225d5d581 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 01:23:33 +0000 Subject: [PATCH 10/18] Remove Recipe prefix from data model, CLI labels, and all user-facing touch points Agent-Logs-Url: https://github.com/radius-project/radius/sessions/8abda089-fdd5-46cf-9246-375f380968d4 Co-authored-by: Reshrahim <61033581+Reshrahim@users.noreply.github.com> --- pkg/cli/cmd/env/show/preview/show.go | 16 ++++----- pkg/cli/cmd/env/show/preview/show_test.go | 24 ++++++------- pkg/cli/objectformats/objectformats.go | 20 +++++------ pkg/cli/recipepack/recipepack.go | 22 ++++++------ pkg/cli/recipepack/recipepack_test.go | 8 ++--- .../v20250801preview/recipepack_conversion.go | 8 ++--- .../converter/recipepack_converter_test.go | 36 +++++++++---------- pkg/corerp/datamodel/recipepack.go | 8 ++--- .../createorupdateenvironment_test.go | 20 +++++------ .../createorupdaterecipepack_test.go | 12 +++---- pkg/recipes/configloader/environment.go | 8 ++--- .../environment_v20250801_bridge_test.go | 4 +-- pkg/recipes/types.go | 8 ++--- 13 files changed, 97 insertions(+), 97 deletions(-) diff --git a/pkg/cli/cmd/env/show/preview/show.go b/pkg/cli/cmd/env/show/preview/show.go index f17f115ceb..2f8b1818a4 100644 --- a/pkg/cli/cmd/env/show/preview/show.go +++ b/pkg/cli/cmd/env/show/preview/show.go @@ -68,10 +68,10 @@ rad env show my-env --group my-env } type EnvRecipes struct { - RecipePack string - ResourceType string - RecipeKind string - RecipeLocation string + RecipePack string + ResourceType string + Kind string + Location string } // Runner is the runner implementation for the `rad env show` preview command. @@ -189,10 +189,10 @@ func (r *Runner) Run(ctx context.Context) error { for resourceType, recipe := range pack.RecipePackResource.Properties.Recipes { envRecipes = append(envRecipes, EnvRecipes{ - RecipePack: ID.Name(), - ResourceType: resourceType, - RecipeKind: string(*recipe.Kind), - RecipeLocation: *recipe.Location, + RecipePack: ID.Name(), + ResourceType: resourceType, + Kind: string(*recipe.Kind), + Location: *recipe.Location, }) } } diff --git a/pkg/cli/cmd/env/show/preview/show_test.go b/pkg/cli/cmd/env/show/preview/show_test.go index 0eabf5977f..1b2bd2caef 100644 --- a/pkg/cli/cmd/env/show/preview/show_test.go +++ b/pkg/cli/cmd/env/show/preview/show_test.go @@ -165,16 +165,16 @@ func Test_Run(t *testing.T) { Format: "table", Obj: []EnvRecipes{ { - RecipePack: "test-recipe-pack", - ResourceType: "test-recipe1", - RecipeKind: string(corerpv20250801.RecipeKindTerraform), - RecipeLocation: "https://example.com/recipe1?ref=v0.1", + RecipePack: "test-recipe-pack", + ResourceType: "test-recipe1", + Kind: string(corerpv20250801.RecipeKindTerraform), + Location: "https://example.com/recipe1?ref=v0.1", }, { - RecipePack: "test-recipe-pack", - ResourceType: "test-recipe2", - RecipeKind: string(corerpv20250801.RecipeKindTerraform), - RecipeLocation: "https://example.com/recipe2?ref=v0.1", + RecipePack: "test-recipe-pack", + ResourceType: "test-recipe2", + Kind: string(corerpv20250801.RecipeKindTerraform), + Location: "https://example.com/recipe2?ref=v0.1", }, }, Options: objectformats.GetRecipesForEnvironmentTableFormat(), @@ -294,10 +294,10 @@ func Test_Run_RecipeSortOrder(t *testing.T) { // Verify the recipes are sorted by RecipePack first, then by ResourceType expectedRecipes := []EnvRecipes{ - {RecipePack: "pack-a", ResourceType: "Applications.Datastores/redisCaches", RecipeKind: "terraform", RecipeLocation: "ghcr.io/radius-project/recipes/redis"}, - {RecipePack: "pack-a", ResourceType: "Applications.Datastores/sqlDatabases", RecipeKind: "terraform", RecipeLocation: "ghcr.io/radius-project/recipes/sql"}, - {RecipePack: "pack-b", ResourceType: "Applications.Dapr/stateStores", RecipeKind: "bicep", RecipeLocation: "ghcr.io/radius-project/recipes/dapr-state"}, - {RecipePack: "pack-b", ResourceType: "Applications.Messaging/rabbitMQQueues", RecipeKind: "bicep", RecipeLocation: "ghcr.io/radius-project/recipes/rabbitmq"}, + {RecipePack: "pack-a", ResourceType: "Applications.Datastores/redisCaches", Kind: "terraform", Location: "ghcr.io/radius-project/recipes/redis"}, + {RecipePack: "pack-a", ResourceType: "Applications.Datastores/sqlDatabases", Kind: "terraform", Location: "ghcr.io/radius-project/recipes/sql"}, + {RecipePack: "pack-b", ResourceType: "Applications.Dapr/stateStores", Kind: "bicep", Location: "ghcr.io/radius-project/recipes/dapr-state"}, + {RecipePack: "pack-b", ResourceType: "Applications.Messaging/rabbitMQQueues", Kind: "bicep", Location: "ghcr.io/radius-project/recipes/rabbitmq"}, } // The third output should be the recipes table diff --git a/pkg/cli/objectformats/objectformats.go b/pkg/cli/objectformats/objectformats.go index 16ae8233a1..2953ba8ca1 100644 --- a/pkg/cli/objectformats/objectformats.go +++ b/pkg/cli/objectformats/objectformats.go @@ -70,12 +70,12 @@ func GetRecipeFormat() output.FormatterOptions { JSONPath: "{ .ResourceType }", }, { - Heading: "RECIPE KIND", - JSONPath: "{ .RecipeKind }", + Heading: "KIND", + JSONPath: "{ .Kind }", }, { - Heading: "RECIPE LOCATION", - JSONPath: "{ .RecipeLocation }", + Heading: "LOCATION", + JSONPath: "{ .Location }", }, }, } @@ -90,11 +90,11 @@ func GetRecipeFormatWithoutHeadings() output.FormatterOptions { }, { Heading: "", - JSONPath: "{ .RecipeKind }", + JSONPath: "{ .Kind }", }, { Heading: "", - JSONPath: "{ .RecipeLocation }", + JSONPath: "{ .Location }", }, }, } @@ -140,12 +140,12 @@ func GetRecipesForEnvironmentTableFormat() output.FormatterOptions { JSONPath: "{ .ResourceType }", }, { - Heading: "RECIPE KIND", - JSONPath: "{ .RecipeKind }", + Heading: "KIND", + JSONPath: "{ .Kind }", }, { - Heading: "RECIPE LOCATION", - JSONPath: "{ .RecipeLocation }", + Heading: "LOCATION", + JSONPath: "{ .Location }", }, }, } diff --git a/pkg/cli/recipepack/recipepack.go b/pkg/cli/recipepack/recipepack.go index efe236af54..d5f9cb822d 100644 --- a/pkg/cli/recipepack/recipepack.go +++ b/pkg/cli/recipepack/recipepack.go @@ -55,7 +55,7 @@ func NewDefaultRecipePackResource() corerpv20250801.RecipePackResource { for _, def := range GetCoreTypesRecipeInfo() { recipes[def.ResourceType] = &corerpv20250801.RecipeDefinition{ Kind: &bicepKind, - Location: to.Ptr(def.RecipeLocation), + Location: to.Ptr(def.Location), } } return corerpv20250801.RecipePackResource{ @@ -110,8 +110,8 @@ func GetOrCreateDefaultRecipePack(ctx context.Context, client *corerpv20250801.R type CoreTypesRecipeInfo struct { // ResourceType is the full resource type (e.g., "Radius.Compute/containers"). ResourceType string - // RecipeLocation is the OCI registry location for the recipe. - RecipeLocation string + // Location is the OCI registry location for the recipe. + Location string } // GetCoreTypesRecipeInfo returns recipe information for all core types. @@ -124,20 +124,20 @@ func GetCoreTypesRecipeInfo() []CoreTypesRecipeInfo { } return []CoreTypesRecipeInfo{ { - ResourceType: "Radius.Compute/containers", - RecipeLocation: "ghcr.io/radius-project/kube-recipes/containers:" + tag, + ResourceType: "Radius.Compute/containers", + Location: "ghcr.io/radius-project/kube-recipes/containers:" + tag, }, { - ResourceType: "Radius.Compute/persistentVolumes", - RecipeLocation: "ghcr.io/radius-project/kube-recipes/persistentvolumes:" + tag, + ResourceType: "Radius.Compute/persistentVolumes", + Location: "ghcr.io/radius-project/kube-recipes/persistentvolumes:" + tag, }, { - ResourceType: "Radius.Compute/routes", - RecipeLocation: "ghcr.io/radius-project/kube-recipes/routes:" + tag, + ResourceType: "Radius.Compute/routes", + Location: "ghcr.io/radius-project/kube-recipes/routes:" + tag, }, { - ResourceType: "Radius.Security/secrets", - RecipeLocation: "ghcr.io/radius-project/kube-recipes/secrets:" + tag, + ResourceType: "Radius.Security/secrets", + Location: "ghcr.io/radius-project/kube-recipes/secrets:" + tag, }, } } diff --git a/pkg/cli/recipepack/recipepack_test.go b/pkg/cli/recipepack/recipepack_test.go index e6da9c51ed..af815bd637 100644 --- a/pkg/cli/recipepack/recipepack_test.go +++ b/pkg/cli/recipepack/recipepack_test.go @@ -41,7 +41,7 @@ func Test_GetDefaultRecipePackDefinition(t *testing.T) { actualResourceTypes := make([]string, len(definitions)) for i, def := range definitions { actualResourceTypes[i] = def.ResourceType - require.NotEmpty(t, def.RecipeLocation, "RecipeLocation should not be empty for %s", def.ResourceType) + require.NotEmpty(t, def.Location, "Location should not be empty for %s", def.ResourceType) } require.ElementsMatch(t, expectedResourceTypes, actualResourceTypes) } @@ -52,8 +52,8 @@ func Test_GetDefaultRecipePackDefinition_UsesLatestTagForEdgeChannel(t *testing. definitions := GetCoreTypesRecipeInfo() for _, def := range definitions { - require.True(t, strings.HasSuffix(def.RecipeLocation, ":latest"), - "Expected :latest tag for edge channel, got %s", def.RecipeLocation) + require.True(t, strings.HasSuffix(def.Location, ":latest"), + "Expected :latest tag for edge channel, got %s", def.Location) } } @@ -78,6 +78,6 @@ func Test_NewDefaultRecipePackResource(t *testing.T) { require.NotNil(t, recipe.Kind) require.Equal(t, corerpv20250801.RecipeKindBicep, *recipe.Kind) require.NotNil(t, recipe.Location) - require.Equal(t, def.RecipeLocation, *recipe.Location) + require.Equal(t, def.Location, *recipe.Location) } } diff --git a/pkg/corerp/api/v20250801preview/recipepack_conversion.go b/pkg/corerp/api/v20250801preview/recipepack_conversion.go index 41421605ac..2b3ca9ff3e 100644 --- a/pkg/corerp/api/v20250801preview/recipepack_conversion.go +++ b/pkg/corerp/api/v20250801preview/recipepack_conversion.go @@ -95,8 +95,8 @@ func toRecipesDataModel(recipes map[string]*RecipeDefinition) map[string]*datamo for key, recipe := range recipes { if recipe != nil { result[key] = &datamodel.RecipeDefinition{ - RecipeKind: toRecipeKindDataModel(recipe.Kind), - RecipeLocation: to.String(recipe.Location), + Kind: toRecipeKindDataModel(recipe.Kind), + Location: to.String(recipe.Location), Parameters: recipe.Parameters, PlainHTTP: to.Bool(recipe.PlainHTTP), } @@ -114,8 +114,8 @@ func fromRecipesDataModel(recipes map[string]*datamodel.RecipeDefinition) map[st for key, recipe := range recipes { if recipe != nil { result[key] = &RecipeDefinition{ - Kind: fromRecipeKindDataModel(recipe.RecipeKind), - Location: new(recipe.RecipeLocation), + Kind: fromRecipeKindDataModel(recipe.Kind), + Location: new(recipe.Location), Parameters: recipe.Parameters, PlainHTTP: new(recipe.PlainHTTP), } diff --git a/pkg/corerp/datamodel/converter/recipepack_converter_test.go b/pkg/corerp/datamodel/converter/recipepack_converter_test.go index 33719670df..9bdb9c0c0b 100644 --- a/pkg/corerp/datamodel/converter/recipepack_converter_test.go +++ b/pkg/corerp/datamodel/converter/recipepack_converter_test.go @@ -58,16 +58,16 @@ func TestRecipePackDataModelToVersioned(t *testing.T) { Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Core/containers": { - RecipeKind: "bicep", - RecipeLocation: "br:myregistry.azurecr.io/recipes/container:1.0", + Kind: "bicep", + Location: "br:myregistry.azurecr.io/recipes/container:1.0", Parameters: map[string]any{ "param1": "value1", }, PlainHTTP: false, }, "Applications.Datastores/sqlDatabases": { - RecipeKind: "terraform", - RecipeLocation: "https://github.com/radius-project/recipes.git//terraform/modules/sql", + Kind: "terraform", + Location: "https://github.com/radius-project/recipes.git//terraform/modules/sql", PlainHTTP: false, }, }, @@ -203,16 +203,16 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Core/containers": { - RecipeKind: "bicep", - RecipeLocation: "br:myregistry.azurecr.io/recipes/container:1.0", + Kind: "bicep", + Location: "br:myregistry.azurecr.io/recipes/container:1.0", Parameters: map[string]any{ "param1": "value1", }, PlainHTTP: false, }, "Applications.Datastores/sqlDatabases": { - RecipeKind: "terraform", - RecipeLocation: "https://github.com/radius-project/recipes.git//terraform/modules/sql", + Kind: "terraform", + Location: "https://github.com/radius-project/recipes.git//terraform/modules/sql", PlainHTTP: false, }, }, @@ -284,8 +284,8 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Core/containers": { - RecipeKind: "bicep", - RecipeLocation: "br:myregistry.azurecr.io/recipes/container:1.0", + Kind: "bicep", + Location: "br:myregistry.azurecr.io/recipes/container:1.0", PlainHTTP: false, // Should default to false when not specified }, }, @@ -327,8 +327,8 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Datastores/sqlDatabases": { - RecipeKind: "terraform", - RecipeLocation: "http://insecure-registry.example.com/recipes/sql", + Kind: "terraform", + Location: "http://insecure-registry.example.com/recipes/sql", PlainHTTP: true, // Explicitly set to true }, }, @@ -386,8 +386,8 @@ func TestRecipePackDataModelFromVersioned(t *testing.T) { for key, expectedRecipe := range tc.expected.Properties.Recipes { actualRecipe, exists := result.Properties.Recipes[key] require.True(t, exists, "Recipe %s should exist", key) - require.Equal(t, expectedRecipe.RecipeKind, actualRecipe.RecipeKind) - require.Equal(t, expectedRecipe.RecipeLocation, actualRecipe.RecipeLocation) + require.Equal(t, expectedRecipe.Kind, actualRecipe.Kind) + require.Equal(t, expectedRecipe.Location, actualRecipe.Location) // Debug output for plainHTTP t.Logf("Recipe %s - Expected PlainHTTP: %v, Actual PlainHTTP: %v", key, expectedRecipe.PlainHTTP, actualRecipe.PlainHTTP) @@ -426,8 +426,8 @@ func TestRecipePackRoundTripConversion(t *testing.T) { Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Core/containers": { - RecipeKind: "bicep", - RecipeLocation: "br:test.azurecr.io/recipes/container:latest", + Kind: "bicep", + Location: "br:test.azurecr.io/recipes/container:latest", Parameters: map[string]any{ "cpu": "0.5", "memory": "1Gi", @@ -468,8 +468,8 @@ func TestRecipePackRoundTripConversion(t *testing.T) { for key, originalRecipe := range originalDataModel.Properties.Recipes { resultRecipe, exists := resultDataModel.Properties.Recipes[key] require.True(t, exists, "Recipe %s should exist after round-trip", key) - require.Equal(t, originalRecipe.RecipeKind, resultRecipe.RecipeKind) - require.Equal(t, originalRecipe.RecipeLocation, resultRecipe.RecipeLocation) + require.Equal(t, originalRecipe.Kind, resultRecipe.Kind) + require.Equal(t, originalRecipe.Location, resultRecipe.Location) require.Equal(t, originalRecipe.PlainHTTP, resultRecipe.PlainHTTP) require.Equal(t, originalRecipe.Parameters, resultRecipe.Parameters) } diff --git a/pkg/corerp/datamodel/recipepack.go b/pkg/corerp/datamodel/recipepack.go index bb13c7d0b2..e326fff9b7 100644 --- a/pkg/corerp/datamodel/recipepack.go +++ b/pkg/corerp/datamodel/recipepack.go @@ -49,11 +49,11 @@ type RecipePackProperties struct { // RecipeDefinition represents a recipe definition in the datamodel. type RecipeDefinition struct { - // RecipeKind is the type of recipe (e.g., terraform, bicep). - RecipeKind string `json:"kind"` + // Kind is the type of recipe (e.g., terraform, bicep). + Kind string `json:"kind"` - // RecipeLocation is the URL or path to the recipe source. - RecipeLocation string `json:"location"` + // Location is the URL or path to the recipe source. + Location string `json:"location"` // Parameters to pass to the recipe. Parameters map[string]any `json:"parameters,omitempty"` diff --git a/pkg/corerp/frontend/controller/environments/v20250801preview/createorupdateenvironment_test.go b/pkg/corerp/frontend/controller/environments/v20250801preview/createorupdateenvironment_test.go index 47dd13f543..efa0cdbbd1 100644 --- a/pkg/corerp/frontend/controller/environments/v20250801preview/createorupdateenvironment_test.go +++ b/pkg/corerp/frontend/controller/environments/v20250801preview/createorupdateenvironment_test.go @@ -378,8 +378,8 @@ func TestCreateOrUpdateEnvironment_RecipePackValidation(t *testing.T) { Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Core/containers": { - RecipeKind: "bicep", - RecipeLocation: "br:myregistry.azurecr.io/recipes/container:1.0", + Kind: "bicep", + Location: "br:myregistry.azurecr.io/recipes/container:1.0", }, }, }, @@ -388,8 +388,8 @@ func TestCreateOrUpdateEnvironment_RecipePackValidation(t *testing.T) { Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Dapr/stateStores": { - RecipeKind: "terraform", - RecipeLocation: "git::https://github.com/recipes/dapr-state", + Kind: "terraform", + Location: "git::https://github.com/recipes/dapr-state", }, }, }, @@ -413,8 +413,8 @@ func TestCreateOrUpdateEnvironment_RecipePackValidation(t *testing.T) { Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Core/containers": { - RecipeKind: "bicep", - RecipeLocation: "br:myregistry.azurecr.io/recipes/container:1.0", + Kind: "bicep", + Location: "br:myregistry.azurecr.io/recipes/container:1.0", }, }, }, @@ -423,8 +423,8 @@ func TestCreateOrUpdateEnvironment_RecipePackValidation(t *testing.T) { Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Core/containers": { - RecipeKind: "terraform", - RecipeLocation: "git::https://github.com/recipes/container", + Kind: "terraform", + Location: "git::https://github.com/recipes/container", }, }, }, @@ -456,8 +456,8 @@ func TestCreateOrUpdateEnvironment_RecipePackValidation(t *testing.T) { Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Core/containers": { - RecipeKind: "bicep", - RecipeLocation: "br:myregistry.azurecr.io/recipes/container:1.0", + Kind: "bicep", + Location: "br:myregistry.azurecr.io/recipes/container:1.0", }, }, }, diff --git a/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go b/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go index 96a17f0f4e..5b3507ddf2 100644 --- a/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go +++ b/pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go @@ -188,16 +188,16 @@ func getTestModels() (*v20250801preview.RecipePackResource, *datamodel.RecipePac Properties: datamodel.RecipePackProperties{ Recipes: map[string]*datamodel.RecipeDefinition{ "Applications.Core/extenders": { - RecipeKind: "bicep", - RecipeLocation: "ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0", + Kind: "bicep", + Location: "ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0", }, "Radius.Resources/postgreSQL": { - RecipeKind: "bicep", - RecipeLocation: "ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0", + Kind: "bicep", + Location: "ghcr.io/radius-project/recipes/local-dev/extender-postgresql:0.50.0", }, "Applications.Datastores/redisCaches": { - RecipeKind: "bicep", - RecipeLocation: "https://github.com/example/recipes/redis-cache.bicep", + Kind: "bicep", + Location: "https://github.com/example/recipes/redis-cache.bicep", Parameters: map[string]any{ "tier": "basic", }, diff --git a/pkg/recipes/configloader/environment.go b/pkg/recipes/configloader/environment.go index 7a4db1c463..0fb898059e 100644 --- a/pkg/recipes/configloader/environment.go +++ b/pkg/recipes/configloader/environment.go @@ -367,10 +367,10 @@ func getRecipeDefinitionFromEnvironmentV20250801(ctx context.Context, environmen // We will remove this field from EnvironmentDefinition once we deprecate Applications.Core. definition := &recipes.EnvironmentDefinition{ Name: "default", - Driver: recipeDefinition.RecipeKind, + Driver: recipeDefinition.Kind, ResourceType: resource.Type(), Parameters: parameters, - TemplatePath: recipeDefinition.RecipeLocation, + TemplatePath: recipeDefinition.Location, PlainHTTP: recipeDefinition.PlainHTTP, } return definition, nil @@ -405,8 +405,8 @@ func fetchRecipeDefinition(ctx context.Context, recipePackIDs []string, armOptio plainHTTP = *definition.PlainHTTP } return &recipes.RecipeDefinition{ - RecipeKind: string(*definition.Kind), - RecipeLocation: string(*definition.Location), + Kind: string(*definition.Kind), + Location: string(*definition.Location), Parameters: definition.Parameters, PlainHTTP: plainHTTP, }, nil diff --git a/pkg/recipes/configloader/environment_v20250801_bridge_test.go b/pkg/recipes/configloader/environment_v20250801_bridge_test.go index b638e7a5fc..64b492d2bc 100644 --- a/pkg/recipes/configloader/environment_v20250801_bridge_test.go +++ b/pkg/recipes/configloader/environment_v20250801_bridge_test.go @@ -302,8 +302,8 @@ func TestFetchRecipeDefinition_Success(t *testing.T) { def, err := fetchRecipeDefinition(context.Background(), []string{recipePackID}, armOpts, resourceType) require.NoError(t, err) require.NotNil(t, def) - require.Equal(t, string(bicepKind), def.RecipeKind) - require.Equal(t, expectedLocation, def.RecipeLocation) + require.Equal(t, string(bicepKind), def.Kind) + require.Equal(t, expectedLocation, def.Location) require.Equal(t, map[string]any{"tier": "basic"}, def.Parameters) require.False(t, def.PlainHTTP) } diff --git a/pkg/recipes/types.go b/pkg/recipes/types.go index 61d512487b..74730f9056 100644 --- a/pkg/recipes/types.go +++ b/pkg/recipes/types.go @@ -152,10 +152,10 @@ type RecipePackResource struct { // RecipeDefinition represents a recipe definition for a specific resource type in a recipe pack. type RecipeDefinition struct { - // RecipeKind represents the type of recipe (e.g., terraform, bicep) - RecipeKind string - // RecipeLocation represents URL or path to the recipe source - RecipeLocation string + // Kind represents the type of recipe (e.g., terraform, bicep) + Kind string + // Location represents URL or path to the recipe source + Location string // Parameters represents parameters to pass to the recipe Parameters map[string]any // PlainHTTP connects to the location using HTTP (not-HTTPS) From 542240f7680a9bd17bc7911de8b8cd23b22c0ce8 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Tue, 12 May 2026 14:05:26 -0700 Subject: [PATCH 11/18] Add direct module support spec --- .../checklists/requirements.md | 38 +++ .../contracts/source-resolver.go | 97 ++++++ specs/001-direct-module-support/data-model.md | 212 ++++++++++++ specs/001-direct-module-support/plan.md | 261 +++++++++++++++ specs/001-direct-module-support/prompt.md | 65 ++++ specs/001-direct-module-support/quickstart.md | 302 ++++++++++++++++++ specs/001-direct-module-support/research.md | 245 ++++++++++++++ specs/001-direct-module-support/spec.md | 212 ++++++++++++ specs/001-direct-module-support/tasks.md | 275 ++++++++++++++++ 9 files changed, 1707 insertions(+) create mode 100644 specs/001-direct-module-support/checklists/requirements.md create mode 100644 specs/001-direct-module-support/contracts/source-resolver.go create mode 100644 specs/001-direct-module-support/data-model.md create mode 100644 specs/001-direct-module-support/plan.md create mode 100644 specs/001-direct-module-support/prompt.md create mode 100644 specs/001-direct-module-support/quickstart.md create mode 100644 specs/001-direct-module-support/research.md create mode 100644 specs/001-direct-module-support/spec.md create mode 100644 specs/001-direct-module-support/tasks.md diff --git a/specs/001-direct-module-support/checklists/requirements.md b/specs/001-direct-module-support/checklists/requirements.md new file mode 100644 index 0000000000..89ff2661c6 --- /dev/null +++ b/specs/001-direct-module-support/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Direct Module Support + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-30 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Full spec rewrite on 2026-04-30 to correct framing: eliminated "recipe registration" language, made input/output resolution core P1 capabilities (not advanced), and restructured story progression. +- Terminology corrections: "direct module support" (not "recipe registration"), "input resolution" and "output resolution" (the system handles these externally), recipes are "linked" to environments (already works today). +- Story progression: P1 = Basic Bicep module support (with I/O resolution) + Basic Terraform module support (with I/O resolution); P2 = AVM modules, version pinning, private auth; P3 = schema inspection, link-time validation. +- 7 user stories (2×P1, 3×P2, 2×P3), 21 functional requirements, 8 success criteria, 8 assumptions, 9 edge cases. +- No [NEEDS CLARIFICATION] markers — complete context available from prototype code, research notes, and quickstart examples. diff --git a/specs/001-direct-module-support/contracts/source-resolver.go b/specs/001-direct-module-support/contracts/source-resolver.go new file mode 100644 index 0000000000..4c0231679a --- /dev/null +++ b/specs/001-direct-module-support/contracts/source-resolver.go @@ -0,0 +1,97 @@ +// Package source provides module source classification and validation for +// Terraform recipe recipe locations. It determines whether a recipeLocation +// refers to a direct Terraform module source (registry, Git, HTTP) or an +// existing OCI/wrapped recipe path, enabling the recipe system to apply +// appropriate execution and output mapping strategies. +// +// This file defines the contract (interface + types) for the source resolver. +// Implementation will be in resolver.go. +package source + +import "context" + +// SourceType classifies the format of a Terraform module source path. +type SourceType int + +const ( + // SourceTypeUnknown indicates the source format could not be classified. + // The system should fall back to existing OCI/wrapped recipe resolution. + SourceTypeUnknown SourceType = iota + + // SourceTypeTerraformRegistry indicates a standard Terraform registry source. + // Format: "namespace/name/provider" (exactly 3 slash-separated segments, no scheme). + // Example: "hashicorp/consul/aws", "Azure/cosmosdb/azurerm" + SourceTypeTerraformRegistry + + // SourceTypeGit indicates a Git-hosted module source. + // Format: "git::https://..." or "git::ssh://..." + // Supports ref specifiers (?ref=v1.0.0) and subdirectories (//modules/vpc). + SourceTypeGit + + // SourceTypeHTTP indicates an HTTP/HTTPS archive source. + // Format: "https://example.com/module.tar.gz" (without git:: prefix) + SourceTypeHTTP + + // SourceTypeS3 indicates an S3-hosted module source. + // Format: "s3::bucket-name/key" + SourceTypeS3 + + // SourceTypeGCS indicates a GCS-hosted module source. + // Format: "gcs::bucket-name/key" + SourceTypeGCS + + // SourceTypeOCI indicates an OCI registry source (existing wrapped recipe path). + // Format: contains "oci://" or matches OCI image reference patterns. + SourceTypeOCI +) + +// ResolvedSource contains the classification result for a recipe location. +type ResolvedSource struct { + // Type is the classified source type. + Type SourceType + + // OriginalPath is the unmodified recipeLocation value. + OriginalPath string + + // IsDirectModule is true when the source is a direct Terraform module + // (not a wrapped/OCI recipe). This determines output mapping strategy. + IsDirectModule bool +} + +// Resolver classifies and validates Terraform module source paths. +type Resolver interface { + // Classify determines the source type of a recipe location without making + // any network calls. Classification is purely based on string pattern matching. + // + // Returns a ResolvedSource with Type set to the detected source type. + // If the format is not recognized, Type is SourceTypeUnknown and + // IsDirectModule is false (indicating fallback to existing behavior). + Classify(recipeLocation string) ResolvedSource + + // ValidateReachability performs a lightweight network check to verify + // that the module source is accessible. This is called at RecipePack + // creation time per FR-014. + // + // For registry modules: HTTP GET to registry API + // For Git sources: git ls-remote + // For HTTP sources: HTTP HEAD request + // + // Returns nil if the source is reachable, or an error describing why + // it could not be reached. The check has a 30-second timeout. + // + // If the source type is SourceTypeUnknown or SourceTypeOCI, this + // method returns nil (no validation for fallback paths). + ValidateReachability(ctx context.Context, recipeLocation string, templateVersion string) error +} + +// IsDirectModuleSource is a convenience function that classifies the given +// recipeLocation and returns true if it represents a direct Terraform module +// source (registry, git, HTTP, S3, or GCS) rather than a wrapped/OCI recipe. +// +// This is the primary entry point for the terraform driver to determine +// which output mapping strategy to use. +func IsDirectModuleSource(recipeLocation string) bool { + // Implementation delegates to the default resolver's Classify method. + // Defined here as a package-level function for ergonomic usage. + return false // placeholder +} diff --git a/specs/001-direct-module-support/data-model.md b/specs/001-direct-module-support/data-model.md new file mode 100644 index 0000000000..8a5d92121e --- /dev/null +++ b/specs/001-direct-module-support/data-model.md @@ -0,0 +1,212 @@ +# Data Model: Direct Terraform and AVM Module Support via Recipe Packs + +## Overview + +This feature extends the existing recipe pack data model with two new fields on `RecipeDefinition` — `recipeParameters` and `outputs` — and broadens the `recipeLocation` field to accept standard Terraform module sources (registry, Git, HTTP, S3, GCS) alongside existing wrapped recipe references. + +## Extended Entities + +### RecipeDefinition (extended schema) + +**Location**: `pkg/corerp/datamodel/recipepack.go` + +```go +type RecipeDefinition struct { + RecipeKind string `json:"recipeKind"` // "terraform" or "bicep" + RecipeLocation string `json:"recipeLocation"` // Template source path/URL + RecipeParameters map[string]any `json:"recipeParameters,omitempty"` // Input parameters with {{context.*}} support + PlainHTTP bool `json:"plainHTTP,omitempty"` // Allow insecure connections + Outputs map[string]string `json:"outputs,omitempty"` // Maps resource property → module output name +} +``` + +**New Fields**: +- `RecipeParameters`: Input parameters passed through to Terraform module variables. Values may contain `{{context.*}}` template expressions resolved at deployment time. +- `Outputs`: Maps resource property names to module output names (e.g., `{"host": "hostname"}` means resource property `host` gets its value from module output `hostname`). When empty/nil, all module outputs pass through with original names. + +**`RecipeLocation` accepts**: +- Terraform Registry: `hashicorp/consul/aws`, `Azure/avm-res-storage-storageaccount/azurerm`, `ballj/postgresql/kubernetes` +- Git URLs: `git::https://github.com/org/terraform-aws-vpc.git` +- Git with ref: `git::https://github.com/org/module.git?ref=v2.0.0` +- Git with subdirectory: `git::https://github.com/org/repo.git//modules/vpc` +- HTTP archives: `https://example.com/modules/vpc.tar.gz` +- S3: `s3::https://bucket.s3.amazonaws.com/module.zip` +- GCS: `gcs::https://bucket.storage.googleapis.com/module.zip` +- Existing OCI/wrapped recipes: `ghcr.io/org/recipe:v1` (unchanged behavior) + +**Template Expressions in RecipeParameters**: Values can contain `{{context.*}}` expressions resolved at deployment time. For example, `{{context.runtime.kubernetes.namespace}}` resolves to the target Kubernetes namespace. Mixed content is supported (e.g., `prefix-{{context.resource.name}}-suffix`). Unrecognized expressions are left as-is. + +**Validation Rules** (at creation time): +- Source must be reachable (lightweight probe, 30s timeout) — definitive failures reject, transient warnings allowed +- Source format must be classifiable by the resolver +- If unclassifiable, accepted without validation (fallback to existing behavior) + +### EnvironmentDefinition (internal, extended) + +**Location**: `pkg/recipes/types.go` + +```go +type EnvironmentDefinition struct { + Name string // Recipe name + Driver string // "terraform" or "bicep" + ResourceType string // Portable resource type + Parameters map[string]any // Default recipe parameters + TemplatePath string // Module source URL/path (expanded behavior) + TemplateVersion string // Module version (used for registry pinning) + PlainHTTP bool // Allow insecure connections + Outputs map[string]string // Maps resource property → module output name +} +``` + +**New Field**: `Outputs` — populated from RecipeDefinition.Outputs via the config loader. + +### RecipeOutput (extended with DirectModule flag) + +**Location**: `pkg/recipes/types.go` + +```go +type RecipeOutput struct { + Resources []string // Deployed resource IDs (from TF state) + Secrets map[string]any // Sensitive output values + Values map[string]any // Non-sensitive output values + Status *rpv1.RecipeStatus + DirectModule bool // True when outputs come from a direct module (skip schema filter) +} +``` + +**Behavioral Change for Direct Modules**: +- `Values`: Populated with ALL non-sensitive Terraform module outputs. If `Outputs` mapping exists, values are renamed (module output name → resource property name). If no mapping, original names pass through. +- `Secrets`: Populated with ALL sensitive Terraform module outputs (same rename logic). +- `DirectModule`: Set to `true` by the TF driver for direct modules. When true, the DynamicProcessor skips schema filtering and dumps all outputs to resource.Properties. +- **`result` output priority**: The system checks for a `result` output FIRST for all sources. If `result` exists and no `outputs` mapping is configured, the module is treated as a wrapped recipe. This prevents misclassifying wrapped recipes hosted on registries. + +**Behavioral Change for Wrapped Recipes** (unchanged): +- Existing logic: looks for `result` output, parses into Resources/Secrets/Values + +## New Internal Types (not persisted) + +### SourceType Enum + +**Location**: `pkg/recipes/source/types.go` + +```go +type SourceType int + +const ( + SourceTypeUnknown SourceType = iota // Unclassified — use fallback + SourceTypeTerraformRegistry // e.g., "hashicorp/consul/aws" + SourceTypeGit // e.g., "git::https://..." + SourceTypeHTTP // e.g., "https://example.com/module.tar.gz" + SourceTypeS3 // e.g., "s3::bucket/key" + SourceTypeGCS // e.g., "gcs::bucket/key" + SourceTypeOCI // Existing OCI/wrapped recipe path +) +``` + +### ResolvedSource + +**Location**: `pkg/recipes/source/types.go` + +```go +type ResolvedSource struct { + Type SourceType // Classified source type + OriginalPath string // Original recipeLocation value + IsDirectModule bool // True if this is a direct TF module (not wrapped) +} +``` + +## State Transitions + +### Recipe Deployment Lifecycle (with direct module) + +``` +┌─────────────────┐ +│ RecipePack │ ← recipeLocation validated at creation +│ Created │ +└────────┬────────┘ + │ Deploy resource using recipe + ▼ +┌─────────────────┐ +│ Source │ ← Classify recipeLocation +│ Classification │ +└────────┬────────┘ + │ Direct module detected + ▼ +┌─────────────────┐ +│ Module │ ← terraform get (fresh download, no cache) +│ Download │ +└────────┬────────┘ + │ Success + ▼ +┌─────────────────┐ +│ Module │ ← Extract variables, outputs, providers +│ Inspection │ +└────────┬────────┘ + │ Generate config with all-output forwarding + ▼ +┌─────────────────┐ +│ Expression │ ← Resolve {{context.*}} in recipeParameters +│ Resolution │ via ResolveParameterExpressions() +└────────┬────────┘ + │ Parameters with resolved context values + ▼ +┌─────────────────┐ +│ Terraform │ ← init + apply +│ Execution │ +└────────┬────────┘ + │ Success + ▼ +┌─────────────────┐ +│ Output │ ← Apply outputs mapping (rename/filter) +│ Mapping │ or pass-through all outputs +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Resource │ ← Outputs accessible via Radius API +│ Deployed │ (bypasses schema filter for direct modules) +└─────────────────┘ +``` + +## Relationships + +``` +RecipePack (1) ──contains──▶ (N) RecipeDefinition + │ │ + │ recipeLocation + recipeParameters + outputs + │ │ + │ ┌─────────┴──────────┐ + │ │ │ + │ Direct Module Wrapped/OCI Recipe + │ (new behavior) (existing behavior) + │ │ │ + │ ┌─────┴─────┐ │ + │ │ │ │ + │ Registry Git/HTTP │ + │ │ │ │ + │ └─────┬─────┘ │ + │ │ │ + ▼ ▼ ▼ +Environment ──uses──▶ TerraformDriver ◀──uses── Environment + (recipePacks, │ + recipeParameters)┌─────┴─────┐ + │ │ + Direct Mode Wrapped Mode + (flat output (result output + + outputs parsing) + mapping) +``` + +## Validation Rules + +| Field | Rule | When Applied | +|-------|------|--------------| +| `RecipeLocation` | Must be non-empty string | Always (existing) | +| `RecipeLocation` | If classifiable as direct module, source must be reachable | RecipePack create/update | +| `RecipeLocation` | Format must match one of: registry, git, http, s3, gcs, or OCI | Soft validation (unknown = fallback) | +| `RecipeKind` | Must be "terraform" for direct module sources | RecipePack create/update | +| `RecipeParameters` | Keys should match module input variable names | At terraform apply time (Terraform validates) | +| `RecipeParameters` | Values may contain `{{context.*}}` template expressions | Resolved at deploy time by ResolveParameterExpressions() | +| `Outputs` | Keys are resource property names, values are module output names | Applied at output mapping time in TF driver | +| `Outputs` | No duplicate target property names allowed | RecipePack create/update (validation pending) | +| `Outputs` | Target property names must not collide with reserved properties (application, environment, status, connections) | RecipePack create/update (validation pending) | diff --git a/specs/001-direct-module-support/plan.md b/specs/001-direct-module-support/plan.md new file mode 100644 index 0000000000..165a95bb4b --- /dev/null +++ b/specs/001-direct-module-support/plan.md @@ -0,0 +1,261 @@ +# Implementation Plan: Direct Module Support + +**Branch**: `001-direct-module-support` | **Date**: 2026-04-30 | **Spec**: `specs/001-direct-module-support/spec.md` +**Input**: Feature specification from `specs/001-direct-module-support/spec.md` + +## Summary + +Enable platform engineers to use any standard Bicep or Terraform module as a Radius recipe — without writing a Radius-specific wrapper. The system classifies `recipeLocation` at creation time (registry, Git, HTTP, S3, GCS, or OCI) using pattern-matching heuristics, resolves `{{context.*}}` template expressions (including single-level ternary and `context.resource.properties.*` paths) in `recipeParameters` at deployment time, passes parameters through to the module's native inputs, and maps module outputs to the resource type's read-only properties via the `outputs` field on RecipePack. A RecipePack is a collection of recipe configurations keyed by resource type. Existing wrapped recipes continue to function identically. + +The prototype (3 commits on branch `001-direct-module-support`) already implements the Terraform-side core: source classification (`pkg/recipes/source/`), direct output mapping in the Terraform driver, config generation for all outputs, and best-effort reachability validation at RecipePack creation time. + +## Technical Context + +**Language/Version**: Go 1.22+ (per `go.mod`) +**Primary Dependencies**: `hashicorp/terraform-exec`, `hashicorp/terraform-config-inspect`, `hashicorp/go-getter`, ARM deployment client (Bicep), `github.com/radius-project/radius` monorepo +**Storage**: Kubernetes Secrets (Terraform state), ARM deployment tracking (Bicep state) +**Testing**: `go test` with `testify`, table-driven tests; functional tests in `test/functional-portable/` +**Target Platform**: Linux containers (Kubernetes), cross-platform CLI +**Project Type**: Cloud-native platform (API server + IaC drivers) +**Performance Goals**: Direct module deployment within 10% of equivalent wrapped-recipe deployment time (SC-002); inaccessible source fails within 60s (SC-005) +**Constraints**: Zero behavioral changes to existing wrapped recipes (FR-014); no new driver or execution engine (A-001); retry delegated to IaC engine (A-012) +**Scale/Scope**: 5 user stories (3×P1, 2×P2); ~8 packages modified; ~2000 LOC new + ~200 LOC modified + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +The project constitution (`/.specify/memory/constitution.md`) is a template placeholder — no project-specific principles have been ratified. Gate passes trivially: no constraints to evaluate. + +**Post-Phase 1 re-check**: Design adheres to existing Radius conventions (Go idioms per `.github/instructions/golang.instructions.md`, existing driver architecture, existing test patterns). No violations detected. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-direct-module-support/ +├── plan.md # This file +├── research.md # Phase 0 output — design decisions R-001 through R-011 +├── data-model.md # Phase 1 output — extended RecipeDefinition, SourceType, ResolvedSource +├── quickstart.md # Phase 1 output — usage examples (AVM, Git, registry) +├── contracts/ +│ └── source-resolver.go # Phase 1 output — Resolver interface contract +└── tasks.md # Phase 2 output (created by /speckit.tasks) +``` + +### Source Code (repository root) + +```text +pkg/ +├── recipes/ +│ ├── source/ # NEW — Source classification & validation +│ │ ├── types.go # SourceType enum, ResolvedSource, Resolver interface +│ │ ├── resolver.go # Pattern-matching classifier, reachability probes +│ │ └── resolver_test.go # Table-driven classification + HTTP mock tests +│ ├── driver/ +│ │ ├── terraform/ +│ │ │ └── terraform.go # MODIFIED — prepareRecipeResponse: direct output mapping +│ │ └── bicep/ +│ │ └── bicep.go # MODIFIED — direct Bicep module deployment path +│ ├── terraform/ +│ │ ├── config/ +│ │ │ └── config.go # MODIFIED — AddAllOutputs(), AddDirectModuleContext() +│ │ ├── execute.go # MODIFIED — direct module context injection + output gen +│ │ └── module.go # MODIFIED — ModuleOutputs collection in inspectModule() +│ ├── paramresolver/ # NEW — {{context.*}} template expression resolver +│ │ ├── resolver.go # ResolveParameterExpressions(), buildContextLookup() +│ │ └── resolver_test.go # Expression resolution tests +│ └── types.go # MODIFIED — RecipeOutput.DirectModule flag +├── corerp/ +│ ├── datamodel/ +│ │ └── recipepack.go # MODIFIED — RecipeDefinition: recipeParameters, outputs +│ └── frontend/controller/recipepacks/ +│ └── createorupdaterecipepack.go # MODIFIED — source validation at creation time +typespec/ +└── Radius.Core/ + └── recipePacks.tsp # MODIFIED — outputs, recipeParameters in TypeSpec model + +test/ +└── functional-portable/ + └── recipes/ # EXTENDED — direct module functional tests +``` + +**Structure Decision**: This feature extends the existing monorepo structure. All new code lives in `pkg/recipes/source/` (classification) and `pkg/recipes/paramresolver/` (expression resolution). Modifications are surgical additions to existing driver, config, and controller packages. No new top-level directories. + +## Complexity Tracking + +No constitution violations to justify — the design follows existing patterns throughout. + +--- + +## Phase 0: Outline & Research + +*Status: **Complete** — see `specs/001-direct-module-support/research.md`* + +All technical unknowns have been resolved. Key decisions: + +| ID | Decision | Rationale | +|----|----------|-----------| +| R-001 | Pattern-matching heuristics for source classification | Deterministic, no network calls, simple ordered rules | +| R-002 | Two-phase resolution: classify → direct or OCI fallback | Avoids latency for unambiguous sources | +| R-003 | Flat output mapping for direct modules (Values + Secrets) | Simplest approach; no Radius-specific conventions imposed | +| R-004 | Best-effort reachability validation at creation time | Catches typos early; 30s timeout; transient → warning | +| R-005 | Version embedded in `recipeLocation` (OCI tag, Git ref, registry syntax) | No separate `templateVersion` field needed (A-009); version is part of the source reference | +| R-006 | Runtime detection via `IsDirectModuleSource()` classifier | No new persisted fields needed | +| R-007 | `AddAllOutputs()` generates forwarding output blocks | Terraform requires declared root outputs | +| R-010 | Sensitive outputs → `Secrets` map (via TF `sensitive` flag) | Security without module modifications | +| R-011 | Explicit `{{context.*}}` expressions (not auto-injection) | Unambiguous; platform engineer controls mapping | + +--- + +## Phase 1: Design & Contracts + +*Status: **Complete** — see `specs/001-direct-module-support/data-model.md`, `contracts/`, `quickstart.md`* + +### Data Model + +See `specs/001-direct-module-support/data-model.md` for full details. Summary of entities: + +#### RecipeDefinition (extended) + +| Field | Type | Description | +|-------|------|-------------| +| `recipeKind` | `string` | `"terraform"` or `"bicep"` | +| `recipeLocation` | `string` | Module source (registry path, Git URL, OCI ref, etc.) | +| `recipeParameters` | `map[string]any` | Input parameters; values support `{{context.*}}` expressions | +| `plainHTTP` | `bool` | Allow insecure OCI connections | +| `outputs` | `map[string]string` | Maps resource type's read-only property names → module output names | + +#### SourceType (new internal enum) + +| Value | Pattern | Direct? | +|-------|---------|---------| +| `TerraformRegistry` | `ns/name/provider` (3 segments, no dots in ns) | Yes | +| `Git` | `git::` prefix | Yes | +| `HTTP` | `http://` or `https://` (no `git::`) | Yes | +| `S3` | `s3::` prefix | Yes | +| `GCS` | `gcs::` prefix | Yes | +| `OCI` | `oci://` or hostname-with-dot first segment | No | +| `Unknown` | Unclassifiable | No (fallback) | + +#### RecipeOutput (extended) + +| Field | Change | +|-------|--------| +| `DirectModule bool` | **New** — signals driver to skip `result` parsing and use flat output mapping | + +#### State Transition + +``` +RecipePack Create → Source Classification → Reachability Probe (best-effort) + ↓ +Resource Deploy → Classify source → Download module → Inspect → Generate config + ↓ + Resolve {{context.*}} expressions → terraform init+apply (or ARM deploy) + ↓ + Map outputs (flat + Secrets for sensitive) → Apply outputs mapping to resource type's read-only properties → Resource ready +``` + +### Interface Contracts + +See `specs/001-direct-module-support/contracts/source-resolver.go`. Key contract: + +```go +type Resolver interface { + Classify(templatePath string) ResolvedSource + ValidateReachability(ctx context.Context, templatePath string) error +} +``` + +**Convenience function** (primary entry point for drivers): +```go +func IsDirectModuleSource(templatePath string) bool +``` + +### Quickstart + +See `specs/001-direct-module-support/quickstart.md` for end-to-end usage examples covering: +1. AVM PostgreSQL module with t-shirt sizing (ternary expressions) +2. Git-hosted module with ref/subdirectory +3. HTTP archive module +4. Output mapping for resource property materialization + +--- + +## Prototype Status + +The branch `001-direct-module-support` (3 commits ahead of main) implements the following: + +| Component | File(s) | Status | +|-----------|---------|--------| +| Source classifier | `pkg/recipes/source/types.go`, `resolver.go` | ✅ Implemented + tested | +| Reachability validation | `pkg/recipes/source/resolver.go` | ✅ Registry + HTTP probes | +| RecipePack controller | `pkg/corerp/frontend/controller/recipepacks/` | ✅ Classification at create time | +| TF driver output mapping | `pkg/recipes/driver/terraform/terraform.go` | ✅ Direct module flat mapping | +| TF config: AddAllOutputs | `pkg/recipes/terraform/config/config.go` | ✅ Forward all module outputs | +| TF config: AddDirectModuleContext | `pkg/recipes/terraform/config/config.go` | ✅ Well-known context variable injection | +| TF execute: direct module path | `pkg/recipes/terraform/execute.go` | ✅ Conditional context + output generation | +| TF module inspection | `pkg/recipes/terraform/module.go` | ✅ ModuleOutputs collection | + +### Not yet implemented (remaining work): + +| Component | Description | Priority | +|-----------|-------------|----------| +| `{{context.*}}` expression resolver | `ResolveParameterExpressions()` in param resolver package, including `context.resource.properties.*` paths | P1 | +| Single-level ternary evaluation | Single-level ternary in `{{context.*}}` expressions (nested ternaries out of scope for V1) | P1 | +| Bicep driver: direct module path | ARM deployment of standard Bicep modules without `result` output | P1 | +| Output mapping application | Apply `outputs` field to map module outputs to the resource type's read-only properties | P1 | +| Shallow merge for parameters | Environment-level parameter override with shallow merge semantics | P1 | +| Private module authentication | Credential passthrough for private registries/repos | P2 | +| Functional tests | End-to-end tests with real modules (registry, Git) | P1 | +| TypeSpec model update | `outputs` and `recipeParameters` in `recipePacks.tsp` | P1 | + +--- + +## Implementation Approach (Phase 2 Preview) + +The following outlines the logical work breakdown for task generation. Tasks should be ordered by dependency. + +### Layer 1: Core Infrastructure (no driver dependencies) + +1. **Parameter expression resolver** — `pkg/recipes/paramresolver/`: `ResolveParameterExpressions()`, `buildContextLookup()`, regex-based `{{context.*}}` replacement (including `context.resource.properties.*` paths for application-to-recipe property resolution), single-level ternary evaluation (`{{expr == "val" ? "trueResult" : "falseResult"}}`; nested ternaries out of scope for V1). Table-driven tests. This is the foundational expression engine for US1, US2, and US3 — all P1. +2. **Output mapping utility** — Apply `outputs` map to translate module output names to the resource type's read-only properties in `RecipeOutput.Values`/`Secrets`. Shared by both drivers. +3. **Shallow merge utility** — Merge RecipePack parameters with environment-level overrides (top-level key precedence). + +### Layer 2: Terraform Driver Completion + +4. **Wire expression resolver into TF execute path** — Replace `AddDirectModuleContext` (auto-injection) with `ResolveParameterExpressions()` per R-011 decision. +5. **Wire output mapping** — After `prepareRecipeResponse`, apply `outputs` mapping to map module outputs to resource type's read-only properties. +6. **Handle `result` vs `outputs` precedence** — Per FR-015: `outputs` mapping takes precedence when both are present. + +### Layer 3: Bicep Driver + +7. **Bicep direct module detection** — Classify `recipeLocation` for Bicep (`br:` OCI references that are standard modules vs. wrapped). +8. **Bicep direct deployment** — Pass resolved parameters as ARM deployment parameters; map ARM outputs to resource type's read-only properties via `outputs`. +9. **Bicep cleanup** — Ensure ARM deployment deletion for direct Bicep modules. + +### Layer 4: API & Validation + +10. **TypeSpec model update** — Add `outputs` and `recipeParameters` to RecipePack model (RecipePack is a collection keyed by resource type). +11. **Reachability validation enhancement** — Wire `ValidateReachability` into create/update controller for Bicep OCI references. Best-effort: definitive failures (404, auth denied) reject; transient failures log warnings but allow linking (per SC-009). +12. **Private module auth** — Credential passthrough from secret store to module fetch. + +### Layer 5: Testing & Integration + +13. **Unit tests** — Expression resolver (including ternary and `context.resource.properties.*`), output mapping, shallow merge, classifier edge cases. +14. **Functional tests** — End-to-end with real Terraform registry module, Git module, Bicep AVM module. Must cover `{{context.resource.properties.*}}` resolution and ternary expressions. +15. **Backward compatibility tests** — Verify wrapped recipes remain unchanged. + +--- + +## Generated Artifacts + +| Artifact | Path | Status | +|----------|------|--------| +| Plan | `specs/001-direct-module-support/plan.md` | ✅ This file | +| Research | `specs/001-direct-module-support/research.md` | ✅ Complete (R-001 through R-011) | +| Data Model | `specs/001-direct-module-support/data-model.md` | ✅ Complete | +| Contracts | `specs/001-direct-module-support/contracts/source-resolver.go` | ✅ Complete | +| Quickstart | `specs/001-direct-module-support/quickstart.md` | ✅ Complete | +| Tasks | `specs/001-direct-module-support/tasks.md` | ⏳ Pending (`/speckit.tasks`) | diff --git a/specs/001-direct-module-support/prompt.md b/specs/001-direct-module-support/prompt.md new file mode 100644 index 0000000000..f7a88db7b7 --- /dev/null +++ b/specs/001-direct-module-support/prompt.md @@ -0,0 +1,65 @@ +# Feature: Direct Module Support via Recipe Packs + +Enable platform engineers to use existing Terraform or Azure verified modules directly as recipes within Radius recipe packs — without wrapping, republishing, or creating Radius-specific recipe artifacts. + +## Problem + +Today, to use an existing IaC module with Radius, engineers must wrap the module into a Radius recipe format, publish it to a registry, and then reference it. This is redundant work. Recipe packs should allow pointing directly at an existing Terraform or AVM module and have it work as-is. + +## Recipe Packs as Environment Configuration + +Recipe packs are resources that define a collection of recipes for different resource types. Environments reference recipe packs via the `recipePacks` property. A recipe pack bundles: + +- **Recipe definitions** per resource type (e.g., `Radius.Data/postgreSqlDatabases`) +- **Recipe kind** (`terraform` or `bicep`) +- **Recipe location** (module source — Terraform registry path, Git URL, etc.) +- **Recipe parameters** with support for `{{context.*}}` template expressions +- **Output mappings** (`outputs`) that map resource property names to module output names + +## Input and Output Mapping + +The key design challenge is matching a module's input variables to resource properties and the module's outputs to computed properties in Radius: + +- **Inputs**: Recipe parameters are defined on the recipe pack with `recipeParameters`. Template expressions like `{{context.resource.name}}` allow injecting Radius context at deploy time. Environment-level `recipeParameters` can provide additional defaults per resource type. +- **Outputs**: The `outputs` field on the recipe definition maps resource schema property names to module output names (e.g., `host: 'hostname'` means the resource's `host` property comes from the module's `hostname` output). For direct modules, all outputs are passed through to resource properties, with `outputs` acting as an optional rename/filter layer. + +## Example Bicep + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'pg-recipepack' + properties: { + recipes: { + 'Radius.Data/postgreSqlDatabases': { + recipeKind: 'terraform' + recipeLocation: 'ballj/postgresql/kubernetes' + recipeParameters: { + namespace: 'default' + name: '{{context.resource.name}}' + object_prefix: 'myapp-pg' + image_tag: 'latest' + } + outputs: { + host: 'hostname' + port: 'port' + database: 'database_name' + username: 'username' + secretName: 'password_secret' + } + } + } + } +} + +resource myenv 'Radius.Core/environments@2025-08-01-preview' = { + name: 'my-env' + properties: { + providers: { + kubernetes: { namespace: 'default' } + } + recipePacks: [ + recipepack.id + ] + } +} +``` diff --git a/specs/001-direct-module-support/quickstart.md b/specs/001-direct-module-support/quickstart.md new file mode 100644 index 0000000000..19c44962dd --- /dev/null +++ b/specs/001-direct-module-support/quickstart.md @@ -0,0 +1,302 @@ +# Quickstart: Direct Terraform and AVM Module Support via Recipe Packs + +## What This Feature Does + +Allows platform engineers to use any standard Terraform module or Azure Verified Modules directly as a recipe's `recipeLocation` in a RecipePack — without wrapping, republishing, or creating Radius-specific artifacts. The system downloads the module at deployment time, passes `recipeParameters` (with `{{context.*}}` expression resolution) as Terraform input variables, and surfaces module outputs as resource properties with optional rename/filter via the `outputs` mapping. + +## Usage Examples + +### Example 1: Azure Verified Module (AVM) — PostgreSQL Flexible Server with T-Shirt Sizing + +Uses the [Azure/avm-res-dbforpostgresql-flexibleserver/azurerm](https://registry.terraform.io/modules/Azure/avm-res-dbforpostgresql-flexibleserver/azurerm) module directly from the Terraform registry. Demonstrates conditional ternary expressions to translate abstract t-shirt sizes (`s`, `m`, `l`) into concrete Azure SKUs and storage configurations. + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'azure-postgres-pack' + properties: { + recipes: { + 'Radius.Data/postgreSqlDatabases': { + recipeKind: 'terraform' + recipeLocation: 'Azure/avm-res-dbforpostgresql-flexibleserver/azurerm' + recipeParameters: { + name: 'pg-{{context.resource.name}}' + location: 'eastus2' + // Single-level ternary: maps "s" to burstable SKU, else general purpose + sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' + storage_mb: '{{context.resource.properties.size == "s" ? "32768" : "65536"}}' + tags: { + environment: '{{context.environment.name}}' + application: '{{context.application.name}}' + } + } + outputs: { + host: 'fqdn' + port: 'port' + database: 'database_name' + username: 'administrator_login' + } + } + } + } +} + +resource myenv 'Radius.Core/environments@2025-08-01-preview' = { + name: 'azure-env' + properties: { + providers: { + azure: { + } + } + recipePacks: [ + recipepack.id + ] + } +} +``` + +Application developers simply specify `size: 's'`, `size: 'm'`, or `size: 'l'` on their resource, and the platform engineer's ternary expressions translate these into the appropriate Azure SKU and storage configuration at deploy time. + +### Example 2: AWS RDS Module — PostgreSQL Database + +Uses the popular [terraform-aws-modules/rds/aws](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws) community module to provision an AWS RDS PostgreSQL instance directly — no wrapping required. Demonstrates ternary expressions for t-shirt size mapping to AWS instance classes and storage. + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'aws-data-pack' + properties: { + recipes: { + 'Radius.Data/postgreSqlDatabases': { + recipeKind: 'terraform' + recipeLocation: 'terraform-aws-modules/rds/aws' + recipeParameters: { + identifier: 'pg-{{context.resource.name}}' + instance_class: '{{context.resource.properties.size == "s" ? "db.t3.micro" : "db.r5.large"}}' + allocated_storage: '{{context.resource.properties.size == "s" ? "20" : "100"}}' + db_name: '{{context.resource.name}}' + create_db_subnet_group: true + subnet_ids: ['subnet-12345678', 'subnet-87654321'] + vpc_security_group_ids: ['sg-12345678'] + tags: { + Environment: '{{context.environment.name}}' + Application: '{{context.application.name}}' + } + } + outputs: { + host: 'db_instance_address' + port: 'db_instance_port' + database: 'db_instance_name' + username: 'db_instance_username' + } + } + } + } +} + +resource myenv 'Radius.Core/environments@2025-08-01-preview' = { + name: 'aws-env' + properties: { + providers: { + aws: { + scope: '/planes/aws/aws/accounts/123456789012/regions/us-east-1' + } + } + recipePacks: [ + recipepack.id + ] + } +} +``` + +## How Outputs Work + +The `outputs` mapping on the recipe definition maps resource property names to module output names. Only mapped outputs are surfaced: + +```bicep +outputs: { + host: 'hostname' // resource.properties.host ← module.hostname + port: 'port' // resource.properties.port ← module.port + database: 'database_name' // resource.properties.database ← module.database_name +} +``` + +Sensitive module outputs (marked `sensitive = true` in the module) are stored securely in the `Secrets` map rather than `Values`. + +## How Parameters Work + +`recipeParameters` map directly to Terraform input variables: + +1. **Recipe pack-level parameters** (in `recipeParameters` on recipe definition) → applied to every deployment +2. **Environment-level parameters** (in `recipeParameters` on environment) → merged with recipe pack params, environment takes precedence +3. **No parameter** for optional variables → Terraform uses the module's default value +4. **Missing required variable** → Terraform error surfaced through recipe failure +5. **`{{context.*}}` expressions** → resolved at deploy time against the recipe context + +### Environment-Level Parameter Overrides + +The environment can override or extend recipe pack parameters. This is useful when the same recipe pack is shared across environments (dev, staging, prod) but certain parameters need to differ. + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'pg-pack' + properties: { + recipes: { + 'Radius.Data/postgreSqlDatabases': { + recipeKind: 'terraform' + recipeLocation: 'Azure/avm-res-dbforpostgresql-flexibleserver/azurerm' + recipeParameters: { + name: 'pg-{{context.resource.name}}' + resource_group_name: '{{context.azure.resourceGroup.name}}' + postgresql_version: '16' + sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' + } + } + } + } +} + +resource devenv 'Radius.Core/environments@2025-08-01-preview' = { + name: 'dev' + properties: { + providers: { + azure: { + scope: '{{context.azure.resourceGroup.id}}' + } + } + recipePacks: [ + recipepack.id + ] + recipeParameters: { + backup_retention_days: '2' + geo_redundant_backup_enabled: 'false' + } + } +} +``` + +In this example, the dev environment adds `backup_retention_days` and disables geo-redundant backups. Environment parameters take precedence over recipe pack parameters when both define the same key. + +### Template Expression Resolution + +Parameter values can contain `{{context.*}}` expressions that inject Radius runtime context into Terraform module variables: + +```bicep +recipeParameters: { + // Direct value — passed as-is to Terraform + instance_class: 'db.t3.micro' + + // Context expression — resolved at deploy time + k8s_namespace: '{{context.runtime.kubernetes.namespace}}' + + // Mixed content — expression resolved, literals preserved + resource_prefix: 'app-{{context.resource.name}}-suffix' + + // Resource properties — access user-specified properties from the resource definition + db_size: '{{context.resource.properties.size}}' + + // Ternary expression — conditional value mapping at deploy time + sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' +} +``` + +### Ternary Expressions + +Ternary expressions enable conditional value mapping inside `{{...}}` expressions. They are evaluated at deploy time when `context.*` values are resolved. + +**Syntax:** `{{ == "" ? "" : ""}}` + +Single-level ternary only (V1 limitation — chained/nested ternaries are not supported): + +```bicep +recipeParameters: { + // Map t-shirt size to Azure PostgreSQL SKU (use separate parameters for multi-way mapping) + sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' + + // Map size to storage — simple conditional + storage_mb: '{{context.resource.properties.size == "s" ? "32768" : "65536"}}' +} +``` + +**Behavior:** +- The left-hand side of `==` is resolved against the context lookup (same paths as regular expressions) +- String comparison is exact-match (case-sensitive) +- The else branch must be a literal string value (not another ternary) +- Values are always returned as strings (Terraform handles type conversion) +- If the condition path is unresolvable, the entire expression is left as-is + +> **Note:** For multi-way mapping (e.g., s/m/l → 3 different values), use separate recipe definitions per tier or handle the mapping in the module itself. Chained ternary support is planned for a future release. + +### Available Template Expressions + +| Expression | Description | +|------------|-------------| +| `{{context.resource.name}}` | Deployed resource name | +| `{{context.resource.id}}` | Deployed resource ID | +| `{{context.resource.type}}` | Deployed resource type | +| `{{context.resource.properties.}}` | User-specified resource property (e.g., `size`, `tier`) | +| `{{context.application.name}}` | Application name | +| `{{context.application.id}}` | Application ID | +| `{{context.environment.name}}` | Environment name | +| `{{context.environment.id}}` | Environment ID | +| `{{context.runtime.kubernetes.namespace}}` | Target Kubernetes namespace | +| `{{context.runtime.kubernetes.environmentNamespace}}` | Environment Kubernetes namespace | +| `{{context.azure.resourceGroup.name}}` | Azure resource group name | +| `{{context.azure.resourceGroup.id}}` | Azure resource group ID | +| `{{context.azure.subscription.subscriptionId}}` | Azure subscription ID | +| `{{context.aws.region}}` | AWS region | +| `{{context.aws.account}}` | AWS account ID | + +## Key Behaviors + +| Behavior | Details | +|----------|---------| +| **Module download** | Fresh download every deployment (no caching) | +| **Version pinning** | Embedded in `recipeLocation` for registry modules (e.g., `Azure/module/azurerm` uses latest, append version constraint in module config), `?ref=` for Git | +| **Provider config** | Uses existing `recipeConfig.terraform.providers` from environment | +| **State management** | Same Kubernetes secret backend as existing recipes | +| **Error handling** | Terraform errors surfaced directly in recipe failure response | +| **Existing recipes** | Zero behavioral changes — fully backward compatible | +| **`result` output** | If present AND no `outputs` mapping configured, uses wrapped recipe convention | +| **`outputs` mapping** | Takes precedence over `result` when configured — only mapped outputs flow through | + +## Development Workflow + +### Building + +```bash +make build +``` + +### Running Tests + +```bash +# Unit tests for the shared expression resolver +go test ./pkg/recipes/paramresolver/... + +# Unit tests for the output mapping utility +go test ./pkg/recipes/outputmapping/... + +# Unit tests for the source resolver +go test ./pkg/recipes/source/... + +# Unit tests for terraform driver changes +go test ./pkg/recipes/driver/terraform/... + +# Unit tests for bicep driver changes +go test ./pkg/recipes/driver/bicep/... + +# Unit tests for terraform executor changes +go test ./pkg/recipes/terraform/... + +# All recipe package tests +go test ./pkg/recipes/... + +# All unit tests +make test +``` + +### Linting + +```bash +make lint +make format-check +``` diff --git a/specs/001-direct-module-support/research.md b/specs/001-direct-module-support/research.md new file mode 100644 index 0000000000..eaca1d12f1 --- /dev/null +++ b/specs/001-direct-module-support/research.md @@ -0,0 +1,245 @@ +# Research: Direct Terraform and AVM Module Support via Recipe Packs + +## R-001: Source Format Detection Strategy + +**Decision**: Use pattern-matching heuristics on the `recipeLocation` string to classify source type before attempting resolution. + +**Rationale**: The existing codebase already uses `hashicorp/go-getter` which handles the actual download. However, we need pre-resolution classification to determine: (a) whether to attempt direct module resolution vs. OCI/wrapped path, (b) which validation to apply at creation time, and (c) whether version pinning applies. Pattern-matching is simple, deterministic, and avoids network calls during classification. + +**Classification Rules**: +| Pattern | Source Type | Example | +|---------|-------------|---------| +| `//` (3-segment, no scheme) | Terraform Registry | `hashicorp/consul/aws` | +| `git::` prefix | Git repository | `git::https://github.com/org/module.git` | +| `http://` or `https://` (no `git::` prefix) | HTTP archive | `https://example.com/module.tar.gz` | +| `s3::` prefix | S3 bucket | `s3::bucket/path` | +| `gcs::` prefix | GCS bucket | `gcs::bucket/path` | +| Contains `oci://` or matches existing OCI patterns | OCI/Wrapped (fallback) | `ghcr.io/org/recipe:v1` | + +**Alternatives Considered**: +1. **Always try direct first, catch errors** — Rejected because it would add latency for existing OCI recipes (network timeout before fallback). +2. **Require explicit `terraform::` prefix** — Rejected per FR-012 (no new schema fields/flags) and the principle of using standard Terraform source syntax. +3. **Parse with go-getter's Detect** — Considered but go-getter's detect is designed for runtime, not pre-validation classification. It doesn't distinguish "valid but unreachable" from "not a valid source format." + +--- + +## R-002: Direct Module Resolution vs. OCI/Wrapped Fallback Strategy + +**Decision**: Implement a two-phase resolution in the config loader's `LoadRecipe` path: +1. Classify `recipeLocation` using source format detector +2. If classified as a known Terraform source (registry, git, http, s3, gcs) → mark as direct module +3. If classification is ambiguous or unknown → attempt direct resolution first, then fall back to existing OCI path + +**Rationale**: Per FR-015, the system must try direct resolution first and fall back. However, for unambiguous sources (3-segment registry paths, `git::` prefixed), we can skip the fallback path entirely, improving performance. The ambiguous case (e.g., a plain HTTPS URL that could be either an OCI manifest or a terraform archive) needs the try-then-fallback approach. + +**Alternatives Considered**: +1. **Purely sequential try/fallback for all paths** — Rejected because it adds unnecessary latency for clearly-Terraform sources. +2. **Add a flag to RecipeDefinition** — Rejected per FR-012 (no new fields). + +--- + +## R-003: Output Mapping for Direct Modules (FR-015 Precedence) + +**Decision**: For direct module recipes, apply a three-tier precedence for output handling: +1. If `outputs` mapping is configured → only mapped outputs flow through (sensitive outputs go to Secrets) +2. Elif module has a `result` output → use wrapped recipe convention (backward compat) +3. Else (direct module, no mapping) → pass all outputs through (sensitive → Secrets, non-sensitive → Values) + +**Rationale**: Per FR-015, the `outputs` mapping gives platform engineers explicit control over which module outputs map to which resource properties. When no mapping is configured, pass-through gives visibility into all module outputs. The `result` output is honored for backward compatibility with modules that follow the wrapped recipe convention but don't have an `outputs` mapping configured. + +**Implementation Approach**: +```go +// In prepareRecipeResponse, detect mode via precedence: +switch { +case hasOutputsMapping: + // Only mapped outputs flow through + values, secrets = outputmapping.Apply(rawOutputs, definition.Outputs) +case hasResultOutput: + // Wrapped recipe convention + recipeResponse.PrepareRecipeResponse(resultValue) +case isDirectModule: + // Pass through all outputs (sensitive → Secrets) + values, secrets = outputmapping.Apply(rawOutputs, nil) +} +``` + +**Alternatives Considered**: +1. **Auto-detect Resources/Secrets from output types** — Rejected; sensitivity flag is the only signal used. +2. **`result` takes precedence over `outputs`** — Rejected because `outputs` mapping is a platform engineer's explicit intent and should win. + +--- + +## R-004: RecipePack Creation-Time Validation + +**Decision**: Add a validation step in `CreateOrUpdateRecipePack` controller that, for Terraform recipe definitions with recognized direct module source formats, attempts to resolve the module source to verify reachability. + +**Rationale**: Per FR-014, the system must validate reachability at creation time. This catches typos, inaccessible registries, and bad URLs early rather than at deploy time. The validation uses a lightweight check (e.g., registry API probe for registry modules, HEAD request for HTTP, `git ls-remote` for Git) rather than a full module download. + +**Implementation Approach**: +- Extract validation into a `ValidateModuleSource(ctx, recipeLocation, templateVersion, secrets)` function in `pkg/recipes/source/` +- For registry modules: HTTP GET to `https://registry.terraform.io/v1/modules/{ns}/{name}/{provider}/{version}` (or configured registry) +- For Git sources: `git ls-remote` to verify the ref exists +- For HTTP sources: HTTP HEAD to verify URL responds with 2xx +- Validation is best-effort with a 30-second timeout; transient failures are logged as warnings but don't block creation + +**Alternatives Considered**: +1. **Full module download at creation time** — Rejected because it's expensive and slow; downloads can be large. +2. **No validation at creation time** — Rejected per FR-014. +3. **Async validation (create succeeds, mark status)** — Rejected for simplicity; synchronous validation with timeout is sufficient. + +--- + +## R-005: Version Pinning for Registry Modules + +**Decision**: Use the existing `TemplateVersion` field on `RecipeDefinition`/`EnvironmentDefinition`. When the source is a Terraform registry module, pass `TemplateVersion` as the `version` constraint in the generated `main.tf.json` module block (this already happens in `config.New()`). + +**Rationale**: The existing code in `pkg/recipes/terraform/config/config.go` already handles this: +```go +if moduleVersion != "" { + moduleConfig["version"] = moduleVersion +} +``` +No new code needed for version pinning itself — only for validating that the specified version exists (as part of creation-time validation in R-004). + +**Alternatives Considered**: +1. **Embed version in recipeLocation** (e.g., `hashicorp/consul/aws@1.2.0`) — Rejected because it diverges from Terraform conventions and the existing `TemplateVersion` field already exists. + +--- + +## R-006: Detecting Direct Module Mode at Execution Time + +**Decision**: Add a field or method to distinguish direct-module mode from wrapped-recipe mode during execution. Use the source resolver's classification result stored as a lightweight signal (e.g., a boolean `IsDirectModule` on `EnvironmentDefinition` or detected at runtime from `TemplatePath`). + +**Rationale**: The driver needs to know whether to use flat output mapping (direct) or `result`-based output mapping (wrapped). Since FR-012 prohibits new data model fields for persistence, the detection happens at runtime by re-classifying `TemplatePath` in the driver layer. + +**Implementation**: +```go +// In pkg/recipes/source/resolver.go +func IsDirectModuleSource(recipeLocation string) bool { + sourceType := Classify(recipeLocation) + return sourceType == SourceTypeTerraformRegistry || + sourceType == SourceTypeGit || + sourceType == SourceTypeHTTP || + sourceType == SourceTypeS3 || + sourceType == SourceTypeGCS +} +``` + +This avoids persisting new fields while giving the driver a clear signal. + +**Alternatives Considered**: +1. **Check for `result` output after module inspection** — Could work but adds complexity and breaks the "no changes to wrapped behavior" guarantee if a direct module happens to have a `result` output. +2. **Add a field to the persisted model** — Rejected per FR-012. + +--- + +## R-007: Terraform Config Generation for Direct Modules + +**Decision**: The existing `config.New()` function already generates valid Terraform configuration for any module source. For direct modules, the key differences are: +1. **Outputs**: Generate output blocks for ALL module outputs (not just `result`) +2. **Recipe Context**: Skip adding `recipe_context` variable (direct modules don't know about it) +3. **Parameters**: Pass through as-is (existing behavior works) + +**Rationale**: The existing config generation is already source-agnostic. The main change is in `AddOutputs()` — for direct modules, we need to generate output blocks that forward all module outputs: +```hcl +output "" { + value = module.. + sensitive = +} +``` + +This requires knowing the module's output names before generating config, which is already available from `inspectModule()`. + +**Implementation**: After `downloadAndInspect()`, pass the inspection result to a new `AddAllOutputs(moduleName, moduleOutputs)` method that generates forwarding outputs for each module output. + +**Alternatives Considered**: +1. **Use a wildcard output** — Not supported by Terraform. +2. **Don't generate output blocks, read from state directly** — Rejected because `terraform output` only returns values for declared outputs in the root module. + +--- + +## R-008: GetRecipeMetadata for Direct Modules + +**Decision**: The existing `GetRecipeMetadata` path already works for direct modules because it uses `downloadAndInspect()` which fetches the module and reads its variables. The returned `Parameters` map already contains all module input variables with their metadata. + +**Rationale**: No changes needed to the metadata retrieval flow. The `inspectModule()` function already parses all variables regardless of source type. This satisfies FR-010 and User Story 5. + +**Alternatives Considered**: None — existing code handles this correctly. + +--- + +## R-009: Error Handling for Direct Module Failures + +**Decision**: Surface Terraform errors directly through existing error paths. Use existing `RecipeError` types with appropriate error codes: +- `RecipeDownloadFailed` for source resolution failures +- `RecipeDeploymentFailed` for terraform apply errors (missing variables, provider failures) +- `RecipeDeletionFailed` for terraform destroy errors + +**Rationale**: Per FR-009, errors must be actionable with relevant Terraform error details. The existing error wrapping in the terraform executor and driver already includes the full Terraform error message. No new error types needed. + +**Alternatives Considered**: +1. **Parse and categorize Terraform errors** — Rejected per YAGNI; raw Terraform errors are already actionable for platform engineers. + +--- + +## R-010: Sensitive Output Handling + +**Decision**: For direct modules, outputs marked `sensitive = true` (TF) or `SecureString`/`SecureObject` type (Bicep ARM) are routed to `RecipeOutput.Secrets` instead of `RecipeOutput.Values`. This applies in both pass-through mode (no outputs mapping) and mapped mode (outputs mapping configured). + +**Rationale**: The existing split between Values and Secrets on RecipeOutput provides the security mechanism. The `outputmapping.Apply()` utility in `pkg/recipes/outputmapping/` handles this routing for both TF and Bicep drivers — sensitive outputs go to Secrets, non-sensitive to Values. + +**Implementation** (via shared `outputmapping` package): +```go +// outputmapping.Apply routes based on OutputValue.Sensitive flag +for propertyName, outputName := range mapping { + if output, ok := rawOutputs[outputName]; ok { + if output.Sensitive { + secrets[propertyName] = output.Value + } else { + values[propertyName] = output.Value + } + } +} +``` + +--- + +## R-011: Context Injection Strategy + +**Decision**: Use explicit `{{context.*}}` template expressions in recipe parameters, resolved by `paramresolver.ResolveParameters()`, instead of automatic variable name-matching. + +**Rationale**: Direct Terraform modules have arbitrarily named input variables. An automatic name-matching approach (e.g., injecting the Kubernetes namespace if a module has a variable named `namespace`) is semantically ambiguous — a `namespace` variable might refer to a Terraform provider namespace, a DNS namespace, or something else entirely. Automatic injection based on variable names would silently produce incorrect values with no way for the platform engineer to detect or override the behavior. + +Template expressions give platform engineers explicit control over which context values flow into which module variables. The mapping is visible in the RecipePack definition, auditable, and unambiguous. This also means modules with non-standard variable names (e.g., `k8s_ns` instead of `namespace`) work correctly — the platform engineer simply writes `{{context.runtime.kubernetes.namespace}}` as the parameter value for `k8s_ns`. + +**Supported Expression Paths**: +| Expression | Description | +|------------|-------------| +| `context.resource.name` | Deployed resource name | +| `context.resource.id` | Deployed resource ID | +| `context.resource.type` | Deployed resource type | +| `context.application.name` | Application name | +| `context.application.id` | Application ID | +| `context.environment.name` | Environment name | +| `context.environment.id` | Environment ID | +| `context.runtime.kubernetes.namespace` | Target Kubernetes namespace | +| `context.runtime.kubernetes.environmentNamespace` | Environment Kubernetes namespace | +| `context.azure.resourceGroup.name` | Azure resource group name | +| `context.azure.resourceGroup.id` | Azure resource group ID | +| `context.azure.subscription.subscriptionId` | Azure subscription ID | +| `context.azure.subscription.id` | Azure subscription ID (alias) | +| `context.aws.region` | AWS region | +| `context.aws.account` | AWS account ID | + +**Implementation Approach**: +- `ResolveParameters()` in `pkg/recipes/paramresolver/resolver.go` scans parameter string values for `{{...}}` patterns using regex +- `buildContextLookup()` creates a flat map from the `recipecontext.Context` struct (e.g., `"context.resource.name" → "my-resource"`) +- `resolveStringValue()` uses `regexp.ReplaceAllStringFunc` to replace each `{{context.*}}` match with the looked-up value +- Unrecognized expressions (no match in the lookup map) are left as-is — no silent failure, no empty substitution +- Called from both TF and Bicep drivers in the direct module path (when no `context` variable/parameter exists) +- Works alongside existing `AddRecipeContext` for wrapped recipes (which still use the single `context` variable) + +**Alternatives Considered**: +1. **Automatic name-matching (`AddDirectModuleContext`)** — Rejected due to semantic ambiguity of variable names. A variable named `namespace` could mean many things. Silent injection of wrong values is worse than requiring explicit mapping. +2. **New schema field for context mapping** — Rejected per FR-012 (no new data model fields). Template expressions in existing `Parameters` achieve the same result. +3. **Helm-style `{{ .Values.* }}` syntax** — Rejected because it conflicts with Terraform's own interpolation syntax (`${...}`) and Go template syntax. The `{{context.*}}` prefix is unambiguous and clearly indicates Radius context injection. diff --git a/specs/001-direct-module-support/spec.md b/specs/001-direct-module-support/spec.md new file mode 100644 index 0000000000..c426dd54cb --- /dev/null +++ b/specs/001-direct-module-support/spec.md @@ -0,0 +1,212 @@ +# Feature Specification: Direct Module Support + +**Feature Branch**: `001-direct-module-support` +**Created**: 2026-04-22 +**Updated**: 2026-04-30 +**Status**: Draft +**Input**: Enable platform engineers to use any standard Bicep or Terraform module as a Radius recipe — without writing a Radius-specific wrapper. Today, using a module as a recipe requires a wrapper that conforms to Radius conventions (a `context` input variable, a structured `result` output). This feature eliminates the wrapper: point `recipeLocation` directly at a standard module, and the system handles input resolution (injecting Radius context like resource name, namespace, etc.) and output resolution (mapping module outputs to resource properties) externally, outside the module. The module doesn't need to know about Radius. + +## The Problem + +Today, every Bicep or Terraform module used as a Radius recipe must be wrapped in a Radius-specific shim: + +- **For both Bicep and Terraform**: The wrapper adds a `context` input variable and a structured `result` output (separating `values`, `secrets`, and `resources`) that conforms to Radius recipe conventions. The platform engineer downloads a community module, writes a wrapper that calls it, re-publishes the wrapper, and references that wrapper as the recipe. The problem is identical regardless of IaC language. + +This wrapper tax has real consequences: + +1. **Friction**: Every module requires a bespoke wrapper before it can be used. Wrapping a single module takes 15–60 minutes and requires understanding both the module's interface and Radius conventions. +2. **Maintenance burden**: When the upstream module releases a new version, the wrapper must be updated and re-published. Wrapper drift causes silent failures. +3. **Ecosystem lock-out**: Thousands of production-ready modules exist in the Terraform Registry, MCR (for Bicep), and Git repositories. The wrapper requirement means none of them work out of the box. + +**Direct module support eliminates the wrapper.** Platform engineers point `recipeLocation` at any standard module. The system resolves inputs (injecting Radius context into the module's native variables) and resolves outputs (mapping the module's native outputs to resource properties) — all externally, without modifying the module. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Bicep Module Support (Priority: P1) + +As a platform engineer, I want to set `recipeLocation` to a standard Bicep module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) and have Radius deploy it directly — passing my `recipeParameters` as the module's ARM parameters and mapping the module's outputs back to Radius resource properties — without writing a Radius-specific wrapper. + +**Today's workflow**: Download the module → write a Radius wrapper that adds the `result` output → publish the wrapper to an OCI registry → reference the wrapper in `recipeLocation`. **With this feature**: set `recipeLocation` to the module's OCI reference directly. + +**Why this priority**: Bicep is Radius's native IaC language. The Bicep driver already handles ARM deployments — extending it to handle standard modules (without the Radius `result` output convention) is the smallest incremental step. This immediately unblocks the AVM Bicep catalog (hundreds of production-ready modules). + +**Independent Test**: Link a recipe pointing to a public AVM Bicep module from MCR, deploy a resource, verify infrastructure is provisioned, and confirm module outputs are accessible as resource properties. + +**Acceptance Scenarios**: + +1. **Given** a RecipePack with `recipeKind: 'bicep'` and `recipeLocation` set to a standard Bicep module OCI reference, **When** a resource using this recipe is deployed with `recipeParameters` providing values for the module's parameters, **Then** the module receives those values and provisions infrastructure successfully. +2. **Given** a Bicep module with output values (e.g., `output endpoint string`), **When** the resource is deployed, **Then** the module's outputs are mapped to the resource type's read-only properties via the `outputs` mapping on the RecipePack. +3. **Given** `recipeParameters` containing `{{context.*}}` template expressions (e.g., `name: 'sa-{{context.resource.name}}'`), **When** the resource is deployed, **Then** expressions are resolved to actual Radius context values before being passed to the module as ARM parameters. +4. **Given** a `recipeLocation` pointing to a non-existent Bicep module, **When** deployment is attempted, **Then** the system returns a clear error indicating the module cannot be fetched. +5. **Given** a resource deployed via a direct Bicep module recipe, **When** the resource is deleted, **Then** the underlying ARM deployment and provisioned infrastructure are cleaned up. + +--- + +### User Story 2 — Terraform Module Support (Priority: P1) + +As a platform engineer, I want to set `recipeLocation` to a standard Terraform module source — a registry path (e.g., `ballj/postgresql/kubernetes`), a Git URL (e.g., `git::https://github.com/org/module.git`), or an HTTP archive — and have Radius download and execute it directly. The system resolves my `recipeParameters` (including `{{context.*}}` expressions) into the module's input variables, and maps the module's outputs back to Radius resource properties. The module doesn't need a `context` variable, a `result` output, or any knowledge of Radius. + +**Today's workflow**: Download the module → write a wrapper that adds the `context` variable and `result` output → publish the wrapper → reference the wrapper. **With this feature**: set `recipeLocation` directly to the module's native source path. + +**Why this priority**: This is the core value proposition for Terraform users. Unlocking the Terraform module ecosystem — thousands of community and official modules — without intermediate wrapping steps is essential for adoption. + +**Independent Test**: Link a recipe pointing to a public Terraform registry module, deploy a resource, verify the module is downloaded and executed, and confirm outputs are accessible as resource properties. + +**Acceptance Scenarios**: + +1. **Given** a RecipePack with `recipeKind: 'terraform'` and `recipeLocation` set to a Terraform registry path (e.g., `ballj/postgresql/kubernetes`), **When** a resource is deployed with `recipeParameters` providing values for the module's input variables, **Then** the module receives those values and executes successfully. +2. **Given** a recipe with `recipeLocation` set to a Git URL (e.g., `git::https://github.com/org/module.git`), **When** a resource is deployed, **Then** the system clones the module from Git and executes it. +3. **Given** a recipe with `recipeLocation` pointing to a Git URL with a ref specifier (e.g., `?ref=v2.0.0`) or a subdirectory path (e.g., `//modules/vpc`), **When** deployed, **Then** the system uses the specified ref and/or navigates to the subdirectory. +4. **Given** a Terraform module with output values, **When** the resource is deployed, **Then** the module's outputs are mapped to the resource type's read-only properties via the `outputs` mapping — non-sensitive outputs in the `Values` map, sensitive outputs (marked `sensitive = true`) in the `Secrets` map. +5. **Given** `recipeParameters` containing `{{context.*}}` expressions, **When** the resource is deployed, **Then** expressions are resolved to actual Radius context values before being passed as Terraform input variables. +6. **Given** a module that requires a variable not supplied by any parameter source, **When** deployment is attempted, **Then** Terraform surfaces a clear error indicating which required variable is missing. +7. **Given** a resource deployed via a direct Terraform module recipe, **When** the resource is deleted, **Then** the system runs `terraform destroy` and cleans up all provisioned infrastructure. + +--- + +### User Story 3 — Application-to-Recipe Property Resolution (Priority: P1) + +As a platform engineer, I want `recipeParameters` to resolve properties injected from the application layer (via `context.resource.properties.*`) so that application developers can influence infrastructure configuration through resource properties without knowing the underlying module details. For example, an application developer sets a `size` property on a resource, and the recipe resolves it into a concrete infrastructure SKU using expressions like ternary operators. + +**Why this priority**: This bridges the application and infrastructure layers — application developers express intent through resource properties, and the recipe translates that intent into concrete module inputs. It builds on the direct module support from P1 stories and works identically for any Bicep or Terraform module. + +**Independent Test**: Link a recipe that uses `context.resource.properties.*` expressions in `recipeParameters`, deploy resources with different property values, and verify each deployment passes the correct resolved values to the module. + +**Acceptance Scenarios**: + +1. **Given** a recipe with `recipeParameters` containing `{{context.resource.properties.*}}` expressions, **When** a resource is deployed with specific property values set by the application developer, **Then** those property values are resolved and passed to the module as input parameters. +2. **Given** a recipe with `recipeParameters` containing a ternary expression (e.g., `{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}`), **When** resources are deployed with different property values, **Then** each deployment passes the correct resolved value to the module. +3. **Given** a recipe with `recipeParameters` that combine context property expressions with literal text (e.g., `name: 'pg-{{context.resource.name}}'`), **When** a resource is deployed, **Then** the module receives the fully resolved parameter values. +--- + +### User Story 4 — Private Module Authentication (Priority: P2) + +As a platform engineer, I want to use modules hosted in private registries, private Git repositories, or private OCI registries as recipes, authenticating with credentials configured through the existing secret store. + +**Why this priority**: Enterprise teams host modules in private repositories. Without authentication support, direct module support is limited to public modules. + +**Independent Test**: Link a recipe pointing to a private Terraform registry module or Git repository, configure credentials via the existing secret mechanism, deploy, and verify the module is fetched successfully. + +**Acceptance Scenarios**: + +1. **Given** a recipe with `recipeLocation` pointing to a private Terraform registry module, **When** registry credentials are configured via the existing secret store mechanism, **Then** the system authenticates and fetches the module successfully. +2. **Given** a recipe with `recipeLocation` pointing to a private Git repository, **When** Git credentials are configured, **Then** the system clones and executes the module. + +--- + +### User Story 5 — Source Reachability Validation at Link Time (Priority: P2) + +As a platform engineer, I want the system to validate that a `recipeLocation` pointing to a direct module source is reachable when I link the recipe to an environment, so I catch typos and inaccessible sources early rather than at deploy time. + +**Why this priority**: Early validation prevents wasted time debugging deploy failures caused by simple typos or unreachable sources. + +**Independent Test**: Link a recipe with a `recipeLocation` pointing to a non-existent module source and verify a validation error is returned. + +**Acceptance Scenarios**: + +1. **Given** a recipe with `recipeLocation` pointing to a non-existent registry module, **When** the recipe is linked to an environment, **Then** the system returns a validation error (definitive failures like 404 or authentication denied reject the operation). +2. **Given** a recipe with a valid `recipeLocation` that experiences a transient network failure during validation, **When** the recipe is linked, **Then** the system logs a warning but allows the operation to proceed. + +--- + +### Edge Cases + +- What happens when a module has no input variables? The recipe deploys successfully with no parameters required. +- What happens when a module has no outputs? The deployment succeeds with an empty output set. +- What happens when the module source becomes unavailable after initial deployments? Existing resources are unaffected; new deployments fail with a clear error. +- How does the system handle modules that expect specific provider configurations? The existing provider configuration mechanism applies — providers are configured through the environment's recipe configuration. +- What happens when a parameter name does not match any module input variable? The underlying IaC engine surfaces a clear error. +- How does the system handle output resolution? The `outputs` mapping on the RecipePack is the preferred path for all recipes (direct and wrapped). If a module also produces a structured `result` output, it is honored for backward compatibility, but the `outputs` mapping takes precedence when both are present. +- What happens when a `{{context.*}}` expression contains a typo? The unrecognized expression is left as a literal string — deliberate design to avoid masking errors. +- What happens when a `{{context.*}}` expression is malformed (e.g., unclosed `{{`, trailing dot)? Malformed expressions are left as literal strings — the IaC engine will surface errors if the literal doesn't match expected input types. +- What happens when a parameter contains multiple `{{context.*}}` expressions? All expressions are independently resolved. +- What happens when a resource deployed via a direct module recipe is updated (e.g., `recipeParameters` change)? The module is re-executed with the new parameters — ARM redeployment for Bicep, `terraform apply` with updated variables for Terraform. This is idempotent by design and consistent with existing recipe behavior. +- What happens when a module has breaking changes between versions? The platform engineer must update the version in `recipeLocation` deliberately — no automatic version bumping occurs. + +## Requirements *(mandatory)* + +### Functional Requirements + +**Input Resolution (Both Bicep and Terraform)**: + +- **FR-001**: System MUST pass `recipeParameters` through to a direct module's native inputs — ARM deployment parameters for Bicep, Terraform input variables for Terraform — without requiring the module to declare any Radius-specific input variables (e.g., no `context` variable needed). +- **FR-002**: System MUST support `{{context.*}}` template expressions in `recipeParameters` values that resolve to Radius runtime context at deployment time. Supported paths include resource metadata (name, id, type, properties), application metadata (name, id), environment metadata (name, id), Kubernetes runtime info (namespace, environmentNamespace), Azure metadata (resource group, subscription), and AWS metadata (region, account). Mixed content (e.g., `prefix-{{context.resource.name}}-suffix`) MUST be resolved while preserving surrounding literal text. Unrecognized paths MUST be left as-is. +- **FR-003**: System MUST support single-level conditional ternary expressions inside `{{...}}` that map context values to concrete configuration values (e.g., `{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}`). Nested ternaries are out of scope for V1. +- **FR-004**: System MUST merge `recipeParameters` from the RecipePack and environment-level overrides using shallow merge (top-level keys only). Environment-level parameters take precedence for overlapping keys, replacing the entire value including nested objects. + +**Output Resolution (Both Bicep and Terraform)**: + +- **FR-005**: System MUST surface module output values as resource properties after successful deployment, without requiring the module to produce a Radius-specific structured `result` output. +- **FR-006**: System MUST support an `outputs` field on the RecipePack that maps module output names to the resource type's read-only properties (e.g., `outputs: { host: 'fqdn', port: 'listen_port' }` where keys are resource property names and values are module output names). +- **FR-007**: For Terraform modules, outputs marked `sensitive = true` MUST be routed to the `Secrets` map instead of the `Values` map, without requiring any module modifications. + +**Bicep Module Support**: + +- **FR-008**: System MUST accept a Bicep module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) as the `recipeLocation` value in a RecipePack with `recipeKind: 'bicep'`, without requiring the module to conform to Radius recipe wrapper conventions. +- **FR-009**: System MUST deploy a direct Bicep module through the existing ARM deployment mechanism, passing resolved `recipeParameters` as ARM deployment parameters. +- **FR-010**: System MUST clean up infrastructure provisioned by a direct Bicep module recipe when the deployed resource is deleted. + +**Terraform Module Support**: + +- **FR-011**: System MUST accept a standard Terraform module source as the `recipeLocation` value in a RecipePack with `recipeKind: 'terraform'`. Supported source formats include: Terraform registry paths (`namespace/name/provider`), Git URLs (`git::https://...` with optional `?ref=` and `//subdir`), HTTP archive URLs, S3 URLs (`s3::...`), and GCS URLs (`gcs::...`). The module MUST NOT be required to include any Radius-specific conventions. +- **FR-012**: System MUST resolve and download the Terraform module at deployment time using standard Terraform module retrieval mechanisms. +- **FR-013**: System MUST execute `terraform destroy` when a resource deployed via a direct Terraform module recipe is deleted. + +**Backward Compatibility**: + +- **FR-014**: System MUST ensure existing recipe workflows (wrapped recipes with `context` variable and `result` output) continue to function identically — zero behavioral changes to existing deployments. +- **FR-015**: System MUST support the `outputs` mapping for both direct modules and existing wrapped recipes. If a module produces a structured `result` output (with `values`, `secrets`, `resources`), it is still honored for backward compatibility. However, the `outputs` mapping on the RecipePack is the preferred path for output resolution and works uniformly across all recipe types. When both `result` and `outputs` are present, the `outputs` mapping takes precedence. + +**Error Handling**: + +- **FR-016**: System MUST surface module execution errors (missing variables, provider failures, permission errors) as recipe deployment failures with actionable error messages including the relevant IaC engine error details. +- **FR-017**: System MUST handle modules with no input variables (deploy with no parameters) and modules with no outputs (succeed with empty output set) without errors. + +**Authentication**: + +- **FR-018**: System MUST support authentication for private module sources (private registries, private Git repositories, private OCI registries) using the existing secret store and credential configuration mechanisms. + +**Validation (Best-Effort)**: + +- **FR-019**: System SHOULD perform best-effort validation that a `recipeLocation` pointing to a direct module source is reachable at recipe link time, using lightweight probes with a reasonable timeout. Definitive failures (404, authentication denied) SHOULD reject the operation. Transient failures SHOULD be logged as warnings but SHOULD NOT block linking. + +### Key Entities + +- **RecipePack (`Radius.Core/recipePacks`)**: The primary API resource for this feature. A RecipePack is a collection of recipe configurations keyed by resource type. Each recipe entry has `recipeLocation` that accepts direct module references — Bicep OCI references (`br:...`), Terraform registry paths (`namespace/name/provider`), Git URLs (`git::https://...`), and other Terraform source formats — alongside existing wrapped recipe references. Key fields per recipe entry: `recipeKind` (terraform or bicep), `recipeLocation` (module source, version included in the reference), `recipeParameters` (input values with `{{context.*}}` expression support), and `outputs` (maps module output names to the resource type's read-only properties). +- **Environment**: Configures the deployment context. Recipes are linked to environments (this already works today). Environments can provide environment-level `recipeParameters` that merge with (and override) recipe-level parameters. +- **Recipe Output (from module outputs)**: For direct modules, module output values are mapped to the resource type's read-only properties via the `outputs` mapping. For Terraform, outputs marked `sensitive = true` are routed to the `Secrets` map. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A platform engineer can configure a recipe using an existing Bicep or Terraform module in under 1 minute by setting `recipeLocation` directly — zero wrapping, zero republishing. +- **SC-002**: End-to-end provisioning time for a direct module recipe is within 10% of an equivalent wrapped-recipe deployment — no significant overhead from the direct path. +- **SC-003**: Module output values are mapped to the resource type's read-only properties after deployment via the `outputs` mapping on the RecipePack. +- **SC-004**: Deleting a resource deployed via a direct module recipe fully destroys the underlying infrastructure with zero orphaned resources. +- **SC-005**: Deployment with an inaccessible `recipeLocation` fails within 60 seconds with a clear, actionable error message. +- **SC-006**: Existing wrapped-recipe workflows continue to function with zero behavioral changes — full backward compatibility. +- **SC-007**: Any standard Bicep module from an OCI registry or Terraform module from the public registry/Git (including AVM modules) that does not require Radius-specific conventions can be used directly as a recipe without modification. +- **SC-008**: Platform engineers can use `{{context.*}}` expressions to inject runtime context (resource name, namespace, environment, etc.) into any module's parameters without modifying the module. +- **SC-009**: Linking a recipe with a `recipeLocation` pointing to a non-existent or unreachable module source performs best-effort validation at link time. Definitive failures (404, authentication denied) return a validation error before deployment. Transient failures are logged as warnings but do not block linking. + +## Assumptions + +- **A-001**: The existing Bicep driver (ARM deployments) and Terraform driver (module execution) are extended to handle direct module references. No new driver or execution engine is introduced. +- **A-002**: Module input variable types are passed through without type transformation. Type checking is delegated to the underlying IaC engine (ARM for Bicep, Terraform CLI for Terraform), which produces clear errors for type mismatches. +- **A-003**: This feature operates alongside the existing recipe workflow. Wrapped recipes continue to work exactly as before. +- **A-004**: Infrastructure state management uses existing mechanisms — ARM deployment tracking for Bicep, Kubernetes secret-backed Terraform state for Terraform. +- **A-005**: Provider configuration uses existing mechanisms — Azure provider context for Bicep, `recipes.Configuration` for Terraform. +- **A-006**: Local filesystem paths as `recipeLocation` are out of scope. The initial scope covers OCI-hosted Bicep modules, Terraform registry modules, Git-hosted modules, and HTTP/S3/GCS archives. +- **A-007**: AVM modules are treated identically to any other direct module — no special handling. The "AVM" designation is purely organizational. +- **A-008**: Recipe linking to environments already works today and is not part of this feature. This feature is solely about what happens when `recipeLocation` points to a standard module instead of a wrapped one. +- **A-009**: Module version is specified as part of `recipeLocation` (e.g., OCI tag for Bicep, `?ref=` for Git, registry version syntax for Terraform). There is no separate `templateVersion` field. +- **A-010**: Direct module deployments use the same observability mechanisms (logging, tracing, metrics) as existing wrapped-recipe deployments. No new observability infrastructure is introduced. +- **A-011**: Sensitive input parameters (e.g., database passwords, API keys) are handled by the existing Radius secret store mechanism. No new secret handling is introduced for direct module support. +- **A-012**: Transient failure retry during module fetch at deploy time is delegated to the underlying IaC engine (Terraform and ARM both have built-in retry for transient failures). No custom Radius-level retry logic is introduced. + +## Future Steps + +The following are good-to-have capabilities that build on the core feature but are not required for initial delivery: + +- **Inspect Module Schema Before Deployment**: Retrieve a module's input variables and outputs before deploying, enabling discoverability and reducing trial-and-error. Engineers can consult module documentation as a workaround today. diff --git a/specs/001-direct-module-support/tasks.md b/specs/001-direct-module-support/tasks.md new file mode 100644 index 0000000000..8590eb70e2 --- /dev/null +++ b/specs/001-direct-module-support/tasks.md @@ -0,0 +1,275 @@ + +# Tasks: Direct Module Support + +**Input**: Design documents from `specs/001-direct-module-support/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Prototype Status**: Branch `001-direct-module-support` (3 commits ahead of main) already implements source classification (`pkg/recipes/source/`), TF direct output mapping, TF config generation (`AddAllOutputs`, `AddDirectModuleContext`), reachability validation, and RecipePack controller wiring. Tasks below cover **remaining work only**. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup + +**Purpose**: Verify prototype state and prepare for remaining implementation + +- [ ] T001 Rebase branch `001-direct-module-support` onto latest main and verify `make build` succeeds +- [ ] T002 Run existing unit tests (`go test ./pkg/recipes/source/... ./pkg/recipes/driver/terraform/... ./pkg/recipes/terraform/...`) and confirm green baseline + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core utilities shared by ALL P1 user stories (US1, US2, US3) — MUST complete before any story work begins. The expression resolver (including `context.resource.properties.*` paths and single-level ternary evaluation) is the foundational engine for all three P1 stories. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [ ] T003 Implement `ResolveParameterExpressions()` and `buildContextLookup()` in `pkg/recipes/paramresolver/resolver.go` — regex-based `{{context.*}}` replacement using flat context map built from `recipecontext.Context` struct; include `context.resource.properties.*` dynamic key enumeration so any user-defined property is accessible (US3 property resolution); unrecognized expressions left as-is per R-011 +- [ ] T004 Implement single-level ternary expression evaluation in `pkg/recipes/paramresolver/resolver.go` — parse `{{expr == "val" ? "trueResult" : "falseResult"}}` syntax; single-level only (nested/chained ternaries are out of scope for V1); string comparison is exact-match, case-sensitive; ternary with unresolvable condition path left as-is +- [ ] T005 Write table-driven unit tests for `ResolveParameterExpressions()` in `pkg/recipes/paramresolver/resolver_test.go` — cover: single expression, multiple expressions in one value, mixed literal+expression, unrecognized expression left as-is, empty map, nil values, nested map traversal, `context.resource.properties.*` resolution (existing property resolves, missing property left as-is, property with special characters, multiple property expressions in one string) +- [ ] T006 Write table-driven unit tests for ternary evaluation in `pkg/recipes/paramresolver/resolver_test.go` — cover: simple ternary true/false, context property lookup in condition, unresolvable condition path (left as-is), mixed ternary + literal text, nested/chained ternary explicitly out of scope (verify left as-is) +- [ ] T007 [P] Implement output mapping utility function `ApplyOutputsMapping(values map[string]any, secrets map[string]any, outputsMap map[string]string) (map[string]any, map[string]any)` in `pkg/recipes/util/outputs.go` — map module output names to the resource type's read-only property names per `outputs` map (keys = resource property names, values = module output names); when `outputs` is nil/empty, pass through all outputs unchanged +- [ ] T008 [P] Write table-driven unit tests for `ApplyOutputsMapping()` in `pkg/recipes/util/outputs_test.go` — cover: output-to-property mapping, pass-through when nil, missing output key in values (skip silently), sensitive output mapping, empty maps +- [ ] T009 [P] Implement shallow merge utility `ShallowMergeParameters(base map[string]any, override map[string]any) map[string]any` in `pkg/recipes/util/merge.go` — top-level key precedence from override per FR-004; nested objects replaced entirely (not deep-merged) +- [ ] T010 [P] Write table-driven unit tests for `ShallowMergeParameters()` in `pkg/recipes/util/merge_test.go` — cover: disjoint keys, overlapping keys (override wins), nested object replaced not merged, nil base, nil override, both nil +- [ ] T011 Add `Outputs map[string]string` field to `EnvironmentDefinition` in `pkg/recipes/types.go` and propagate from `RecipeDefinition.Outputs` through the config loader path + +**Checkpoint**: Foundation ready — expression resolver (with `context.resource.properties.*` and single-level ternary), output mapping, shallow merge, and EnvironmentDefinition.Outputs all available for driver integration. All three P1 stories (US1, US2, US3) can now proceed. + +--- + +## Phase 3: User Story 2 — Terraform Module Support (Priority: P1) 🎯 MVP + +**Goal**: Platform engineers set `recipeLocation` to a Terraform registry path, Git URL, or HTTP archive. The system downloads the module, resolves `{{context.*}}` expressions (including `context.resource.properties.*` and single-level ternary) in parameters, executes it, and maps outputs to the resource type's read-only properties. + +**Independent Test**: Link a recipe to a public Terraform registry module (e.g., `ballj/postgresql/kubernetes`), deploy a resource with `recipeParameters` including `{{context.resource.name}}`, verify module executes and outputs are accessible as resource properties. + +### Implementation for User Story 2 + +- [ ] T012 [US2] Replace `AddDirectModuleContext` auto-injection with `ResolveParameterExpressions()` call in `pkg/recipes/terraform/execute.go` — in the direct module code path, resolve expressions in `EnvironmentDefinition.Parameters` before writing them to the generated config; remove the old well-known variable name matching per R-011 +- [ ] T013 [US2] Wire `ShallowMergeParameters()` into the Terraform execute path in `pkg/recipes/terraform/execute.go` — merge RecipePack-level `recipeParameters` with environment-level parameters (environment wins) before expression resolution +- [ ] T014 [US2] Wire `ApplyOutputsMapping()` into `prepareRecipeResponse()` in `pkg/recipes/driver/terraform/terraform.go` — after flat output collection for direct modules, apply `EnvironmentDefinition.Outputs` to map module output names to the resource type's read-only properties in `RecipeOutput.Values` and `RecipeOutput.Secrets` +- [ ] T015 [US2] Implement `result` vs `outputs` precedence logic in `pkg/recipes/driver/terraform/terraform.go` — per FR-015: if module has a `result` output AND no `outputs` mapping is configured, treat as wrapped recipe; if `outputs` mapping exists, it takes precedence +- [ ] T016 [US2] Update unit tests in `pkg/recipes/driver/terraform/terraform_test.go` — add test cases for: direct module with outputs mapping to read-only properties, direct module without outputs mapping (pass-through), direct module with `result` output and no `outputs` mapping (wrapped behavior), direct module with both `result` and `outputs` (outputs wins) +- [ ] T017 [US2] Update unit tests in `pkg/recipes/terraform/execute_test.go` — add test cases for: expression resolution in direct module path (including `context.resource.properties.*` and ternary), shallow merge of parameters, context lookup populated from recipe context + +**Checkpoint**: Terraform direct module path fully wired — expression resolution (with property paths and ternary), parameter merge, output mapping to read-only properties, result/outputs precedence + +--- + +## Phase 4: User Story 1 — Bicep Module Support (Priority: P1) + +**Goal**: Platform engineers set `recipeLocation` to a Bicep module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`). The system deploys it via ARM, passes resolved parameters (including `context.resource.properties.*` and ternary expressions), and maps ARM outputs to the resource type's read-only properties. + +**Independent Test**: Link a recipe to a public AVM Bicep module from MCR, deploy a resource with `recipeParameters`, verify ARM deployment succeeds and module outputs are accessible as resource properties. + +### Implementation for User Story 1 + +- [ ] T018 [US1] Implement direct Bicep module detection in `pkg/recipes/driver/bicep/bicep.go` — detect when `recipeLocation` is a standard Bicep module (no Radius `result` output convention); use a flag or heuristic (e.g., absence of `result` in module outputs after ARM template inspection, or explicit `outputs` mapping presence) +- [ ] T019 [US1] Implement direct Bicep deployment path in `pkg/recipes/driver/bicep/bicep.go` — resolve `{{context.*}}` expressions in parameters via `ResolveParameterExpressions()`, pass resolved parameters as ARM deployment parameters, invoke ARM deployment +- [ ] T020 [US1] Wire `ShallowMergeParameters()` into the Bicep driver path in `pkg/recipes/driver/bicep/bicep.go` — merge RecipePack-level and environment-level parameters before expression resolution +- [ ] T021 [US1] Map ARM deployment outputs to `RecipeOutput` in `pkg/recipes/driver/bicep/bicep.go` — apply `ApplyOutputsMapping()` to map module output names to the resource type's read-only property names per `outputs` field +- [ ] T022 [US1] Ensure ARM deployment cleanup for direct Bicep modules in `pkg/recipes/driver/bicep/bicep.go` — verify ARM deployment deletion works for direct module deployments on resource delete +- [ ] T023 [US1] Write unit tests for Bicep direct module path in `pkg/recipes/driver/bicep/bicep_test.go` — cover: direct module detection, parameter resolution, output mapping to read-only properties, ARM deployment creation, ARM deployment deletion/cleanup + +**Checkpoint**: Bicep direct module path complete — all three P1 user stories (Terraform + Bicep + Property Resolution) are now functional + +--- + +## Phase 5: User Story 4 — Private Module Authentication (Priority: P2) + +**Goal**: Platform engineers use modules from private registries, Git repos, or OCI registries by configuring credentials through the existing secret store. + +**Independent Test**: Link a recipe to a private Terraform registry module, configure credentials via existing secret mechanism, deploy, and verify module is fetched successfully. + +### Implementation for User Story 4 + +- [ ] T024 [US4] Implement credential passthrough for private Terraform registry modules in `pkg/recipes/terraform/execute.go` — read credentials from the existing secret store and pass as Terraform CLI config (`.terraformrc` or `TF_TOKEN_*` environment variables) before `terraform init` +- [ ] T025 [US4] Implement credential passthrough for private Git repositories in `pkg/recipes/terraform/execute.go` — configure Git credentials (`GIT_ASKPASS` or credential helper) from secret store before module download +- [ ] T026 [US4] Write unit tests for private module credential injection in `pkg/recipes/terraform/execute_test.go` — cover: registry token injection, Git credential configuration, missing credentials (fall through gracefully), credential scoping (credentials only applied when source matches) + +**Checkpoint**: Private module authentication works for Terraform registry and Git sources + +--- + +## Phase 6: User Story 5 — Source Reachability Validation at Link Time (Priority: P2) + +**Goal**: The system performs best-effort validation that a `recipeLocation` pointing to a direct module source is reachable at recipe link time. Per SC-009: definitive failures (404, authentication denied) reject the operation; transient failures log warnings but allow linking. + +**Independent Test**: Link a recipe with a `recipeLocation` pointing to a non-existent module and verify a validation error is returned. Link with a transiently unreachable source and verify a warning is logged but the operation succeeds. + +### Implementation for User Story 5 + +- [ ] T027 [US5] Wire `ValidateReachability()` into `CreateOrUpdateRecipePack` controller for Bicep OCI references in `pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack.go` — extend existing Terraform reachability validation to also cover `br:` Bicep module references (HEAD request to OCI manifest); per SC-009: definitive failures (404, auth denied) reject, transient failures log warning but allow linking +- [ ] T028 [US5] Write unit tests for Bicep OCI reachability validation in `pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go` — cover: valid Bicep OCI reference passes, non-existent reference returns rejection error (definitive failure), transient network failure logs warning but allows creation, authentication denied returns rejection error + +**Checkpoint**: Reachability validation covers both Terraform and Bicep module sources with best-effort semantics (SC-009) + +--- + +## Phase 7: TypeSpec Model Alignment (Priority: P1) + +**Goal**: Align the TypeSpec API model with the implementation — add `outputs` and `recipeParameters` fields. A RecipePack is a collection of recipe configurations keyed by resource type. + +- [ ] T029 Add `outputs` field (type `Record`) and `recipeParameters` field (type `Record`) to the RecipePack recipe definition model in `typespec/Radius.Core/recipePacks.tsp` — RecipePack is a collection keyed by resource type; no `templateVersion` field (version is part of `recipeLocation` per A-009) +- [ ] T030 Regenerate API client code from updated TypeSpec model (`make generate`) and fix any compilation errors in generated code +- [ ] T031 Update `RecipeDefinition` struct in `pkg/corerp/datamodel/recipepack.go` to align JSON tags with generated API model — ensure `recipeParameters` and `outputs` fields serialize correctly for round-trip API calls + +**Checkpoint**: TypeSpec model, generated code, and internal data model are aligned + +--- + +## Phase 8: Functional Tests (Priority: P1) + +**Goal**: End-to-end validation with real modules — must cover `{{context.resource.properties.*}}` resolution and ternary expressions + +- [ ] T032 [P] Create functional test for Terraform registry module deployment in `test/functional-portable/recipes/` — use a lightweight public registry module (e.g., `ballj/postgresql/kubernetes`), configure `recipeParameters` with `{{context.*}}` expressions including `{{context.resource.properties.*}}` and ternary, verify deployment succeeds and outputs are mapped to resource read-only properties +- [ ] T033 [P] Create functional test for Git-hosted module deployment in `test/functional-portable/recipes/` — use `git::https://` source with `?ref=` version pin, verify module download, execution, and output mapping to read-only properties +- [ ] T034 [P] Create functional test for Bicep AVM module deployment in `test/functional-portable/recipes/` — use a public AVM Bicep module from MCR (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`), configure `recipeParameters`, verify ARM deployment succeeds and outputs are mapped to resource read-only properties +- [ ] T035 Create functional test for backward compatibility in `test/functional-portable/recipes/` — deploy a wrapped recipe (with `context` variable and `result` output) and verify zero behavioral changes + +**Checkpoint**: End-to-end tests validate the complete direct module flow for Terraform and Bicep, including property resolution and ternary expressions + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] T036 [P] Run full linting pass (`make lint && make format-check`) and fix any issues across all modified packages +- [ ] T037 [P] Update inline code documentation (GoDoc comments) for all new exported functions and types in `pkg/recipes/paramresolver/`, `pkg/recipes/util/`, and modified driver files +- [ ] T038 Verify `make build` succeeds and run full unit test suite (`make test`) — confirm no regressions across the codebase +- [ ] T039 Run `specs/001-direct-module-support/quickstart.md` scenarios manually against a local Radius environment to validate end-to-end UX + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories +- **US2 Terraform (Phase 3)**: Depends on Foundational — first MVP increment +- **US1 Bicep (Phase 4)**: Depends on Foundational — can run in parallel with Phase 3 +- **US4 Private Auth (Phase 5)**: Depends on Foundational — can run in parallel with Phases 3–4 +- **US5 Reachability (Phase 6)**: Depends on Foundational — can run in parallel with Phases 3–5 +- **TypeSpec (Phase 7)**: No story dependency — can run in parallel with any phase after Setup +- **Functional Tests (Phase 8)**: Depends on Phases 3 + 4 (Terraform + Bicep paths wired with expression resolver) +- **Polish (Phase 9)**: Depends on all other phases + +### User Story Dependencies + +- **US1 (P1 Bicep)**: Can start after Foundational — no dependencies on other stories +- **US2 (P1 Terraform)**: Can start after Foundational — no dependencies on other stories +- **US3 (P1 Property Resolution)**: Implemented in Foundational phase — expression resolver includes `context.resource.properties.*` and ternary; validated through US1/US2 driver wiring and functional tests +- **US4 (P2 Private Auth)**: Independent of all other stories +- **US5 (P2 Reachability)**: Independent of all other stories (extends existing validation) + +### Within Each User Story + +- Core implementation before integration wiring +- Wiring before unit tests +- All tasks within a story are sequential unless marked [P] + +### Parallel Opportunities + +``` +After Foundational completes (which includes US3 expression engine), +all of these can run simultaneously: + + ┌─ Phase 3: US2 Terraform ─────────────┐ + ├─ Phase 4: US1 Bicep ─────────────────┤ + ├─ Phase 5: US4 Private Auth ──────────┤──▶ Phase 8: Functional Tests + ├─ Phase 6: US5 Reachability ──────────┤ + └─ Phase 7: TypeSpec ──────────────────┘ +``` + +Within Foundational: T007+T008, T009+T010 can all run in parallel with each other (and with the T003→T004→T005→T006 sequential chain). + +--- + +## Parallel Example: Foundational Phase + +```bash +# Sequential (has dependency — same file, expression resolver): +Task T003: "Implement ResolveParameterExpressions() with context.resource.properties.* in pkg/recipes/paramresolver/resolver.go" +Task T004: "Implement single-level ternary evaluation in pkg/recipes/paramresolver/resolver.go" +Task T005: "Write tests for expression resolver (basic + properties) in pkg/recipes/paramresolver/resolver_test.go" +Task T006: "Write tests for ternary evaluation in pkg/recipes/paramresolver/resolver_test.go" + +# Parallel with T003–T006 (different files, no dependencies): +Task T007: "Implement ApplyOutputsMapping() in pkg/recipes/util/outputs.go" +Task T008: "Write tests for ApplyOutputsMapping() in pkg/recipes/util/outputs_test.go" +Task T009: "Implement ShallowMergeParameters() in pkg/recipes/util/merge.go" +Task T010: "Write tests for ShallowMergeParameters() in pkg/recipes/util/merge_test.go" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 2 — Terraform Only) + +1. Complete Phase 1: Setup (verify prototype baseline) +2. Complete Phase 2: Foundational (expression resolver with properties + ternary, output mapping, shallow merge) +3. Complete Phase 3: User Story 2 (Terraform direct module path) +4. **STOP and VALIDATE**: Test with a real Terraform registry module end-to-end (including `context.resource.properties.*` and ternary) +5. Deploy/demo if ready — this alone unlocks the Terraform module ecosystem with property resolution + +### Incremental Delivery + +1. Setup + Foundational → Foundation ready (US3 expression engine included) +2. Add US2 (Terraform) → Test independently → Demo (**MVP!** — includes property resolution + ternary) +3. Add US1 (Bicep) → Test independently → Demo (full P1 delivery — all 3 P1 stories complete) +4. Add US4 + US5 → Test independently → Demo (enterprise features) +5. TypeSpec + Functional Tests + Polish → Release ready + +### Parallel Team Strategy + +With multiple developers after Foundational completes: + +- **Developer A**: US2 Terraform (Phase 3) then Functional Tests (Phase 8) +- **Developer B**: US1 Bicep (Phase 4) then US5 Reachability (Phase 6) +- **Developer C**: US4 Private Auth (Phase 5) then Polish (Phase 9) +- **Developer D**: TypeSpec (Phase 7) then Polish (Phase 9) + +--- + +## Summary + +| Metric | Value | +|--------|-------| +| **Total tasks** | 39 | +| **Setup** | 2 tasks (Phase 1) | +| **Foundational (incl. US3 engine)** | 9 tasks (Phase 2) | +| **US2 Terraform (P1)** | 6 tasks (Phase 3) | +| **US1 Bicep (P1)** | 6 tasks (Phase 4) | +| **US4 Private Auth (P2)** | 3 tasks (Phase 5) | +| **US5 Reachability (P2)** | 2 tasks (Phase 6) | +| **TypeSpec Alignment (P1)** | 3 tasks (Phase 7) | +| **Functional Tests (P1)** | 4 tasks (Phase 8) | +| **Polish** | 4 tasks (Phase 9) | +| **Parallel opportunities** | 5 phases can run simultaneously after Foundational | +| **Suggested MVP** | Phases 1–3 (US2 Terraform: 17 tasks, includes US3 property resolution) | +| **P1 stories** | US1, US2, US3 — all delivered by end of Phase 4 | + +--- + +## Notes + +- [P] tasks = different files, no dependencies on incomplete tasks +- [Story] label maps task to specific user story for traceability +- **US3 (Property Resolution) is P1** — its expression engine (ternary + `context.resource.properties.*`) is built in Foundational and ships with US1/US2 +- Prototype code (source classifier, TF output mapping, config generation) is already implemented — not re-tasked +- RecipePack is a collection of recipe configurations keyed by resource type +- Module version is part of `recipeLocation` (OCI tag, Git ref, registry syntax) — no separate `templateVersion` field (A-009) +- Output mapping maps module outputs to the resource type's read-only properties (not rename/filter) +- Reachability validation is best-effort: definitive failures reject, transient failures warn (SC-009) +- Each user story is independently completable and testable +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently From e4adaa69d7fb38804917956767dcb1c2383a69c6 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim <61033581+Reshrahim@users.noreply.github.com> Date: Tue, 12 May 2026 14:18:12 -0700 Subject: [PATCH 12/18] Update spec.md Signed-off-by: Reshma Abdul Rahim <61033581+Reshrahim@users.noreply.github.com> --- specs/001-direct-module-support/spec.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/specs/001-direct-module-support/spec.md b/specs/001-direct-module-support/spec.md index c426dd54cb..5db0e471ad 100644 --- a/specs/001-direct-module-support/spec.md +++ b/specs/001-direct-module-support/spec.md @@ -4,21 +4,16 @@ **Created**: 2026-04-22 **Updated**: 2026-04-30 **Status**: Draft -**Input**: Enable platform engineers to use any standard Bicep or Terraform module as a Radius recipe — without writing a Radius-specific wrapper. Today, using a module as a recipe requires a wrapper that conforms to Radius conventions (a `context` input variable, a structured `result` output). This feature eliminates the wrapper: point `recipeLocation` directly at a standard module, and the system handles input resolution (injecting Radius context like resource name, namespace, etc.) and output resolution (mapping module outputs to resource properties) externally, outside the module. The module doesn't need to know about Radius. +**Input**: Enable platform engineers to use any standard Bicep or Terraform module as a Radius Recipe — without writing a Radius-specific wrapper. Today, using a module as a recipe requires a wrapper that conforms to Radius conventions (a `context` input variable, a structured `result` output). This feature eliminates the wrapper: point `recipeLocation` directly at a standard module, and the system handles input resolution (injecting Radius context like resource name, namespace, etc.) and output resolution (mapping module outputs to resource properties) externally, outside the module. The module doesn't need to know about Radius. ## The Problem -Today, every Bicep or Terraform module used as a Radius recipe must be wrapped in a Radius-specific shim: +Today, every Bicep or Terraform module used as a Radius Recipe must be wrapped in a Radius-specific adapter. The wrapper adds a context input and a structured result output that conforms to Radius recipe conventions. To use a community module, platform engineers must create the wrapper, publish it to a distribution source (Git for Terraform, OCI for Bicep), and keep it updated as the upstream module evolves. -- **For both Bicep and Terraform**: The wrapper adds a `context` input variable and a structured `result` output (separating `values`, `secrets`, and `resources`) that conforms to Radius recipe conventions. The platform engineer downloads a community module, writes a wrapper that calls it, re-publishes the wrapper, and references that wrapper as the recipe. The problem is identical regardless of IaC language. +1. Adoption friction — every module requires a custom recipe wrapper that must be published and versioned separately from the original module before it can be used. +1. Maintenance burden — upstream module updates require wrapper changes, validation, and republishing, creating version drift over time. -This wrapper tax has real consequences: - -1. **Friction**: Every module requires a bespoke wrapper before it can be used. Wrapping a single module takes 15–60 minutes and requires understanding both the module's interface and Radius conventions. -2. **Maintenance burden**: When the upstream module releases a new version, the wrapper must be updated and re-published. Wrapper drift causes silent failures. -3. **Ecosystem lock-out**: Thousands of production-ready modules exist in the Terraform Registry, MCR (for Bicep), and Git repositories. The wrapper requirement means none of them work out of the box. - -**Direct module support eliminates the wrapper.** Platform engineers point `recipeLocation` at any standard module. The system resolves inputs (injecting Radius context into the module's native variables) and resolves outputs (mapping the module's native outputs to resource properties) — all externally, without modifying the module. +Direct module support eliminates the Recipe wrapper. Platform engineers point recipeLocation directly at any standard module, and Radius handles context injection and output mapping externally—without modifying, republishing, or forking the upstream module. ## User Scenarios & Testing *(mandatory)* From 509e422b630be7f75b1910b81ae2cca881b08ebf Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Fri, 15 May 2026 13:29:47 -0700 Subject: [PATCH 13/18] feat: add foundational utilities and TF driver integration for direct module support Phase 2 - Foundational utilities: - Add paramresolver package with ResolveParameterExpressions() for {{context.*}} template expression resolution including single-level ternary evaluation - Add util.ApplyOutputsMapping() for mapping module outputs to resource properties - Add util.ShallowMergeParameters() for merging recipe parameters - Add Outputs field to EnvironmentDefinition in types.go Phase 3 - Terraform driver integration: - Wire expression resolution and shallow merge into generateConfig for direct modules - Add result vs outputs precedence logic in prepareRecipeResponse (FR-015) - Add collectFlatOutputs helper for direct module output collection - Add comprehensive tests for all new functionality Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/recipes/driver/terraform/terraform.go | 51 ++- .../driver/terraform/terraform_test.go | 149 +++++++ pkg/recipes/paramresolver/resolver.go | 176 ++++++++ pkg/recipes/paramresolver/resolver_test.go | 419 ++++++++++++++++++ pkg/recipes/terraform/execute.go | 26 ++ pkg/recipes/types.go | 3 + pkg/recipes/util/merge.go | 36 ++ pkg/recipes/util/merge_test.go | 86 ++++ pkg/recipes/util/outputs.go | 46 ++ pkg/recipes/util/outputs_test.go | 99 +++++ 10 files changed, 1086 insertions(+), 5 deletions(-) create mode 100644 pkg/recipes/paramresolver/resolver.go create mode 100644 pkg/recipes/paramresolver/resolver_test.go create mode 100644 pkg/recipes/util/merge.go create mode 100644 pkg/recipes/util/merge_test.go create mode 100644 pkg/recipes/util/outputs.go create mode 100644 pkg/recipes/util/outputs_test.go diff --git a/pkg/recipes/driver/terraform/terraform.go b/pkg/recipes/driver/terraform/terraform.go index 5c07fc23b2..0fce3771e4 100644 --- a/pkg/recipes/driver/terraform/terraform.go +++ b/pkg/recipes/driver/terraform/terraform.go @@ -179,6 +179,13 @@ func (d *terraformDriver) Delete(ctx context.Context, opts driver.DeleteOptions) // prepareRecipeResponse populates the recipe response from the module output named "result" and the // resources deployed by the Terraform module. The outputs and resources are retrieved from the input Terraform JSON state. +// +// Precedence logic (FR-015): +// - If an `outputs` mapping is configured on the definition, it takes precedence: all module outputs +// are collected flat and then renamed via the mapping. +// - If no `outputs` mapping exists and the module has a `result` output, the module is treated as a +// wrapped recipe (existing behavior). +// - If neither is present, all module outputs pass through unchanged. func (d *terraformDriver) prepareRecipeResponse(ctx context.Context, definition recipes.EnvironmentDefinition, tfState *tfjson.State) (*recipes.RecipeOutput, error) { // We need to use reflect.DeepEqual to compare the struct that has a slice with an empty struct. // The reason is that Go does not allow comparison of structs that contain slices. @@ -189,13 +196,27 @@ func (d *terraformDriver) prepareRecipeResponse(ctx context.Context, definition recipeResponse := &recipes.RecipeOutput{} if tfState.Values != nil && tfState.Values.Outputs != nil { - // We populate the recipe response from the 'result' output (if set). moduleOutputs := tfState.Values.Outputs - if result, ok := moduleOutputs[recipes.ResultPropertyName].Value.(map[string]any); ok { - err := recipeResponse.PrepareRecipeResponse(result) - if err != nil { - return &recipes.RecipeOutput{}, err + hasOutputsMapping := len(definition.Outputs) > 0 + _, hasResultOutput := moduleOutputs[recipes.ResultPropertyName] + + if hasOutputsMapping { + // Direct module with outputs mapping — collect all outputs flat, then apply mapping. + values, secrets := collectFlatOutputs(moduleOutputs) + recipeResponse.Values, recipeResponse.Secrets = recipes_util.ApplyOutputsMapping(values, secrets, definition.Outputs) + } else if hasResultOutput { + // Wrapped recipe — use existing result output parsing. + if result, ok := moduleOutputs[recipes.ResultPropertyName].Value.(map[string]any); ok { + err := recipeResponse.PrepareRecipeResponse(result) + if err != nil { + return &recipes.RecipeOutput{}, err + } } + } else { + // Direct module without mapping — pass through all outputs unchanged. + values, secrets := collectFlatOutputs(moduleOutputs) + recipeResponse.Values = values + recipeResponse.Secrets = secrets } } @@ -450,3 +471,23 @@ func (d *terraformDriver) getDeployedOutputResources(ctx context.Context, module return recipeResources, nil } + +// collectFlatOutputs collects all Terraform state outputs into flat value and secret maps. +// Sensitive outputs go into secrets, non-sensitive into values. +func collectFlatOutputs(outputs map[string]*tfjson.StateOutput) (map[string]any, map[string]any) { + values := make(map[string]any) + secrets := make(map[string]any) + + for name, output := range outputs { + if output == nil { + continue + } + if output.Sensitive { + secrets[name] = output.Value + } else { + values[name] = output.Value + } + } + + return values, secrets +} diff --git a/pkg/recipes/driver/terraform/terraform_test.go b/pkg/recipes/driver/terraform/terraform_test.go index 9d60d1937f..3cfa7859dd 100644 --- a/pkg/recipes/driver/terraform/terraform_test.go +++ b/pkg/recipes/driver/terraform/terraform_test.go @@ -783,6 +783,155 @@ func Test_Terraform_PrepareRecipeResponse(t *testing.T) { } } +func Test_Terraform_PrepareRecipeResponse_DirectModule(t *testing.T) { + d := &terraformDriver{} + tests := []struct { + desc string + definition recipes.EnvironmentDefinition + state *tfjson.State + expectedResponse *recipes.RecipeOutput + expectedErr error + }{ + { + desc: "direct module with outputs mapping", + definition: recipes.EnvironmentDefinition{ + Name: "postgres", + Driver: recipes.TemplateKindTerraform, + TemplatePath: "ballj/postgresql/kubernetes", + ResourceType: "Applications.Datastores/sqlDatabases", + TemplateVersion: "1.0", + Outputs: map[string]string{"host": "hostname", "port": "port_number"}, + }, + state: &tfjson.State{ + Values: &tfjson.StateValues{ + Outputs: map[string]*tfjson.StateOutput{ + "hostname": {Value: "pg.example.com"}, + "port_number": {Value: json.Number("5432")}, + "extra": {Value: "ignored"}, + }, + RootModule: &tfjson.StateModule{}, + }, + }, + expectedResponse: &recipes.RecipeOutput{ + Values: map[string]any{"host": "pg.example.com", "port": json.Number("5432")}, + Secrets: map[string]any{}, + Status: &rpv1.RecipeStatus{ + TemplateKind: recipes.TemplateKindTerraform, + TemplatePath: "ballj/postgresql/kubernetes", + TemplateVersion: "1.0", + }, + }, + }, + { + desc: "direct module without outputs mapping - pass through all", + definition: recipes.EnvironmentDefinition{ + Name: "postgres", + Driver: recipes.TemplateKindTerraform, + TemplatePath: "ballj/postgresql/kubernetes", + ResourceType: "Applications.Datastores/sqlDatabases", + TemplateVersion: "1.0", + }, + state: &tfjson.State{ + Values: &tfjson.StateValues{ + Outputs: map[string]*tfjson.StateOutput{ + "hostname": {Value: "pg.example.com"}, + "password": {Value: "secret123", Sensitive: true}, + }, + RootModule: &tfjson.StateModule{}, + }, + }, + expectedResponse: &recipes.RecipeOutput{ + Values: map[string]any{"hostname": "pg.example.com"}, + Secrets: map[string]any{"password": "secret123"}, + Status: &rpv1.RecipeStatus{ + TemplateKind: recipes.TemplateKindTerraform, + TemplatePath: "ballj/postgresql/kubernetes", + TemplateVersion: "1.0", + }, + }, + }, + { + desc: "module with result output and no outputs mapping - wrapped behavior", + definition: recipes.EnvironmentDefinition{ + Name: "mongo-azure", + Driver: recipes.TemplateKindTerraform, + TemplatePath: "radiusdev.azurecr.io/recipes/mongo:1.0", + ResourceType: "Applications.Datastores/mongoDatabases", + TemplateVersion: "1.0", + }, + state: &tfjson.State{ + Values: &tfjson.StateValues{ + Outputs: map[string]*tfjson.StateOutput{ + recipes.ResultPropertyName: { + Value: map[string]any{ + "values": map[string]any{ + "host": "mongohost", + }, + "secrets": map[string]any{ + "connectionString": "mongodb://...", + }, + "resources": []any{"resource1"}, + }, + }, + }, + RootModule: &tfjson.StateModule{}, + }, + }, + expectedResponse: &recipes.RecipeOutput{ + Values: map[string]any{"host": "mongohost"}, + Secrets: map[string]any{"connectionString": "mongodb://..."}, + Resources: []string{"resource1"}, + Status: &rpv1.RecipeStatus{ + TemplateKind: recipes.TemplateKindTerraform, + TemplatePath: "radiusdev.azurecr.io/recipes/mongo:1.0", + TemplateVersion: "1.0", + }, + }, + }, + { + desc: "module with both result and outputs mapping - outputs wins", + definition: recipes.EnvironmentDefinition{ + Name: "postgres", + Driver: recipes.TemplateKindTerraform, + TemplatePath: "mymodule/postgres:1.0", + ResourceType: "Applications.Datastores/sqlDatabases", + TemplateVersion: "1.0", + Outputs: map[string]string{"host": "hostname"}, + }, + state: &tfjson.State{ + Values: &tfjson.StateValues{ + Outputs: map[string]*tfjson.StateOutput{ + recipes.ResultPropertyName: { + Value: map[string]any{ + "values": map[string]any{"host": "from-result"}, + }, + }, + "hostname": {Value: "from-flat-output"}, + }, + RootModule: &tfjson.StateModule{}, + }, + }, + expectedResponse: &recipes.RecipeOutput{ + Values: map[string]any{"host": "from-flat-output"}, + Secrets: map[string]any{}, + Status: &rpv1.RecipeStatus{ + TemplateKind: recipes.TemplateKindTerraform, + TemplatePath: "mymodule/postgres:1.0", + TemplateVersion: "1.0", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + recipeResponse, err := d.prepareRecipeResponse(context.Background(), tt.definition, tt.state) + require.Equal(t, tt.expectedErr, err) + require.Equal(t, tt.expectedResponse, recipeResponse) + }) + } +} + func Test_FindSecretIDs(t *testing.T) { ctx := context.TODO() definition := recipes.EnvironmentDefinition{TemplatePath: "git::https://dev.azure.com/project/module"} diff --git a/pkg/recipes/paramresolver/resolver.go b/pkg/recipes/paramresolver/resolver.go new file mode 100644 index 0000000000..79229663b9 --- /dev/null +++ b/pkg/recipes/paramresolver/resolver.go @@ -0,0 +1,176 @@ +/* +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 paramresolver + +import ( + "fmt" + "regexp" + "strings" + + "github.com/radius-project/radius/pkg/recipes/recipecontext" +) + +// expressionPattern matches {{context.*}} template expressions, including ternary expressions. +var expressionPattern = regexp.MustCompile(`\{\{([^}]+)\}\}`) + +// ternaryPattern matches single-level ternary expressions: expr == "val" ? "trueResult" : "falseResult" +var ternaryPattern = regexp.MustCompile(`^\s*(.+?)\s*==\s*"([^"]*)"\s*\?\s*"([^"]*)"\s*:\s*"([^"]*)"\s*$`) + +// ResolveParameterExpressions resolves {{context.*}} template expressions in recipe parameters. +// It traverses the parameter map recursively and replaces expressions with values from the context lookup. +// Unrecognized expressions are left as-is per R-011. +func ResolveParameterExpressions(params map[string]any, ctx *recipecontext.Context) map[string]any { + if params == nil { + return nil + } + + lookup := buildContextLookup(ctx) + result := make(map[string]any, len(params)) + for k, v := range params { + result[k] = resolveValue(v, lookup) + } + return result +} + +// resolveValue resolves template expressions in a single value. It handles strings, maps, and slices recursively. +func resolveValue(v any, lookup map[string]string) any { + switch val := v.(type) { + case string: + return resolveString(val, lookup) + case map[string]any: + resolved := make(map[string]any, len(val)) + for k, inner := range val { + resolved[k] = resolveValue(inner, lookup) + } + return resolved + case []any: + resolved := make([]any, len(val)) + for i, inner := range val { + resolved[i] = resolveValue(inner, lookup) + } + return resolved + default: + return v + } +} + +// resolveString replaces all {{...}} expressions in a string with their resolved values. +func resolveString(s string, lookup map[string]string) string { + return expressionPattern.ReplaceAllStringFunc(s, func(match string) string { + // Strip {{ and }} + inner := match[2 : len(match)-2] + + // Try ternary evaluation first. + if result, ok := evaluateTernary(inner, lookup); ok { + return result + } + + // Simple context path lookup. + key := strings.TrimSpace(inner) + if val, ok := lookup[key]; ok { + return val + } + + // Unrecognized expression — leave as-is per R-011. + return match + }) +} + +// evaluateTernary evaluates a single-level ternary expression of the form: +// expr == "val" ? "trueResult" : "falseResult" +// Returns the resolved result and true if the expression is a valid ternary, or ("", false) otherwise. +// If the condition path cannot be resolved, the entire ternary is left as-is. +func evaluateTernary(inner string, lookup map[string]string) (string, bool) { + matches := ternaryPattern.FindStringSubmatch(inner) + if matches == nil { + return "", false + } + + conditionPath := strings.TrimSpace(matches[1]) + expectedValue := matches[2] + trueResult := matches[3] + falseResult := matches[4] + + conditionValue, ok := lookup[conditionPath] + if !ok { + // Unresolvable condition — leave entire expression as-is. + return fmt.Sprintf("{{%s}}", inner), true + } + + if conditionValue == expectedValue { + return trueResult, true + } + return falseResult, true +} + +// buildContextLookup builds a flat key-value map from the recipe context for expression resolution. +// Keys use dot-separated paths (e.g., "context.resource.name", "context.runtime.kubernetes.namespace"). +func buildContextLookup(ctx *recipecontext.Context) map[string]string { + if ctx == nil { + return map[string]string{} + } + + lookup := map[string]string{ + "context.resource.name": ctx.Resource.Name, + "context.resource.id": ctx.Resource.ID, + "context.resource.type": ctx.Resource.Type, + + "context.application.name": ctx.Application.Name, + "context.application.id": ctx.Application.ID, + + "context.environment.name": ctx.Environment.Name, + "context.environment.id": ctx.Environment.ID, + } + + // Add runtime.kubernetes fields. + if ctx.Runtime.Kubernetes != nil { + lookup["context.runtime.kubernetes.namespace"] = ctx.Runtime.Kubernetes.Namespace + lookup["context.runtime.kubernetes.environmentNamespace"] = ctx.Runtime.Kubernetes.EnvironmentNamespace + } + + // Add Azure provider fields. + if ctx.Azure != nil { + lookup["context.azure.resourceGroup.name"] = ctx.Azure.ResourceGroup.Name + lookup["context.azure.resourceGroup.id"] = ctx.Azure.ResourceGroup.ID + lookup["context.azure.subscription.subscriptionId"] = ctx.Azure.Subscription.SubscriptionID + lookup["context.azure.subscription.id"] = ctx.Azure.Subscription.ID + } + + // Add AWS provider fields. + if ctx.AWS != nil { + lookup["context.aws.region"] = ctx.AWS.Region + lookup["context.aws.account"] = ctx.AWS.Account + } + + // Add dynamic resource properties (context.resource.properties.*). + for key, val := range ctx.Resource.Properties { + lookup[fmt.Sprintf("context.resource.properties.%s", key)] = fmt.Sprintf("%v", val) + } + + // Add connection properties (context.resource.connections..*). + for connName, conn := range ctx.Resource.Connections { + prefix := fmt.Sprintf("context.resource.connections.%s", connName) + lookup[prefix+".id"] = conn.ID + lookup[prefix+".name"] = conn.Name + lookup[prefix+".type"] = conn.Type + for propKey, propVal := range conn.Properties { + lookup[fmt.Sprintf("%s.properties.%s", prefix, propKey)] = fmt.Sprintf("%v", propVal) + } + } + + return lookup +} diff --git a/pkg/recipes/paramresolver/resolver_test.go b/pkg/recipes/paramresolver/resolver_test.go new file mode 100644 index 0000000000..d84aa5d857 --- /dev/null +++ b/pkg/recipes/paramresolver/resolver_test.go @@ -0,0 +1,419 @@ +/* +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 paramresolver + +import ( + "testing" + + "github.com/radius-project/radius/pkg/recipes" + "github.com/radius-project/radius/pkg/recipes/recipecontext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testContext() *recipecontext.Context { + return &recipecontext.Context{ + Resource: recipecontext.Resource{ + ResourceInfo: recipecontext.ResourceInfo{ + Name: "my-resource", + ID: "/planes/radius/local/resourceGroups/test/providers/Applications.Core/extenders/my-resource", + }, + Type: "Applications.Core/extenders", + Properties: map[string]any{ + "host": "myhost.example.com", + "port": 5432, + }, + Connections: map[string]recipes.ConnectedResource{ + "db": { + ID: "/planes/radius/local/resourceGroups/test/providers/Applications.Core/extenders/my-db", + Name: "my-db", + Type: "Applications.Core/extenders", + Properties: map[string]any{ + "connectionString": "postgres://myhost:5432/mydb", + }, + }, + }, + }, + Application: recipecontext.ResourceInfo{ + Name: "my-app", + ID: "/planes/radius/local/resourceGroups/test/providers/Applications.Core/applications/my-app", + }, + Environment: recipecontext.ResourceInfo{ + Name: "my-env", + ID: "/planes/radius/local/resourceGroups/test/providers/Applications.Core/environments/my-env", + }, + Runtime: recipes.RuntimeConfiguration{ + Kubernetes: &recipes.KubernetesRuntime{ + Namespace: "my-namespace", + EnvironmentNamespace: "my-env-namespace", + }, + }, + Azure: &recipecontext.ProviderAzure{ + ResourceGroup: recipecontext.AzureResourceGroup{ + Name: "my-rg", + ID: "/subscriptions/sub-id/resourceGroups/my-rg", + }, + Subscription: recipecontext.AzureSubscription{ + SubscriptionID: "sub-id", + ID: "/subscriptions/sub-id", + }, + }, + AWS: &recipecontext.ProviderAWS{ + Region: "us-east-1", + Account: "123456789", + }, + } +} + +func Test_ResolveParameterExpressions(t *testing.T) { + tests := []struct { + name string + params map[string]any + ctx *recipecontext.Context + expected map[string]any + }{ + { + name: "nil params returns nil", + params: nil, + ctx: testContext(), + expected: nil, + }, + { + name: "empty map returns empty map", + params: map[string]any{}, + ctx: testContext(), + expected: map[string]any{}, + }, + { + name: "single expression resolves", + params: map[string]any{ + "name": "{{context.resource.name}}", + }, + ctx: testContext(), + expected: map[string]any{ + "name": "my-resource", + }, + }, + { + name: "multiple expressions in one value", + params: map[string]any{ + "tag": "{{context.application.name}}-{{context.environment.name}}", + }, + ctx: testContext(), + expected: map[string]any{ + "tag": "my-app-my-env", + }, + }, + { + name: "mixed literal and expression", + params: map[string]any{ + "name": "prefix-{{context.resource.name}}-suffix", + }, + ctx: testContext(), + expected: map[string]any{ + "name": "prefix-my-resource-suffix", + }, + }, + { + name: "unrecognized expression left as-is", + params: map[string]any{ + "value": "{{context.unknown.field}}", + }, + ctx: testContext(), + expected: map[string]any{ + "value": "{{context.unknown.field}}", + }, + }, + { + name: "non-string values pass through", + params: map[string]any{ + "count": 42, + "enabled": true, + "ratio": 3.14, + }, + ctx: testContext(), + expected: map[string]any{ + "count": 42, + "enabled": true, + "ratio": 3.14, + }, + }, + { + name: "nested map traversal", + params: map[string]any{ + "outer": map[string]any{ + "inner": "{{context.resource.name}}", + "static": "no-change", + }, + }, + ctx: testContext(), + expected: map[string]any{ + "outer": map[string]any{ + "inner": "my-resource", + "static": "no-change", + }, + }, + }, + { + name: "slice values resolved", + params: map[string]any{ + "tags": []any{"{{context.resource.name}}", "static-tag"}, + }, + ctx: testContext(), + expected: map[string]any{ + "tags": []any{"my-resource", "static-tag"}, + }, + }, + { + name: "nil context returns expressions as-is", + params: map[string]any{ + "name": "{{context.resource.name}}", + }, + ctx: nil, + expected: map[string]any{ + "name": "{{context.resource.name}}", + }, + }, + { + name: "kubernetes runtime fields resolve", + params: map[string]any{ + "namespace": "{{context.runtime.kubernetes.namespace}}", + }, + ctx: testContext(), + expected: map[string]any{ + "namespace": "my-namespace", + }, + }, + { + name: "azure provider fields resolve", + params: map[string]any{ + "rg": "{{context.azure.resourceGroup.name}}", + }, + ctx: testContext(), + expected: map[string]any{ + "rg": "my-rg", + }, + }, + { + name: "aws provider fields resolve", + params: map[string]any{ + "region": "{{context.aws.region}}", + }, + ctx: testContext(), + expected: map[string]any{ + "region": "us-east-1", + }, + }, + { + name: "context.resource.properties resolves existing property", + params: map[string]any{ + "host": "{{context.resource.properties.host}}", + }, + ctx: testContext(), + expected: map[string]any{ + "host": "myhost.example.com", + }, + }, + { + name: "context.resource.properties resolves numeric property as string", + params: map[string]any{ + "port": "{{context.resource.properties.port}}", + }, + ctx: testContext(), + expected: map[string]any{ + "port": "5432", + }, + }, + { + name: "context.resource.properties missing property left as-is", + params: map[string]any{ + "missing": "{{context.resource.properties.nonexistent}}", + }, + ctx: testContext(), + expected: map[string]any{ + "missing": "{{context.resource.properties.nonexistent}}", + }, + }, + { + name: "multiple property expressions in one string", + params: map[string]any{ + "url": "{{context.resource.properties.host}}:{{context.resource.properties.port}}", + }, + ctx: testContext(), + expected: map[string]any{ + "url": "myhost.example.com:5432", + }, + }, + { + name: "connection property resolves", + params: map[string]any{ + "connStr": "{{context.resource.connections.db.properties.connectionString}}", + }, + ctx: testContext(), + expected: map[string]any{ + "connStr": "postgres://myhost:5432/mydb", + }, + }, + { + name: "connection metadata resolves", + params: map[string]any{ + "dbName": "{{context.resource.connections.db.name}}", + }, + ctx: testContext(), + expected: map[string]any{ + "dbName": "my-db", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ResolveParameterExpressions(tt.params, tt.ctx) + if tt.expected == nil { + assert.Nil(t, result) + } else { + require.Equal(t, tt.expected, result) + } + }) + } +} + +func Test_TernaryExpressions(t *testing.T) { + tests := []struct { + name string + params map[string]any + ctx *recipecontext.Context + expected map[string]any + }{ + { + name: "ternary true branch", + params: map[string]any{ + "sku": `{{context.environment.name == "my-env" ? "Standard" : "Basic"}}`, + }, + ctx: testContext(), + expected: map[string]any{ + "sku": "Standard", + }, + }, + { + name: "ternary false branch", + params: map[string]any{ + "sku": `{{context.environment.name == "production" ? "Premium" : "Basic"}}`, + }, + ctx: testContext(), + expected: map[string]any{ + "sku": "Basic", + }, + }, + { + name: "ternary with context property in condition", + params: map[string]any{ + "tier": `{{context.resource.properties.host == "myhost.example.com" ? "dedicated" : "shared"}}`, + }, + ctx: testContext(), + expected: map[string]any{ + "tier": "dedicated", + }, + }, + { + name: "ternary with unresolvable condition left as-is", + params: map[string]any{ + "value": `{{context.unknown.path == "x" ? "yes" : "no"}}`, + }, + ctx: testContext(), + expected: map[string]any{ + "value": `{{context.unknown.path == "x" ? "yes" : "no"}}`, + }, + }, + { + name: "mixed ternary and literal text", + params: map[string]any{ + "label": `env-{{context.environment.name == "my-env" ? "dev" : "prod"}}-ready`, + }, + ctx: testContext(), + expected: map[string]any{ + "label": "env-dev-ready", + }, + }, + { + name: "nested/chained ternary left as-is (out of scope)", + params: map[string]any{ + // This doesn't match ternary pattern because quotes contain nested ternary + "value": `{{context.environment.name == "a" ? "{{context.resource.name == "b" ? "c" : "d"}}" : "e"}}`, + }, + ctx: testContext(), + expected: map[string]any{ + // Inner braces break the outer regex match — left as-is + "value": `{{context.environment.name == "a" ? "{{context.resource.name == "b" ? "c" : "d"}}" : "e"}}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ResolveParameterExpressions(tt.params, tt.ctx) + require.Equal(t, tt.expected, result) + }) + } +} + +func Test_buildContextLookup(t *testing.T) { + t.Run("nil context returns empty map", func(t *testing.T) { + lookup := buildContextLookup(nil) + assert.Empty(t, lookup) + }) + + t.Run("populates all expected keys", func(t *testing.T) { + ctx := testContext() + lookup := buildContextLookup(ctx) + + assert.Equal(t, "my-resource", lookup["context.resource.name"]) + assert.Equal(t, "my-app", lookup["context.application.name"]) + assert.Equal(t, "my-env", lookup["context.environment.name"]) + assert.Equal(t, "my-namespace", lookup["context.runtime.kubernetes.namespace"]) + assert.Equal(t, "my-rg", lookup["context.azure.resourceGroup.name"]) + assert.Equal(t, "us-east-1", lookup["context.aws.region"]) + assert.Equal(t, "myhost.example.com", lookup["context.resource.properties.host"]) + assert.Equal(t, "5432", lookup["context.resource.properties.port"]) + assert.Equal(t, "my-db", lookup["context.resource.connections.db.name"]) + assert.Equal(t, "postgres://myhost:5432/mydb", lookup["context.resource.connections.db.properties.connectionString"]) + }) + + t.Run("handles nil kubernetes runtime", func(t *testing.T) { + ctx := testContext() + ctx.Runtime.Kubernetes = nil + lookup := buildContextLookup(ctx) + _, ok := lookup["context.runtime.kubernetes.namespace"] + assert.False(t, ok) + }) + + t.Run("handles nil azure provider", func(t *testing.T) { + ctx := testContext() + ctx.Azure = nil + lookup := buildContextLookup(ctx) + _, ok := lookup["context.azure.resourceGroup.name"] + assert.False(t, ok) + }) + + t.Run("handles nil aws provider", func(t *testing.T) { + ctx := testContext() + ctx.AWS = nil + lookup := buildContextLookup(ctx) + _, ok := lookup["context.aws.region"] + assert.False(t, ok) + }) +} diff --git a/pkg/recipes/terraform/execute.go b/pkg/recipes/terraform/execute.go index a36b781bd3..d62fe2fcac 100644 --- a/pkg/recipes/terraform/execute.go +++ b/pkg/recipes/terraform/execute.go @@ -32,10 +32,12 @@ import ( "github.com/radius-project/radius/pkg/components/kubernetesclient/kubernetesclientprovider" "github.com/radius-project/radius/pkg/components/metrics" "github.com/radius-project/radius/pkg/components/secret/secretprovider" + "github.com/radius-project/radius/pkg/recipes/paramresolver" "github.com/radius-project/radius/pkg/recipes/recipecontext" "github.com/radius-project/radius/pkg/recipes/terraform/config" "github.com/radius-project/radius/pkg/recipes/terraform/config/backends" "github.com/radius-project/radius/pkg/recipes/terraform/config/providers" + recipes_util "github.com/radius-project/radius/pkg/recipes/util" "github.com/radius-project/radius/pkg/sdk" "github.com/radius-project/radius/pkg/ucp/ucplog" "go.opentelemetry.io/otel/attribute" @@ -374,6 +376,30 @@ func (e *executor) generateConfig(ctx context.Context, tf *tfexec.Terraform, opt if err = tfConfig.AddRecipeContext(ctx, options.EnvRecipe.Name, recipectx); err != nil { return "", err } + } else { + // Direct module path: resolve {{context.*}} expressions in parameters. + logger.Info("Direct module detected — resolving parameter expressions") + + recipectx, err := recipecontext.New(options.ResourceRecipe, options.EnvConfig) + if err != nil { + return "", err + } + + if options.ResourceRecipe != nil { + recipectx.Resource.Connections = options.ResourceRecipe.ConnectedResourcesProperties + } + + // Merge environment-level and resource-level parameters (environment wins per FR-004). + mergedParams := recipes_util.ShallowMergeParameters(options.ResourceRecipe.Parameters, options.EnvRecipe.Parameters) + + // Resolve {{context.*}} expressions in the merged parameters. + resolvedParams := paramresolver.ResolveParameterExpressions(mergedParams, recipectx) + + // Update the TF config module with resolved parameters. + if resolvedParams != nil { + moduleCfg := tfConfig.Module[options.EnvRecipe.Name] + moduleCfg.SetParams(config.RecipeParams(resolvedParams)) + } } if loadedModule.ResultOutputExists { if err = tfConfig.AddOutputs(options.EnvRecipe.Name); err != nil { diff --git a/pkg/recipes/types.go b/pkg/recipes/types.go index 61d512487b..b7f89baf05 100644 --- a/pkg/recipes/types.go +++ b/pkg/recipes/types.go @@ -83,6 +83,9 @@ type EnvironmentDefinition struct { TemplateVersion string // Allows insecure connections to registry without SSL check. PlainHTTP bool + // Outputs maps resource property names to module output names for direct module support. + // When nil or empty, all module outputs pass through with original names. + Outputs map[string]string } // ResourceMetadata represents recipe details provided while deploying a portable or a user-defined resource. diff --git a/pkg/recipes/util/merge.go b/pkg/recipes/util/merge.go new file mode 100644 index 0000000000..d8ef06c99b --- /dev/null +++ b/pkg/recipes/util/merge.go @@ -0,0 +1,36 @@ +/* +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 util + +// ShallowMergeParameters merges two parameter maps with top-level key precedence from override. +// Nested objects are replaced entirely, not deep-merged (per FR-004). +func ShallowMergeParameters(base map[string]any, override map[string]any) map[string]any { + if base == nil && override == nil { + return nil + } + + result := make(map[string]any) + + for k, v := range base { + result[k] = v + } + for k, v := range override { + result[k] = v + } + + return result +} diff --git a/pkg/recipes/util/merge_test.go b/pkg/recipes/util/merge_test.go new file mode 100644 index 0000000000..9c3d15fbb3 --- /dev/null +++ b/pkg/recipes/util/merge_test.go @@ -0,0 +1,86 @@ +/* +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 util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_ShallowMergeParameters(t *testing.T) { + tests := []struct { + name string + base map[string]any + override map[string]any + expected map[string]any + }{ + { + name: "disjoint keys merged", + base: map[string]any{"a": "1"}, + override: map[string]any{"b": "2"}, + expected: map[string]any{"a": "1", "b": "2"}, + }, + { + name: "overlapping keys - override wins", + base: map[string]any{"a": "base", "b": "base"}, + override: map[string]any{"a": "override"}, + expected: map[string]any{"a": "override", "b": "base"}, + }, + { + name: "nested object replaced not merged", + base: map[string]any{ + "config": map[string]any{"x": 1, "y": 2}, + }, + override: map[string]any{ + "config": map[string]any{"z": 3}, + }, + expected: map[string]any{ + "config": map[string]any{"z": 3}, + }, + }, + { + name: "nil base", + base: nil, + override: map[string]any{"a": "1"}, + expected: map[string]any{"a": "1"}, + }, + { + name: "nil override", + base: map[string]any{"a": "1"}, + override: nil, + expected: map[string]any{"a": "1"}, + }, + { + name: "both nil", + base: nil, + override: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ShallowMergeParameters(tt.base, tt.override) + if tt.expected == nil { + assert.Nil(t, result) + } else { + assert.Equal(t, tt.expected, result) + } + }) + } +} diff --git a/pkg/recipes/util/outputs.go b/pkg/recipes/util/outputs.go new file mode 100644 index 0000000000..14d3a09943 --- /dev/null +++ b/pkg/recipes/util/outputs.go @@ -0,0 +1,46 @@ +/* +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 util + +// ApplyOutputsMapping maps module output names to resource property names using the provided outputs map. +// Keys in outputsMap are resource property names, values are module output names. +// When outputsMap is nil or empty, all values and secrets pass through unchanged. +func ApplyOutputsMapping(values map[string]any, secrets map[string]any, outputsMap map[string]string) (map[string]any, map[string]any) { + if len(outputsMap) == 0 { + if values == nil { + values = map[string]any{} + } + if secrets == nil { + secrets = map[string]any{} + } + return values, secrets + } + + mappedValues := make(map[string]any) + mappedSecrets := make(map[string]any) + + for propertyName, outputName := range outputsMap { + if val, ok := values[outputName]; ok { + mappedValues[propertyName] = val + } + if val, ok := secrets[outputName]; ok { + mappedSecrets[propertyName] = val + } + } + + return mappedValues, mappedSecrets +} diff --git a/pkg/recipes/util/outputs_test.go b/pkg/recipes/util/outputs_test.go new file mode 100644 index 0000000000..689a85611d --- /dev/null +++ b/pkg/recipes/util/outputs_test.go @@ -0,0 +1,99 @@ +/* +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 util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_ApplyOutputsMapping(t *testing.T) { + tests := []struct { + name string + values map[string]any + secrets map[string]any + outputsMap map[string]string + expectedValues map[string]any + expectedSecrets map[string]any + }{ + { + name: "nil outputs map passes through values", + values: map[string]any{"hostname": "myhost", "port": 5432}, + secrets: map[string]any{"password": "secret"}, + outputsMap: nil, + expectedValues: map[string]any{"hostname": "myhost", "port": 5432}, + expectedSecrets: map[string]any{"password": "secret"}, + }, + { + name: "empty outputs map passes through values", + values: map[string]any{"hostname": "myhost"}, + secrets: map[string]any{"password": "secret"}, + outputsMap: map[string]string{}, + expectedValues: map[string]any{"hostname": "myhost"}, + expectedSecrets: map[string]any{"password": "secret"}, + }, + { + name: "maps output names to property names", + values: map[string]any{"hostname": "myhost", "port_number": 5432}, + secrets: map[string]any{}, + outputsMap: map[string]string{"host": "hostname", "port": "port_number"}, + expectedValues: map[string]any{"host": "myhost", "port": 5432}, + expectedSecrets: map[string]any{}, + }, + { + name: "missing output key in values is skipped silently", + values: map[string]any{"hostname": "myhost"}, + secrets: map[string]any{}, + outputsMap: map[string]string{"host": "hostname", "port": "nonexistent"}, + expectedValues: map[string]any{"host": "myhost"}, + expectedSecrets: map[string]any{}, + }, + { + name: "sensitive output mapping", + values: map[string]any{}, + secrets: map[string]any{"db_password": "secret123"}, + outputsMap: map[string]string{"password": "db_password"}, + expectedValues: map[string]any{}, + expectedSecrets: map[string]any{"password": "secret123"}, + }, + { + name: "nil values and secrets with nil outputs map", + values: nil, + secrets: nil, + outputsMap: nil, + expectedValues: map[string]any{}, + expectedSecrets: map[string]any{}, + }, + { + name: "empty maps with outputs mapping", + values: map[string]any{}, + secrets: map[string]any{}, + outputsMap: map[string]string{"host": "hostname"}, + expectedValues: map[string]any{}, + expectedSecrets: map[string]any{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + values, secrets := ApplyOutputsMapping(tt.values, tt.secrets, tt.outputsMap) + assert.Equal(t, tt.expectedValues, values) + assert.Equal(t, tt.expectedSecrets, secrets) + }) + } +} From 6807655a929aeea04081a6286f41d780e0e90e03 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Fri, 15 May 2026 13:40:12 -0700 Subject: [PATCH 14/18] feat: add Bicep driver integration for direct module support - Detect direct modules via absence of 'context' parameter - Add expression resolution and shallow merge for direct module params - Add outputs mapping with result/outputs precedence (FR-015) - Add collectARMOutputValues() and wrapARMParameters() helpers - Update prepareRecipeResponse signature to accept EnvironmentDefinition - Add 6 new test cases for direct module scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/recipes/driver/bicep/bicep.go | 91 +++++++++++---- pkg/recipes/driver/bicep/bicep_test.go | 150 ++++++++++++++++++++++++- 2 files changed, 219 insertions(+), 22 deletions(-) diff --git a/pkg/recipes/driver/bicep/bicep.go b/pkg/recipes/driver/bicep/bicep.go index 033d782f7d..98d8b44339 100644 --- a/pkg/recipes/driver/bicep/bicep.go +++ b/pkg/recipes/driver/bicep/bicep.go @@ -36,6 +36,7 @@ import ( "github.com/radius-project/radius/pkg/portableresources/processors" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/driver" + "github.com/radius-project/radius/pkg/recipes/paramresolver" "github.com/radius-project/radius/pkg/recipes/recipecontext" recipes_util "github.com/radius-project/radius/pkg/recipes/util" "github.com/radius-project/radius/pkg/rp/util" @@ -126,7 +127,17 @@ func (d *bicepDriver) Execute(ctx context.Context, opts driver.ExecuteOptions) ( // get the parameters after resolving the conflict between developer and operator parameters // if the recipe template also has the context parameter defined then add it to the parameter for deployment isContextParameterDefined := hasContextParameter(recipeData) - parameters := createRecipeParameters(opts.Recipe.Parameters, opts.Definition.Parameters, isContextParameterDefined, recipeContext) + + var parameters map[string]any + if isContextParameterDefined { + // Wrapped recipe — use existing context injection flow. + parameters = createRecipeParameters(opts.Recipe.Parameters, opts.Definition.Parameters, true, recipeContext) + } else { + // Direct module — resolve {{context.*}} expressions and merge parameters. + mergedParams := recipes_util.ShallowMergeParameters(opts.Recipe.Parameters, opts.Definition.Parameters) + resolvedParams := paramresolver.ResolveParameterExpressions(mergedParams, recipeContext) + parameters = wrapARMParameters(resolvedParams) + } deploymentName := deploymentPrefix + strconv.FormatInt(time.Now().UnixNano(), 10) deploymentID, err := createDeploymentID(recipeContext.Resource.ID, deploymentName) @@ -168,7 +179,7 @@ func (d *bicepDriver) Execute(ctx context.Context, opts driver.ExecuteOptions) ( return nil, recipes.NewRecipeError(recipes.RecipeDeploymentFailed, fmt.Sprintf("failed to deploy recipe %s of type %s", opts.BaseOptions.Recipe.Name, opts.BaseOptions.Definition.ResourceType), recipes_util.ExecutionError, recipes.GetErrorDetails(err)) } - recipeResponse, err := d.prepareRecipeResponse(opts.BaseOptions.Definition.TemplatePath, resp.Properties.Outputs, resp.Properties.OutputResources) + recipeResponse, err := d.prepareRecipeResponse(opts.BaseOptions.Definition, resp.Properties.Outputs, resp.Properties.OutputResources) if err != nil { return nil, recipes.NewRecipeError(recipes.InvalidRecipeOutputs, fmt.Sprintf("failed to read the recipe output %q: %s", recipes.ResultPropertyName, err.Error()), recipes_util.ExecutionError, recipes.GetErrorDetails(err)) } @@ -373,32 +384,45 @@ func newProviderConfig(resourceGroup string, envProviders coredm.Providers) clie // prepareRecipeResponse populates the recipe response from parsing the deployment output 'result' object and the // resources created by the template. -func (d *bicepDriver) prepareRecipeResponse(templatePath string, outputs any, resources []*armresources.ResourceReference) (*recipes.RecipeOutput, error) { - // We populate the recipe response from the 'result' output (if set) - // and the resources created by the template. - // - // Note that there are two ways a resource can be returned: - // - Implicitly when it is created in the template (it will be in 'resources'). - // - Explicitly as part of the 'result' output. - // - // The latter is needed because non-ARM and non-UCP resources are not returned as part of the implicit 'resources' - // collection. For us this mostly means Kubernetes resources - the user has to be explicit. +// +// Precedence logic (FR-015): +// - If an `outputs` mapping is configured on the definition, it takes precedence: all ARM deployment outputs +// are collected flat and then renamed via the mapping. +// - If no `outputs` mapping exists and the deployment has a `result` output, the module is treated as a +// wrapped recipe (existing behavior). +// - If neither is present, all ARM deployment outputs pass through unchanged. +func (d *bicepDriver) prepareRecipeResponse(definition recipes.EnvironmentDefinition, outputs any, resources []*armresources.ResourceReference) (*recipes.RecipeOutput, error) { recipeResponse := &recipes.RecipeOutput{} out, ok := outputs.(map[string]any) - if ok { - if result, ok := out[recipes.ResultPropertyName].(map[string]any); ok { - if resultValue, ok := result["value"].(map[string]any); ok { - err := recipeResponse.PrepareRecipeResponse(resultValue) - if err != nil { - return &recipes.RecipeOutput{}, err + if ok && len(out) > 0 { + hasOutputsMapping := len(definition.Outputs) > 0 + _, hasResultOutput := out[recipes.ResultPropertyName] + + if hasOutputsMapping { + // Direct module with outputs mapping — collect all ARM outputs flat, then apply mapping. + values := collectARMOutputValues(out) + mappedValues, mappedSecrets := recipes_util.ApplyOutputsMapping(values, map[string]any{}, definition.Outputs) + recipeResponse.Values = mappedValues + recipeResponse.Secrets = mappedSecrets + } else if hasResultOutput { + // Wrapped recipe — use existing result output parsing. + if result, ok := out[recipes.ResultPropertyName].(map[string]any); ok { + if resultValue, ok := result["value"].(map[string]any); ok { + err := recipeResponse.PrepareRecipeResponse(resultValue) + if err != nil { + return &recipes.RecipeOutput{}, err + } } } + } else { + // Direct module without mapping — pass through all ARM outputs unchanged. + recipeResponse.Values = collectARMOutputValues(out) } } recipeResponse.Status = &rpv1.RecipeStatus{ TemplateKind: recipes.TemplateKindBicep, - TemplatePath: templatePath, + TemplatePath: definition.TemplatePath, } // process the 'resources' created by the template @@ -409,6 +433,20 @@ func (d *bicepDriver) prepareRecipeResponse(templatePath string, outputs any, re return recipeResponse, nil } +// collectARMOutputValues extracts values from ARM deployment outputs. +// ARM outputs have the shape: {"outputName": {"type": "...", "value": ...}, ...} +func collectARMOutputValues(outputs map[string]any) map[string]any { + values := make(map[string]any) + for name, raw := range outputs { + if outputMap, ok := raw.(map[string]any); ok { + if val, ok := outputMap["value"]; ok { + values[name] = val + } + } + } + return values +} + // getGCOutputResources [GC stands for Garbage Collection] compares two slices of resource ids and // returns a slice of OutputResources that contains the elements that are in the "previous" slice but not in the "current". func (d *bicepDriver) getGCOutputResources(current []string, previous []string) ([]rpv1.OutputResource, error) { @@ -453,3 +491,18 @@ func getRegistryAuthClient(ctx context.Context, secrets recipes.SecretData, temp return newRegistryClient.GetAuthClient(ctx, templatePath) } + +// wrapARMParameters wraps raw parameter values into the ARM parameter format: {"key": {"value": val}}. +func wrapARMParameters(params map[string]any) map[string]any { + if params == nil { + return map[string]any{} + } + + wrapped := make(map[string]any, len(params)) + for k, v := range params { + wrapped[k] = map[string]any{ + "value": v, + } + } + return wrapped +} diff --git a/pkg/recipes/driver/bicep/bicep_test.go b/pkg/recipes/driver/bicep/bicep_test.go index 3920b91e96..5638eac732 100644 --- a/pkg/recipes/driver/bicep/bicep_test.go +++ b/pkg/recipes/driver/bicep/bicep_test.go @@ -314,7 +314,7 @@ func Test_Bicep_PrepareRecipeResponse_Success(t *testing.T) { }, PrevState: []string{}, } - actualResponse, err := d.prepareRecipeResponse(opts.BaseOptions.Definition.TemplatePath, response, resources) + actualResponse, err := d.prepareRecipeResponse(opts.BaseOptions.Definition, response, resources) require.NoError(t, err) require.Equal(t, expectedResponse, actualResponse) } @@ -351,7 +351,7 @@ func Test_Bicep_PrepareRecipeResponse_EmptySecret(t *testing.T) { }, } - actualResponse, err := d.prepareRecipeResponse("radiusdev.azurecr.io/recipes/functionaltest/parameters/mongodatabases/azure:1.0", response, resources) + actualResponse, err := d.prepareRecipeResponse(recipes.EnvironmentDefinition{TemplatePath: "radiusdev.azurecr.io/recipes/functionaltest/parameters/mongodatabases/azure:1.0"}, response, resources) require.NoError(t, err) require.Equal(t, expectedResponse, actualResponse) } @@ -373,7 +373,7 @@ func Test_Bicep_PrepareRecipeResponse_EmptyResult(t *testing.T) { }, } - actualResponse, err := d.prepareRecipeResponse("radiusdev.azurecr.io/recipes/functionaltest/parameters/mongodatabases/azure:1.0", response, resources) + actualResponse, err := d.prepareRecipeResponse(recipes.EnvironmentDefinition{TemplatePath: "radiusdev.azurecr.io/recipes/functionaltest/parameters/mongodatabases/azure:1.0"}, response, resources) require.NoError(t, err) require.Equal(t, expectedResponse, actualResponse) } @@ -586,3 +586,147 @@ func Test_Bicep_Delete_Success_AfterRetry(t *testing.T) { }) require.NoError(t, err) } + +func Test_Bicep_PrepareRecipeResponse_DirectModule(t *testing.T) { + d := &bicepDriver{} + + tests := []struct { + desc string + definition recipes.EnvironmentDefinition + outputs any + resources []*armresources.ResourceReference + expectedResponse *recipes.RecipeOutput + expectedErr error + }{ + { + desc: "direct module with outputs mapping", + definition: recipes.EnvironmentDefinition{ + TemplatePath: "br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3", + Outputs: map[string]string{"endpoint": "storageEndpoint", "name": "storageName"}, + }, + outputs: map[string]any{ + "storageEndpoint": map[string]any{"type": "string", "value": "https://sa.blob.core.windows.net"}, + "storageName": map[string]any{"type": "string", "value": "mystorageaccount"}, + "extra": map[string]any{"type": "string", "value": "ignored"}, + }, + resources: []*armresources.ResourceReference{ + {ID: new("/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sa")}, + }, + expectedResponse: &recipes.RecipeOutput{ + Values: map[string]any{"endpoint": "https://sa.blob.core.windows.net", "name": "mystorageaccount"}, + Secrets: map[string]any{}, + Resources: []string{"/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sa"}, + Status: &rpv1.RecipeStatus{ + TemplateKind: recipes.TemplateKindBicep, + TemplatePath: "br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3", + }, + }, + }, + { + desc: "direct module without outputs mapping - pass through all", + definition: recipes.EnvironmentDefinition{ + TemplatePath: "br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3", + }, + outputs: map[string]any{ + "storageEndpoint": map[string]any{"type": "string", "value": "https://sa.blob.core.windows.net"}, + "storageName": map[string]any{"type": "string", "value": "mystorageaccount"}, + }, + resources: nil, + expectedErr: nil, + expectedResponse: &recipes.RecipeOutput{ + Values: map[string]any{ + "storageEndpoint": "https://sa.blob.core.windows.net", + "storageName": "mystorageaccount", + }, + Status: &rpv1.RecipeStatus{ + TemplateKind: recipes.TemplateKindBicep, + TemplatePath: "br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3", + }, + }, + }, + { + desc: "module with result and no outputs mapping - wrapped behavior", + definition: recipes.EnvironmentDefinition{ + TemplatePath: "radiusdev.azurecr.io/recipes/mongo:1.0", + }, + outputs: map[string]any{ + "result": map[string]any{ + "value": map[string]any{ + "values": map[string]any{"host": "mongohost"}, + "secrets": map[string]any{"connStr": "mongodb://..."}, + "resources": []any{"res1"}, + }, + }, + }, + resources: nil, + expectedResponse: &recipes.RecipeOutput{ + Values: map[string]any{"host": "mongohost"}, + Secrets: map[string]any{"connStr": "mongodb://..."}, + Resources: []string{"res1"}, + Status: &rpv1.RecipeStatus{ + TemplateKind: recipes.TemplateKindBicep, + TemplatePath: "radiusdev.azurecr.io/recipes/mongo:1.0", + }, + }, + }, + { + desc: "module with both result and outputs mapping - outputs wins", + definition: recipes.EnvironmentDefinition{ + TemplatePath: "br:myregistry/module:1.0", + Outputs: map[string]string{"host": "hostname"}, + }, + outputs: map[string]any{ + "result": map[string]any{ + "value": map[string]any{"values": map[string]any{"host": "from-result"}}, + }, + "hostname": map[string]any{"type": "string", "value": "from-arm-output"}, + }, + resources: nil, + expectedResponse: &recipes.RecipeOutput{ + Values: map[string]any{"host": "from-arm-output"}, + Secrets: map[string]any{}, + Status: &rpv1.RecipeStatus{ + TemplateKind: recipes.TemplateKindBicep, + TemplatePath: "br:myregistry/module:1.0", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + resp, err := d.prepareRecipeResponse(tt.definition, tt.outputs, tt.resources) + require.Equal(t, tt.expectedErr, err) + require.Equal(t, tt.expectedResponse, resp) + }) + } +} + +func Test_WrapARMParameters(t *testing.T) { + tests := []struct { + name string + params map[string]any + expected map[string]any + }{ + { + name: "nil params returns empty map", + params: nil, + expected: map[string]any{}, + }, + { + name: "wraps values in ARM format", + params: map[string]any{"name": "mysa", "sku": "Standard"}, + expected: map[string]any{ + "name": map[string]any{"value": "mysa"}, + "sku": map[string]any{"value": "Standard"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := wrapARMParameters(tt.params) + require.Equal(t, tt.expected, result) + }) + } +} From 35827deef19cf18ec6fcca7ae13f3daa8e849124 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Fri, 15 May 2026 13:59:44 -0700 Subject: [PATCH 15/18] feat: add outputs field to RecipeDefinition TypeSpec model and data pipeline - Add outputs (Record) to RecipeDefinition in recipePacks.tsp - Update parameters description to mention {{context.*}} expression support - Add Outputs field to datamodel RecipeDefinition and internal RecipeDefinition - Wire outputs through API conversion (toRecipesDataModel/fromRecipesDataModel) - Propagate Outputs from RecipeDefinition to EnvironmentDefinition in config loader - Add outputs to test data for round-trip conversion validation - Regenerate API client code from updated TypeSpec model - Merge PR #11760 (standardize recipe parameter naming) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../v20250801preview/recipepack_conversion.go | 20 +++++++++++++------ .../testdata/recipepackresource.json | 4 ++++ .../testdata/recipepackresourcedatamodel.json | 4 ++++ .../v20250801preview/zz_generated_models.go | 7 ++++++- .../zz_generated_models_serde.go | 4 ++++ pkg/corerp/datamodel/recipepack.go | 4 ++++ pkg/recipes/configloader/environment.go | 11 ++++++---- pkg/recipes/types.go | 3 +++ .../preview/2025-08-01-preview/openapi.json | 9 ++++++++- typespec/Radius.Core/recipePacks.tsp | 5 ++++- 10 files changed, 58 insertions(+), 13 deletions(-) diff --git a/pkg/corerp/api/v20250801preview/recipepack_conversion.go b/pkg/corerp/api/v20250801preview/recipepack_conversion.go index 2b3ca9ff3e..26b7d73655 100644 --- a/pkg/corerp/api/v20250801preview/recipepack_conversion.go +++ b/pkg/corerp/api/v20250801preview/recipepack_conversion.go @@ -94,12 +94,16 @@ func toRecipesDataModel(recipes map[string]*RecipeDefinition) map[string]*datamo result := make(map[string]*datamodel.RecipeDefinition) for key, recipe := range recipes { if recipe != nil { - result[key] = &datamodel.RecipeDefinition{ - Kind: toRecipeKindDataModel(recipe.Kind), - Location: to.String(recipe.Location), - Parameters: recipe.Parameters, - PlainHTTP: to.Bool(recipe.PlainHTTP), + def := &datamodel.RecipeDefinition{ + Kind: toRecipeKindDataModel(recipe.Kind), + Location: to.String(recipe.Location), + Parameters: recipe.Parameters, + PlainHTTP: to.Bool(recipe.PlainHTTP), + } + if recipe.Outputs != nil { + def.Outputs = to.StringMap(recipe.Outputs) } + result[key] = def } } return result @@ -113,12 +117,16 @@ func fromRecipesDataModel(recipes map[string]*datamodel.RecipeDefinition) map[st result := make(map[string]*RecipeDefinition) for key, recipe := range recipes { if recipe != nil { - result[key] = &RecipeDefinition{ + def := &RecipeDefinition{ Kind: fromRecipeKindDataModel(recipe.Kind), Location: new(recipe.Location), Parameters: recipe.Parameters, PlainHTTP: new(recipe.PlainHTTP), } + if recipe.Outputs != nil { + def.Outputs = *to.StringMapPtr(recipe.Outputs) + } + result[key] = def } } return result diff --git a/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json b/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json index 2d32584a04..e1809a6f0e 100644 --- a/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json +++ b/pkg/corerp/api/v20250801preview/testdata/recipepackresource.json @@ -29,6 +29,10 @@ "parameters": { "size": "small" }, + "outputs": { + "host": "redis_host", + "port": "redis_port" + }, "plainHTTP": true } } diff --git a/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json b/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json index 502972e236..eabf97e5f2 100644 --- a/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json +++ b/pkg/corerp/api/v20250801preview/testdata/recipepackresourcedatamodel.json @@ -41,6 +41,10 @@ "parameters": { "size": "small" }, + "outputs": { + "host": "redis_host", + "port": "redis_port" + }, "plainHTTP": true } } diff --git a/pkg/corerp/api/v20250801preview/zz_generated_models.go b/pkg/corerp/api/v20250801preview/zz_generated_models.go index 71b7514fae..612c12adbc 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_models.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_models.go @@ -516,7 +516,12 @@ type RecipeDefinition struct { // REQUIRED; URL path to the recipe Location *string - // Parameters to pass to the recipe + // Maps resource property names to module output names. Keys are the resource type's read-only property names, values are + // the module output names. When empty or not specified, all module outputs pass + // through with their original names + Outputs map[string]*string + + // Parameters to pass to the recipe. Values may contain {{context.*}} template expressions resolved at deployment time Parameters map[string]any // Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for diff --git a/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go b/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go index 363fc8dc69..34d572b3ea 100644 --- a/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go +++ b/pkg/corerp/api/v20250801preview/zz_generated_models_serde.go @@ -1267,6 +1267,7 @@ func (r RecipeDefinition) MarshalJSON() ([]byte, error) { objectMap := make(map[string]any) populate(objectMap, "kind", r.Kind) populate(objectMap, "location", r.Location) + populate(objectMap, "outputs", r.Outputs) populate(objectMap, "parameters", r.Parameters) populate(objectMap, "plainHttp", r.PlainHTTP) return json.Marshal(objectMap) @@ -1287,6 +1288,9 @@ func (r *RecipeDefinition) UnmarshalJSON(data []byte) error { case "location": err = unpopulate(val, "Location", &r.Location) delete(rawMsg, key) + case "outputs": + err = unpopulate(val, "Outputs", &r.Outputs) + delete(rawMsg, key) case "parameters": err = unpopulate(val, "Parameters", &r.Parameters) delete(rawMsg, key) diff --git a/pkg/corerp/datamodel/recipepack.go b/pkg/corerp/datamodel/recipepack.go index e326fff9b7..597b4bf4c6 100644 --- a/pkg/corerp/datamodel/recipepack.go +++ b/pkg/corerp/datamodel/recipepack.go @@ -58,6 +58,10 @@ type RecipeDefinition struct { // Parameters to pass to the recipe. Parameters map[string]any `json:"parameters,omitempty"` + // Outputs maps resource property names to module output names for direct module support. + // When nil or empty, all module outputs pass through with original names. + Outputs map[string]string `json:"outputs,omitempty"` + // PlainHTTP connects to the location using HTTP (not-HTTPS). PlainHTTP bool `json:"plainHTTP,omitempty"` } diff --git a/pkg/recipes/configloader/environment.go b/pkg/recipes/configloader/environment.go index 0fb898059e..533c56da8c 100644 --- a/pkg/recipes/configloader/environment.go +++ b/pkg/recipes/configloader/environment.go @@ -31,6 +31,7 @@ import ( recipes_util "github.com/radius-project/radius/pkg/recipes/util" "github.com/radius-project/radius/pkg/rp/kube" "github.com/radius-project/radius/pkg/rp/util" + "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/pkg/ucp/resources" "github.com/radius-project/radius/pkg/ucp/resources/radius" ) @@ -371,6 +372,7 @@ func getRecipeDefinitionFromEnvironmentV20250801(ctx context.Context, environmen ResourceType: resource.Type(), Parameters: parameters, TemplatePath: recipeDefinition.Location, + Outputs: recipeDefinition.Outputs, PlainHTTP: recipeDefinition.PlainHTTP, } return definition, nil @@ -405,10 +407,11 @@ func fetchRecipeDefinition(ctx context.Context, recipePackIDs []string, armOptio plainHTTP = *definition.PlainHTTP } return &recipes.RecipeDefinition{ - Kind: string(*definition.Kind), - Location: string(*definition.Location), - Parameters: definition.Parameters, - PlainHTTP: plainHTTP, + Kind: string(*definition.Kind), + Location: string(*definition.Location), + Parameters: definition.Parameters, + Outputs: to.StringMap(definition.Outputs), + PlainHTTP: plainHTTP, }, nil } } diff --git a/pkg/recipes/types.go b/pkg/recipes/types.go index 080b352231..206349c3d1 100644 --- a/pkg/recipes/types.go +++ b/pkg/recipes/types.go @@ -161,6 +161,9 @@ type RecipeDefinition struct { Location string // Parameters represents parameters to pass to the recipe Parameters map[string]any + // Outputs maps resource property names to module output names for direct module support. + // When nil or empty, all module outputs pass through with original names. + Outputs map[string]string // PlainHTTP connects to the location using HTTP (not-HTTPS) PlainHTTP bool } diff --git a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json index 2d884f48bd..4c47586081 100644 --- a/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json +++ b/swagger/specification/radius/resource-manager/Radius.Core/preview/2025-08-01-preview/openapi.json @@ -2003,8 +2003,15 @@ }, "parameters": { "type": "object", - "description": "Parameters to pass to the recipe", + "description": "Parameters to pass to the recipe. Values may contain {{context.*}} template expressions resolved at deployment time", "additionalProperties": {} + }, + "outputs": { + "type": "object", + "description": "Maps resource property names to module output names. Keys are the resource type's read-only property names, values are the module output names. When empty or not specified, all module outputs pass through with their original names", + "additionalProperties": { + "type": "string" + } } }, "required": [ diff --git a/typespec/Radius.Core/recipePacks.tsp b/typespec/Radius.Core/recipePacks.tsp index 5e3c788d7f..7f6d40e194 100644 --- a/typespec/Radius.Core/recipePacks.tsp +++ b/typespec/Radius.Core/recipePacks.tsp @@ -66,8 +66,11 @@ model RecipeDefinition { @doc("URL path to the recipe") location: string; - @doc("Parameters to pass to the recipe") + @doc("Parameters to pass to the recipe. Values may contain {{context.*}} template expressions resolved at deployment time") parameters?: Record; + + @doc("Maps resource property names to module output names. Keys are the resource type's read-only property names, values are the module output names. When empty or not specified, all module outputs pass through with their original names") + outputs?: Record; } @doc("The type of recipe") From 4a7355fc945d91c19aab15cfdd590258f828bd7e Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Fri, 15 May 2026 14:43:14 -0700 Subject: [PATCH 16/18] test: add functional test scaffolding for direct module support - Add Test_DirectModule_Terraform: validates TF direct module with outputs mapping and {{context.*}} expression resolution in parameters - Add Test_DirectModule_BackwardCompat: regression test verifying wrapped recipes (context variable + result output) work unchanged - Add Bicep templates for both test scenarios - Tests follow existing recipepacks_test.go patterns (namespace creation, resource type registration, deploy executor, post-step verification) Note: These tests require a running Radius cluster to execute. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../noncloud/resources/directmodule_test.go | 238 ++++++++++++++++++ .../testdata/directmodule-compat-test.bicep | 68 +++++ .../testdata/directmodule-tf-test.bicep | 66 +++++ 3 files changed, 372 insertions(+) create mode 100644 test/functional-portable/dynamicrp/noncloud/resources/directmodule_test.go create mode 100644 test/functional-portable/dynamicrp/noncloud/resources/testdata/directmodule-compat-test.bicep create mode 100644 test/functional-portable/dynamicrp/noncloud/resources/testdata/directmodule-tf-test.bicep diff --git a/test/functional-portable/dynamicrp/noncloud/resources/directmodule_test.go b/test/functional-portable/dynamicrp/noncloud/resources/directmodule_test.go new file mode 100644 index 0000000000..6e178b1309 --- /dev/null +++ b/test/functional-portable/dynamicrp/noncloud/resources/directmodule_test.go @@ -0,0 +1,238 @@ +/* +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 resource_test + +import ( + "context" + "strings" + "testing" + + "github.com/radius-project/radius/test" + "github.com/radius-project/radius/test/radcli" + "github.com/radius-project/radius/test/rp" + "github.com/radius-project/radius/test/step" + "github.com/radius-project/radius/test/testutil" + "github.com/radius-project/radius/test/validation" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Test_DirectModule_Terraform tests deployment of a direct (non-wrapped) Terraform module +// via recipe packs. This validates: +// - {{context.resource.name}} and {{context.runtime.kubernetes.namespace}} expression resolution +// - outputs mapping from module output names to resource property names +// - Terraform module download and execution via HTTP archive source +// +// Steps: +// 1. Create Kubernetes namespace +// 2. Register user-defined resource type +// 3. Deploy Bicep template with direct module recipe pack (outputs mapping + expression params) +// 4. Verify Kubernetes deployments are created in the target namespace +func Test_DirectModule_Terraform(t *testing.T) { + template := "testdata/directmodule-tf-test.bicep" + appName := "directmodule-tf-app" + appNamespace := "directmodule-tf-ns" + parentResourceTypeName := "Test.Resources/userTypeAlpha" + parentResourceTypeParam := strings.Split(parentResourceTypeName, "/")[1] + filepath := "testdata/testresourcetypes.yaml" + options := rp.NewRPTestOptions(t) + cli := radcli.NewCLI(t, options.ConfigFilePath) + + test := rp.NewRPTest(t, appName, []rp.TestStep{ + { + Executor: step.NewFuncExecutor(func(ctx context.Context, t *testing.T, options test.TestOptions) { + _, err := options.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: appNamespace, + }, + }, metav1.CreateOptions{}) + if err != nil && !strings.Contains(err.Error(), "already exists") { + require.NoError(t, err) + } + }), + SkipKubernetesOutputResourceValidation: true, + SkipObjectValidation: true, + SkipResourceDeletion: true, + }, + { + Executor: step.NewFuncExecutor(func(ctx context.Context, t *testing.T, options test.TestOptions) { + _, err := cli.ResourceTypeCreate(ctx, parentResourceTypeParam, filepath) + require.NoError(t, err) + }), + SkipKubernetesOutputResourceValidation: true, + SkipObjectValidation: true, + SkipResourceDeletion: true, + }, + { + Executor: step.NewDeployExecutor(template, testutil.GetTerraformRecipeModuleServerURL(), "appName="+appName), + SkipObjectValidation: true, + SkipResourceDeletion: false, + SkipKubernetesOutputResourceValidation: true, + RPResources: &validation.RPResourceSet{ + Resources: []validation.RPResource{ + { + Name: "directmodule-tf-recipe-pack", + Type: "radius.core/recipepacks", + }, + { + Name: "directmodule-tf-env", + Type: "radius.core/environments", + }, + { + Name: appName, + Type: validation.ApplicationsResource, + App: appName, + }, + { + Name: "directmodule-tf-resource", + Type: "test.resources/usertypealpha", + App: appName, + }, + }, + }, + PostStepVerify: func(ctx context.Context, t *testing.T, test rp.RPTest) { + // Verify that Terraform created Kubernetes deployments in the target namespace. + // The direct module should have resolved {{context.runtime.kubernetes.namespace}} + // to the namespace configured in the environment's providers. + deployments, err := test.Options.K8sClient.AppsV1().Deployments(appNamespace).List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotEmpty(t, deployments.Items, "Expected Terraform module to create deployments in namespace %s", appNamespace) + + t.Logf("Found %d deployments in namespace %s", len(deployments.Items), appNamespace) + for _, deploy := range deployments.Items { + t.Logf(" Deployment: %s", deploy.Name) + } + + // Clean up namespace + err = test.Options.K8sClient.CoreV1().Namespaces().Delete(ctx, appNamespace, metav1.DeleteOptions{}) + if err != nil { + t.Logf("Warning: Failed to delete namespace %s: %v", appNamespace, err) + } + }, + }, + }) + + test.Test(t) +} + +// Test_DirectModule_BackwardCompat tests that traditional wrapped recipes (with context variable +// and result output) continue to work correctly after the direct module support changes. +// This is a regression test to ensure no behavioral changes for existing recipe patterns. +// +// Steps: +// 1. Create Kubernetes namespace +// 2. Register user-defined resource type +// 3. Deploy Bicep template with wrapped recipe (existing pattern) +// 4. Verify deployment succeeds and Kubernetes resources are created +// 5. Verify recipe parameter reconciliation (environment overrides recipe pack params) +func Test_DirectModule_BackwardCompat(t *testing.T) { + template := "testdata/directmodule-compat-test.bicep" + appName := "directmodule-compat-app" + appNamespace := "directmodule-compat-ns" + parentResourceTypeName := "Test.Resources/userTypeAlpha" + parentResourceTypeParam := strings.Split(parentResourceTypeName, "/")[1] + filepath := "testdata/testresourcetypes.yaml" + options := rp.NewRPTestOptions(t) + cli := radcli.NewCLI(t, options.ConfigFilePath) + + test := rp.NewRPTest(t, appName, []rp.TestStep{ + { + Executor: step.NewFuncExecutor(func(ctx context.Context, t *testing.T, options test.TestOptions) { + _, err := options.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: appNamespace, + }, + }, metav1.CreateOptions{}) + if err != nil && !strings.Contains(err.Error(), "already exists") { + require.NoError(t, err) + } + }), + SkipKubernetesOutputResourceValidation: true, + SkipObjectValidation: true, + SkipResourceDeletion: true, + }, + { + Executor: step.NewFuncExecutor(func(ctx context.Context, t *testing.T, options test.TestOptions) { + _, err := cli.ResourceTypeCreate(ctx, parentResourceTypeParam, filepath) + require.NoError(t, err) + }), + SkipKubernetesOutputResourceValidation: true, + SkipObjectValidation: true, + SkipResourceDeletion: true, + }, + { + Executor: step.NewDeployExecutor(template, testutil.GetBicepRecipeRegistry(), testutil.GetBicepRecipeVersion(), "appName="+appName), + SkipObjectValidation: true, + SkipResourceDeletion: false, + SkipKubernetesOutputResourceValidation: true, + RPResources: &validation.RPResourceSet{ + Resources: []validation.RPResource{ + { + Name: "directmodule-compat-recipe-pack", + Type: "radius.core/recipepacks", + }, + { + Name: "directmodule-compat-env", + Type: "radius.core/environments", + }, + { + Name: appName, + Type: validation.ApplicationsResource, + App: appName, + }, + { + Name: "directmodule-compat-resource", + Type: "test.resources/usertypealpha", + App: appName, + }, + }, + }, + PostStepVerify: func(ctx context.Context, t *testing.T, test rp.RPTest) { + // Verify that the wrapped recipe deployed Kubernetes resources. + deployments, err := test.Options.K8sClient.AppsV1().Deployments(appNamespace).List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.NotEmpty(t, deployments.Items, "Expected wrapped recipe to create deployments in namespace %s", appNamespace) + + t.Logf("Found %d deployments in namespace %s", len(deployments.Items), appNamespace) + + // Verify recipe parameter reconciliation: environment sets port=9090, recipe pack sets port=8080. + // Environment-level parameters should override recipe pack parameters. + foundPort := false + for _, deploy := range deployments.Items { + for _, container := range deploy.Spec.Template.Spec.Containers { + for _, port := range container.Ports { + if port.ContainerPort == 9090 { + foundPort = true + t.Logf(" ✓ Container %s has expected port 9090 (env override)", container.Name) + } + } + } + } + require.True(t, foundPort, "Expected container with port 9090 (environment parameter should override recipe pack parameter)") + + // Clean up namespace + err = test.Options.K8sClient.CoreV1().Namespaces().Delete(ctx, appNamespace, metav1.DeleteOptions{}) + if err != nil { + t.Logf("Warning: Failed to delete namespace %s: %v", appNamespace, err) + } + }, + }, + }) + + test.Test(t) +} diff --git a/test/functional-portable/dynamicrp/noncloud/resources/testdata/directmodule-compat-test.bicep b/test/functional-portable/dynamicrp/noncloud/resources/testdata/directmodule-compat-test.bicep new file mode 100644 index 0000000000..670177d969 --- /dev/null +++ b/test/functional-portable/dynamicrp/noncloud/resources/testdata/directmodule-compat-test.bicep @@ -0,0 +1,68 @@ +extension radius +extension testresources +extension kubernetes with { + kubeConfig: '' + namespace: 'directmodule-compat-ns' +} as kubernetes + +param registry string + +param version string + +@description('Name of the Radius Application.') +param appName string + +// Recipe pack using a traditional wrapped recipe (with context variable and result output). +// This test verifies backward compatibility — wrapped recipes should continue to work +// identically after the direct module support changes. +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'directmodule-compat-recipe-pack' + location: 'global' + properties: { + recipes: { + 'Test.Resources/userTypeAlpha': { + kind: 'bicep' + location: '${registry}/test/testrecipes/test-bicep-recipes/dynamicrp_recipe:${version}' + parameters: { + port: 8080 + } + } + } + } +} + +resource env 'Radius.Core/environments@2025-08-01-preview' = { + name: 'directmodule-compat-env' + location: 'global' + properties: { + recipePacks: [ + recipepack.id + ] + providers: { + kubernetes: { + namespace: 'directmodule-compat-ns' + } + } + recipeParameters: { + 'Test.Resources/userTypeAlpha': { + port: 9090 + } + } + } +} + +resource app 'Applications.Core/applications@2023-10-01-preview' = { + name: appName + location: 'global' + properties: { + environment: env.id + } +} + +resource compatresource 'Test.Resources/userTypeAlpha@2023-10-01-preview' = { + name: 'directmodule-compat-resource' + properties: { + environment: env.id + application: app.id + } +} diff --git a/test/functional-portable/dynamicrp/noncloud/resources/testdata/directmodule-tf-test.bicep b/test/functional-portable/dynamicrp/noncloud/resources/testdata/directmodule-tf-test.bicep new file mode 100644 index 0000000000..9b15c0fb27 --- /dev/null +++ b/test/functional-portable/dynamicrp/noncloud/resources/testdata/directmodule-tf-test.bicep @@ -0,0 +1,66 @@ +extension radius +extension testresources +extension kubernetes with { + kubeConfig: '' + namespace: 'directmodule-tf-ns' +} as kubernetes + +@description('The URL of the server hosting test Terraform modules.') +param moduleServer string + +@description('Name of the Radius Application.') +param appName string + +// Recipe pack with a direct Terraform module (no wrapped result output). +// Uses outputs mapping to map module outputs to resource properties and +// parameters with {{context.*}} expressions for expression resolution testing. +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'directmodule-tf-recipe-pack' + location: 'global' + properties: { + recipes: { + 'Test.Resources/userTypeAlpha': { + kind: 'terraform' + location: '${moduleServer}/kubernetes-redis.zip//modules' + parameters: { + redis_cache_name: '{{context.resource.name}}-cache' + namespace: '{{context.runtime.kubernetes.namespace}}' + } + outputs: { + host: 'kubernetes_deployment_name' + } + } + } + } +} + +resource env 'Radius.Core/environments@2025-08-01-preview' = { + name: 'directmodule-tf-env' + location: 'global' + properties: { + recipePacks: [ + recipepack.id + ] + providers: { + kubernetes: { + namespace: 'directmodule-tf-ns' + } + } + } +} + +resource app 'Applications.Core/applications@2023-10-01-preview' = { + name: appName + location: 'global' + properties: { + environment: env.id + } +} + +resource directmoduleresource 'Test.Resources/userTypeAlpha@2023-10-01-preview' = { + name: 'directmodule-tf-resource' + properties: { + environment: env.id + application: app.id + } +} From 5121a1b05b3043181f52f12d7fb25958f767b22a Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Fri, 15 May 2026 15:50:39 -0700 Subject: [PATCH 17/18] chore: add outputs to Bicep types and direct module examples - Add outputs property to RecipeDefinition in Bicep type definitions so Bicep files can reference the outputs field without squiggles - Add env.bicep example showing terraform-aws-modules/rds/aws as a direct module with {{context.*}} expressions and outputs mapping - Add main.tf example showing the refactored direct module pattern - Fix gofmt formatting in resolver_test.go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../radius.core/2025-08-01-preview/types.json | 3395 +++++++++-------- pkg/recipes/paramresolver/resolver_test.go | 2 +- .../examples/env.bicep | 74 + .../examples/main.tf | 209 + 4 files changed, 1989 insertions(+), 1691 deletions(-) create mode 100644 specs/001-direct-module-support/examples/env.bicep create mode 100644 specs/001-direct-module-support/examples/main.tf diff --git a/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json b/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json index e314a4c545..ee959ae1e4 100644 --- a/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json +++ b/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json @@ -1,1693 +1,1708 @@ [ - { - "$type": "StringType" - }, - { - "$type": "StringLiteralType", - "value": "Radius.Core/applications" - }, - { - "$type": "StringLiteralType", - "value": "2025-08-01-preview" - }, - { - "$type": "ObjectType", - "name": "Radius.Core/applications", - "properties": { - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 10, - "description": "The resource id" - }, - "name": { - "type": { - "$ref": "#/0" - }, - "flags": 25, - "description": "The resource name" - }, - "type": { - "type": { - "$ref": "#/1" - }, - "flags": 10, - "description": "The resource type" - }, - "apiVersion": { - "type": { - "$ref": "#/2" - }, - "flags": 10, - "description": "The resource api version" - }, - "properties": { - "type": { - "$ref": "#/4" - }, - "flags": 1, - "description": "Application properties" - }, - "tags": { - "type": { - "$ref": "#/32" - }, - "flags": 0, - "description": "Resource tags." - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The geo-location where the resource lives" - }, - "systemData": { - "type": { - "$ref": "#/33" - }, - "flags": 2, - "description": "Metadata pertaining to creation and last modification of the resource." - } - } - }, - { - "$type": "ObjectType", - "name": "ApplicationProperties", - "properties": { - "provisioningState": { - "type": { - "$ref": "#/13" - }, - "flags": 2, - "description": "Provisioning state of the resource at the time the operation was called" - }, - "environment": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "Fully qualified resource ID for the environment that the application is linked to" - }, - "status": { - "type": { - "$ref": "#/14" - }, - "flags": 2, - "description": "Status of a resource." - } - } - }, - { - "$type": "StringLiteralType", - "value": "Creating" - }, - { - "$type": "StringLiteralType", - "value": "Updating" - }, - { - "$type": "StringLiteralType", - "value": "Deleting" - }, - { - "$type": "StringLiteralType", - "value": "Accepted" - }, - { - "$type": "StringLiteralType", - "value": "Provisioning" - }, - { - "$type": "StringLiteralType", - "value": "Succeeded" - }, - { - "$type": "StringLiteralType", - "value": "Failed" - }, - { - "$type": "StringLiteralType", - "value": "Canceled" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/5" - }, - { - "$ref": "#/6" - }, - { - "$ref": "#/7" - }, - { - "$ref": "#/8" - }, - { - "$ref": "#/9" - }, - { - "$ref": "#/10" - }, - { - "$ref": "#/11" - }, - { - "$ref": "#/12" - } - ] - }, - { - "$type": "ObjectType", - "name": "ResourceStatus", - "properties": { - "compute": { - "type": { - "$ref": "#/15" - }, - "flags": 0, - "description": "Represents backing compute resource" - }, - "recipe": { - "type": { - "$ref": "#/28" - }, - "flags": 2, - "description": "Recipe status at deployment time for a resource." - }, - "outputResources": { - "type": { - "$ref": "#/31" - }, - "flags": 0, - "description": "Properties of an output resource" - } - } - }, - { - "$type": "DiscriminatedObjectType", - "name": "EnvironmentCompute", - "discriminator": "kind", - "baseProperties": { - "resourceId": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The resource id of the compute resource for application environment." - }, - "identity": { - "type": { - "$ref": "#/16" - }, - "flags": 0, - "description": "IdentitySettings is the external identity setting." - } - }, - "elements": { - "aci": { - "$ref": "#/24" - }, - "kubernetes": { - "$ref": "#/26" - } - } - }, - { - "$type": "ObjectType", - "name": "IdentitySettings", - "properties": { - "kind": { - "type": { - "$ref": "#/22" - }, - "flags": 1, - "description": "IdentitySettingKind is the kind of supported external identity setting" - }, - "oidcIssuer": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The URI for your compute platform's OIDC issuer" - }, - "resource": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The resource ID of the provisioned identity" - }, - "managedIdentity": { - "type": { - "$ref": "#/23" - }, - "flags": 0, - "description": "The list of user assigned managed identities" - } - } - }, - { - "$type": "StringLiteralType", - "value": "undefined" - }, - { - "$type": "StringLiteralType", - "value": "azure.com.workload" - }, - { - "$type": "StringLiteralType", - "value": "userAssigned" - }, - { - "$type": "StringLiteralType", - "value": "systemAssigned" - }, - { - "$type": "StringLiteralType", - "value": "systemAssignedUserAssigned" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/17" - }, - { - "$ref": "#/18" - }, - { - "$ref": "#/19" - }, - { - "$ref": "#/20" - }, - { - "$ref": "#/21" - } - ] - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "AzureContainerInstanceCompute", - "properties": { - "resourceGroup": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The resource group to use for the environment." - }, - "kind": { - "type": { - "$ref": "#/25" - }, - "flags": 1, - "description": "Discriminator property for EnvironmentCompute." - } - } - }, - { - "$type": "StringLiteralType", - "value": "aci" - }, - { - "$type": "ObjectType", - "name": "KubernetesCompute", - "properties": { - "namespace": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "The namespace to use for the environment." - }, - "kind": { - "type": { - "$ref": "#/27" - }, - "flags": 1, - "description": "Discriminator property for EnvironmentCompute." - } - } - }, - { - "$type": "StringLiteralType", - "value": "kubernetes" - }, - { - "$type": "ObjectType", - "name": "RecipeStatus", - "properties": { - "templateKind": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "TemplateKind is the kind of the recipe template used by the portable resource upon deployment." - }, - "templatePath": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "TemplatePath is the path of the recipe consumed by the portable resource upon deployment." - }, - "templateVersion": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "TemplateVersion is the version number of the template." - } - } - }, - { - "$type": "ObjectType", - "name": "OutputResource", - "properties": { - "localId": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The logical identifier scoped to the owning Radius resource. This is only needed or used when a resource has a dependency relationship. LocalIDs do not have any particular format or meaning beyond being compared to determine dependency relationships." - }, - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The UCP resource ID of the underlying resource." - }, - "radiusManaged": { - "type": { - "$ref": "#/30" - }, - "flags": 0, - "description": "Determines whether Radius manages the lifecycle of the underlying resource." - } - } - }, - { - "$type": "BooleanType" - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/29" - } - }, - { - "$type": "ObjectType", - "name": "TrackedResourceTags", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "SystemData", - "properties": { - "createdBy": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The identity that created the resource." - }, - "createdByType": { - "type": { - "$ref": "#/38" - }, - "flags": 0, - "description": "The type of identity that created the resource." - }, - "createdAt": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The timestamp of resource creation (UTC)." - }, - "lastModifiedBy": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The identity that last modified the resource." - }, - "lastModifiedByType": { - "type": { - "$ref": "#/43" - }, - "flags": 0, - "description": "The type of identity that created the resource." - }, - "lastModifiedAt": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The timestamp of resource last modification (UTC)" - } - } - }, - { - "$type": "StringLiteralType", - "value": "User" - }, - { - "$type": "StringLiteralType", - "value": "Application" - }, - { - "$type": "StringLiteralType", - "value": "ManagedIdentity" - }, - { - "$type": "StringLiteralType", - "value": "Key" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/34" - }, - { - "$ref": "#/35" - }, - { - "$ref": "#/36" - }, - { - "$ref": "#/37" - } - ] - }, - { - "$type": "StringLiteralType", - "value": "User" - }, - { - "$type": "StringLiteralType", - "value": "Application" - }, - { - "$type": "StringLiteralType", - "value": "ManagedIdentity" - }, - { - "$type": "StringLiteralType", - "value": "Key" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/39" - }, - { - "$ref": "#/40" - }, - { - "$ref": "#/41" - }, - { - "$ref": "#/42" - } - ] - }, - { - "$type": "ResourceType", - "name": "Radius.Core/applications@2025-08-01-preview", - "body": { - "$ref": "#/3" - }, - "readableScopes": 0, - "writableScopes": 0, - "functions": {} - }, - { - "$type": "StringLiteralType", - "value": "Radius.Core/bicepConfigs" - }, - { - "$type": "StringLiteralType", - "value": "2025-08-01-preview" - }, - { - "$type": "ObjectType", - "name": "Radius.Core/bicepConfigs", - "properties": { - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 10, - "description": "The resource id" - }, - "name": { - "type": { - "$ref": "#/0" - }, - "flags": 25, - "description": "The resource name" - }, - "type": { - "type": { - "$ref": "#/45" - }, - "flags": 10, - "description": "The resource type" - }, - "apiVersion": { - "type": { - "$ref": "#/46" - }, - "flags": 10, - "description": "The resource api version" - }, - "properties": { - "type": { - "$ref": "#/48" - }, - "flags": 1, - "description": "Bicep configuration properties." - }, - "tags": { - "type": { - "$ref": "#/65" - }, - "flags": 0, - "description": "Resource tags." - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The geo-location where the resource lives" - }, - "systemData": { - "type": { - "$ref": "#/33" - }, - "flags": 2, - "description": "Metadata pertaining to creation and last modification of the resource." - } - } - }, - { - "$type": "ObjectType", - "name": "BicepConfigProperties", - "properties": { - "provisioningState": { - "type": { - "$ref": "#/57" - }, - "flags": 2, - "description": "Provisioning state of the resource at the time the operation was called" - }, - "referencedBy": { - "type": { - "$ref": "#/58" - }, - "flags": 2, - "description": "Environments that reference this Bicep configuration." - }, - "registryAuthentications": { - "type": { - "$ref": "#/64" - }, - "flags": 0, - "description": "Authentication configuration for private Bicep registries, keyed by registry hostname (e.g. 'corp.acr.io'). The Bicep driver looks up credentials by the host parsed from the recipe template path." - } - } - }, - { - "$type": "StringLiteralType", - "value": "Creating" - }, - { - "$type": "StringLiteralType", - "value": "Updating" - }, - { - "$type": "StringLiteralType", - "value": "Deleting" - }, - { - "$type": "StringLiteralType", - "value": "Accepted" - }, - { - "$type": "StringLiteralType", - "value": "Provisioning" - }, - { - "$type": "StringLiteralType", - "value": "Succeeded" - }, - { - "$type": "StringLiteralType", - "value": "Failed" - }, - { - "$type": "StringLiteralType", - "value": "Canceled" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/49" - }, - { - "$ref": "#/50" - }, - { - "$ref": "#/51" - }, - { - "$ref": "#/52" - }, - { - "$ref": "#/53" - }, - { - "$ref": "#/54" - }, - { - "$ref": "#/55" - }, - { - "$ref": "#/56" - } - ] - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "BicepRegistryAuthentication", - "properties": { - "authenticationMethod": { - "type": { - "$ref": "#/63" - }, - "flags": 0, - "description": "Supported authentication methods for private Bicep registries." - }, - "basicAuthSecretId": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The ID of an Applications.Core/SecretStore resource containing username and password for BasicAuth. Required when authenticationMethod is 'BasicAuth'." - }, - "azureWiClientId": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "Azure Workload Identity client ID. Required when authenticationMethod is 'AzureWI'." - }, - "azureWiTenantId": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "Azure Workload Identity tenant ID. Required when authenticationMethod is 'AzureWI'." - }, - "awsIamRoleArn": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "AWS IAM Role ARN for IRSA authentication. Required when authenticationMethod is 'AwsIrsa'." - } - } - }, - { - "$type": "StringLiteralType", - "value": "BasicAuth" - }, - { - "$type": "StringLiteralType", - "value": "AzureWI" - }, - { - "$type": "StringLiteralType", - "value": "AwsIrsa" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/60" - }, - { - "$ref": "#/61" - }, - { - "$ref": "#/62" - } - ] - }, - { - "$type": "ObjectType", - "name": "BicepConfigPropertiesRegistryAuthentications", - "properties": {}, - "additionalProperties": { - "$ref": "#/59" - } - }, - { - "$type": "ObjectType", - "name": "TrackedResourceTags", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ResourceType", - "name": "Radius.Core/bicepConfigs@2025-08-01-preview", - "body": { - "$ref": "#/47" - }, - "readableScopes": 0, - "writableScopes": 0, - "functions": {} - }, - { - "$type": "StringLiteralType", - "value": "Radius.Core/environments" - }, - { - "$type": "StringLiteralType", - "value": "2025-08-01-preview" - }, - { - "$type": "ObjectType", - "name": "Radius.Core/environments", - "properties": { - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 10, - "description": "The resource id" - }, - "name": { - "type": { - "$ref": "#/0" - }, - "flags": 25, - "description": "The resource name" - }, - "type": { - "type": { - "$ref": "#/67" - }, - "flags": 10, - "description": "The resource type" - }, - "apiVersion": { - "type": { - "$ref": "#/68" - }, - "flags": 10, - "description": "The resource api version" - }, - "properties": { - "type": { - "$ref": "#/70" - }, - "flags": 1, - "description": "Environment properties" - }, - "tags": { - "type": { - "$ref": "#/88" - }, - "flags": 0, - "description": "Resource tags." - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The geo-location where the resource lives" - }, - "systemData": { - "type": { - "$ref": "#/33" - }, - "flags": 2, - "description": "Metadata pertaining to creation and last modification of the resource." - } - } - }, - { - "$type": "ObjectType", - "name": "EnvironmentProperties", - "properties": { - "provisioningState": { - "type": { - "$ref": "#/79" - }, - "flags": 2, - "description": "Provisioning state of the resource at the time the operation was called" - }, - "recipePacks": { - "type": { - "$ref": "#/80" - }, - "flags": 0, - "description": "List of Recipe Pack resource IDs linked to this environment." - }, - "recipeParameters": { - "type": { - "$ref": "#/83" - }, - "flags": 0, - "description": "Recipe specific parameters that apply to all resources of a given type in this environment." - }, - "providers": { - "type": { - "$ref": "#/84" - }, - "flags": 0 - }, - "terraformConfig": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "Resource ID of a Radius.Core/terraformConfigs resource providing Terraform recipe settings." - }, - "bicepConfig": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "Resource ID of a Radius.Core/bicepConfigs resource providing Bicep recipe settings." - }, - "simulated": { - "type": { - "$ref": "#/30" - }, - "flags": 0, - "description": "Simulated environment." - } - } - }, - { - "$type": "StringLiteralType", - "value": "Creating" - }, - { - "$type": "StringLiteralType", - "value": "Updating" - }, - { - "$type": "StringLiteralType", - "value": "Deleting" - }, - { - "$type": "StringLiteralType", - "value": "Accepted" - }, - { - "$type": "StringLiteralType", - "value": "Provisioning" - }, - { - "$type": "StringLiteralType", - "value": "Succeeded" - }, - { - "$type": "StringLiteralType", - "value": "Failed" - }, - { - "$type": "StringLiteralType", - "value": "Canceled" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/71" - }, - { - "$ref": "#/72" - }, - { - "$ref": "#/73" - }, - { - "$ref": "#/74" - }, - { - "$ref": "#/75" - }, - { - "$ref": "#/76" - }, - { - "$ref": "#/77" - }, - { - "$ref": "#/78" - } - ] - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "AnyType" - }, - { - "$type": "ObjectType", - "name": "RecipeParameterValue", - "properties": {}, - "additionalProperties": { - "$ref": "#/81" - } - }, - { - "$type": "ObjectType", - "name": "EnvironmentPropertiesRecipeParameters", - "properties": {}, - "additionalProperties": { - "$ref": "#/82" - } - }, - { - "$type": "ObjectType", - "name": "Providers", - "properties": { - "azure": { - "type": { - "$ref": "#/85" - }, - "flags": 0, - "description": "The Azure cloud provider definition." - }, - "kubernetes": { - "type": { - "$ref": "#/86" - }, - "flags": 0 - }, - "aws": { - "type": { - "$ref": "#/87" - }, - "flags": 0, - "description": "The AWS cloud provider definition." - } - } - }, - { - "$type": "ObjectType", - "name": "ProvidersAzure", - "properties": { - "subscriptionId": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "Azure subscription ID hosting deployed resources." - }, - "resourceGroupName": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "Optional resource group name." - }, - "identity": { - "type": { - "$ref": "#/16" - }, - "flags": 0, - "description": "IdentitySettings is the external identity setting." - } - } - }, - { - "$type": "ObjectType", - "name": "ProvidersKubernetes", - "properties": { - "namespace": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "Kubernetes namespace to deploy workloads into." - } - } - }, - { - "$type": "ObjectType", - "name": "ProvidersAws", - "properties": { - "accountId": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "AWS account ID for AWS resources to be deployed into." - }, - "region": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "AWS region for AWS resources to be deployed into." - } - } - }, - { - "$type": "ObjectType", - "name": "TrackedResourceTags", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ResourceType", - "name": "Radius.Core/environments@2025-08-01-preview", - "body": { - "$ref": "#/69" - }, - "readableScopes": 0, - "writableScopes": 0, - "functions": {} - }, - { - "$type": "StringLiteralType", - "value": "Radius.Core/recipePacks" - }, - { - "$type": "StringLiteralType", - "value": "2025-08-01-preview" - }, - { - "$type": "ObjectType", - "name": "Radius.Core/recipePacks", - "properties": { - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 10, - "description": "The resource id" - }, - "name": { - "type": { - "$ref": "#/0" - }, - "flags": 25, - "description": "The resource name" - }, - "type": { - "type": { - "$ref": "#/90" - }, - "flags": 10, - "description": "The resource type" - }, - "apiVersion": { - "type": { - "$ref": "#/91" - }, - "flags": 10, - "description": "The resource api version" - }, - "properties": { - "type": { - "$ref": "#/93" - }, - "flags": 1, - "description": "Recipe Pack properties" - }, - "tags": { - "type": { - "$ref": "#/110" - }, - "flags": 0, - "description": "Resource tags." - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The geo-location where the resource lives" - }, - "systemData": { - "type": { - "$ref": "#/33" - }, - "flags": 2, - "description": "Metadata pertaining to creation and last modification of the resource." - } - } - }, - { - "$type": "ObjectType", - "name": "RecipePackProperties", - "properties": { - "provisioningState": { - "type": { - "$ref": "#/102" - }, - "flags": 2, - "description": "Provisioning state of the resource at the time the operation was called" - }, - "referencedBy": { - "type": { - "$ref": "#/103" - }, - "flags": 2, - "description": "List of environment IDs that reference this recipe pack" - }, - "recipes": { - "type": { - "$ref": "#/109" - }, - "flags": 1, - "description": "Map of resource types to their recipe configurations" - } - } - }, - { - "$type": "StringLiteralType", - "value": "Creating" - }, - { - "$type": "StringLiteralType", - "value": "Updating" - }, - { - "$type": "StringLiteralType", - "value": "Deleting" - }, - { - "$type": "StringLiteralType", - "value": "Accepted" - }, - { - "$type": "StringLiteralType", - "value": "Provisioning" - }, - { - "$type": "StringLiteralType", - "value": "Succeeded" - }, - { - "$type": "StringLiteralType", - "value": "Failed" - }, - { - "$type": "StringLiteralType", - "value": "Canceled" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/94" - }, - { - "$ref": "#/95" - }, - { - "$ref": "#/96" - }, - { - "$ref": "#/97" - }, - { - "$ref": "#/98" - }, - { - "$ref": "#/99" - }, - { - "$ref": "#/100" - }, - { - "$ref": "#/101" - } - ] - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "RecipeDefinition", - "properties": { - "kind": { - "type": { - "$ref": "#/107" - }, - "flags": 1, - "description": "The type of recipe" - }, - "plainHttp": { - "type": { - "$ref": "#/30" - }, - "flags": 0, - "description": "Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for example in a locally hosted registry for Bicep recipes. Defaults to false (use HTTPS/TLS)" - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "URL path to the recipe" - }, - "parameters": { - "type": { - "$ref": "#/108" - }, - "flags": 0, - "description": "Parameters to pass to the recipe" - } - } - }, - { - "$type": "StringLiteralType", - "value": "terraform" - }, - { - "$type": "StringLiteralType", - "value": "bicep" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/105" - }, - { - "$ref": "#/106" - } - ] - }, - { - "$type": "ObjectType", - "name": "RecipeDefinitionParameters", - "properties": {}, - "additionalProperties": { - "$ref": "#/81" - } - }, - { - "$type": "ObjectType", - "name": "RecipePackPropertiesRecipes", - "properties": {}, - "additionalProperties": { - "$ref": "#/104" - } - }, - { - "$type": "ObjectType", - "name": "TrackedResourceTags", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ResourceType", - "name": "Radius.Core/recipePacks@2025-08-01-preview", - "body": { - "$ref": "#/92" - }, - "readableScopes": 0, - "writableScopes": 0, - "functions": {} - }, - { - "$type": "StringLiteralType", - "value": "Radius.Core/terraformConfigs" - }, - { - "$type": "StringLiteralType", - "value": "2025-08-01-preview" - }, - { - "$type": "ObjectType", - "name": "Radius.Core/terraformConfigs", - "properties": { - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 10, - "description": "The resource id" - }, - "name": { - "type": { - "$ref": "#/0" - }, - "flags": 25, - "description": "The resource name" - }, - "type": { - "type": { - "$ref": "#/112" - }, - "flags": 10, - "description": "The resource type" - }, - "apiVersion": { - "type": { - "$ref": "#/113" - }, - "flags": 10, - "description": "The resource api version" - }, - "properties": { - "type": { - "$ref": "#/115" - }, - "flags": 1, - "description": "Terraform configuration properties." - }, - "tags": { - "type": { - "$ref": "#/137" - }, - "flags": 0, - "description": "Resource tags." - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The geo-location where the resource lives" - }, - "systemData": { - "type": { - "$ref": "#/33" - }, - "flags": 2, - "description": "Metadata pertaining to creation and last modification of the resource." - } - } - }, - { - "$type": "ObjectType", - "name": "TerraformConfigProperties", - "properties": { - "provisioningState": { - "type": { - "$ref": "#/124" - }, - "flags": 2, - "description": "Provisioning state of the resource at the time the operation was called" - }, - "referencedBy": { - "type": { - "$ref": "#/125" - }, - "flags": 2, - "description": "Environments that reference this Terraform configuration." - }, - "terraformrc": { - "type": { - "$ref": "#/126" - }, - "flags": 0, - "description": "Terraform CLI configuration file (.terraformrc) settings. See https://developer.hashicorp.com/terraform/cli/config for details." - }, - "env": { - "type": { - "$ref": "#/136" - }, - "flags": 0, - "description": "Environment variables injected during Terraform recipe execution." - } - } - }, - { - "$type": "StringLiteralType", - "value": "Creating" - }, - { - "$type": "StringLiteralType", - "value": "Updating" - }, - { - "$type": "StringLiteralType", - "value": "Deleting" - }, - { - "$type": "StringLiteralType", - "value": "Accepted" - }, - { - "$type": "StringLiteralType", - "value": "Provisioning" - }, - { - "$type": "StringLiteralType", - "value": "Succeeded" - }, - { - "$type": "StringLiteralType", - "value": "Failed" - }, - { - "$type": "StringLiteralType", - "value": "Canceled" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/116" - }, - { - "$ref": "#/117" - }, - { - "$ref": "#/118" - }, - { - "$ref": "#/119" - }, - { - "$ref": "#/120" - }, - { - "$ref": "#/121" - }, - { - "$ref": "#/122" - }, - { - "$ref": "#/123" - } - ] - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "TerraformrcConfig", - "properties": { - "providerInstallation": { - "type": { - "$ref": "#/127" - }, - "flags": 0, - "description": "Provider installation configuration for Terraform CLI." - }, - "credentials": { - "type": { - "$ref": "#/135" - }, - "flags": 0, - "description": "Credentials for authenticating to private Terraform registries (HTTP-based, e.g. app.terraform.io). Map of registry hostname to credential configuration. Rendered as native `credentials \"hostname\" {}` blocks in the generated .terraformrc. Note: this is for Terraform CLI registry auth (HTTP), not for Git-based module sources; Git auth is a separate mechanism." - } - } - }, - { - "$type": "ObjectType", - "name": "TerraformProviderInstallation", - "properties": { - "networkMirror": { - "type": { - "$ref": "#/128" - }, - "flags": 0, - "description": "Network mirror configuration for Terraform providers." - }, - "direct": { - "type": { - "$ref": "#/131" - }, - "flags": 0, - "description": "Direct provider installation configuration." - } - } - }, - { - "$type": "ObjectType", - "name": "TerraformProviderMirror", - "properties": { - "url": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The URL of the provider mirror." - }, - "include": { - "type": { - "$ref": "#/129" - }, - "flags": 0, - "description": "Provider address patterns to include from this mirror." - }, - "exclude": { - "type": { - "$ref": "#/130" - }, - "flags": 0, - "description": "Provider address patterns to exclude from this mirror." - } - } - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "TerraformProviderDirect", - "properties": { - "include": { - "type": { - "$ref": "#/132" - }, - "flags": 0, - "description": "Provider address patterns to include for direct installation." - }, - "exclude": { - "type": { - "$ref": "#/133" - }, - "flags": 0, - "description": "Provider address patterns to exclude from direct installation." - } - } - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "TerraformCredentialConfig", - "properties": { - "secret": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The ID of an Applications.Core/SecretStore resource containing the authentication token. The secret store must have a secret named 'token'." - } - } - }, - { - "$type": "ObjectType", - "name": "TerraformrcConfigCredentials", - "properties": {}, - "additionalProperties": { - "$ref": "#/134" - } - }, - { - "$type": "ObjectType", - "name": "TerraformConfigPropertiesEnv", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "TrackedResourceTags", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" + { + "$type": "StringType" + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/applications" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/applications", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/1" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/2" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/4" + }, + "flags": 1, + "description": "Application properties" + }, + "tags": { + "type": { + "$ref": "#/32" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "ApplicationProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/13" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "environment": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "Fully qualified resource ID for the environment that the application is linked to" + }, + "status": { + "type": { + "$ref": "#/14" + }, + "flags": 2, + "description": "Status of a resource." + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/5" + }, + { + "$ref": "#/6" + }, + { + "$ref": "#/7" + }, + { + "$ref": "#/8" + }, + { + "$ref": "#/9" + }, + { + "$ref": "#/10" + }, + { + "$ref": "#/11" + }, + { + "$ref": "#/12" + } + ] + }, + { + "$type": "ObjectType", + "name": "ResourceStatus", + "properties": { + "compute": { + "type": { + "$ref": "#/15" + }, + "flags": 0, + "description": "Represents backing compute resource" + }, + "recipe": { + "type": { + "$ref": "#/28" + }, + "flags": 2, + "description": "Recipe status at deployment time for a resource." + }, + "outputResources": { + "type": { + "$ref": "#/31" + }, + "flags": 0, + "description": "Properties of an output resource" + } + } + }, + { + "$type": "DiscriminatedObjectType", + "name": "EnvironmentCompute", + "discriminator": "kind", + "baseProperties": { + "resourceId": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The resource id of the compute resource for application environment." + }, + "identity": { + "type": { + "$ref": "#/16" + }, + "flags": 0, + "description": "IdentitySettings is the external identity setting." + } + }, + "elements": { + "aci": { + "$ref": "#/24" + }, + "kubernetes": { + "$ref": "#/26" + } + } + }, + { + "$type": "ObjectType", + "name": "IdentitySettings", + "properties": { + "kind": { + "type": { + "$ref": "#/22" + }, + "flags": 1, + "description": "IdentitySettingKind is the kind of supported external identity setting" + }, + "oidcIssuer": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The URI for your compute platform's OIDC issuer" + }, + "resource": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The resource ID of the provisioned identity" + }, + "managedIdentity": { + "type": { + "$ref": "#/23" + }, + "flags": 0, + "description": "The list of user assigned managed identities" + } + } + }, + { + "$type": "StringLiteralType", + "value": "undefined" + }, + { + "$type": "StringLiteralType", + "value": "azure.com.workload" + }, + { + "$type": "StringLiteralType", + "value": "userAssigned" + }, + { + "$type": "StringLiteralType", + "value": "systemAssigned" + }, + { + "$type": "StringLiteralType", + "value": "systemAssignedUserAssigned" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/17" + }, + { + "$ref": "#/18" + }, + { + "$ref": "#/19" + }, + { + "$ref": "#/20" + }, + { + "$ref": "#/21" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "AzureContainerInstanceCompute", + "properties": { + "resourceGroup": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The resource group to use for the environment." + }, + "kind": { + "type": { + "$ref": "#/25" + }, + "flags": 1, + "description": "Discriminator property for EnvironmentCompute." + } + } + }, + { + "$type": "StringLiteralType", + "value": "aci" + }, + { + "$type": "ObjectType", + "name": "KubernetesCompute", + "properties": { + "namespace": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The namespace to use for the environment." + }, + "kind": { + "type": { + "$ref": "#/27" + }, + "flags": 1, + "description": "Discriminator property for EnvironmentCompute." + } + } + }, + { + "$type": "StringLiteralType", + "value": "kubernetes" + }, + { + "$type": "ObjectType", + "name": "RecipeStatus", + "properties": { + "templateKind": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "TemplateKind is the kind of the recipe template used by the portable resource upon deployment." + }, + "templatePath": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "TemplatePath is the path of the recipe consumed by the portable resource upon deployment." + }, + "templateVersion": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "TemplateVersion is the version number of the template." + } + } + }, + { + "$type": "ObjectType", + "name": "OutputResource", + "properties": { + "localId": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The logical identifier scoped to the owning Radius resource. This is only needed or used when a resource has a dependency relationship. LocalIDs do not have any particular format or meaning beyond being compared to determine dependency relationships." + }, + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The UCP resource ID of the underlying resource." + }, + "radiusManaged": { + "type": { + "$ref": "#/30" + }, + "flags": 0, + "description": "Determines whether Radius manages the lifecycle of the underlying resource." + } + } + }, + { + "$type": "BooleanType" + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/29" + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "SystemData", + "properties": { + "createdBy": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The identity that created the resource." + }, + "createdByType": { + "type": { + "$ref": "#/38" + }, + "flags": 0, + "description": "The type of identity that created the resource." + }, + "createdAt": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The timestamp of resource creation (UTC)." + }, + "lastModifiedBy": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The identity that last modified the resource." + }, + "lastModifiedByType": { + "type": { + "$ref": "#/43" + }, + "flags": 0, + "description": "The type of identity that created the resource." + }, + "lastModifiedAt": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The timestamp of resource last modification (UTC)" + } + } + }, + { + "$type": "StringLiteralType", + "value": "User" + }, + { + "$type": "StringLiteralType", + "value": "Application" + }, + { + "$type": "StringLiteralType", + "value": "ManagedIdentity" + }, + { + "$type": "StringLiteralType", + "value": "Key" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/34" + }, + { + "$ref": "#/35" + }, + { + "$ref": "#/36" + }, + { + "$ref": "#/37" + } + ] + }, + { + "$type": "StringLiteralType", + "value": "User" + }, + { + "$type": "StringLiteralType", + "value": "Application" + }, + { + "$type": "StringLiteralType", + "value": "ManagedIdentity" + }, + { + "$type": "StringLiteralType", + "value": "Key" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/39" + }, + { + "$ref": "#/40" + }, + { + "$ref": "#/41" + }, + { + "$ref": "#/42" + } + ] + }, + { + "$type": "ResourceType", + "name": "Radius.Core/applications@2025-08-01-preview", + "body": { + "$ref": "#/3" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/bicepConfigs" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/bicepConfigs", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/45" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/46" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/48" + }, + "flags": 1, + "description": "Bicep configuration properties." + }, + "tags": { + "type": { + "$ref": "#/65" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "BicepConfigProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/57" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "referencedBy": { + "type": { + "$ref": "#/58" + }, + "flags": 2, + "description": "Environments that reference this Bicep configuration." + }, + "registryAuthentications": { + "type": { + "$ref": "#/64" + }, + "flags": 0, + "description": "Authentication configuration for private Bicep registries, keyed by registry hostname (e.g. 'corp.acr.io'). The Bicep driver looks up credentials by the host parsed from the recipe template path." + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/49" + }, + { + "$ref": "#/50" + }, + { + "$ref": "#/51" + }, + { + "$ref": "#/52" + }, + { + "$ref": "#/53" + }, + { + "$ref": "#/54" + }, + { + "$ref": "#/55" + }, + { + "$ref": "#/56" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "BicepRegistryAuthentication", + "properties": { + "authenticationMethod": { + "type": { + "$ref": "#/63" + }, + "flags": 0, + "description": "Supported authentication methods for private Bicep registries." + }, + "basicAuthSecretId": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The ID of an Applications.Core/SecretStore resource containing username and password for BasicAuth. Required when authenticationMethod is 'BasicAuth'." + }, + "azureWiClientId": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Azure Workload Identity client ID. Required when authenticationMethod is 'AzureWI'." + }, + "azureWiTenantId": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Azure Workload Identity tenant ID. Required when authenticationMethod is 'AzureWI'." + }, + "awsIamRoleArn": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "AWS IAM Role ARN for IRSA authentication. Required when authenticationMethod is 'AwsIrsa'." + } + } + }, + { + "$type": "StringLiteralType", + "value": "BasicAuth" + }, + { + "$type": "StringLiteralType", + "value": "AzureWI" + }, + { + "$type": "StringLiteralType", + "value": "AwsIrsa" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/60" + }, + { + "$ref": "#/61" + }, + { + "$ref": "#/62" + } + ] + }, + { + "$type": "ObjectType", + "name": "BicepConfigPropertiesRegistryAuthentications", + "properties": {}, + "additionalProperties": { + "$ref": "#/59" + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ResourceType", + "name": "Radius.Core/bicepConfigs@2025-08-01-preview", + "body": { + "$ref": "#/47" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/environments" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/environments", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/67" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/68" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/70" + }, + "flags": 1, + "description": "Environment properties" + }, + "tags": { + "type": { + "$ref": "#/88" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "EnvironmentProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/79" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "recipePacks": { + "type": { + "$ref": "#/80" + }, + "flags": 0, + "description": "List of Recipe Pack resource IDs linked to this environment." + }, + "recipeParameters": { + "type": { + "$ref": "#/83" + }, + "flags": 0, + "description": "Recipe specific parameters that apply to all resources of a given type in this environment." + }, + "providers": { + "type": { + "$ref": "#/84" + }, + "flags": 0 + }, + "terraformConfig": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Resource ID of a Radius.Core/terraformConfigs resource providing Terraform recipe settings." + }, + "bicepConfig": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Resource ID of a Radius.Core/bicepConfigs resource providing Bicep recipe settings." + }, + "simulated": { + "type": { + "$ref": "#/30" + }, + "flags": 0, + "description": "Simulated environment." + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/71" + }, + { + "$ref": "#/72" + }, + { + "$ref": "#/73" + }, + { + "$ref": "#/74" + }, + { + "$ref": "#/75" + }, + { + "$ref": "#/76" + }, + { + "$ref": "#/77" + }, + { + "$ref": "#/78" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "AnyType" + }, + { + "$type": "ObjectType", + "name": "RecipeParameterValue", + "properties": {}, + "additionalProperties": { + "$ref": "#/81" + } + }, + { + "$type": "ObjectType", + "name": "EnvironmentPropertiesRecipeParameters", + "properties": {}, + "additionalProperties": { + "$ref": "#/82" + } + }, + { + "$type": "ObjectType", + "name": "Providers", + "properties": { + "azure": { + "type": { + "$ref": "#/85" + }, + "flags": 0, + "description": "The Azure cloud provider definition." + }, + "kubernetes": { + "type": { + "$ref": "#/86" + }, + "flags": 0 + }, + "aws": { + "type": { + "$ref": "#/87" + }, + "flags": 0, + "description": "The AWS cloud provider definition." + } + } + }, + { + "$type": "ObjectType", + "name": "ProvidersAzure", + "properties": { + "subscriptionId": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "Azure subscription ID hosting deployed resources." + }, + "resourceGroupName": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Optional resource group name." + }, + "identity": { + "type": { + "$ref": "#/16" + }, + "flags": 0, + "description": "IdentitySettings is the external identity setting." + } + } + }, + { + "$type": "ObjectType", + "name": "ProvidersKubernetes", + "properties": { + "namespace": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "Kubernetes namespace to deploy workloads into." + } + } + }, + { + "$type": "ObjectType", + "name": "ProvidersAws", + "properties": { + "accountId": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "AWS account ID for AWS resources to be deployed into." + }, + "region": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "AWS region for AWS resources to be deployed into." + } + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ResourceType", + "name": "Radius.Core/environments@2025-08-01-preview", + "body": { + "$ref": "#/69" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/recipePacks" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/recipePacks", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/90" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/91" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/93" + }, + "flags": 1, + "description": "Recipe Pack properties" + }, + "tags": { + "type": { + "$ref": "#/110" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "RecipePackProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/102" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "referencedBy": { + "type": { + "$ref": "#/103" + }, + "flags": 2, + "description": "List of environment IDs that reference this recipe pack" + }, + "recipes": { + "type": { + "$ref": "#/109" + }, + "flags": 1, + "description": "Map of resource types to their recipe configurations" + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/94" + }, + { + "$ref": "#/95" + }, + { + "$ref": "#/96" + }, + { + "$ref": "#/97" + }, + { + "$ref": "#/98" + }, + { + "$ref": "#/99" + }, + { + "$ref": "#/100" + }, + { + "$ref": "#/101" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "RecipeDefinition", + "properties": { + "kind": { + "type": { + "$ref": "#/107" + }, + "flags": 1, + "description": "The type of recipe" + }, + "plainHttp": { + "type": { + "$ref": "#/30" + }, + "flags": 0, + "description": "Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for example in a locally hosted registry for Bicep recipes. Defaults to false (use HTTPS/TLS)" + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "URL path to the recipe" + }, + "parameters": { + "type": { + "$ref": "#/108" + }, + "flags": 0, + "description": "Parameters to pass to the recipe. Values may contain {{context.*}} template expressions resolved at deployment time" + }, + "outputs": { + "type": { + "$ref": "#/139" + }, + "flags": 0, + "description": "Maps resource property names to module output names. Keys are the resource type's read-only property names, values are the module output names. When empty or not specified, all module outputs pass through with their original names" + } + } + }, + { + "$type": "StringLiteralType", + "value": "terraform" + }, + { + "$type": "StringLiteralType", + "value": "bicep" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/105" + }, + { + "$ref": "#/106" + } + ] + }, + { + "$type": "ObjectType", + "name": "RecipeDefinitionParameters", + "properties": {}, + "additionalProperties": { + "$ref": "#/81" + } + }, + { + "$type": "ObjectType", + "name": "RecipePackPropertiesRecipes", + "properties": {}, + "additionalProperties": { + "$ref": "#/104" + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ResourceType", + "name": "Radius.Core/recipePacks@2025-08-01-preview", + "body": { + "$ref": "#/92" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/terraformConfigs" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/terraformConfigs", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/112" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/113" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/115" + }, + "flags": 1, + "description": "Terraform configuration properties." + }, + "tags": { + "type": { + "$ref": "#/137" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformConfigProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/124" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "referencedBy": { + "type": { + "$ref": "#/125" + }, + "flags": 2, + "description": "Environments that reference this Terraform configuration." + }, + "terraformrc": { + "type": { + "$ref": "#/126" + }, + "flags": 0, + "description": "Terraform CLI configuration file (.terraformrc) settings. See https://developer.hashicorp.com/terraform/cli/config for details." + }, + "env": { + "type": { + "$ref": "#/136" + }, + "flags": 0, + "description": "Environment variables injected during Terraform recipe execution." + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/116" + }, + { + "$ref": "#/117" + }, + { + "$ref": "#/118" + }, + { + "$ref": "#/119" + }, + { + "$ref": "#/120" + }, + { + "$ref": "#/121" + }, + { + "$ref": "#/122" + }, + { + "$ref": "#/123" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TerraformrcConfig", + "properties": { + "providerInstallation": { + "type": { + "$ref": "#/127" + }, + "flags": 0, + "description": "Provider installation configuration for Terraform CLI." + }, + "credentials": { + "type": { + "$ref": "#/135" + }, + "flags": 0, + "description": "Credentials for authenticating to private Terraform registries (HTTP-based, e.g. app.terraform.io). Map of registry hostname to credential configuration. Rendered as native `credentials \"hostname\" {}` blocks in the generated .terraformrc. Note: this is for Terraform CLI registry auth (HTTP), not for Git-based module sources; Git auth is a separate mechanism." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformProviderInstallation", + "properties": { + "networkMirror": { + "type": { + "$ref": "#/128" + }, + "flags": 0, + "description": "Network mirror configuration for Terraform providers." + }, + "direct": { + "type": { + "$ref": "#/131" + }, + "flags": 0, + "description": "Direct provider installation configuration." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformProviderMirror", + "properties": { + "url": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The URL of the provider mirror." + }, + "include": { + "type": { + "$ref": "#/129" + }, + "flags": 0, + "description": "Provider address patterns to include from this mirror." + }, + "exclude": { + "type": { + "$ref": "#/130" + }, + "flags": 0, + "description": "Provider address patterns to exclude from this mirror." + } + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TerraformProviderDirect", + "properties": { + "include": { + "type": { + "$ref": "#/132" + }, + "flags": 0, + "description": "Provider address patterns to include for direct installation." + }, + "exclude": { + "type": { + "$ref": "#/133" + }, + "flags": 0, + "description": "Provider address patterns to exclude from direct installation." + } + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TerraformCredentialConfig", + "properties": { + "secret": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The ID of an Applications.Core/SecretStore resource containing the authentication token. The secret store must have a secret named 'token'." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformrcConfigCredentials", + "properties": {}, + "additionalProperties": { + "$ref": "#/134" + } + }, + { + "$type": "ObjectType", + "name": "TerraformConfigPropertiesEnv", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ResourceType", + "name": "Radius.Core/terraformConfigs@2025-08-01-preview", + "body": { + "$ref": "#/114" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "ObjectType", + "name": "RecipeDefinitionOutputs", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } } - }, - { - "$type": "ResourceType", - "name": "Radius.Core/terraformConfigs@2025-08-01-preview", - "body": { - "$ref": "#/114" - }, - "readableScopes": 0, - "writableScopes": 0, - "functions": {} - } ] \ No newline at end of file diff --git a/pkg/recipes/paramresolver/resolver_test.go b/pkg/recipes/paramresolver/resolver_test.go index d84aa5d857..032803a95d 100644 --- a/pkg/recipes/paramresolver/resolver_test.go +++ b/pkg/recipes/paramresolver/resolver_test.go @@ -156,7 +156,7 @@ func Test_ResolveParameterExpressions(t *testing.T) { name: "nested map traversal", params: map[string]any{ "outer": map[string]any{ - "inner": "{{context.resource.name}}", + "inner": "{{context.resource.name}}", "static": "no-change", }, }, diff --git a/specs/001-direct-module-support/examples/env.bicep b/specs/001-direct-module-support/examples/env.bicep new file mode 100644 index 0000000000..f8f5d862d1 --- /dev/null +++ b/specs/001-direct-module-support/examples/env.bicep @@ -0,0 +1,74 @@ +extension radius + +@description('JSON-encoded list of subnet IDs for the DB subnet group') +param subnetIds string + +@description('JSON-encoded list of VPC security group IDs') +param vpcSecurityGroupIds string + +@description('RDS instance class') +param instanceClass string = 'db.t3.micro' + +@description('Allocated storage in GB') +param allocatedStorage int = 20 + +@description('Kubernetes namespace for the environment') +param namespace string = 'default' + +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'aws-mysql-pack' + location: 'global' + properties: { + recipes: { + 'Applications.Datastores/sqlDatabases': { + kind: 'terraform' + location: 'terraform-aws-modules/rds/aws' + parameters: { + // RDS instance config + identifier: '{{context.resource.name}}' + engine: 'mysql' + engine_version: '8.4' + family: 'mysql8.4' + major_engine_version: '8.4' + instance_class: instanceClass + allocated_storage: allocatedStorage + storage_type: 'gp3' + + // Database config + db_name: '{{context.resource.properties.database}}' + username: '{{context.resource.properties.username}}' + port: 3306 + + // Networking + vpc_security_group_ids: vpcSecurityGroupIds + create_db_subnet_group: true + subnet_ids: subnetIds + + // Operational + skip_final_snapshot: true + apply_immediately: true + } + outputs: { + host: 'db_instance_address' + port: 'db_instance_port' + database: 'db_instance_name' + } + } + } + } +} + +resource env 'Radius.Core/environments@2025-08-01-preview' = { + name: 'aws-mysql-env' + location: 'global' + properties: { + recipePacks: [ + recipepack.id + ] + providers: { + kubernetes: { + namespace: namespace + } + } + } +} diff --git a/specs/001-direct-module-support/examples/main.tf b/specs/001-direct-module-support/examples/main.tf new file mode 100644 index 0000000000..c1123b68ee --- /dev/null +++ b/specs/001-direct-module-support/examples/main.tf @@ -0,0 +1,209 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.37.1" + } + } +} + +////////////////////////////////////////// +// Direct module variables (no var.context) +////////////////////////////////////////// + +variable "resource_name" { + description = "Name of the Radius resource" + type = string +} + +variable "application_name" { + description = "Name of the Radius application" + type = string + default = "" +} + +variable "environment_name" { + description = "Name of the Radius environment" + type = string + default = "" +} + +variable "namespace" { + description = "Kubernetes namespace for secret lookup" + type = string +} + +variable "database" { + description = "MySQL database name" + type = string + default = "mysql_db" +} + +variable "secret_name" { + description = "Name of the Kubernetes secret containing DB credentials" + type = string +} + +variable "version" { + description = "MySQL engine version" + type = string + default = "8.4" +} + +variable "vpcId" { + description = "AWS VPC ID for the RDS instance" + type = string +} + +variable "subnetIds" { + description = "JSON-encoded list of subnet IDs for the DB subnet group" + type = string +} + +variable "instanceClass" { + description = "RDS instance class (e.g., db.t3.micro)" + type = string + default = "db.t3.micro" +} + +variable "allocatedStorage" { + description = "Allocated storage in GB" + type = number + default = 20 +} + +////////////////////////////////////////// +// MySQL locals +////////////////////////////////////////// + +locals { + port = 3306 + + unique_suffix = substr(md5(var.resource_name), 0, 13) + + # RDS identifier: lowercase alphanumeric and hyphens, max 63 chars + sanitized_identifier = "rds-dbinstance-${local.unique_suffix}" + + # Database name: alphanumeric and underscores only + sanitized_database = replace(var.database, "/[^0-9A-Za-z_]/", "_") + + tags = { + "radapp.io/resource" = var.resource_name + "radapp.io/application" = var.application_name + "radapp.io/environment" = var.environment_name + } +} + +////////////////////////////////////////// +// Credentials +////////////////////////////////////////// + +data "kubernetes_secret" "db_credentials" { + metadata { + name = var.secret_name + namespace = var.namespace + } +} + +////////////////////////////////////////// +// RDS security group +////////////////////////////////////////// + +data "aws_vpc" "selected" { + id = var.vpcId +} + +module "rds_security_group" { + source = "terraform-aws-modules/security-group/aws" + version = "~> 5.0" + + name = "rds-sg-${local.unique_suffix}" + description = "Security group for RDS MySQL - ${var.resource_name}" + vpc_id = var.vpcId + + ingress_with_cidr_blocks = [ + { + from_port = local.port + to_port = local.port + protocol = "tcp" + description = "MySQL access" + cidr_blocks = data.aws_vpc.selected.cidr_block + } + ] + + egress_rules = ["all-all"] + + tags = local.tags +} + +////////////////////////////////////////// +// RDS instance +////////////////////////////////////////// + +module "db" { + source = "terraform-aws-modules/rds/aws" + version = "~> 6.0" + + identifier = local.sanitized_identifier + + engine = "mysql" + engine_version = var.version + family = "mysql${var.version}" + major_engine_version = var.version + instance_class = var.instanceClass + + db_name = local.sanitized_database + username = try(data.kubernetes_secret.db_credentials.data["USERNAME"], "") + password = try(data.kubernetes_secret.db_credentials.data["PASSWORD"], "") + port = local.port + + allocated_storage = var.allocatedStorage + storage_type = "gp3" + + create_db_subnet_group = true + db_subnet_group_name = "rds-dbsubnetgroup-${local.unique_suffix}" + subnet_ids = jsondecode(var.subnetIds) + + vpc_security_group_ids = [module.rds_security_group.security_group_id] + + skip_final_snapshot = true + apply_immediately = true + + parameters = [ + { + name = "character_set_client" + value = "utf8mb4" + }, + { + name = "character_set_server" + value = "utf8mb4" + } + ] + + tags = local.tags +} + +////////////////////////////////////////// +// Direct outputs (no result wrapper) +////////////////////////////////////////// + +output "host" { + description = "RDS instance endpoint address" + value = module.db.db_instance_address +} + +output "port" { + description = "RDS instance port" + value = module.db.db_instance_port +} + +output "database" { + description = "Database name" + value = local.sanitized_database +} From fb2a0fc3d5e6e5d585c29f6cfd7477f1110dd31f Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Mon, 18 May 2026 20:51:14 -0700 Subject: [PATCH 18/18] Tf implementation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Reshma Abdul Rahim --- .terraform-version | 2 +- hack/bicep-types-radius/generated/index.json | 4 +- .../radius.core/2025-08-01-preview/types.json | 3410 ++++++++--------- .../backend/processor/dynamicresource.go | 4 +- pkg/recipes/terraform/config/config.go | 23 + pkg/recipes/terraform/config/types.go | 10 +- pkg/recipes/terraform/config/types_test.go | 17 + pkg/recipes/terraform/execute.go | 15 + pkg/recipes/terraform/version.go | 2 +- 9 files changed, 1773 insertions(+), 1714 deletions(-) diff --git a/.terraform-version b/.terraform-version index 0b94c5f8fa..3d0e62313c 100644 --- a/.terraform-version +++ b/.terraform-version @@ -1 +1 @@ -1.14.9 +1.11.4 diff --git a/hack/bicep-types-radius/generated/index.json b/hack/bicep-types-radius/generated/index.json index 07ac98c569..ab191d792f 100644 --- a/hack/bicep-types-radius/generated/index.json +++ b/hack/bicep-types-radius/generated/index.json @@ -55,10 +55,10 @@ "$ref": "radius/radius.core/2025-08-01-preview/types.json#/89" }, "Radius.Core/recipePacks@2025-08-01-preview": { - "$ref": "radius/radius.core/2025-08-01-preview/types.json#/111" + "$ref": "radius/radius.core/2025-08-01-preview/types.json#/112" }, "Radius.Core/terraformConfigs@2025-08-01-preview": { - "$ref": "radius/radius.core/2025-08-01-preview/types.json#/138" + "$ref": "radius/radius.core/2025-08-01-preview/types.json#/139" } }, "resourceFunctions": {}, diff --git a/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json b/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json index ee959ae1e4..3fcef65fdf 100644 --- a/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json +++ b/hack/bicep-types-radius/generated/radius/radius.core/2025-08-01-preview/types.json @@ -1,1708 +1,1708 @@ [ - { - "$type": "StringType" - }, - { - "$type": "StringLiteralType", - "value": "Radius.Core/applications" - }, - { - "$type": "StringLiteralType", - "value": "2025-08-01-preview" - }, - { - "$type": "ObjectType", - "name": "Radius.Core/applications", - "properties": { - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 10, - "description": "The resource id" - }, - "name": { - "type": { - "$ref": "#/0" - }, - "flags": 25, - "description": "The resource name" - }, - "type": { - "type": { - "$ref": "#/1" - }, - "flags": 10, - "description": "The resource type" - }, - "apiVersion": { - "type": { - "$ref": "#/2" - }, - "flags": 10, - "description": "The resource api version" - }, - "properties": { - "type": { - "$ref": "#/4" - }, - "flags": 1, - "description": "Application properties" - }, - "tags": { - "type": { - "$ref": "#/32" - }, - "flags": 0, - "description": "Resource tags." - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The geo-location where the resource lives" - }, - "systemData": { - "type": { - "$ref": "#/33" - }, - "flags": 2, - "description": "Metadata pertaining to creation and last modification of the resource." - } - } - }, - { - "$type": "ObjectType", - "name": "ApplicationProperties", - "properties": { - "provisioningState": { - "type": { - "$ref": "#/13" - }, - "flags": 2, - "description": "Provisioning state of the resource at the time the operation was called" - }, - "environment": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "Fully qualified resource ID for the environment that the application is linked to" - }, - "status": { - "type": { - "$ref": "#/14" - }, - "flags": 2, - "description": "Status of a resource." - } - } - }, - { - "$type": "StringLiteralType", - "value": "Creating" - }, - { - "$type": "StringLiteralType", - "value": "Updating" - }, - { - "$type": "StringLiteralType", - "value": "Deleting" - }, - { - "$type": "StringLiteralType", - "value": "Accepted" - }, - { - "$type": "StringLiteralType", - "value": "Provisioning" - }, - { - "$type": "StringLiteralType", - "value": "Succeeded" - }, - { - "$type": "StringLiteralType", - "value": "Failed" - }, - { - "$type": "StringLiteralType", - "value": "Canceled" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/5" - }, - { - "$ref": "#/6" - }, - { - "$ref": "#/7" - }, - { - "$ref": "#/8" - }, - { - "$ref": "#/9" - }, - { - "$ref": "#/10" - }, - { - "$ref": "#/11" - }, - { - "$ref": "#/12" - } - ] - }, - { - "$type": "ObjectType", - "name": "ResourceStatus", - "properties": { - "compute": { - "type": { - "$ref": "#/15" - }, - "flags": 0, - "description": "Represents backing compute resource" - }, - "recipe": { - "type": { - "$ref": "#/28" - }, - "flags": 2, - "description": "Recipe status at deployment time for a resource." - }, - "outputResources": { - "type": { - "$ref": "#/31" - }, - "flags": 0, - "description": "Properties of an output resource" - } - } - }, - { - "$type": "DiscriminatedObjectType", - "name": "EnvironmentCompute", - "discriminator": "kind", - "baseProperties": { - "resourceId": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The resource id of the compute resource for application environment." - }, - "identity": { - "type": { - "$ref": "#/16" - }, - "flags": 0, - "description": "IdentitySettings is the external identity setting." - } - }, - "elements": { - "aci": { - "$ref": "#/24" - }, - "kubernetes": { - "$ref": "#/26" - } - } - }, - { - "$type": "ObjectType", - "name": "IdentitySettings", - "properties": { - "kind": { - "type": { - "$ref": "#/22" - }, - "flags": 1, - "description": "IdentitySettingKind is the kind of supported external identity setting" - }, - "oidcIssuer": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The URI for your compute platform's OIDC issuer" - }, - "resource": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The resource ID of the provisioned identity" - }, - "managedIdentity": { - "type": { - "$ref": "#/23" - }, - "flags": 0, - "description": "The list of user assigned managed identities" - } - } - }, - { - "$type": "StringLiteralType", - "value": "undefined" - }, - { - "$type": "StringLiteralType", - "value": "azure.com.workload" - }, - { - "$type": "StringLiteralType", - "value": "userAssigned" - }, - { - "$type": "StringLiteralType", - "value": "systemAssigned" - }, - { - "$type": "StringLiteralType", - "value": "systemAssignedUserAssigned" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/17" - }, - { - "$ref": "#/18" - }, - { - "$ref": "#/19" - }, - { - "$ref": "#/20" - }, - { - "$ref": "#/21" - } - ] - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "AzureContainerInstanceCompute", - "properties": { - "resourceGroup": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The resource group to use for the environment." - }, - "kind": { - "type": { - "$ref": "#/25" - }, - "flags": 1, - "description": "Discriminator property for EnvironmentCompute." - } - } - }, - { - "$type": "StringLiteralType", - "value": "aci" - }, - { - "$type": "ObjectType", - "name": "KubernetesCompute", - "properties": { - "namespace": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "The namespace to use for the environment." - }, - "kind": { - "type": { - "$ref": "#/27" - }, - "flags": 1, - "description": "Discriminator property for EnvironmentCompute." - } - } - }, - { - "$type": "StringLiteralType", - "value": "kubernetes" - }, - { - "$type": "ObjectType", - "name": "RecipeStatus", - "properties": { - "templateKind": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "TemplateKind is the kind of the recipe template used by the portable resource upon deployment." - }, - "templatePath": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "TemplatePath is the path of the recipe consumed by the portable resource upon deployment." - }, - "templateVersion": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "TemplateVersion is the version number of the template." - } - } - }, - { - "$type": "ObjectType", - "name": "OutputResource", - "properties": { - "localId": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The logical identifier scoped to the owning Radius resource. This is only needed or used when a resource has a dependency relationship. LocalIDs do not have any particular format or meaning beyond being compared to determine dependency relationships." - }, - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The UCP resource ID of the underlying resource." - }, - "radiusManaged": { - "type": { - "$ref": "#/30" - }, - "flags": 0, - "description": "Determines whether Radius manages the lifecycle of the underlying resource." - } - } - }, - { - "$type": "BooleanType" - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/29" - } - }, - { - "$type": "ObjectType", - "name": "TrackedResourceTags", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "SystemData", - "properties": { - "createdBy": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The identity that created the resource." - }, - "createdByType": { - "type": { - "$ref": "#/38" - }, - "flags": 0, - "description": "The type of identity that created the resource." - }, - "createdAt": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The timestamp of resource creation (UTC)." - }, - "lastModifiedBy": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The identity that last modified the resource." - }, - "lastModifiedByType": { - "type": { - "$ref": "#/43" - }, - "flags": 0, - "description": "The type of identity that created the resource." - }, - "lastModifiedAt": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The timestamp of resource last modification (UTC)" - } - } - }, - { - "$type": "StringLiteralType", - "value": "User" - }, - { - "$type": "StringLiteralType", - "value": "Application" - }, - { - "$type": "StringLiteralType", - "value": "ManagedIdentity" - }, - { - "$type": "StringLiteralType", - "value": "Key" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/34" - }, - { - "$ref": "#/35" - }, - { - "$ref": "#/36" - }, - { - "$ref": "#/37" - } - ] - }, - { - "$type": "StringLiteralType", - "value": "User" - }, - { - "$type": "StringLiteralType", - "value": "Application" - }, - { - "$type": "StringLiteralType", - "value": "ManagedIdentity" - }, - { - "$type": "StringLiteralType", - "value": "Key" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/39" - }, - { - "$ref": "#/40" - }, - { - "$ref": "#/41" - }, - { - "$ref": "#/42" - } - ] - }, - { - "$type": "ResourceType", - "name": "Radius.Core/applications@2025-08-01-preview", - "body": { - "$ref": "#/3" - }, - "readableScopes": 0, - "writableScopes": 0, - "functions": {} - }, - { - "$type": "StringLiteralType", - "value": "Radius.Core/bicepConfigs" - }, - { - "$type": "StringLiteralType", - "value": "2025-08-01-preview" - }, - { - "$type": "ObjectType", - "name": "Radius.Core/bicepConfigs", - "properties": { - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 10, - "description": "The resource id" - }, - "name": { - "type": { - "$ref": "#/0" - }, - "flags": 25, - "description": "The resource name" - }, - "type": { - "type": { - "$ref": "#/45" - }, - "flags": 10, - "description": "The resource type" - }, - "apiVersion": { - "type": { - "$ref": "#/46" - }, - "flags": 10, - "description": "The resource api version" - }, - "properties": { - "type": { - "$ref": "#/48" - }, - "flags": 1, - "description": "Bicep configuration properties." - }, - "tags": { - "type": { - "$ref": "#/65" - }, - "flags": 0, - "description": "Resource tags." - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The geo-location where the resource lives" - }, - "systemData": { - "type": { - "$ref": "#/33" - }, - "flags": 2, - "description": "Metadata pertaining to creation and last modification of the resource." - } - } - }, - { - "$type": "ObjectType", - "name": "BicepConfigProperties", - "properties": { - "provisioningState": { - "type": { - "$ref": "#/57" - }, - "flags": 2, - "description": "Provisioning state of the resource at the time the operation was called" - }, - "referencedBy": { - "type": { - "$ref": "#/58" - }, - "flags": 2, - "description": "Environments that reference this Bicep configuration." - }, - "registryAuthentications": { - "type": { - "$ref": "#/64" - }, - "flags": 0, - "description": "Authentication configuration for private Bicep registries, keyed by registry hostname (e.g. 'corp.acr.io'). The Bicep driver looks up credentials by the host parsed from the recipe template path." - } - } - }, - { - "$type": "StringLiteralType", - "value": "Creating" - }, - { - "$type": "StringLiteralType", - "value": "Updating" - }, - { - "$type": "StringLiteralType", - "value": "Deleting" - }, - { - "$type": "StringLiteralType", - "value": "Accepted" - }, - { - "$type": "StringLiteralType", - "value": "Provisioning" - }, - { - "$type": "StringLiteralType", - "value": "Succeeded" - }, - { - "$type": "StringLiteralType", - "value": "Failed" - }, - { - "$type": "StringLiteralType", - "value": "Canceled" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/49" - }, - { - "$ref": "#/50" - }, - { - "$ref": "#/51" - }, - { - "$ref": "#/52" - }, - { - "$ref": "#/53" - }, - { - "$ref": "#/54" - }, - { - "$ref": "#/55" - }, - { - "$ref": "#/56" - } - ] - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "BicepRegistryAuthentication", - "properties": { - "authenticationMethod": { - "type": { - "$ref": "#/63" - }, - "flags": 0, - "description": "Supported authentication methods for private Bicep registries." - }, - "basicAuthSecretId": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The ID of an Applications.Core/SecretStore resource containing username and password for BasicAuth. Required when authenticationMethod is 'BasicAuth'." - }, - "azureWiClientId": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "Azure Workload Identity client ID. Required when authenticationMethod is 'AzureWI'." - }, - "azureWiTenantId": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "Azure Workload Identity tenant ID. Required when authenticationMethod is 'AzureWI'." - }, - "awsIamRoleArn": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "AWS IAM Role ARN for IRSA authentication. Required when authenticationMethod is 'AwsIrsa'." - } - } - }, - { - "$type": "StringLiteralType", - "value": "BasicAuth" - }, - { - "$type": "StringLiteralType", - "value": "AzureWI" - }, - { - "$type": "StringLiteralType", - "value": "AwsIrsa" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/60" - }, - { - "$ref": "#/61" - }, - { - "$ref": "#/62" - } - ] - }, - { - "$type": "ObjectType", - "name": "BicepConfigPropertiesRegistryAuthentications", - "properties": {}, - "additionalProperties": { - "$ref": "#/59" - } - }, - { - "$type": "ObjectType", - "name": "TrackedResourceTags", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ResourceType", - "name": "Radius.Core/bicepConfigs@2025-08-01-preview", - "body": { - "$ref": "#/47" - }, - "readableScopes": 0, - "writableScopes": 0, - "functions": {} - }, - { - "$type": "StringLiteralType", - "value": "Radius.Core/environments" - }, - { - "$type": "StringLiteralType", - "value": "2025-08-01-preview" - }, - { - "$type": "ObjectType", - "name": "Radius.Core/environments", - "properties": { - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 10, - "description": "The resource id" - }, - "name": { - "type": { - "$ref": "#/0" - }, - "flags": 25, - "description": "The resource name" - }, - "type": { - "type": { - "$ref": "#/67" - }, - "flags": 10, - "description": "The resource type" - }, - "apiVersion": { - "type": { - "$ref": "#/68" - }, - "flags": 10, - "description": "The resource api version" - }, - "properties": { - "type": { - "$ref": "#/70" - }, - "flags": 1, - "description": "Environment properties" - }, - "tags": { - "type": { - "$ref": "#/88" - }, - "flags": 0, - "description": "Resource tags." - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The geo-location where the resource lives" - }, - "systemData": { - "type": { - "$ref": "#/33" - }, - "flags": 2, - "description": "Metadata pertaining to creation and last modification of the resource." - } - } - }, - { - "$type": "ObjectType", - "name": "EnvironmentProperties", - "properties": { - "provisioningState": { - "type": { - "$ref": "#/79" - }, - "flags": 2, - "description": "Provisioning state of the resource at the time the operation was called" - }, - "recipePacks": { - "type": { - "$ref": "#/80" - }, - "flags": 0, - "description": "List of Recipe Pack resource IDs linked to this environment." - }, - "recipeParameters": { - "type": { - "$ref": "#/83" - }, - "flags": 0, - "description": "Recipe specific parameters that apply to all resources of a given type in this environment." - }, - "providers": { - "type": { - "$ref": "#/84" - }, - "flags": 0 - }, - "terraformConfig": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "Resource ID of a Radius.Core/terraformConfigs resource providing Terraform recipe settings." - }, - "bicepConfig": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "Resource ID of a Radius.Core/bicepConfigs resource providing Bicep recipe settings." - }, - "simulated": { - "type": { - "$ref": "#/30" - }, - "flags": 0, - "description": "Simulated environment." - } - } - }, - { - "$type": "StringLiteralType", - "value": "Creating" - }, - { - "$type": "StringLiteralType", - "value": "Updating" - }, - { - "$type": "StringLiteralType", - "value": "Deleting" - }, - { - "$type": "StringLiteralType", - "value": "Accepted" - }, - { - "$type": "StringLiteralType", - "value": "Provisioning" - }, - { - "$type": "StringLiteralType", - "value": "Succeeded" - }, - { - "$type": "StringLiteralType", - "value": "Failed" - }, - { - "$type": "StringLiteralType", - "value": "Canceled" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/71" - }, - { - "$ref": "#/72" - }, - { - "$ref": "#/73" - }, - { - "$ref": "#/74" - }, - { - "$ref": "#/75" - }, - { - "$ref": "#/76" - }, - { - "$ref": "#/77" - }, - { - "$ref": "#/78" - } - ] - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "AnyType" - }, - { - "$type": "ObjectType", - "name": "RecipeParameterValue", - "properties": {}, - "additionalProperties": { - "$ref": "#/81" - } - }, - { - "$type": "ObjectType", - "name": "EnvironmentPropertiesRecipeParameters", - "properties": {}, - "additionalProperties": { - "$ref": "#/82" - } - }, - { - "$type": "ObjectType", - "name": "Providers", - "properties": { - "azure": { - "type": { - "$ref": "#/85" - }, - "flags": 0, - "description": "The Azure cloud provider definition." - }, - "kubernetes": { - "type": { - "$ref": "#/86" - }, - "flags": 0 - }, - "aws": { - "type": { - "$ref": "#/87" - }, - "flags": 0, - "description": "The AWS cloud provider definition." - } - } - }, - { - "$type": "ObjectType", - "name": "ProvidersAzure", - "properties": { - "subscriptionId": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "Azure subscription ID hosting deployed resources." - }, - "resourceGroupName": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "Optional resource group name." - }, - "identity": { - "type": { - "$ref": "#/16" - }, - "flags": 0, - "description": "IdentitySettings is the external identity setting." - } - } - }, - { - "$type": "ObjectType", - "name": "ProvidersKubernetes", - "properties": { - "namespace": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "Kubernetes namespace to deploy workloads into." - } - } - }, - { - "$type": "ObjectType", - "name": "ProvidersAws", - "properties": { - "accountId": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "AWS account ID for AWS resources to be deployed into." - }, - "region": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "AWS region for AWS resources to be deployed into." - } - } - }, - { - "$type": "ObjectType", - "name": "TrackedResourceTags", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ResourceType", - "name": "Radius.Core/environments@2025-08-01-preview", - "body": { - "$ref": "#/69" - }, - "readableScopes": 0, - "writableScopes": 0, - "functions": {} - }, - { - "$type": "StringLiteralType", - "value": "Radius.Core/recipePacks" - }, - { - "$type": "StringLiteralType", - "value": "2025-08-01-preview" - }, - { - "$type": "ObjectType", - "name": "Radius.Core/recipePacks", - "properties": { - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 10, - "description": "The resource id" - }, - "name": { - "type": { - "$ref": "#/0" - }, - "flags": 25, - "description": "The resource name" - }, - "type": { - "type": { - "$ref": "#/90" - }, - "flags": 10, - "description": "The resource type" - }, - "apiVersion": { - "type": { - "$ref": "#/91" - }, - "flags": 10, - "description": "The resource api version" - }, - "properties": { - "type": { - "$ref": "#/93" - }, - "flags": 1, - "description": "Recipe Pack properties" - }, - "tags": { - "type": { - "$ref": "#/110" - }, - "flags": 0, - "description": "Resource tags." - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The geo-location where the resource lives" - }, - "systemData": { - "type": { - "$ref": "#/33" - }, - "flags": 2, - "description": "Metadata pertaining to creation and last modification of the resource." - } - } - }, - { - "$type": "ObjectType", - "name": "RecipePackProperties", - "properties": { - "provisioningState": { - "type": { - "$ref": "#/102" - }, - "flags": 2, - "description": "Provisioning state of the resource at the time the operation was called" - }, - "referencedBy": { - "type": { - "$ref": "#/103" - }, - "flags": 2, - "description": "List of environment IDs that reference this recipe pack" - }, - "recipes": { - "type": { - "$ref": "#/109" - }, - "flags": 1, - "description": "Map of resource types to their recipe configurations" - } - } - }, - { - "$type": "StringLiteralType", - "value": "Creating" - }, - { - "$type": "StringLiteralType", - "value": "Updating" - }, - { - "$type": "StringLiteralType", - "value": "Deleting" - }, - { - "$type": "StringLiteralType", - "value": "Accepted" - }, - { - "$type": "StringLiteralType", - "value": "Provisioning" - }, - { - "$type": "StringLiteralType", - "value": "Succeeded" - }, - { - "$type": "StringLiteralType", - "value": "Failed" - }, - { - "$type": "StringLiteralType", - "value": "Canceled" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/94" - }, - { - "$ref": "#/95" - }, - { - "$ref": "#/96" - }, - { - "$ref": "#/97" - }, - { - "$ref": "#/98" - }, - { - "$ref": "#/99" - }, - { - "$ref": "#/100" - }, - { - "$ref": "#/101" - } - ] - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "RecipeDefinition", - "properties": { - "kind": { - "type": { - "$ref": "#/107" - }, - "flags": 1, - "description": "The type of recipe" - }, - "plainHttp": { - "type": { - "$ref": "#/30" - }, - "flags": 0, - "description": "Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for example in a locally hosted registry for Bicep recipes. Defaults to false (use HTTPS/TLS)" - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 1, - "description": "URL path to the recipe" - }, - "parameters": { - "type": { - "$ref": "#/108" - }, - "flags": 0, - "description": "Parameters to pass to the recipe. Values may contain {{context.*}} template expressions resolved at deployment time" - }, - "outputs": { - "type": { - "$ref": "#/139" - }, - "flags": 0, - "description": "Maps resource property names to module output names. Keys are the resource type's read-only property names, values are the module output names. When empty or not specified, all module outputs pass through with their original names" - } - } - }, - { - "$type": "StringLiteralType", - "value": "terraform" - }, - { - "$type": "StringLiteralType", - "value": "bicep" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/105" - }, - { - "$ref": "#/106" - } - ] - }, - { - "$type": "ObjectType", - "name": "RecipeDefinitionParameters", - "properties": {}, - "additionalProperties": { - "$ref": "#/81" - } - }, - { - "$type": "ObjectType", - "name": "RecipePackPropertiesRecipes", - "properties": {}, - "additionalProperties": { - "$ref": "#/104" - } - }, - { - "$type": "ObjectType", - "name": "TrackedResourceTags", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ResourceType", - "name": "Radius.Core/recipePacks@2025-08-01-preview", - "body": { - "$ref": "#/92" - }, - "readableScopes": 0, - "writableScopes": 0, - "functions": {} - }, - { - "$type": "StringLiteralType", - "value": "Radius.Core/terraformConfigs" - }, - { - "$type": "StringLiteralType", - "value": "2025-08-01-preview" - }, - { - "$type": "ObjectType", - "name": "Radius.Core/terraformConfigs", - "properties": { - "id": { - "type": { - "$ref": "#/0" - }, - "flags": 10, - "description": "The resource id" - }, - "name": { - "type": { - "$ref": "#/0" - }, - "flags": 25, - "description": "The resource name" - }, - "type": { - "type": { - "$ref": "#/112" - }, - "flags": 10, - "description": "The resource type" - }, - "apiVersion": { - "type": { - "$ref": "#/113" - }, - "flags": 10, - "description": "The resource api version" - }, - "properties": { - "type": { - "$ref": "#/115" - }, - "flags": 1, - "description": "Terraform configuration properties." - }, - "tags": { - "type": { - "$ref": "#/137" - }, - "flags": 0, - "description": "Resource tags." - }, - "location": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The geo-location where the resource lives" - }, - "systemData": { - "type": { - "$ref": "#/33" - }, - "flags": 2, - "description": "Metadata pertaining to creation and last modification of the resource." - } - } - }, - { - "$type": "ObjectType", - "name": "TerraformConfigProperties", - "properties": { - "provisioningState": { - "type": { - "$ref": "#/124" - }, - "flags": 2, - "description": "Provisioning state of the resource at the time the operation was called" - }, - "referencedBy": { - "type": { - "$ref": "#/125" - }, - "flags": 2, - "description": "Environments that reference this Terraform configuration." - }, - "terraformrc": { - "type": { - "$ref": "#/126" - }, - "flags": 0, - "description": "Terraform CLI configuration file (.terraformrc) settings. See https://developer.hashicorp.com/terraform/cli/config for details." - }, - "env": { - "type": { - "$ref": "#/136" - }, - "flags": 0, - "description": "Environment variables injected during Terraform recipe execution." - } - } - }, - { - "$type": "StringLiteralType", - "value": "Creating" - }, - { - "$type": "StringLiteralType", - "value": "Updating" - }, - { - "$type": "StringLiteralType", - "value": "Deleting" - }, - { - "$type": "StringLiteralType", - "value": "Accepted" - }, - { - "$type": "StringLiteralType", - "value": "Provisioning" - }, - { - "$type": "StringLiteralType", - "value": "Succeeded" - }, - { - "$type": "StringLiteralType", - "value": "Failed" - }, - { - "$type": "StringLiteralType", - "value": "Canceled" - }, - { - "$type": "UnionType", - "elements": [ - { - "$ref": "#/116" - }, - { - "$ref": "#/117" - }, - { - "$ref": "#/118" - }, - { - "$ref": "#/119" - }, - { - "$ref": "#/120" - }, - { - "$ref": "#/121" - }, - { - "$ref": "#/122" - }, - { - "$ref": "#/123" - } - ] - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "TerraformrcConfig", - "properties": { - "providerInstallation": { - "type": { - "$ref": "#/127" - }, - "flags": 0, - "description": "Provider installation configuration for Terraform CLI." - }, - "credentials": { - "type": { - "$ref": "#/135" - }, - "flags": 0, - "description": "Credentials for authenticating to private Terraform registries (HTTP-based, e.g. app.terraform.io). Map of registry hostname to credential configuration. Rendered as native `credentials \"hostname\" {}` blocks in the generated .terraformrc. Note: this is for Terraform CLI registry auth (HTTP), not for Git-based module sources; Git auth is a separate mechanism." - } - } - }, - { - "$type": "ObjectType", - "name": "TerraformProviderInstallation", - "properties": { - "networkMirror": { - "type": { - "$ref": "#/128" - }, - "flags": 0, - "description": "Network mirror configuration for Terraform providers." - }, - "direct": { - "type": { - "$ref": "#/131" - }, - "flags": 0, - "description": "Direct provider installation configuration." - } - } - }, - { - "$type": "ObjectType", - "name": "TerraformProviderMirror", - "properties": { - "url": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The URL of the provider mirror." - }, - "include": { - "type": { - "$ref": "#/129" - }, - "flags": 0, - "description": "Provider address patterns to include from this mirror." - }, - "exclude": { - "type": { - "$ref": "#/130" - }, - "flags": 0, - "description": "Provider address patterns to exclude from this mirror." - } - } - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "TerraformProviderDirect", - "properties": { - "include": { - "type": { - "$ref": "#/132" - }, - "flags": 0, - "description": "Provider address patterns to include for direct installation." - }, - "exclude": { - "type": { - "$ref": "#/133" - }, - "flags": 0, - "description": "Provider address patterns to exclude from direct installation." - } - } - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ArrayType", - "itemType": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "TerraformCredentialConfig", - "properties": { - "secret": { - "type": { - "$ref": "#/0" - }, - "flags": 0, - "description": "The ID of an Applications.Core/SecretStore resource containing the authentication token. The secret store must have a secret named 'token'." - } - } - }, - { - "$type": "ObjectType", - "name": "TerraformrcConfigCredentials", - "properties": {}, - "additionalProperties": { - "$ref": "#/134" - } - }, - { - "$type": "ObjectType", - "name": "TerraformConfigPropertiesEnv", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ObjectType", - "name": "TrackedResourceTags", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } - }, - { - "$type": "ResourceType", - "name": "Radius.Core/terraformConfigs@2025-08-01-preview", - "body": { - "$ref": "#/114" - }, - "readableScopes": 0, - "writableScopes": 0, - "functions": {} - }, - { - "$type": "ObjectType", - "name": "RecipeDefinitionOutputs", - "properties": {}, - "additionalProperties": { - "$ref": "#/0" - } + { + "$type": "StringType" + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/applications" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/applications", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/1" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/2" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/4" + }, + "flags": 1, + "description": "Application properties" + }, + "tags": { + "type": { + "$ref": "#/32" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "ApplicationProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/13" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "environment": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "Fully qualified resource ID for the environment that the application is linked to" + }, + "status": { + "type": { + "$ref": "#/14" + }, + "flags": 2, + "description": "Status of a resource." + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/5" + }, + { + "$ref": "#/6" + }, + { + "$ref": "#/7" + }, + { + "$ref": "#/8" + }, + { + "$ref": "#/9" + }, + { + "$ref": "#/10" + }, + { + "$ref": "#/11" + }, + { + "$ref": "#/12" + } + ] + }, + { + "$type": "ObjectType", + "name": "ResourceStatus", + "properties": { + "compute": { + "type": { + "$ref": "#/15" + }, + "flags": 0, + "description": "Represents backing compute resource" + }, + "recipe": { + "type": { + "$ref": "#/28" + }, + "flags": 2, + "description": "Recipe status at deployment time for a resource." + }, + "outputResources": { + "type": { + "$ref": "#/31" + }, + "flags": 0, + "description": "Properties of an output resource" + } + } + }, + { + "$type": "DiscriminatedObjectType", + "name": "EnvironmentCompute", + "discriminator": "kind", + "baseProperties": { + "resourceId": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The resource id of the compute resource for application environment." + }, + "identity": { + "type": { + "$ref": "#/16" + }, + "flags": 0, + "description": "IdentitySettings is the external identity setting." + } + }, + "elements": { + "aci": { + "$ref": "#/24" + }, + "kubernetes": { + "$ref": "#/26" + } + } + }, + { + "$type": "ObjectType", + "name": "IdentitySettings", + "properties": { + "kind": { + "type": { + "$ref": "#/22" + }, + "flags": 1, + "description": "IdentitySettingKind is the kind of supported external identity setting" + }, + "oidcIssuer": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The URI for your compute platform's OIDC issuer" + }, + "resource": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The resource ID of the provisioned identity" + }, + "managedIdentity": { + "type": { + "$ref": "#/23" + }, + "flags": 0, + "description": "The list of user assigned managed identities" + } + } + }, + { + "$type": "StringLiteralType", + "value": "undefined" + }, + { + "$type": "StringLiteralType", + "value": "azure.com.workload" + }, + { + "$type": "StringLiteralType", + "value": "userAssigned" + }, + { + "$type": "StringLiteralType", + "value": "systemAssigned" + }, + { + "$type": "StringLiteralType", + "value": "systemAssignedUserAssigned" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/17" + }, + { + "$ref": "#/18" + }, + { + "$ref": "#/19" + }, + { + "$ref": "#/20" + }, + { + "$ref": "#/21" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "AzureContainerInstanceCompute", + "properties": { + "resourceGroup": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The resource group to use for the environment." + }, + "kind": { + "type": { + "$ref": "#/25" + }, + "flags": 1, + "description": "Discriminator property for EnvironmentCompute." + } + } + }, + { + "$type": "StringLiteralType", + "value": "aci" + }, + { + "$type": "ObjectType", + "name": "KubernetesCompute", + "properties": { + "namespace": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "The namespace to use for the environment." + }, + "kind": { + "type": { + "$ref": "#/27" + }, + "flags": 1, + "description": "Discriminator property for EnvironmentCompute." + } + } + }, + { + "$type": "StringLiteralType", + "value": "kubernetes" + }, + { + "$type": "ObjectType", + "name": "RecipeStatus", + "properties": { + "templateKind": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "TemplateKind is the kind of the recipe template used by the portable resource upon deployment." + }, + "templatePath": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "TemplatePath is the path of the recipe consumed by the portable resource upon deployment." + }, + "templateVersion": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "TemplateVersion is the version number of the template." + } + } + }, + { + "$type": "ObjectType", + "name": "OutputResource", + "properties": { + "localId": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The logical identifier scoped to the owning Radius resource. This is only needed or used when a resource has a dependency relationship. LocalIDs do not have any particular format or meaning beyond being compared to determine dependency relationships." + }, + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The UCP resource ID of the underlying resource." + }, + "radiusManaged": { + "type": { + "$ref": "#/30" + }, + "flags": 0, + "description": "Determines whether Radius manages the lifecycle of the underlying resource." + } + } + }, + { + "$type": "BooleanType" + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/29" + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "SystemData", + "properties": { + "createdBy": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The identity that created the resource." + }, + "createdByType": { + "type": { + "$ref": "#/38" + }, + "flags": 0, + "description": "The type of identity that created the resource." + }, + "createdAt": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The timestamp of resource creation (UTC)." + }, + "lastModifiedBy": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The identity that last modified the resource." + }, + "lastModifiedByType": { + "type": { + "$ref": "#/43" + }, + "flags": 0, + "description": "The type of identity that created the resource." + }, + "lastModifiedAt": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The timestamp of resource last modification (UTC)" + } + } + }, + { + "$type": "StringLiteralType", + "value": "User" + }, + { + "$type": "StringLiteralType", + "value": "Application" + }, + { + "$type": "StringLiteralType", + "value": "ManagedIdentity" + }, + { + "$type": "StringLiteralType", + "value": "Key" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/34" + }, + { + "$ref": "#/35" + }, + { + "$ref": "#/36" + }, + { + "$ref": "#/37" + } + ] + }, + { + "$type": "StringLiteralType", + "value": "User" + }, + { + "$type": "StringLiteralType", + "value": "Application" + }, + { + "$type": "StringLiteralType", + "value": "ManagedIdentity" + }, + { + "$type": "StringLiteralType", + "value": "Key" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/39" + }, + { + "$ref": "#/40" + }, + { + "$ref": "#/41" + }, + { + "$ref": "#/42" + } + ] + }, + { + "$type": "ResourceType", + "name": "Radius.Core/applications@2025-08-01-preview", + "body": { + "$ref": "#/3" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/bicepConfigs" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/bicepConfigs", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/45" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/46" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/48" + }, + "flags": 1, + "description": "Bicep configuration properties." + }, + "tags": { + "type": { + "$ref": "#/65" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "BicepConfigProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/57" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "referencedBy": { + "type": { + "$ref": "#/58" + }, + "flags": 2, + "description": "Environments that reference this Bicep configuration." + }, + "registryAuthentications": { + "type": { + "$ref": "#/64" + }, + "flags": 0, + "description": "Authentication configuration for private Bicep registries, keyed by registry hostname (e.g. 'corp.acr.io'). The Bicep driver looks up credentials by the host parsed from the recipe template path." + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/49" + }, + { + "$ref": "#/50" + }, + { + "$ref": "#/51" + }, + { + "$ref": "#/52" + }, + { + "$ref": "#/53" + }, + { + "$ref": "#/54" + }, + { + "$ref": "#/55" + }, + { + "$ref": "#/56" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "BicepRegistryAuthentication", + "properties": { + "authenticationMethod": { + "type": { + "$ref": "#/63" + }, + "flags": 0, + "description": "Supported authentication methods for private Bicep registries." + }, + "basicAuthSecretId": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The ID of an Applications.Core/SecretStore resource containing username and password for BasicAuth. Required when authenticationMethod is 'BasicAuth'." + }, + "azureWiClientId": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Azure Workload Identity client ID. Required when authenticationMethod is 'AzureWI'." + }, + "azureWiTenantId": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Azure Workload Identity tenant ID. Required when authenticationMethod is 'AzureWI'." + }, + "awsIamRoleArn": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "AWS IAM Role ARN for IRSA authentication. Required when authenticationMethod is 'AwsIrsa'." + } + } + }, + { + "$type": "StringLiteralType", + "value": "BasicAuth" + }, + { + "$type": "StringLiteralType", + "value": "AzureWI" + }, + { + "$type": "StringLiteralType", + "value": "AwsIrsa" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/60" + }, + { + "$ref": "#/61" + }, + { + "$ref": "#/62" + } + ] + }, + { + "$type": "ObjectType", + "name": "BicepConfigPropertiesRegistryAuthentications", + "properties": {}, + "additionalProperties": { + "$ref": "#/59" + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ResourceType", + "name": "Radius.Core/bicepConfigs@2025-08-01-preview", + "body": { + "$ref": "#/47" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/environments" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/environments", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/67" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/68" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/70" + }, + "flags": 1, + "description": "Environment properties" + }, + "tags": { + "type": { + "$ref": "#/88" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "EnvironmentProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/79" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "recipePacks": { + "type": { + "$ref": "#/80" + }, + "flags": 0, + "description": "List of Recipe Pack resource IDs linked to this environment." + }, + "recipeParameters": { + "type": { + "$ref": "#/83" + }, + "flags": 0, + "description": "Recipe specific parameters that apply to all resources of a given type in this environment." + }, + "providers": { + "type": { + "$ref": "#/84" + }, + "flags": 0 + }, + "terraformConfig": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Resource ID of a Radius.Core/terraformConfigs resource providing Terraform recipe settings." + }, + "bicepConfig": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Resource ID of a Radius.Core/bicepConfigs resource providing Bicep recipe settings." + }, + "simulated": { + "type": { + "$ref": "#/30" + }, + "flags": 0, + "description": "Simulated environment." + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/71" + }, + { + "$ref": "#/72" + }, + { + "$ref": "#/73" + }, + { + "$ref": "#/74" + }, + { + "$ref": "#/75" + }, + { + "$ref": "#/76" + }, + { + "$ref": "#/77" + }, + { + "$ref": "#/78" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "AnyType" + }, + { + "$type": "ObjectType", + "name": "RecipeParameterValue", + "properties": {}, + "additionalProperties": { + "$ref": "#/81" + } + }, + { + "$type": "ObjectType", + "name": "EnvironmentPropertiesRecipeParameters", + "properties": {}, + "additionalProperties": { + "$ref": "#/82" + } + }, + { + "$type": "ObjectType", + "name": "Providers", + "properties": { + "azure": { + "type": { + "$ref": "#/85" + }, + "flags": 0, + "description": "The Azure cloud provider definition." + }, + "kubernetes": { + "type": { + "$ref": "#/86" + }, + "flags": 0 + }, + "aws": { + "type": { + "$ref": "#/87" + }, + "flags": 0, + "description": "The AWS cloud provider definition." + } + } + }, + { + "$type": "ObjectType", + "name": "ProvidersAzure", + "properties": { + "subscriptionId": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "Azure subscription ID hosting deployed resources." + }, + "resourceGroupName": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "Optional resource group name." + }, + "identity": { + "type": { + "$ref": "#/16" + }, + "flags": 0, + "description": "IdentitySettings is the external identity setting." + } + } + }, + { + "$type": "ObjectType", + "name": "ProvidersKubernetes", + "properties": { + "namespace": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "Kubernetes namespace to deploy workloads into." + } + } + }, + { + "$type": "ObjectType", + "name": "ProvidersAws", + "properties": { + "accountId": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "AWS account ID for AWS resources to be deployed into." + }, + "region": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "AWS region for AWS resources to be deployed into." + } + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ResourceType", + "name": "Radius.Core/environments@2025-08-01-preview", + "body": { + "$ref": "#/69" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/recipePacks" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/recipePacks", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/90" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/91" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/93" + }, + "flags": 1, + "description": "Recipe Pack properties" + }, + "tags": { + "type": { + "$ref": "#/111" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "RecipePackProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/102" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "referencedBy": { + "type": { + "$ref": "#/103" + }, + "flags": 2, + "description": "List of environment IDs that reference this recipe pack" + }, + "recipes": { + "type": { + "$ref": "#/110" + }, + "flags": 1, + "description": "Map of resource types to their recipe configurations" + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/94" + }, + { + "$ref": "#/95" + }, + { + "$ref": "#/96" + }, + { + "$ref": "#/97" + }, + { + "$ref": "#/98" + }, + { + "$ref": "#/99" + }, + { + "$ref": "#/100" + }, + { + "$ref": "#/101" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "RecipeDefinition", + "properties": { + "kind": { + "type": { + "$ref": "#/107" + }, + "flags": 1, + "description": "The type of recipe" + }, + "plainHttp": { + "type": { + "$ref": "#/30" + }, + "flags": 0, + "description": "Connect to the location using HTTP (not HTTPS). This should be used when the location is known not to support HTTPS, for example in a locally hosted registry for Bicep recipes. Defaults to false (use HTTPS/TLS)" + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 1, + "description": "URL path to the recipe" + }, + "parameters": { + "type": { + "$ref": "#/108" + }, + "flags": 0, + "description": "Parameters to pass to the recipe. Values may contain {{context.*}} template expressions resolved at deployment time" + }, + "outputs": { + "type": { + "$ref": "#/109" + }, + "flags": 0, + "description": "Maps resource property names to module output names. Keys are the resource type's read-only property names, values are the module output names. When empty or not specified, all module outputs pass through with their original names" + } + } + }, + { + "$type": "StringLiteralType", + "value": "terraform" + }, + { + "$type": "StringLiteralType", + "value": "bicep" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/105" + }, + { + "$ref": "#/106" + } + ] + }, + { + "$type": "ObjectType", + "name": "RecipeDefinitionParameters", + "properties": {}, + "additionalProperties": { + "$ref": "#/81" + } + }, + { + "$type": "ObjectType", + "name": "RecipeDefinitionOutputs", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "RecipePackPropertiesRecipes", + "properties": {}, + "additionalProperties": { + "$ref": "#/104" + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ResourceType", + "name": "Radius.Core/recipePacks@2025-08-01-preview", + "body": { + "$ref": "#/92" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + }, + { + "$type": "StringLiteralType", + "value": "Radius.Core/terraformConfigs" + }, + { + "$type": "StringLiteralType", + "value": "2025-08-01-preview" + }, + { + "$type": "ObjectType", + "name": "Radius.Core/terraformConfigs", + "properties": { + "id": { + "type": { + "$ref": "#/0" + }, + "flags": 10, + "description": "The resource id" + }, + "name": { + "type": { + "$ref": "#/0" + }, + "flags": 25, + "description": "The resource name" + }, + "type": { + "type": { + "$ref": "#/113" + }, + "flags": 10, + "description": "The resource type" + }, + "apiVersion": { + "type": { + "$ref": "#/114" + }, + "flags": 10, + "description": "The resource api version" + }, + "properties": { + "type": { + "$ref": "#/116" + }, + "flags": 1, + "description": "Terraform configuration properties." + }, + "tags": { + "type": { + "$ref": "#/138" + }, + "flags": 0, + "description": "Resource tags." + }, + "location": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The geo-location where the resource lives" + }, + "systemData": { + "type": { + "$ref": "#/33" + }, + "flags": 2, + "description": "Metadata pertaining to creation and last modification of the resource." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformConfigProperties", + "properties": { + "provisioningState": { + "type": { + "$ref": "#/125" + }, + "flags": 2, + "description": "Provisioning state of the resource at the time the operation was called" + }, + "referencedBy": { + "type": { + "$ref": "#/126" + }, + "flags": 2, + "description": "Environments that reference this Terraform configuration." + }, + "terraformrc": { + "type": { + "$ref": "#/127" + }, + "flags": 0, + "description": "Terraform CLI configuration file (.terraformrc) settings. See https://developer.hashicorp.com/terraform/cli/config for details." + }, + "env": { + "type": { + "$ref": "#/137" + }, + "flags": 0, + "description": "Environment variables injected during Terraform recipe execution." + } + } + }, + { + "$type": "StringLiteralType", + "value": "Creating" + }, + { + "$type": "StringLiteralType", + "value": "Updating" + }, + { + "$type": "StringLiteralType", + "value": "Deleting" + }, + { + "$type": "StringLiteralType", + "value": "Accepted" + }, + { + "$type": "StringLiteralType", + "value": "Provisioning" + }, + { + "$type": "StringLiteralType", + "value": "Succeeded" + }, + { + "$type": "StringLiteralType", + "value": "Failed" + }, + { + "$type": "StringLiteralType", + "value": "Canceled" + }, + { + "$type": "UnionType", + "elements": [ + { + "$ref": "#/117" + }, + { + "$ref": "#/118" + }, + { + "$ref": "#/119" + }, + { + "$ref": "#/120" + }, + { + "$ref": "#/121" + }, + { + "$ref": "#/122" + }, + { + "$ref": "#/123" + }, + { + "$ref": "#/124" + } + ] + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TerraformrcConfig", + "properties": { + "providerInstallation": { + "type": { + "$ref": "#/128" + }, + "flags": 0, + "description": "Provider installation configuration for Terraform CLI." + }, + "credentials": { + "type": { + "$ref": "#/136" + }, + "flags": 0, + "description": "Credentials for authenticating to private Terraform registries (HTTP-based, e.g. app.terraform.io). Map of registry hostname to credential configuration. Rendered as native `credentials \"hostname\" {}` blocks in the generated .terraformrc. Note: this is for Terraform CLI registry auth (HTTP), not for Git-based module sources; Git auth is a separate mechanism." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformProviderInstallation", + "properties": { + "networkMirror": { + "type": { + "$ref": "#/129" + }, + "flags": 0, + "description": "Network mirror configuration for Terraform providers." + }, + "direct": { + "type": { + "$ref": "#/132" + }, + "flags": 0, + "description": "Direct provider installation configuration." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformProviderMirror", + "properties": { + "url": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The URL of the provider mirror." + }, + "include": { + "type": { + "$ref": "#/130" + }, + "flags": 0, + "description": "Provider address patterns to include from this mirror." + }, + "exclude": { + "type": { + "$ref": "#/131" + }, + "flags": 0, + "description": "Provider address patterns to exclude from this mirror." + } + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TerraformProviderDirect", + "properties": { + "include": { + "type": { + "$ref": "#/133" + }, + "flags": 0, + "description": "Provider address patterns to include for direct installation." + }, + "exclude": { + "type": { + "$ref": "#/134" + }, + "flags": 0, + "description": "Provider address patterns to exclude from direct installation." + } + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ArrayType", + "itemType": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TerraformCredentialConfig", + "properties": { + "secret": { + "type": { + "$ref": "#/0" + }, + "flags": 0, + "description": "The ID of an Applications.Core/SecretStore resource containing the authentication token. The secret store must have a secret named 'token'." + } + } + }, + { + "$type": "ObjectType", + "name": "TerraformrcConfigCredentials", + "properties": {}, + "additionalProperties": { + "$ref": "#/135" + } + }, + { + "$type": "ObjectType", + "name": "TerraformConfigPropertiesEnv", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" + } + }, + { + "$type": "ObjectType", + "name": "TrackedResourceTags", + "properties": {}, + "additionalProperties": { + "$ref": "#/0" } + }, + { + "$type": "ResourceType", + "name": "Radius.Core/terraformConfigs@2025-08-01-preview", + "body": { + "$ref": "#/115" + }, + "readableScopes": 0, + "writableScopes": 0, + "functions": {} + } ] \ No newline at end of file diff --git a/pkg/dynamicrp/backend/processor/dynamicresource.go b/pkg/dynamicrp/backend/processor/dynamicresource.go index 101443ad0c..b350e329ff 100644 --- a/pkg/dynamicrp/backend/processor/dynamicresource.go +++ b/pkg/dynamicrp/backend/processor/dynamicresource.go @@ -58,8 +58,8 @@ func (d *DynamicProcessor) Process(ctx context.Context, resource *datamodel.Dyna validator.AddOptionalAnyField(key, &value) } for key, value := range options.RecipeOutput.Secrets { - value := value.(string) - validator.AddOptionalSecretField(key, &value) + strValue := fmt.Sprintf("%v", value) + validator.AddOptionalSecretField(key, &strValue) } err := validator.SetAndValidate(options.RecipeOutput) diff --git a/pkg/recipes/terraform/config/config.go b/pkg/recipes/terraform/config/config.go index 38c14dd5b3..084713e691 100644 --- a/pkg/recipes/terraform/config/config.go +++ b/pkg/recipes/terraform/config/config.go @@ -96,6 +96,7 @@ func (cfg *TerraformConfig) Save(ctx context.Context, workingDir string) error { } logger.Info(fmt.Sprintf("Writing Terraform JSON config to file: %s", getMainConfigFilePath(workingDir))) + logger.Info(fmt.Sprintf("Terraform JSON config content:\n%s", buf.String())) if err := os.WriteFile(getMainConfigFilePath(workingDir), buf.Bytes(), modeConfigFile); err != nil { return fmt.Errorf("error creating file: %w", err) } @@ -296,3 +297,25 @@ func (cfg *TerraformConfig) AddOutputs(localModuleName string) error { return nil } + +// AddMappedOutputs generates output blocks for each entry in the outputs mapping. +// This is used for direct modules that don't have a "result" output but declare +// individual outputs that should be mapped to resource properties. +func (cfg *TerraformConfig) AddMappedOutputs(localModuleName string, outputsMap map[string]string) error { + if localModuleName == "" { + return errors.New("module name cannot be empty") + } + if len(outputsMap) == 0 { + return nil + } + + cfg.Output = make(map[string]any, len(outputsMap)) + for _, moduleOutputName := range outputsMap { + cfg.Output[moduleOutputName] = map[string]any{ + "value": "${module." + localModuleName + "." + moduleOutputName + "}", + "sensitive": true, + } + } + + return nil +} diff --git a/pkg/recipes/terraform/config/types.go b/pkg/recipes/terraform/config/types.go index 7bfefa4513..30e3b9e1a1 100644 --- a/pkg/recipes/terraform/config/types.go +++ b/pkg/recipes/terraform/config/types.go @@ -16,8 +16,6 @@ limitations under the License. package config -import "maps" - const ( // moduleSourceKey represents the key for the module source parameter. moduleSourceKey = "source" @@ -34,8 +32,14 @@ type TFModuleConfig map[string]any type RecipeParams map[string]any // SetParams sets the recipe parameters in the Terraform module configuration. +// Nil values are skipped to avoid passing explicit nulls in main.tf.json, +// which would override module variable defaults. func (tf TFModuleConfig) SetParams(params RecipeParams) { - maps.Copy(tf, params) + for k, v := range params { + if v != nil { + tf[k] = v + } + } } // TerraformConfig represents the Terraform configuration file structure for properties populated in the configuration by Radius. diff --git a/pkg/recipes/terraform/config/types_test.go b/pkg/recipes/terraform/config/types_test.go index 8331fd55a6..95dc73b3f8 100644 --- a/pkg/recipes/terraform/config/types_test.go +++ b/pkg/recipes/terraform/config/types_test.go @@ -38,3 +38,20 @@ func TestSetParams(t *testing.T) { require.Equal(t, c["foo"].(map[string]any), map[string]any{"bar": "baz"}) require.Equal(t, c["bar"].(map[string]any), map[string]any{"baz": "foo"}) } + +func TestSetParams_SkipsNilValues(t *testing.T) { + c := TFModuleConfig{ + "existing": "value", + } + + c.SetParams(RecipeParams{ + "valid_param": "hello", + "nil_param": nil, + }) + + require.Equal(t, 2, len(c)) + require.Equal(t, "value", c["existing"]) + require.Equal(t, "hello", c["valid_param"]) + _, exists := c["nil_param"] + require.False(t, exists, "nil params should not be added to module config") +} diff --git a/pkg/recipes/terraform/execute.go b/pkg/recipes/terraform/execute.go index d62fe2fcac..1c35e9e1ea 100644 --- a/pkg/recipes/terraform/execute.go +++ b/pkg/recipes/terraform/execute.go @@ -389,9 +389,20 @@ func (e *executor) generateConfig(ctx context.Context, tf *tfexec.Terraform, opt recipectx.Resource.Connections = options.ResourceRecipe.ConnectedResourcesProperties } + // Debug: log resource properties available for expression resolution. + logger.Info("Direct module context properties", "resourceName", recipectx.Resource.Name, "propertiesCount", len(recipectx.Resource.Properties)) + for k, v := range recipectx.Resource.Properties { + logger.Info("Direct module context property", "key", k, "value", fmt.Sprintf("%v", v)) + } + // Merge environment-level and resource-level parameters (environment wins per FR-004). mergedParams := recipes_util.ShallowMergeParameters(options.ResourceRecipe.Parameters, options.EnvRecipe.Parameters) + logger.Info("Direct module merged params", "paramCount", len(mergedParams)) + for k, v := range mergedParams { + logger.Info("Direct module merged param", "key", k, "value", fmt.Sprintf("%v", v)) + } + // Resolve {{context.*}} expressions in the merged parameters. resolvedParams := paramresolver.ResolveParameterExpressions(mergedParams, recipectx) @@ -405,6 +416,10 @@ func (e *executor) generateConfig(ctx context.Context, tf *tfexec.Terraform, opt if err = tfConfig.AddOutputs(options.EnvRecipe.Name); err != nil { return "", err } + } else if len(options.EnvRecipe.Outputs) > 0 { + if err = tfConfig.AddMappedOutputs(options.EnvRecipe.Name, options.EnvRecipe.Outputs); err != nil { + return "", err + } } // Add more configurations here. diff --git a/pkg/recipes/terraform/version.go b/pkg/recipes/terraform/version.go index 759c2c25c5..04d32de912 100644 --- a/pkg/recipes/terraform/version.go +++ b/pkg/recipes/terraform/version.go @@ -27,7 +27,7 @@ package terraform // `go run`, and other invocations that do not go through the Makefile; // the TestTerraformVersionMatchesFile test guarantees it stays in sync // with the file. -var terraformVersion = "1.14.9" +var terraformVersion = "1.11.4" // TerraformVersion returns the Terraform version Radius will install. func TerraformVersion() string {