From abe5efca5d0d2e9e557a525f8177d6fc9e8ad1b8 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Mon, 30 Dec 2024 14:29:49 -0800 Subject: [PATCH] Implement resource provider API for dynamic rp This change implements the standard resource provider API for dynamic resources. This enables the dynamic-rp to serve the standard CRUDL API functionality for user-defined types. This change does NOT add recipe support, that will come in the next change. The pattern that's supported by this change is an "inert" resource which has no backend functionality. Recipe support will be a superset of this functionality. Signed-off-by: Ryan Nowak --- .github/workflows/lint.yaml | 1 + build/prettier.mk | 12 +- pkg/dynamicrp/api/dynamicresource.go | 38 ++++++ .../api/dynamicresource_conversion.go | 122 +++++++++++++++++ .../api/dynamicresource_conversion_test.go | 126 ++++++++++++++++++ .../testdata/dynamicresource-datamodel.json | 20 +++ .../testdata/dynamicresource-resource.json | 12 ++ .../backend/dynamicresource_controller.go | 80 +++++++++++ .../dynamicresource_controller_test.go | 73 ++++++++++ .../backend/inert_delete_controller.go | 45 +++++++ .../backend/inert_delete_controller_test.go | 55 ++++++++ pkg/dynamicrp/backend/inert_put_controller.go | 40 ++++++ .../backend/inert_put_controller_test.go | 41 ++++++ pkg/dynamicrp/backend/service.go | 13 +- .../converter/dynamicresource_converter.go | 52 ++++++++ pkg/dynamicrp/datamodel/dynamicresource.go | 34 +++++ pkg/dynamicrp/frontend/routes.go | 58 +++++++- .../integrationtest/dynamic/providers_test.go | 61 ++++++++- 18 files changed, 864 insertions(+), 19 deletions(-) create mode 100644 pkg/dynamicrp/api/dynamicresource.go create mode 100644 pkg/dynamicrp/api/dynamicresource_conversion.go create mode 100644 pkg/dynamicrp/api/dynamicresource_conversion_test.go create mode 100644 pkg/dynamicrp/api/testdata/dynamicresource-datamodel.json create mode 100644 pkg/dynamicrp/api/testdata/dynamicresource-resource.json create mode 100644 pkg/dynamicrp/backend/dynamicresource_controller.go create mode 100644 pkg/dynamicrp/backend/dynamicresource_controller_test.go create mode 100644 pkg/dynamicrp/backend/inert_delete_controller.go create mode 100644 pkg/dynamicrp/backend/inert_delete_controller_test.go create mode 100644 pkg/dynamicrp/backend/inert_put_controller.go create mode 100644 pkg/dynamicrp/backend/inert_put_controller_test.go create mode 100644 pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go create mode 100644 pkg/dynamicrp/datamodel/dynamicresource.go diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 93cc92fcdd..685ec57e1f 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -104,6 +104,7 @@ jobs: node-version: "22" - name: Run `make format-check` id: format-check + continue-on-error: true run: | make format-check - name: Check for formatting failures diff --git a/build/prettier.mk b/build/prettier.mk index 5425a4508b..f680af93f9 100644 --- a/build/prettier.mk +++ b/build/prettier.mk @@ -14,16 +14,18 @@ # limitations under the License. # ------------------------------------------------------------ -.PHONY: prettier-check prettier-format me prettier +##@ Formatting (of JSON files) PRETTIER_VERSION := 3.3.3 -format-check: +.PHONY: format-check +format-check: ## Checks the formatting of JSON files. @echo "$(ARROW) Checking for formatting issues using prettier..." @echo "" - @npx prettier@$(PRETTIER_VERSION) --check "*/**/*.{ts,js,mjs,json}" + @npx --yes prettier@$(PRETTIER_VERSION) --check "*/**/*.{ts,js,mjs,json}" -format-write: +.PHONY: format-write +format-write: ## Updates the formatting of JSON files. @echo "$(ARROW) Reformatting files using prettier..." @echo "" - @npx prettier@$(PRETTIER_VERSION) --write "*/**/*.{ts,js,mjs,json}" + @npx --yes prettier@$(PRETTIER_VERSION) --write "*/**/*.{ts,js,mjs,json}" diff --git a/pkg/dynamicrp/api/dynamicresource.go b/pkg/dynamicrp/api/dynamicresource.go new file mode 100644 index 0000000000..35a95941c9 --- /dev/null +++ b/pkg/dynamicrp/api/dynamicresource.go @@ -0,0 +1,38 @@ +/* +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 api + +// DynamicResource is used as the versioned resource model for dynamic resources. +// +// A dynamic resource uses a user-provided OpenAPI specification to define the resource schema. Therefore, +// the properties of the resource are not known at compile time. +type DynamicResource struct { + // ID is the resource ID. + ID *string `json:"id"` + // Name is the resource name. + Name *string `json:"name"` + // Type is the resource type. + Type *string `json:"type"` + // Location is the resource location. + Location *string `json:"location"` + // Tags are the resource tags. + Tags map[string]*string `json:"tags,omitempty"` + // Properties stores the properties of the resource. + Properties map[string]any `json:"properties,omitempty"` + // SystemData stores the system data of the resource. + SystemData map[string]any `json:"systemData,omitempty"` +} diff --git a/pkg/dynamicrp/api/dynamicresource_conversion.go b/pkg/dynamicrp/api/dynamicresource_conversion.go new file mode 100644 index 0000000000..1934e3c041 --- /dev/null +++ b/pkg/dynamicrp/api/dynamicresource_conversion.go @@ -0,0 +1,122 @@ +/* +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 api + +import ( + "encoding/json" + "fmt" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/dynamicrp/datamodel" + "github.com/radius-project/radius/pkg/to" +) + +const ( + // TODO + Version = "2023-01-01" +) + +// ConvertTo converts the versioned model to the datamodel. +func (d *DynamicResource) ConvertTo() (v1.DataModelInterface, error) { + // Note: we always round-trip the properties through JSON to ensure that the conversion is possible, and + // to make a defensive copy of the data. + bs, err := json.Marshal(d.Properties) + if err != nil { + return nil, fmt.Errorf("failed to marshal properties: %w", err) + } + + properties := map[string]any{} + err = json.Unmarshal(bs, &properties) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal properties: %w", err) + } + + dm := &datamodel.DynamicResource{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: to.String(d.ID), + Name: to.String(d.Name), + Type: to.String(d.Type), + Location: to.String(d.Location), + Tags: to.StringMap(d.Tags), + }, + InternalMetadata: v1.InternalMetadata{ + UpdatedAPIVersion: Version, + }, + }, + Properties: properties, + } + + return dm, nil +} + +// ConvertFrom converts the datamodel to the versioned model. +func (d *DynamicResource) ConvertFrom(src v1.DataModelInterface) error { + dm, ok := src.(*datamodel.DynamicResource) + if !ok { + return v1.ErrInvalidModelConversion + } + + // Note: we always round-trip the properties through JSON to ensure that the conversion is possible, and + // to make a defensive copy of the data. + bs, err := json.Marshal(dm.Properties) + if err != nil { + return fmt.Errorf("failed to marshal properties: %w", err) + } + + properties := map[string]any{} + err = json.Unmarshal(bs, &properties) + if err != nil { + return fmt.Errorf("failed to unmarshal properties: %w", err) + } + + d.ID = &dm.ID + d.Name = &dm.Name + d.Type = &dm.Type + d.Location = &dm.Location + d.Tags = *to.StringMapPtr(dm.Tags) + d.SystemData = fromSystemDataDataModel(dm.SystemData) + d.Properties = properties + d.Properties["provisioningState"] = fromProvisioningStateDataModel(dm.AsyncProvisioningState) + + return nil +} + +func fromSystemDataDataModel(input v1.SystemData) map[string]any { + bs, err := json.Marshal(input) + if err != nil { + // This should never fail. We've designed the SystemData type to be serializable. + panic("marshalling system data failed: " + err.Error()) + } + + result := map[string]any{} + err = json.Unmarshal(bs, &result) + if err != nil { + // This should never fail. We've designed the SystemData type to be serializable. + panic("unmarshalling system data failed: " + err.Error()) + } + + return result +} + +func fromProvisioningStateDataModel(input v1.ProvisioningState) string { + if input == "" { + return string(v1.ProvisioningStateSucceeded) + } + + return string(input) +} diff --git a/pkg/dynamicrp/api/dynamicresource_conversion_test.go b/pkg/dynamicrp/api/dynamicresource_conversion_test.go new file mode 100644 index 0000000000..936448b6a1 --- /dev/null +++ b/pkg/dynamicrp/api/dynamicresource_conversion_test.go @@ -0,0 +1,126 @@ +/* +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 api + +import ( + "encoding/json" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/dynamicrp/datamodel" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/test/testutil" + + "github.com/stretchr/testify/require" +) + +func Test_DynamicResource_ConvertVersionedToDataModel(t *testing.T) { + conversionTests := []struct { + filename string + expected *datamodel.DynamicResource + err error + }{ + { + filename: "dynamicresource-resource.json", + expected: &datamodel.DynamicResource{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test/providers/Applications.Test/testResources/testResource", + Name: "testResource", + Type: "Applications.Test/testResources", + Location: "global", + Tags: map[string]string{ + "env": "dev", + }, + }, + InternalMetadata: v1.InternalMetadata{ + UpdatedAPIVersion: Version, + }, + }, + Properties: map[string]any{ + "message": "Hello, world!", + }, + }, + }, + } + + for _, tt := range conversionTests { + t.Run(tt.filename, func(t *testing.T) { + rawPayload := testutil.ReadFixture(tt.filename) + r := &DynamicResource{} + err := json.Unmarshal(rawPayload, r) + require.NoError(t, err) + + dm, err := r.ConvertTo() + + if tt.err != nil { + require.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + ct := dm.(*datamodel.DynamicResource) + require.Equal(t, tt.expected, ct) + } + }) + } +} + +func Test_DynamicResource_ConvertDataModelToVersioned(t *testing.T) { + conversionTests := []struct { + filename string + expected *DynamicResource + err error + }{ + { + filename: "dynamicresource-datamodel.json", + expected: &DynamicResource{ + ID: to.Ptr("/planes/radius/local/resourceGroups/test/providers/Applications.Test/testResources/testResource"), + Name: to.Ptr("testResource"), + Type: to.Ptr("Applications.Test/testResources"), + Location: to.Ptr("global"), + Tags: map[string]*string{ + "env": to.Ptr("dev"), + }, + Properties: map[string]any{ + "provisioningState": fromProvisioningStateDataModel(v1.ProvisioningStateSucceeded), + "message": "Hello, world!", + }, + }, + }, + } + + for _, tt := range conversionTests { + t.Run(tt.filename, func(t *testing.T) { + rawPayload := testutil.ReadFixture(tt.filename) + dm := &datamodel.DynamicResource{} + err := json.Unmarshal(rawPayload, dm) + require.NoError(t, err) + + resource := &DynamicResource{} + err = resource.ConvertFrom(dm) + + // Avoid hardcoding the SystemData field in tests. + tt.expected.SystemData = fromSystemDataDataModel(dm.SystemData) + + if tt.err != nil { + require.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, resource) + } + }) + } +} diff --git a/pkg/dynamicrp/api/testdata/dynamicresource-datamodel.json b/pkg/dynamicrp/api/testdata/dynamicresource-datamodel.json new file mode 100644 index 0000000000..0b5c40958f --- /dev/null +++ b/pkg/dynamicrp/api/testdata/dynamicresource-datamodel.json @@ -0,0 +1,20 @@ +{ + "id": "/planes/radius/local/resourceGroups/test/providers/Applications.Test/testResources/testResource", + "name": "testResource", + "type": "Applications.Test/testResources", + "location": "global", + "systemData": { + "createdBy": "fakeid@live.com", + "createdByType": "User", + "createdAt": "2021-09-24T19:09:54.2403864Z", + "lastModifiedBy": "fakeid@live.com", + "lastModifiedByType": "User", + "lastModifiedAt": "2021-09-24T20:09:54.2403864Z" + }, + "tags": { + "env": "dev" + }, + "properties": { + "message": "Hello, world!" + } +} diff --git a/pkg/dynamicrp/api/testdata/dynamicresource-resource.json b/pkg/dynamicrp/api/testdata/dynamicresource-resource.json new file mode 100644 index 0000000000..2d544a69b9 --- /dev/null +++ b/pkg/dynamicrp/api/testdata/dynamicresource-resource.json @@ -0,0 +1,12 @@ +{ + "id": "/planes/radius/local/resourceGroups/test/providers/Applications.Test/testResources/testResource", + "name": "testResource", + "type": "Applications.Test/testResources", + "location": "global", + "tags": { + "env": "dev" + }, + "properties": { + "message": "Hello, world!" + } +} diff --git a/pkg/dynamicrp/backend/dynamicresource_controller.go b/pkg/dynamicrp/backend/dynamicresource_controller.go new file mode 100644 index 0000000000..0563d4dc6b --- /dev/null +++ b/pkg/dynamicrp/backend/dynamicresource_controller.go @@ -0,0 +1,80 @@ +/* +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 backend + +import ( + "context" + "fmt" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" + "github.com/radius-project/radius/pkg/ucp/resources" +) + +// DynamicResourceController is the async operation controller to perform processing on dynamic resources. +// +// This controller will use the capabilities and the operation to determine the correct controller to use. +type DynamicResourceController struct { + ctrl.BaseController +} + +// NewDynamicResourceController creates a new DynamicResourcePutController. +func NewDynamicResourceController(opts ctrl.Options) (ctrl.Controller, error) { + return &DynamicResourceController{ + BaseController: ctrl.NewBaseAsyncController(opts), + }, nil +} + +// Run implements the async controller interface. +func (c *DynamicResourceController) Run(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + // This is where we have the opportunity to branch out to different controllers based on: + // - The operation type. (eg: PUT, DELETE, etc) + // - The capabilities of the resource type. (eg: Does it support recipes?) + controller, err := c.selectController(request) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to create controller: %w", err) + } + + return controller.Run(ctx, request) + +} + +func (c *DynamicResourceController) selectController(request *ctrl.Request) (ctrl.Controller, error) { + ot, ok := v1.ParseOperationType(request.OperationType) + if !ok { + return nil, fmt.Errorf("invalid operation type: %q", request.OperationType) + } + + id, err := resources.ParseResource(request.ResourceID) + if err != nil { + return nil, fmt.Errorf("invalid resource ID: %q", request.ResourceID) + } + + options := ctrl.Options{ + DatabaseClient: c.DatabaseClient(), + ResourceType: id.Type(), + } + + switch ot.Method { + case v1.OperationDelete: + return NewInertDeleteController(options) + case v1.OperationPut: + return NewInertPutController(options) + default: + return nil, fmt.Errorf("unsupported operation type: %q", request.OperationType) + } +} diff --git a/pkg/dynamicrp/backend/dynamicresource_controller_test.go b/pkg/dynamicrp/backend/dynamicresource_controller_test.go new file mode 100644 index 0000000000..7726165746 --- /dev/null +++ b/pkg/dynamicrp/backend/dynamicresource_controller_test.go @@ -0,0 +1,73 @@ +/* +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 backend + +import ( + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" + "github.com/stretchr/testify/require" +) + +func Test_DynamicResourceController_selectController(t *testing.T) { + setup := func() *DynamicResourceController { + opts := ctrl.Options{} + controller, err := NewDynamicResourceController(opts) + require.NoError(t, err) + return controller.(*DynamicResourceController) + } + + t.Run("inert PUT", func(t *testing.T) { + controller := setup() + request := &ctrl.Request{ + ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/Applications.Test/testResources/test-resource", + OperationType: v1.OperationType{Type: "Applications.Test/testResources", Method: v1.OperationPut}.String(), + } + + selected, err := controller.selectController(request) + require.NoError(t, err) + + require.IsType(t, &InertPutController{}, selected) + }) + + t.Run("inert DELETE", func(t *testing.T) { + controller := setup() + request := &ctrl.Request{ + ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/Applications.Test/testResources/test-resource", + OperationType: v1.OperationType{Type: "Applications.Test/testResources", Method: v1.OperationDelete}.String(), + } + + selected, err := controller.selectController(request) + require.NoError(t, err) + + require.IsType(t, &InertDeleteController{}, selected) + }) + + t.Run("unknown operation", func(t *testing.T) { + controller := setup() + request := &ctrl.Request{ + ResourceID: "/planes/radius/local/resourceGroups/test-group/providers/Applications.Test/testResources/test-resource", + OperationType: v1.OperationType{Type: "Applications.Test/testResources", Method: v1.OperationGet}.String(), + } + + selected, err := controller.selectController(request) + require.Error(t, err) + require.Equal(t, "unsupported operation type: \"APPLICATIONS.TEST/TESTRESOURCES|GET\"", err.Error()) + require.Nil(t, selected) + }) +} diff --git a/pkg/dynamicrp/backend/inert_delete_controller.go b/pkg/dynamicrp/backend/inert_delete_controller.go new file mode 100644 index 0000000000..16289b93af --- /dev/null +++ b/pkg/dynamicrp/backend/inert_delete_controller.go @@ -0,0 +1,45 @@ +/* +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 backend + +import ( + "context" + + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" +) + +// InertDeleteController is the async operation controller to perform DELETE processing on "inert" dynamic resources. +type InertDeleteController struct { + ctrl.BaseController +} + +// NewInertDeleteController creates a new InertDeleteController. +func NewInertDeleteController(opts ctrl.Options) (ctrl.Controller, error) { + return &InertDeleteController{ + BaseController: ctrl.NewBaseAsyncController(opts), + }, nil +} + +// Run implements the async controller interface. +func (c *InertDeleteController) Run(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + err := c.DatabaseClient().Delete(ctx, request.ResourceID) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} diff --git a/pkg/dynamicrp/backend/inert_delete_controller_test.go b/pkg/dynamicrp/backend/inert_delete_controller_test.go new file mode 100644 index 0000000000..9d77ad3846 --- /dev/null +++ b/pkg/dynamicrp/backend/inert_delete_controller_test.go @@ -0,0 +1,55 @@ +/* +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 backend + +import ( + "testing" + + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" + "github.com/radius-project/radius/pkg/components/database" + "github.com/radius-project/radius/test/testcontext" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func Test_InertDeleteController_Run(t *testing.T) { + setup := func() (*InertDeleteController, *database.MockClient) { + mockctrl := gomock.NewController(t) + databaseClient := database.NewMockClient(mockctrl) + + opts := ctrl.Options{ + DatabaseClient: databaseClient, + } + + controller, err := NewInertDeleteController(opts) + require.NoError(t, err) + return controller.(*InertDeleteController), databaseClient + } + + controller, databaseClient := setup() + + request := &ctrl.Request{ + ResourceID: "/planes/radius/testing/resourceGroups/test-group/providers/Applications.Test/exampleResources/my-example", + } + + // Controller needs to call delete on the resource. + databaseClient.EXPECT().Delete(gomock.Any(), request.ResourceID).Return(nil).Times(1) + + result, err := controller.Run(testcontext.New(t), request) + require.NoError(t, err) + require.Equal(t, ctrl.Result{}, result) +} diff --git a/pkg/dynamicrp/backend/inert_put_controller.go b/pkg/dynamicrp/backend/inert_put_controller.go new file mode 100644 index 0000000000..2895cf72c5 --- /dev/null +++ b/pkg/dynamicrp/backend/inert_put_controller.go @@ -0,0 +1,40 @@ +/* +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 backend + +import ( + "context" + + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" +) + +// InertPutController is the async operation controller to perform PUT processing on "inert" dynamic resources. +type InertPutController struct { + ctrl.BaseController +} + +// NewInertPutController creates a new InertPutController. +func NewInertPutController(opts ctrl.Options) (ctrl.Controller, error) { + return &InertPutController{ + BaseController: ctrl.NewBaseAsyncController(opts), + }, nil +} + +// Run implements the async controller interface. +func (c *InertPutController) Run(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + return ctrl.Result{}, nil +} diff --git a/pkg/dynamicrp/backend/inert_put_controller_test.go b/pkg/dynamicrp/backend/inert_put_controller_test.go new file mode 100644 index 0000000000..5060aac096 --- /dev/null +++ b/pkg/dynamicrp/backend/inert_put_controller_test.go @@ -0,0 +1,41 @@ +/* +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 backend + +import ( + "testing" + + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" + "github.com/radius-project/radius/test/testcontext" + "github.com/stretchr/testify/require" +) + +func Test_InertPutController_Run(t *testing.T) { + setup := func() *InertPutController { + opts := ctrl.Options{} + controller, err := NewInertPutController(opts) + require.NoError(t, err) + return controller.(*InertPutController) + } + + controller := setup() + + request := &ctrl.Request{} + result, err := controller.Run(testcontext.New(t), request) + require.NoError(t, err) + require.Equal(t, ctrl.Result{}, result) +} diff --git a/pkg/dynamicrp/backend/service.go b/pkg/dynamicrp/backend/service.go index 710a0fccf0..834f1aad7c 100644 --- a/pkg/dynamicrp/backend/service.go +++ b/pkg/dynamicrp/backend/service.go @@ -19,7 +19,9 @@ package backend import ( "context" + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" "github.com/radius-project/radius/pkg/armrpc/asyncoperation/worker" + "github.com/radius-project/radius/pkg/dynamicrp" "github.com/radius-project/radius/pkg/recipes/controllerconfig" ) @@ -70,7 +72,7 @@ func (w *Service) Run(ctx context.Context) error { w.Service.QueueClient = queueClient w.Service.OperationStatusManager = w.options.StatusManager - err = w.registerControllers(ctx) + err = w.registerControllers() if err != nil { return err } @@ -78,7 +80,10 @@ func (w *Service) Run(ctx context.Context) error { return w.Start(ctx) } -func (w *Service) registerControllers(ctx context.Context) error { - // No controllers yet. - return nil +func (w *Service) registerControllers() error { + options := ctrl.Options{ + DatabaseClient: w.Service.DatabaseClient, + } + + return w.Service.Controllers().RegisterDefault(NewDynamicResourceController, options) } diff --git a/pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go b/pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go new file mode 100644 index 0000000000..6483c5e136 --- /dev/null +++ b/pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go @@ -0,0 +1,52 @@ +/* +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 converter + +import ( + "encoding/json" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/dynamicrp/api" + "github.com/radius-project/radius/pkg/dynamicrp/datamodel" +) + +// DynamicResourceDataModelFromVersioned converts version agnostic datamodel to versioned model. +func DynamicResourceDataModelToVersioned(model *datamodel.DynamicResource, version string) (v1.VersionedModelInterface, error) { + // NOTE: DynamicResource is used for all API versions. + // + // We don't/can't validate the API version here, that must be done before calling the API. + versioned := &api.DynamicResource{} + if err := versioned.ConvertFrom(model); err != nil { + return nil, err + } + return versioned, nil +} + +// DynamicResourceDataModelFromVersioned converts versioned model to datamodel. +func DynamicResourceDataModelFromVersioned(content []byte, version string) (*datamodel.DynamicResource, error) { + // NOTE: DynamicResource is used for all API versions. + // + // We don't/can't validate the API version here, that must be done before calling the API. + vm := &api.DynamicResource{} + if err := json.Unmarshal(content, vm); err != nil { + return nil, err + } + dm, err := vm.ConvertTo() + if err != nil { + return nil, err + } + return dm.(*datamodel.DynamicResource), nil +} diff --git a/pkg/dynamicrp/datamodel/dynamicresource.go b/pkg/dynamicrp/datamodel/dynamicresource.go new file mode 100644 index 0000000000..6b33c2e72f --- /dev/null +++ b/pkg/dynamicrp/datamodel/dynamicresource.go @@ -0,0 +1,34 @@ +/* +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 datamodel + +import ( + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" +) + +var _ v1.ResourceDataModel = (*DynamicResource)(nil) + +// DynamicResource is used as the data model for dynamic resources (UDT). +// +// A dynamic resource uses a user-provided OpenAPI specification to define the resource schema. Therefore, +// the properties of the resource are not known at compile time. +type DynamicResource struct { + v1.BaseResource + + // Properties stores the properties of the resource being tracked. + Properties map[string]any `json:"properties"` +} diff --git a/pkg/dynamicrp/frontend/routes.go b/pkg/dynamicrp/frontend/routes.go index ca18546e07..dd387b3934 100644 --- a/pkg/dynamicrp/frontend/routes.go +++ b/pkg/dynamicrp/frontend/routes.go @@ -18,11 +18,14 @@ package frontend import ( "strings" + "time" "github.com/go-chi/chi/v5" 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/frontend/defaultoperation" + "github.com/radius-project/radius/pkg/dynamicrp/datamodel" + "github.com/radius-project/radius/pkg/dynamicrp/datamodel/converter" "github.com/radius-project/radius/pkg/validator" ) @@ -44,16 +47,63 @@ func (s *Service) registerRoutes(r *chi.Mux, controllerOptions controller.Option pathBase = pathBase + "/" } - r.Route(pathBase+"planes/radius/{planeName}/providers/{providerNamespace}", func(r chi.Router) { - r.Route("/locations/{locationName}", func(r chi.Router) { - r.Get("/{or:operation[Rr]esults}/{operationID}", dynamicOperationHandler(v1.OperationGet, controllerOptions, makeGetOperationResultController)) - r.Get("/{os:operation[Ss]tatuses}/{operationID}", dynamicOperationHandler(v1.OperationGet, controllerOptions, makeGetOperationStatusController)) + r.Route(pathBase+"planes/radius/{planeName}", func(r chi.Router) { + + // Plane-scoped + r.Route("/providers/{providerNamespace}", func(r chi.Router) { + + // Plane-scoped LIST operation + r.Get("/{resourceType}", dynamicOperationHandler(v1.OperationPlaneScopeList, controllerOptions, makeListResourceAtPlaneScopeController)) + + // Async operation status/results + r.Route("/locations/{locationName}", func(r chi.Router) { + r.Get("/{or:operation[Rr]esults}/{operationID}", dynamicOperationHandler(v1.OperationGet, controllerOptions, makeGetOperationResultController)) + r.Get("/{os:operation[Ss]tatuses}/{operationID}", dynamicOperationHandler(v1.OperationGet, controllerOptions, makeGetOperationStatusController)) + }) + }) + + // Resource-group-scoped + r.Route("/{rg:resource[gG]roups}/{resourceGroupName}/providers/{providerNamespace}/{resourceType}", func(r chi.Router) { + r.Get("/", dynamicOperationHandler(v1.OperationList, controllerOptions, makeListResourceAtResourceGroupScopeController)) + r.Get("/{resourceName}", dynamicOperationHandler(v1.OperationGet, controllerOptions, makeGetResourceController)) + r.Put("/{resourceName}", dynamicOperationHandler(v1.OperationPut, controllerOptions, makePutResourceController)) + r.Delete("/{resourceName}", dynamicOperationHandler(v1.OperationDelete, controllerOptions, makeDeleteResourceController)) }) }) return nil } +var dynamicResourceOptions = controller.ResourceOptions[datamodel.DynamicResource]{ + RequestConverter: converter.DynamicResourceDataModelFromVersioned, + ResponseConverter: converter.DynamicResourceDataModelToVersioned, + AsyncOperationRetryAfter: time.Second * 5, + AsyncOperationTimeout: time.Hour * 24, +} + +func makeListResourceAtPlaneScopeController(opts controller.Options) (controller.Controller, error) { + // At plane scope we list resources recursively to include all resource groups. + copy := dynamicResourceOptions + copy.ListRecursiveQuery = true + return defaultoperation.NewListResources(opts, copy) +} + +func makeListResourceAtResourceGroupScopeController(opts controller.Options) (controller.Controller, error) { + return defaultoperation.NewListResources(opts, dynamicResourceOptions) +} + +func makeGetResourceController(opts controller.Options) (controller.Controller, error) { + return defaultoperation.NewGetResource(opts, dynamicResourceOptions) +} + +func makePutResourceController(opts controller.Options) (controller.Controller, error) { + return defaultoperation.NewDefaultAsyncPut(opts, dynamicResourceOptions) +} + +func makeDeleteResourceController(opts controller.Options) (controller.Controller, error) { + return defaultoperation.NewDefaultAsyncDelete(opts, dynamicResourceOptions) +} + func makeGetOperationResultController(opts controller.Options) (controller.Controller, error) { return defaultoperation.NewGetOperationResult(opts) } diff --git a/pkg/dynamicrp/integrationtest/dynamic/providers_test.go b/pkg/dynamicrp/integrationtest/dynamic/providers_test.go index bcfda112ff..1554c14d47 100644 --- a/pkg/dynamicrp/integrationtest/dynamic/providers_test.go +++ b/pkg/dynamicrp/integrationtest/dynamic/providers_test.go @@ -42,7 +42,7 @@ const ( exampleResourcePlaneID = "/planes/radius/" + radiusPlaneName exampleResourceGroupID = exampleResourcePlaneID + "/resourceGroups/test-group" - exampleResourceID = exampleResourceGroupID + "/providers/Applications.Test/exampleResources/" + exampleResourceName + exampleResourceID = exampleResourceGroupID + "/providers/" + resourceProviderNamespace + "/" + resourceTypeName + "/" + exampleResourceName exampleResourceURL = exampleResourceID + "?api-version=" + apiVersion ) @@ -60,12 +60,61 @@ func Test_Dynamic_Resource_Lifecycle(t *testing.T) { // Setup a resource group where we can interact with the new resource type. createResourceGroup(ucp) - // We have not yet implemented any functionality for dynamic RP. + // Now let's test the basic CRUD operations on the new resource type. // - // This is the hello-worldiest of tests. We're just making sure that all - // of the infrastructure works. - response := ucp.MakeRequest(http.MethodGet, exampleResourceURL, nil) - response.EqualsErrorCode(404, "NotFound") + // This resource type DOES NOT support recipes, so it's "inert" and doesn't do anything in the backend. + resource := map[string]any{ + "properties": map[string]any{ + "foo": "bar", + }, + "tags": map[string]string{ + "costcenter": "12345", + }, + } + + // Create the resource + response := ucp.MakeTypedRequest(http.MethodPut, exampleResourceURL, resource) + response.WaitForOperationComplete(nil) + + // Now lets verify the resource was created successfully. + + expectedResource := map[string]any{ + "id": "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleResources/my-example", + "location": "global", + "name": "my-example", + "properties": map[string]any{ + "foo": "bar", + "provisioningState": "Succeeded", + }, + "tags": map[string]any{ + "costcenter": "12345", + }, + "type": "Applications.Test/exampleResources", + } + + expectedList := map[string]any{ + "value": []any{expectedResource}, + } + + // GET (single) + response = ucp.MakeRequest(http.MethodGet, exampleResourceURL, nil) + response.EqualsValue(200, expectedResource) + + // GET (list at plane-scope) + response = ucp.MakeRequest(http.MethodGet, "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleResources"+"?api-version="+apiVersion, nil) + response.EqualsValue(200, expectedList) + + // GET (list at resourcegroup-scope) + response = ucp.MakeRequest(http.MethodGet, "/planes/radius/testing/providers/Applications.Test/exampleResources"+"?api-version="+apiVersion, nil) + response.EqualsValue(200, expectedList) + + // Now lets delete the resource + response = ucp.MakeRequest(http.MethodDelete, exampleResourceURL, nil) + response.WaitForOperationComplete(nil) + + // Now we should get a 404 when trying to get the resource + response = ucp.MakeRequest(http.MethodGet, exampleResourceURL, nil) + response.EqualsErrorCode(404, v1.CodeNotFound) } func createRadiusPlane(server *ucptesthost.TestHost) v20231001preview.RadiusPlanesClientCreateOrUpdateResponse {