diff --git a/pkg/portableresources/backend/controller/createorupdateresource.go b/pkg/portableresources/backend/controller/createorupdateresource.go index 30f36fe853..ecf058d955 100644 --- a/pkg/portableresources/backend/controller/createorupdateresource.go +++ b/pkg/portableresources/backend/controller/createorupdateresource.go @@ -177,7 +177,7 @@ func (c *CreateOrUpdateResource[P, T]) executeRecipeIfNeeded(ctx context.Context if err != nil { return nil, fmt.Errorf("failed to get connected resource IDs: %w", err) } - connectedResourcesProperties := make(map[string]map[string]any) + connectedResourcesProperties := make(map[string]recipes.ConnectedResource) // If there are connected resources, we need to fetch their properties and add them to the recipe context. for connName, connectedResourceID := range connectionsAndSourceIDs { @@ -188,12 +188,17 @@ func (c *CreateOrUpdateResource[P, T]) executeRecipeIfNeeded(ctx context.Context return nil, fmt.Errorf("failed to get connected resource %s: %w", connectedResourceID, err) } - connectedResourceProperties, err := resourceutil.GetPropertiesFromResource(connectedResource.Data) + connectedResourceMetadata, err := resourceutil.GetAllPropertiesFromResource(connectedResource.Data, connectedResourceID) if err != nil { - return nil, fmt.Errorf("failed to get properties from connected resource %s: %w", connectedResourceID, err) + return nil, fmt.Errorf("failed to get metadata from connected resource %s: %w", connectedResourceID, err) } - connectedResourcesProperties[connName] = connectedResourceProperties + connectedResourcesProperties[connName] = recipes.ConnectedResource{ + ID: connectedResourceMetadata.ID, + Name: connectedResourceMetadata.Name, + Type: connectedResourceMetadata.Type, + Properties: connectedResourceMetadata.Properties, + } } metadata := recipes.ResourceMetadata{ diff --git a/pkg/portableresources/backend/controller/createorupdateresource_test.go b/pkg/portableresources/backend/controller/createorupdateresource_test.go index 0e7e1ba555..d7c09f2c8e 100644 --- a/pkg/portableresources/backend/controller/createorupdateresource_test.go +++ b/pkg/portableresources/backend/controller/createorupdateresource_test.go @@ -368,7 +368,7 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { "p1": "v1", }, Properties: properties, - ConnectedResourcesProperties: map[string]map[string]any{}, + ConnectedResourcesProperties: map[string]recipes.ConnectedResource{}, } prevState := []string{ @@ -470,3 +470,34 @@ func TestCreateOrUpdateResource_Run(t *testing.T) { }) } } + +func TestGetAllPropertiesFromResource_Integration(t *testing.T) { + // Simple integration test for resourceutil.GetAllPropertiesFromResource + connectedResourceID := "/planes/radius/local/resourceGroups/radius-test-rg/providers/Applications.Datastores/sqlDatabases/test-db" + + // Create a test resource with properties + testResource := struct { + Properties map[string]any `json:"properties"` + }{ + Properties: map[string]any{ + "host": "localhost", + "port": 5432, + "database": "testdb", + }, + } + + // Test the GetAllPropertiesFromResource function + metadata, err := resourceutil.GetAllPropertiesFromResource(testResource, connectedResourceID) + + // Assertions + require.NoError(t, err) + require.NotNil(t, metadata) + require.Equal(t, connectedResourceID, metadata.ID) + require.Equal(t, "test-db", metadata.Name) + require.Equal(t, "Applications.Datastores/sqlDatabases", metadata.Type) + require.Equal(t, map[string]any{ + "host": "localhost", + "port": float64(5432), // JSON unmarshaling converts to float64 + "database": "testdb", + }, metadata.Properties) +} diff --git a/pkg/recipes/configloader/environment.go b/pkg/recipes/configloader/environment.go index 894b3fa6e3..697425d6ea 100644 --- a/pkg/recipes/configloader/environment.go +++ b/pkg/recipes/configloader/environment.go @@ -287,6 +287,7 @@ func getRecipeDefinitionFromEnvironmentV20250801(ctx context.Context, environmen ResourceType: resource.Type(), Parameters: parameters, TemplatePath: recipeDefinition.RecipeLocation, + PlainHTTP: recipeDefinition.PlainHTTP, } return definition, nil } @@ -315,10 +316,15 @@ func fetchRecipeDefinition(ctx context.Context, recipePackIDs []string, armOptio // Convert recipes map for recipePackResourceType, definition := range recipePackResource.Properties.Recipes { if strings.EqualFold(recipePackResourceType, resourceType) { + var plainHTTP bool + if definition.PlainHTTP != nil { + plainHTTP = *definition.PlainHTTP + } return &recipes.RecipeDefinition{ RecipeKind: string(*definition.RecipeKind), RecipeLocation: string(*definition.RecipeLocation), Parameters: definition.Parameters, + PlainHTTP: plainHTTP, }, nil } } diff --git a/pkg/recipes/engine/engine_test.go b/pkg/recipes/engine/engine_test.go index 0fa50eea20..f443b9bb75 100644 --- a/pkg/recipes/engine/engine_test.go +++ b/pkg/recipes/engine/engine_test.go @@ -61,9 +61,14 @@ func Test_Engine_Execute_Success(t *testing.T) { Parameters: map[string]any{ "resourceName": "resource1", }, - ConnectedResourcesProperties: map[string]map[string]any{ + ConnectedResourcesProperties: map[string]recipes.ConnectedResource{ "database": { - "name": "db", + ID: "/planes/radius/local/resourceGroups/radius-test-rg/providers/Applications.Datastores/sqlDatabases/database", + Name: "database", + Type: "Applications.Datastores/sqlDatabases", + Properties: map[string]any{ + "name": "db", + }, }, }, } diff --git a/pkg/recipes/recipecontext/context_test.go b/pkg/recipes/recipecontext/context_test.go index f006526cb2..8441e696c7 100644 --- a/pkg/recipes/recipecontext/context_test.go +++ b/pkg/recipes/recipecontext/context_test.go @@ -304,3 +304,82 @@ func TestNewContext_failures(t *testing.T) { }) } } + +func TestNewContext_WithConnectedResources(t *testing.T) { + testMetadata := &recipes.ResourceMetadata{ + ResourceID: "/planes/radius/local/resourceGroups/testGroup/providers/applications.datastores/mongodatabases/mongo0", + ApplicationID: "/planes/radius/local/resourceGroups/testGroup/providers/applications.core/applications/testApplication", + EnvironmentID: "/planes/radius/local/resourceGroups/testGroup/providers/applications.core/environments/testEnvironment", + Name: "recipe0", + Properties: map[string]any{ + "property1": "value1", + }, + ConnectedResourcesProperties: map[string]recipes.ConnectedResource{ + "database": { + ID: "/planes/radius/local/resourceGroups/testGroup/providers/applications.datastores/sqldatabases/testdb", + Name: "testdb", + Type: "Applications.Datastores/sqlDatabases", + Properties: map[string]any{ + "host": "localhost", + "port": 5432, + "database": "testdb", + }, + }, + "cache": { + ID: "/planes/radius/local/resourceGroups/testGroup/providers/applications.datastores/redis/testcache", + Name: "testcache", + Type: "Applications.Datastores/redis", + Properties: map[string]any{ + "host": "cache-host", + "port": 6379, + }, + }, + }, + } + + testConfig := &recipes.Configuration{ + Providers: coredm.Providers{ + Azure: coredm.ProvidersAzure{ + Scope: "/planes/radius/local/resourceGroups/testGroup", + }, + AWS: coredm.ProvidersAWS{ + Scope: "/planes/aws/aws/accounts/1234567890/regions/us-west-2", + }, + }, + } + + result, err := New(testMetadata, testConfig) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify basic resource properties + require.Equal(t, "mongo0", result.Resource.Name) + require.Equal(t, testMetadata.ResourceID, result.Resource.ID) + require.Equal(t, "applications.datastores/mongodatabases", result.Resource.Type) + + // Verify connected resources metadata is available (connections are set by recipe engines) + require.NotNil(t, testMetadata.ConnectedResourcesProperties) + require.Len(t, testMetadata.ConnectedResourcesProperties, 2) + + // Verify the metadata contains the expected connected resources + dbConn, exists := testMetadata.ConnectedResourcesProperties["database"] + require.True(t, exists) + require.Equal(t, "/planes/radius/local/resourceGroups/testGroup/providers/applications.datastores/sqldatabases/testdb", dbConn.ID) + require.Equal(t, "testdb", dbConn.Name) + require.Equal(t, "Applications.Datastores/sqlDatabases", dbConn.Type) + require.Equal(t, map[string]any{ + "host": "localhost", + "port": 5432, + "database": "testdb", + }, dbConn.Properties) + + cacheConn, exists := testMetadata.ConnectedResourcesProperties["cache"] + require.True(t, exists) + require.Equal(t, "/planes/radius/local/resourceGroups/testGroup/providers/applications.datastores/redis/testcache", cacheConn.ID) + require.Equal(t, "testcache", cacheConn.Name) + require.Equal(t, "Applications.Datastores/redis", cacheConn.Type) + require.Equal(t, map[string]any{ + "host": "cache-host", + "port": 6379, + }, cacheConn.Properties) +} diff --git a/pkg/recipes/recipecontext/types.go b/pkg/recipes/recipecontext/types.go index 96c962997e..ba2ae90cd6 100644 --- a/pkg/recipes/recipecontext/types.go +++ b/pkg/recipes/recipecontext/types.go @@ -55,10 +55,13 @@ type Resource struct { Properties map[string]any `json:"properties,omitempty"` // Connections represent a map of connections to other resources. - // The key is the connection name, and the value is a map of connected resource properties. - // We enrich the recipe context with this, allowing the recipe to access properties of connected resources using the following format: - // context.resource.connections.[connection-name].[connected-resource-property] - Connections map[string]map[string]any `json:"connections,omitempty"` + // The key is the connection name, and the value contains the connected resource's metadata and properties. + // We enrich the recipe context with this, allowing the recipe to access connected resource info using: + // context.resource.connections.[connection-name].properties.[property-name] + // context.resource.connections.[connection-name].id + // context.resource.connections.[connection-name].name + // context.resource.connections.[connection-name].type + Connections map[string]recipes.ConnectedResource `json:"connections,omitempty"` } // ResourceInfo represents name and id of the resource diff --git a/pkg/recipes/terraform/execute_test.go b/pkg/recipes/terraform/execute_test.go index 1fd946f33c..df9a09d005 100644 --- a/pkg/recipes/terraform/execute_test.go +++ b/pkg/recipes/terraform/execute_test.go @@ -43,9 +43,14 @@ func TestGenerateConfig(t *testing.T) { TemplatePath: "test/module/source", }, ResourceRecipe: &recipes.ResourceMetadata{ - ConnectedResourcesProperties: map[string]map[string]any{ + ConnectedResourcesProperties: map[string]recipes.ConnectedResource{ "conn1": { - "dbName": "db", + ID: "/planes/radius/local/resourceGroups/radius-test-rg/providers/Applications.Datastores/redis/redis", + Name: "redis", + Type: "Applications.Datastores/redis", + Properties: map[string]any{ + "dbName": "db", + }, }, }, }, diff --git a/pkg/recipes/types.go b/pkg/recipes/types.go index aaf3e884b4..61d512487b 100644 --- a/pkg/recipes/types.go +++ b/pkg/recipes/types.go @@ -24,6 +24,18 @@ import ( rpv1 "github.com/radius-project/radius/pkg/rp/v1" ) +// ConnectedResource represents a connected resource's metadata and properties +type ConnectedResource struct { + // ID represents the fully qualified resource id + ID string `json:"id"` + // Name represents the resource name + Name string `json:"name"` + // Type represents the resource type + Type string `json:"type"` + // Properties represents the resource properties + Properties map[string]any `json:"properties,omitempty"` +} + // Configuration represents runtime and cloud provider configuration, which is used by the driver while deploying recipes. type Configuration struct { // Kubernetes Runtime configuration for the environment. @@ -85,11 +97,10 @@ type ResourceMetadata struct { ResourceID string // Properties represents the properties of the resource that the recipe is deploying Properties map[string]any - // ConnectedResourcesProperties represents the properties of the connected resources that the recipe is deploying. - // the key is connection name and the value is a map of properties for the connected resource. - // properties are inturn a map of key/value pairs, where the key is the property name and the value is the property value. - // these properties are passed into the recipe context. - ConnectedResourcesProperties map[string]map[string]any + // ConnectedResourcesProperties represents the connected resources that the recipe is deploying. + // The key is connection name and the value contains the connected resource's metadata and properties. + // These are passed into the recipe context. + ConnectedResourcesProperties map[string]ConnectedResource // Parameters represents key/value pairs to pass into the recipe template. Overrides any parameters set by the environment. Parameters map[string]any } @@ -147,6 +158,8 @@ type RecipeDefinition struct { RecipeLocation string // Parameters represents parameters to pass to the recipe Parameters map[string]any + // PlainHTTP connects to the location using HTTP (not-HTTPS) + PlainHTTP bool } // PrepareRecipeOutput populates the recipe output from the recipe deployment output stored in the "result" object. diff --git a/pkg/resourceutil/utils.go b/pkg/resourceutil/utils.go index 32610609b7..035f00cb62 100644 --- a/pkg/resourceutil/utils.go +++ b/pkg/resourceutil/utils.go @@ -92,3 +92,34 @@ func GetConnectionNameandSourceIDs[P any](resource P) (map[string]string, error) return connectionNamesAndSourceIDs, nil } + +// ResourceMetadata represents resource metadata including ID, Name, Type and Properties +type ResourceMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Properties map[string]any `json:"properties,omitempty"` +} + +// GetAllPropertiesFromResource extracts the resource metadata including ID, Name, Type and properties +// by parsing the resource ID and extracting properties. +func GetAllPropertiesFromResource[P any](resource P, resourceID string) (*ResourceMetadata, error) { + // Parse resource ID to get name and type + parsedResourceID, err := resources.Parse(resourceID) + if err != nil { + return nil, fmt.Errorf("failed to parse resource ID %s: %w", resourceID, err) + } + + // Get properties using existing method + properties, err := GetPropertiesFromResource(resource) + if err != nil { + return nil, err + } + + return &ResourceMetadata{ + ID: resourceID, + Name: parsedResourceID.Name(), + Type: parsedResourceID.Type(), + Properties: properties, + }, nil +} diff --git a/pkg/resourceutil/utils_test.go b/pkg/resourceutil/utils_test.go index 0d41c99752..8a7e6ad22a 100644 --- a/pkg/resourceutil/utils_test.go +++ b/pkg/resourceutil/utils_test.go @@ -48,6 +48,11 @@ func (p *PropertiesTestResource) OutputResources() []rpv1.OutputResource { return nil } +type InvalidResourceForAll struct { + Properties map[string]any `json:"properties"` + BadField func() `json:"badField"` // Functions cannot be marshaled +} + func TestGetPropertiesFromResource(t *testing.T) { tests := []struct { name string @@ -305,3 +310,127 @@ func TestGetConnectionNameandSourceIDs_InvalidJSONMarshaling(t *testing.T) { require.Nil(t, result) require.Contains(t, err.Error(), errMarshalResource) } + +func TestGetAllPropertiesFromResource(t *testing.T) { + tests := []struct { + name string + resource any + resourceID string + expected *ResourceMetadata + expectError bool + errorMsg string + }{ + { + name: "Valid resource with properties", + resource: &PropertiesTestResource{ + Properties: map[string]any{ + "Application": TestApplicationID, + "Environment": TestEnvironmentID, + "host": "localhost", + "port": float64(5432), + }, + }, + resourceID: TestResourceID, + expected: &ResourceMetadata{ + ID: TestResourceID, + Name: "tr", + Type: "MyResources.Test/testResources", + Properties: map[string]any{ + "Application": TestApplicationID, + "Environment": TestEnvironmentID, + "host": "localhost", + "port": float64(5432), + }, + }, + expectError: false, + }, + { + name: "Resource with empty properties", + resource: &PropertiesTestResource{ + Properties: nil, + }, + resourceID: TestResourceID, + expected: &ResourceMetadata{ + ID: TestResourceID, + Name: "tr", + Type: "MyResources.Test/testResources", + Properties: map[string]any{}, + }, + expectError: false, + }, + { + name: "Invalid resource ID", + resource: &PropertiesTestResource{ + Properties: map[string]any{ + "host": "localhost", + }, + }, + resourceID: "invalid-resource-id", + expected: nil, + expectError: true, + errorMsg: "failed to parse resource ID", + }, + { + name: "Resource with complex nested properties", + resource: &PropertiesTestResource{ + Properties: map[string]any{ + "Application": TestApplicationID, + "Environment": TestEnvironmentID, + "config": map[string]any{ + "nested": "value", + "array": []string{"item1", "item2"}, + }, + "enabled": true, + }, + }, + resourceID: TestResourceID, + expected: &ResourceMetadata{ + ID: TestResourceID, + Name: "tr", + Type: "MyResources.Test/testResources", + Properties: map[string]any{ + "Application": TestApplicationID, + "Environment": TestEnvironmentID, + "config": map[string]any{ + "nested": "value", + "array": []any{"item1", "item2"}, // JSON unmarshaling converts to []any + }, + "enabled": true, + }, + }, + expectError: false, + }, + { + name: "Resource with GetPropertiesFromResource error", + resource: &InvalidResourceForAll{ + Properties: map[string]any{ + "host": "localhost", + }, + BadField: func() {}, // This will cause JSON marshaling to fail + }, + resourceID: TestResourceID, + expected: nil, + expectError: true, + errorMsg: errMarshalResource, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GetAllPropertiesFromResource(tt.resource, tt.resourceID) + + if tt.expectError { + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tt.expected.ID, result.ID) + require.Equal(t, tt.expected.Name, result.Name) + require.Equal(t, tt.expected.Type, result.Type) + require.Equal(t, tt.expected.Properties, result.Properties) + } + }) + } +} diff --git a/test/testrecipes/test-bicep-recipes/dynamicrp_recipe.bicep b/test/testrecipes/test-bicep-recipes/dynamicrp_recipe.bicep index 935ccaa2b7..93f824edfb 100644 --- a/test/testrecipes/test-bicep-recipes/dynamicrp_recipe.bicep +++ b/test/testrecipes/test-bicep-recipes/dynamicrp_recipe.bicep @@ -41,7 +41,7 @@ resource usertypealpha 'apps/Deployment@v1' = { env: [ { name: 'CONN_INJECTED' - value: contains(context.resource, 'connections') && contains(context.resource.connections, 'externalresource') ? context.resource.connections.externalresource.configMap : '' + value: contains(context.resource, 'connections') && contains(context.resource.connections, 'externalresource') ? context.resource.connections.externalresource.properties.configMap : '' } ] } diff --git a/test/testrecipes/test-terraform-recipes/parent-udt/main.tf b/test/testrecipes/test-terraform-recipes/parent-udt/main.tf index 3a7177261c..229da2cbc3 100644 --- a/test/testrecipes/test-terraform-recipes/parent-udt/main.tf +++ b/test/testrecipes/test-terraform-recipes/parent-udt/main.tf @@ -58,7 +58,7 @@ resource "kubernetes_deployment" "usertypealpha" { env { name = "CONN_INJECTED" - value = try(var.context.resource.connections.externalresource.configMap, "") + value = try(var.context.resource.connections.externalresource.properties.configMap, "") } } }