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 {