Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/dynamicrp/api/dynamicresource_conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ func (d *DynamicResource) ConvertFrom(src v1.DataModelInterface) error {
if err != nil {
return fmt.Errorf("failed to unmarshal properties: %w", err)
}
if properties == nil {
properties = map[string]any{}
}

d.ID = &dm.ID
d.Name = &dm.Name
Expand Down
11 changes: 11 additions & 0 deletions pkg/dynamicrp/api/dynamicresource_conversion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,14 @@ func Test_DynamicResource_ConvertDataModelToVersioned(t *testing.T) {
})
}
}

func Test_DynamicResource_ConvertDataModelToVersioned_NilProperties(t *testing.T) {
dm := &datamodel.DynamicResource{}
resource := &DynamicResource{}

err := resource.ConvertFrom(dm)
require.NoError(t, err)
require.Equal(t, map[string]any{
"provisioningState": fromProvisioningStateDataModel(dm.AsyncProvisioningState),
}, resource.Properties)
}
104 changes: 104 additions & 0 deletions pkg/dynamicrp/frontend/defaultsfilter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
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 frontend

import (
"context"

v1 "github.com/radius-project/radius/pkg/armrpc/api/v1"
"github.com/radius-project/radius/pkg/armrpc/frontend/controller"
"github.com/radius-project/radius/pkg/armrpc/rest"
"github.com/radius-project/radius/pkg/dynamicrp/datamodel"
"github.com/radius-project/radius/pkg/schema"
"github.com/radius-project/radius/pkg/ucp/api/v20231001preview"
"github.com/radius-project/radius/pkg/ucp/ucplog"
)

type defaultsUpdateFilter controller.UpdateFilter[datamodel.DynamicResource]

// makeDefaultsFilter applies schema defaults before a resource is saved.
//
// PUT replaces the resource, so omitted properties use current defaults instead of old values.
func makeDefaultsFilter(ucpClient *v20231001preview.ClientFactory) defaultsUpdateFilter {
return func(
ctx context.Context,
newResource *datamodel.DynamicResource,
oldResource *datamodel.DynamicResource,
options *controller.Options,
) (rest.Response, error) {
return applySchemaDefaults(ctx, newResource, ucpClient)
}
}

func makeUpdateFilters(
defaultsFilter defaultsUpdateFilter,
encryptionFilter encryptionUpdateFilter,
) []controller.UpdateFilter[datamodel.DynamicResource] {
// Distinct types prevent callers from passing encryption first.
return []controller.UpdateFilter[datamodel.DynamicResource]{
controller.UpdateFilter[datamodel.DynamicResource](defaultsFilter),
controller.UpdateFilter[datamodel.DynamicResource](encryptionFilter),
}
}

// applySchemaDefaults adds defaults from the resource schema.
func applySchemaDefaults(
Comment thread
AzureMike marked this conversation as resolved.
ctx context.Context,
newResource *datamodel.DynamicResource,
ucpClient *v20231001preview.ClientFactory,
) (rest.Response, error) {
logger := ucplog.FromContextOrDiscard(ctx)
serviceCtx := v1.ARMRequestContextFromContext(ctx)

if newResource == nil {
return nil, nil
}

resourceID := serviceCtx.ResourceID.String()
resourceType := serviceCtx.ResourceID.Type()
apiVersion := serviceCtx.APIVersion

schemaData, err := schema.GetSchema(ctx, ucpClient, resourceID, resourceType, apiVersion)
if err != nil {
logger.Error(err, "Failed to fetch schema for defaults",
"resourceType", resourceType, "apiVersion", apiVersion)
return rest.NewInternalServerErrorARMResponse(v1.ErrorResponse{
Error: &v1.ErrorDetails{
Code: v1.CodeInternal,
Message: "Failed to fetch schema to apply property defaults",
},
}), nil
}

if schemaData == nil {
return nil, nil
}

// Keep Properties nil when no default applies.
properties := newResource.Properties
if properties == nil {
properties = map[string]any{}
}

if applied := schema.ApplyDefaults(properties, schemaData); applied > 0 {
newResource.Properties = properties
logger.V(ucplog.LevelDebug).Info("Applied schema defaults",
"count", applied, "resourceType", resourceType, "resourceID", resourceID)
}

return nil, nil
}
109 changes: 109 additions & 0 deletions pkg/dynamicrp/frontend/defaultsfilter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
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 frontend

import (
"testing"

v1 "github.com/radius-project/radius/pkg/armrpc/api/v1"
"github.com/radius-project/radius/pkg/armrpc/rest"
"github.com/radius-project/radius/pkg/dynamicrp/datamodel"
"github.com/stretchr/testify/require"
)

func TestMakeDefaultsFilter_SchemaFetchError(t *testing.T) {
ucpClient, err := testUCPClientFactoryWithError()
require.NoError(t, err)

resource := &datamodel.DynamicResource{
Properties: map[string]any{"size": "L"},
}

response, err := makeDefaultsFilter(ucpClient)(createTestContext(), resource, nil, nil)
require.NoError(t, err)

errorResponse, ok := response.(*rest.InternalServerErrorResponse)
require.True(t, ok)
require.Equal(t, v1.CodeInternal, errorResponse.Body.Error.Code)
require.Equal(t, "Failed to fetch schema to apply property defaults", errorResponse.Body.Error.Message)
require.Equal(t, map[string]any{"size": "L"}, resource.Properties)
}

func TestMakeDefaultsFilter_NilProperties(t *testing.T) {
t.Run("materializes declared defaults", func(t *testing.T) {
ucpClient, err := createFakeUCPClientFactory(map[string]any{
"type": "object",
"properties": map[string]any{
"size": map[string]any{"type": "string", "default": "S"},
},
})
require.NoError(t, err)

resource := &datamodel.DynamicResource{}
response, err := makeDefaultsFilter(ucpClient)(createTestContext(), resource, nil, nil)
require.NoError(t, err)
require.Nil(t, response)
require.Equal(t, map[string]any{"size": "S"}, resource.Properties)
})

t.Run("remains nil when no defaults apply", func(t *testing.T) {
ucpClient, err := createFakeUCPClientFactory(map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
})
require.NoError(t, err)

resource := &datamodel.DynamicResource{}
response, err := makeDefaultsFilter(ucpClient)(createTestContext(), resource, nil, nil)
require.NoError(t, err)
require.Nil(t, response)
require.Nil(t, resource.Properties)
})
}

func TestMakeUpdateFilters_DefaultsBeforeEncryption(t *testing.T) {
ucpClient, err := createFakeUCPClientFactory(map[string]any{
"type": "object",
"properties": map[string]any{
"password": map[string]any{
"type": "string",
"default": "default-password",
"x-radius-sensitive": true,
},
},
})
require.NoError(t, err)

resource := &datamodel.DynamicResource{}
filters := makeUpdateFilters(
makeDefaultsFilter(ucpClient),
makeEncryptionFilter(ucpClient, createTestHandler(t)),
)
for _, filter := range filters {
response, err := filter(createTestContext(), resource, nil, nil)
require.NoError(t, err)
require.Nil(t, response)
}

encryptedPassword, ok := resource.Properties["password"].(map[string]any)
require.True(t, ok, "defaulted password should be encrypted")
require.Contains(t, encryptedPassword, "encrypted")
require.Contains(t, encryptedPassword, "nonce")
require.Contains(t, encryptedPassword, "version")
}
4 changes: 3 additions & 1 deletion pkg/dynamicrp/frontend/encryptionfilter.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import (
"github.com/radius-project/radius/pkg/ucp/ucplog"
)

type encryptionUpdateFilter controller.UpdateFilter[datamodel.DynamicResource]

// makeEncryptionFilter creates an UpdateFilter that encrypts sensitive fields in the resource's
// Properties map before saving to the database.
//
Expand All @@ -41,7 +43,7 @@ import (
func makeEncryptionFilter(
ucpClient *v20231001preview.ClientFactory,
handler *encryption.SensitiveDataHandler,
) controller.UpdateFilter[datamodel.DynamicResource] {
) encryptionUpdateFilter {
return func(
ctx context.Context,
newResource *datamodel.DynamicResource,
Expand Down
8 changes: 6 additions & 2 deletions pkg/dynamicrp/frontend/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,17 @@ func (s *Service) registerRoutes(
// Create encryption filter for sensitive fields
encryptionFilter := makeEncryptionFilter(ucpClient, handler)

// Apply defaults before encrypting sensitive fields.
defaultsFilter := makeDefaultsFilter(ucpClient)

// Resource options with encryption filter applied to PUT operations
resourceOptions := controller.ResourceOptions[datamodel.DynamicResource]{
RequestConverter: converter.DynamicResourceDataModelFromVersioned,
ResponseConverter: converter.DynamicResourceDataModelToVersioned,
UpdateFilters: []controller.UpdateFilter[datamodel.DynamicResource]{
UpdateFilters: makeUpdateFilters(
defaultsFilter,
encryptionFilter,
},
),
AsyncOperationRetryAfter: time.Second * 5,
AsyncOperationTimeout: time.Hour * 24,
}
Expand Down
Loading
Loading