From 318808c4a86e2a1eacbed54f1413de4614b4d979 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Wed, 19 Jun 2024 08:43:34 -0700 Subject: [PATCH] Initial implementation of user-defined-types (#7686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change implements the skeleton of user-defined types. The changes here enable the following: - Users can author a resource of type `System.Resources/resourceProviders` to create a user-defined-type. - Users can use the UCP API to register and query `resourceProviders`. - Users can use the UCP API to execute the full lifecycle of a user-defined-type. Right now the user-defined-type RP will use our default operation (synchronous) controllers to implement the resource lifecycle. There is no background processing. The next step will include the ability to execute asynchronous operations like recipes. - This pull request fixes a bug in Radius and has an approved issue (issue link required). - This pull request adds or changes features of Radius and has an approved issue (issue link required). Part of: #6688 **note: This change is going into a feature-branch where we can iterate on the user-defined-type design before integrating it with main. The PR is an FYI 😆.** --------- Signed-off-by: ytimocin Signed-off-by: dependabot[bot] Signed-off-by: willdavsmith Signed-off-by: Ryan Nowak Co-authored-by: Yetkin Timocin Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Will Smith Signed-off-by: Ryan Nowak --- pkg/armrpc/asyncoperation/worker/registry.go | 43 ++- .../asyncoperation/worker/registry_test.go | 7 +- pkg/armrpc/asyncoperation/worker/worker.go | 10 +- pkg/armrpc/builder/builder_test.go | 3 +- pkg/dynamicrp/api/dynamicresource.go | 32 ++ .../api/dynamicresource_conversion.go | 96 ++++++ .../api/dynamicresource_conversion_test.go | 126 ++++++++ .../testdata/dynamicresource-datamodel.json | 20 ++ .../testdata/dynamicresource-resource.json | 12 + .../backend/controller/dynamic/process.go | 71 ++++ .../controller/dynamic/process_test.go | 17 + pkg/dynamicrp/backend/service.go | 6 +- .../converter/dynamicresource_converter.go | 48 +++ pkg/dynamicrp/datamodel/dynamicresource.go | 38 +++ pkg/dynamicrp/frontend/routes.go | 143 +++++++++ .../integrationtest/dynamic/providers_test.go | 207 ++++++++++++ ...0240101preview_emptylist_responsebody.json | 3 + ...ce_v20240101preview_list_responsebody.json | 14 + ...resource_v20240101preview_requestbody.json | 6 + ...esource_v20240101preview_responsebody.json | 10 + .../integrationtest/dynamic/util_test.go | 56 ++++ pkg/dynamicrp/testhost/doc.go | 18 ++ pkg/dynamicrp/testhost/host.go | 173 ++++++++++ .../resourcegroups/trackedresourceprocess.go | 12 +- .../trackedresourceprocess_test.go | 18 +- pkg/ucp/backend/service.go | 13 +- pkg/ucp/datamodel/genericresource.go | 1 + pkg/ucp/datamodel/location.go | 3 + pkg/ucp/datamodel/resourceprovider_util.go | 32 ++ pkg/ucp/frontend/controller/radius/proxy.go | 45 ++- .../frontend/controller/radius/proxy_test.go | 49 ++- .../controller/resourcegroups/util.go | 151 ++++++++- .../controller/resourcegroups/util_test.go | 302 +++++++++++++++++- pkg/ucp/frontend/modules/types.go | 9 + pkg/ucp/frontend/radius/module.go | 7 +- pkg/ucp/frontend/radius/routes.go | 48 ++- pkg/ucp/integrationtests/radius/proxy_test.go | 2 +- .../integrationtests/testserver/testserver.go | 26 +- 38 files changed, 1793 insertions(+), 84 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/controller/dynamic/process.go create mode 100644 pkg/dynamicrp/backend/controller/dynamic/process_test.go create mode 100644 pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go create mode 100644 pkg/dynamicrp/datamodel/dynamicresource.go create mode 100644 pkg/dynamicrp/integrationtest/dynamic/providers_test.go create mode 100644 pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_emptylist_responsebody.json create mode 100644 pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_list_responsebody.json create mode 100644 pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_requestbody.json create mode 100644 pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_responsebody.json create mode 100644 pkg/dynamicrp/integrationtest/dynamic/util_test.go create mode 100644 pkg/dynamicrp/testhost/doc.go create mode 100644 pkg/dynamicrp/testhost/host.go create mode 100644 pkg/ucp/datamodel/resourceprovider_util.go diff --git a/pkg/armrpc/asyncoperation/worker/registry.go b/pkg/armrpc/asyncoperation/worker/registry.go index 814fc85b6d2..5fef1ce5d0f 100644 --- a/pkg/armrpc/asyncoperation/worker/registry.go +++ b/pkg/armrpc/asyncoperation/worker/registry.go @@ -25,6 +25,15 @@ import ( "github.com/radius-project/radius/pkg/ucp/dataprovider" ) +const ( + // ResourceTypeAny is a wildcard for any resource type. + ResourceTypeAny = "*" + + // OperationMethodAny is a wildcard for any operation method. + OperationMethodAny = "*" +) + +// ControllerFactoryFunc is a factory function to create a controller. type ControllerFactoryFunc func(opts ctrl.Options) (ctrl.Controller, error) // ControllerRegistry is an registry to register async controllers. @@ -32,6 +41,11 @@ type ControllerRegistry struct { ctrlMap map[string]ctrl.Controller ctrlMapMu sync.RWMutex sp dataprovider.DataStorageProvider + + // Fallback allows the registration of a controller that will be used + // for operations that don't match any other operation type. + fallbackFactory ControllerFactoryFunc + fallbackOpts ctrl.Options } // NewControllerRegistry creates an ControllerRegistry instance. @@ -48,6 +62,13 @@ func (h *ControllerRegistry) Register(ctx context.Context, resourceType string, defer h.ctrlMapMu.Unlock() ot := v1.OperationType{Type: resourceType, Method: method} + if resourceType == ResourceTypeAny && method == OperationMethodAny { + // This is a fallback controller. Skip registration for now so we can create instances + // dynamically when needed. + h.fallbackFactory = factoryFn + h.fallbackOpts = opts + return nil + } storageClient, err := h.sp.GetStorageClient(ctx, resourceType) if err != nil { @@ -66,13 +87,29 @@ func (h *ControllerRegistry) Register(ctx context.Context, resourceType string, } // Get gets the registered async controller instance. -func (h *ControllerRegistry) Get(operationType v1.OperationType) ctrl.Controller { +func (h *ControllerRegistry) Get(ctx context.Context, operationType v1.OperationType) (ctrl.Controller, error) { h.ctrlMapMu.RLock() defer h.ctrlMapMu.RUnlock() if h, ok := h.ctrlMap[operationType.String()]; ok { - return h + return h, nil } - return nil + // If no controller is found, then look for a default controller. + if h.fallbackFactory == nil { + return nil, nil + } + + storageClient, err := h.sp.GetStorageClient(ctx, operationType.Type) + if err != nil { + return nil, err + } + + // Copy the options so we can update it. + opts := h.fallbackOpts + + opts.StorageClient = storageClient + opts.ResourceType = operationType.Type + + return h.fallbackFactory(opts) } diff --git a/pkg/armrpc/asyncoperation/worker/registry_test.go b/pkg/armrpc/asyncoperation/worker/registry_test.go index 7fa94b271b2..b2f8689a7d9 100644 --- a/pkg/armrpc/asyncoperation/worker/registry_test.go +++ b/pkg/armrpc/asyncoperation/worker/registry_test.go @@ -63,8 +63,11 @@ func TestRegister_Get(t *testing.T) { }, ctrlOpts) require.NoError(t, err) - ctrl := registry.Get(opGet) + ctrl, err := registry.Get(context.Background(), opGet) + require.NoError(t, err) require.NotNil(t, ctrl) - ctrl = registry.Get(opPut) + + ctrl, err = registry.Get(context.Background(), opPut) + require.NoError(t, err) require.NotNil(t, ctrl) } diff --git a/pkg/armrpc/asyncoperation/worker/worker.go b/pkg/armrpc/asyncoperation/worker/worker.go index 1031a013cdb..e00c767170e 100644 --- a/pkg/armrpc/asyncoperation/worker/worker.go +++ b/pkg/armrpc/asyncoperation/worker/worker.go @@ -168,7 +168,15 @@ func (w *AsyncRequestProcessWorker) Start(ctx context.Context) error { } reqCtx = v1.WithARMRequestContext(reqCtx, armReqCtx) - asyncCtrl := w.registry.Get(armReqCtx.OperationType) + asyncCtrl, err := w.registry.Get(reqCtx, armReqCtx.OperationType) + if err != nil { + opLogger.Error(err, "failed to get async controller.") + if err := w.requestQueue.FinishMessage(reqCtx, msgreq); err != nil { + opLogger.Error(err, "failed to finish the message") + } + return + } + if asyncCtrl == nil { opLogger.Error(nil, "cannot process unknown operation: "+armReqCtx.OperationType.String()) if err := w.requestQueue.FinishMessage(reqCtx, msgreq); err != nil { diff --git a/pkg/armrpc/builder/builder_test.go b/pkg/armrpc/builder/builder_test.go index fee7ed8d1f8..c0c0f20654d 100644 --- a/pkg/armrpc/builder/builder_test.go +++ b/pkg/armrpc/builder/builder_test.go @@ -261,7 +261,8 @@ func TestApplyAsyncHandler(t *testing.T) { } for _, op := range expectedOperations { - jobCtrl := registry.Get(op) + jobCtrl, err := registry.Get(context.Background(), op) + require.NoError(t, err) require.NotNil(t, jobCtrl) } } diff --git a/pkg/dynamicrp/api/dynamicresource.go b/pkg/dynamicrp/api/dynamicresource.go new file mode 100644 index 00000000000..f0a602136af --- /dev/null +++ b/pkg/dynamicrp/api/dynamicresource.go @@ -0,0 +1,32 @@ +/* +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 is implemented internally to dynamic-rp, and uses a user-provided +// OpenAPI specification to define the resource schema. Since the resource is internal +// and dynamically generated, this struct is used to represent all dynamic resources. +type DynamicResource struct { + ID *string `json:"id"` + Name *string `json:"name"` + Type *string `json:"type"` + Location *string `json:"location"` + Tags map[string]*string `json:"tags,omitempty"` + Properties map[string]any `json:"properties,omitempty"` + 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 00000000000..b7c7d89c603 --- /dev/null +++ b/pkg/dynamicrp/api/dynamicresource_conversion.go @@ -0,0 +1,96 @@ +/* +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" + + 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" +) + +func (d *DynamicResource) ConvertTo() (v1.DataModelInterface, error) { + 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: d.Properties, + } + + return dm, nil +} + +func (d *DynamicResource) ConvertFrom(src v1.DataModelInterface) error { + dm, ok := src.(*datamodel.DynamicResource) + if !ok { + return v1.ErrInvalidModelConversion + } + + 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 = dm.Properties + if d.Properties == nil { + d.Properties = map[string]any{} + } + 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 00000000000..936448b6a1c --- /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 00000000000..0b5c40958f4 --- /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 00000000000..f2569eacb52 --- /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!" + } +} \ No newline at end of file diff --git a/pkg/dynamicrp/backend/controller/dynamic/process.go b/pkg/dynamicrp/backend/controller/dynamic/process.go new file mode 100644 index 00000000000..63eba67e3b3 --- /dev/null +++ b/pkg/dynamicrp/backend/controller/dynamic/process.go @@ -0,0 +1,71 @@ +/* +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 dynamic + +import ( + "context" + "fmt" + "net/http" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" +) + +var _ ctrl.Controller = (*Controller)(nil) + +// Controller is the async operation controller to perform background processing on tracked resources. +type Controller struct { + ctrl.BaseController +} + +// NewController creates a new Controller controller which is used to process resources asynchronously. +func NewController(opts ctrl.Options) (ctrl.Controller, error) { + return &Controller{ + BaseController: ctrl.NewBaseAsyncController(opts), + }, nil +} + +// Run implements the async operation controller to process resources asynchronously. +func (c *Controller) Run(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + operationType, _ := v1.ParseOperationType(request.OperationType) + switch operationType.Method { + case http.MethodPut: + return c.processPut(ctx, request) + case http.MethodDelete: + return c.processDelete(ctx, request) + default: + e := v1.ErrorDetails{ + Code: v1.CodeInvalid, + Message: fmt.Sprintf("Invalid operation type: %q", operationType), + Target: request.ResourceID, + } + return ctrl.NewFailedResult(e), nil + } +} + +func (c *Controller) processDelete(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + err := c.StorageClient().Delete(ctx, request.ResourceID) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (c *Controller) processPut(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + return ctrl.Result{}, nil +} diff --git a/pkg/dynamicrp/backend/controller/dynamic/process_test.go b/pkg/dynamicrp/backend/controller/dynamic/process_test.go new file mode 100644 index 00000000000..db95e2ea5b7 --- /dev/null +++ b/pkg/dynamicrp/backend/controller/dynamic/process_test.go @@ -0,0 +1,17 @@ +/* +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 dynamic diff --git a/pkg/dynamicrp/backend/service.go b/pkg/dynamicrp/backend/service.go index 370563c80bd..a5a8d6bd490 100644 --- a/pkg/dynamicrp/backend/service.go +++ b/pkg/dynamicrp/backend/service.go @@ -39,10 +39,14 @@ func NewService(options *dynamicrp.Options) *Service { ProviderName: "dynamic-rp", Options: hostoptions.HostOptions{ Config: &hostoptions.ProviderConfig{ + Bicep: options.Config.Bicep, Env: options.Config.Environment, - StorageProvider: options.Config.Storage, + Logging: options.Config.Logging, SecretProvider: options.Config.Secrets, QueueProvider: options.Config.Queue, + StorageProvider: options.Config.Storage, + Terraform: options.Config.Terraform, + WorkerServer: &options.Config.Worker, }, }, }, diff --git a/pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go b/pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go new file mode 100644 index 00000000000..776a6c9038a --- /dev/null +++ b/pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go @@ -0,0 +1,48 @@ +/* +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. + 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. + 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 00000000000..b99dbe2e113 --- /dev/null +++ b/pkg/dynamicrp/datamodel/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 datamodel + +import ( + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" +) + +// DynamicResource is used as the data model for dynamic resources. +// +// A dynamic resource is implemented internally to UCP, and uses a user-provided +// OpenAPI specification to define the resource schema. Since the resource is internal +// to UCP and dynamically generated, this struct is used to represent all dynamic resources. +type DynamicResource struct { + v1.BaseResource + + // Properties stores the properties of the resource being tracked. + Properties map[string]any `json:"properties"` +} + +// ResourceTypeName gives the type of the resource. +func (r *DynamicResource) ResourceTypeName() string { + return r.Type +} diff --git a/pkg/dynamicrp/frontend/routes.go b/pkg/dynamicrp/frontend/routes.go index 3988d532b34..38ea26bef83 100644 --- a/pkg/dynamicrp/frontend/routes.go +++ b/pkg/dynamicrp/frontend/routes.go @@ -17,14 +17,157 @@ limitations under the License. package frontend import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "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/armrpc/frontend/server" + "github.com/radius-project/radius/pkg/armrpc/rest" + "github.com/radius-project/radius/pkg/dynamicrp/api" + "github.com/radius-project/radius/pkg/dynamicrp/datamodel" + "github.com/radius-project/radius/pkg/ucp/resources" "github.com/radius-project/radius/pkg/validator" ) +const ( + operationBaseRoute = "/planes/radius/{planeName}/providers/{providerNamespace}/locations/{locationName}" + planeScopedResourceCollectionRoute = "/planes/radius/{planeName}/providers/{providerNamespace}/{resourceType}" + resourceCollectionRoute = "/planes/radius/{planeName}/{rg:resource[gG]roups}/{resourceGroupName}/providers/{providerNamespace}/{resourceType}" + resourceRoute = resourceCollectionRoute + "/{resourceName}" +) + func (s *Service) registerRoutes(r *chi.Mux) error { + ctrlOpts := controller.Options{ + Address: fmt.Sprintf("%s:%d", s.options.Config.Server.Host, s.options.Config.Server.Port), + PathBase: s.options.Config.Server.PathBase, + DataProvider: s.options.StorageProvider, + StatusManager: s.options.StatusManager, + + KubeClient: nil, // Unused by DynamicRP + StorageClient: nil, // Set dynamically + ResourceType: "", // Set dynamically + } + // Return ARM errors for invalid requests. r.NotFound(validator.APINotFoundHandler()) r.MethodNotAllowed(validator.APIMethodNotAllowedHandler()) + r.Route(s.options.Config.Server.PathBase, func(r chi.Router) { + register(r, "GET "+operationBaseRoute+"/operationResults/{operationID}", v1.OperationGet, ctrlOpts, func(opts controller.Options, ctrlOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewGetOperationResult(opts) + }) + + register(r, "GET "+operationBaseRoute+"/operationStatuses/{operationID}", v1.OperationGet, ctrlOpts, func(opts controller.Options, ctrlOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewGetOperationStatus(opts) + }) + + register(r, "GET "+planeScopedResourceCollectionRoute, v1.OperationPlaneScopeList, ctrlOpts, func(ctrlOpts controller.Options, resourceOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + resourceOpts.ListRecursiveQuery = true + return defaultoperation.NewListResources[*datamodel.DynamicResource, datamodel.DynamicResource](ctrlOpts, resourceOpts) + }) + + register(r, "GET "+resourceCollectionRoute, v1.OperationList, ctrlOpts, func(ctrlOpts controller.Options, resourceOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewListResources[*datamodel.DynamicResource, datamodel.DynamicResource](ctrlOpts, resourceOpts) + }) + + register(r, "GET "+resourceRoute, v1.OperationGet, ctrlOpts, func(ctrlOpts controller.Options, resourceOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewGetResource[*datamodel.DynamicResource, datamodel.DynamicResource](ctrlOpts, resourceOpts) + }) + + register(r, "PUT "+resourceRoute, v1.OperationPut, ctrlOpts, func(ctrlOpts controller.Options, resourceOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewDefaultSyncPut[*datamodel.DynamicResource, datamodel.DynamicResource](ctrlOpts, resourceOpts) + }) + + register(r, "DELETE "+resourceRoute, v1.OperationDelete, ctrlOpts, func(ctrlOpts controller.Options, resourceOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewDefaultSyncDelete[*datamodel.DynamicResource, datamodel.DynamicResource](ctrlOpts, resourceOpts) + }) + }) + return nil } + +type controllerFactory = func(opts controller.Options, ctrlOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) + +func register(r chi.Router, pattern string, method v1.OperationMethod, opts controller.Options, factory controllerFactory) { + r.Handle(pattern, dynamicOperationType(method, opts, factory)) +} + +func dynamicOperationType(method v1.OperationMethod, opts controller.Options, factory func(opts controller.Options, ctrlOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, err := resources.Parse(r.URL.Path) + if err != nil { + result := rest.NewBadRequestResponse(err.Error()) + err = result.Apply(r.Context(), w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + return + } + + operationType := v1.OperationType{Type: strings.ToUpper(id.Type()), Method: method} + + // Copy the options and initalize them dynamically for this type. + opts := opts + opts.ResourceType = id.Type() + + client, err := opts.DataProvider.GetStorageClient(r.Context(), id.Type()) + if err != nil { + result := rest.NewBadRequestResponse(err.Error()) + err = result.Apply(r.Context(), w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + return + } + + opts.StorageClient = client + + ctrlOpts := controller.ResourceOptions[datamodel.DynamicResource]{ + RequestConverter: func(content []byte, version string) (*datamodel.DynamicResource, error) { + api := &api.DynamicResource{} + + err := json.Unmarshal(content, api) + if err != nil { + return nil, err + } + + dm, err := api.ConvertTo() + if err != nil { + return nil, err + } + + return dm.(*datamodel.DynamicResource), nil + }, + ResponseConverter: func(resource *datamodel.DynamicResource, version string) (v1.VersionedModelInterface, error) { + api := &api.DynamicResource{} + err = api.ConvertFrom(resource) + if err != nil { + return nil, err + } + + return api, nil + }, + } + + ctrl, err := factory(opts, ctrlOpts) + if err != nil { + result := rest.NewBadRequestResponse(err.Error()) + err = result.Apply(r.Context(), w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + return + } + + handler := server.HandlerForController(ctrl, operationType) + handler.ServeHTTP(w, r) + }) +} diff --git a/pkg/dynamicrp/integrationtest/dynamic/providers_test.go b/pkg/dynamicrp/integrationtest/dynamic/providers_test.go new file mode 100644 index 00000000000..873899fcb5e --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/providers_test.go @@ -0,0 +1,207 @@ +/* +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 dynamic + +import ( + "context" + "net/http" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/dynamicrp/testhost" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/stretchr/testify/require" +) + +const ( + radiusPlaneName = "testing" + resourceProviderNamespace = "Applications.Test" + resourceTypeName = "exampleResources" + locationName = v1.LocationGlobal + apiVersion = "2024-01-01" + + resourceGroupName = "test-group" + exampleResourceName = "my-example" + + exampleResourcePlaneID = "/planes/radius/" + radiusPlaneName + exampleResourceGroupID = exampleResourcePlaneID + "/resourceGroups/test-group" + exampleResourcePlaneCollectionURL = exampleResourcePlaneID + "/providers/Applications.Test/exampleResources?api-version=" + apiVersion + exampleResourceCollectionURL = exampleResourceGroupID + "/providers/Applications.Test/exampleResources?api-version=" + apiVersion + + exampleResourceID = exampleResourceGroupID + "/providers/Applications.Test/exampleResources/" + exampleResourceName + exampleResourceURL = exampleResourceID + "?api-version=" + apiVersion + + exampleResourceEmptyListResponseFixture = "testdata/exampleresource_v20240101preview_emptylist_responsebody.json" + exampleResourceListResponseFixture = "testdata/exampleresource_v20240101preview_list_responsebody.json" + + exampleResourceRequestFixture = "testdata/exampleresource_v20240101preview_requestbody.json" + exampleResourceResponseFixture = "testdata/exampleresource_v20240101preview_responsebody.json" +) + +// This test covers the lifecycle of a dynamic resource. +func Test_Dynamic_Resource_Lifecycle(t *testing.T) { + _, server := testhost.Start(t) + + // Setup a resource provider (Applications.Test/exampleResources) + createRadiusPlane(server) + createResourceProvider(server) + createResourceType(server) + createAPIVersion(server) + createLocation(server) + + // Setup a resource group where we can interact with the new resource type. + createResourceGroup(server) + + // List should start empty + response := server.MakeRequest(http.MethodGet, exampleResourceCollectionURL, nil) + response.EqualsFixture(200, exampleResourceEmptyListResponseFixture) + + // Getting a specific resource should return 404. + response = server.MakeRequest(http.MethodGet, exampleResourceURL, nil) + response.EqualsErrorCode(404, "NotFound") + + // Create a resource + response = server.MakeFixtureRequest(http.MethodPut, exampleResourceURL, exampleResourceRequestFixture) + response.EqualsFixture(200, exampleResourceResponseFixture) + + // List should now contain the resource + response = server.MakeRequest(http.MethodGet, exampleResourceCollectionURL, nil) + response.EqualsFixture(200, exampleResourceListResponseFixture) + + // Getting the resource should return 200 + response = server.MakeRequest(http.MethodGet, exampleResourceURL, nil) + response.EqualsFixture(200, exampleResourceResponseFixture) + + // Deleting a resource should return 200 + response = server.MakeRequest(http.MethodDelete, exampleResourceURL, nil) + response.EqualsStatusCode(200) + + // Now the resource is gone + response = server.MakeRequest(http.MethodGet, exampleResourceCollectionURL, nil) + response.EqualsFixture(200, exampleResourceEmptyListResponseFixture) + response = server.MakeRequest(http.MethodGet, exampleResourceURL, nil) + response.EqualsErrorCode(404, "NotFound") +} + +func createRadiusPlane(server *testserver.TestServer) v20231001preview.RadiusPlanesClientCreateOrUpdateResponse { + ctx := context.Background() + + plane := v20231001preview.RadiusPlaneResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.RadiusPlaneResourceProperties{ + // Note: this is a workaround. Properties is marked as a required field in + // the API. Without passing *something* here the body will be rejected. + ProvisioningState: to.Ptr(v20231001preview.ProvisioningStateSucceeded), + ResourceProviders: map[string]*string{}, + }, + } + + client := server.UCP().NewRadiusPlanesClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, plane, nil) + require.NoError(server.T(), err) + + response, err := poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) + + return response +} + +func createResourceProvider(server *testserver.TestServer) { + ctx := context.Background() + + resourceProvider := v20231001preview.ResourceProviderResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.ResourceProviderProperties{}, + } + + client := server.UCP().NewResourceProvidersClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, resourceProvider, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createResourceType(server *testserver.TestServer) { + ctx := context.Background() + + resourceType := v20231001preview.ResourceTypeResource{ + Properties: &v20231001preview.ResourceTypeProperties{ + DefaultAPIVersion: to.Ptr(apiVersion), + }, + } + + client := server.UCP().NewResourceTypesClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, resourceTypeName, resourceType, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createAPIVersion(server *testserver.TestServer) { + ctx := context.Background() + + apiVersionResource := v20231001preview.APIVersionResource{ + Properties: &v20231001preview.APIVersionProperties{}, + } + + client := server.UCP().NewAPIVersionsClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, resourceTypeName, apiVersion, apiVersionResource, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createLocation(server *testserver.TestServer) { + ctx := context.Background() + + location := v20231001preview.LocationResource{ + Properties: &v20231001preview.LocationProperties{ + ResourceTypes: map[string]*v20231001preview.LocationResourceType{ + resourceTypeName: { + APIVersions: map[string]map[string]any{ + apiVersion: map[string]any{}, + }, + }, + }, + }, + } + + client := server.UCP().NewLocationsClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, locationName, location, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createResourceGroup(server *testserver.TestServer) { + ctx := context.Background() + + resourceGroup := v20231001preview.ResourceGroupResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.ResourceGroupProperties{}, + } + + client := server.UCP().NewResourceGroupsClient() + _, err := client.CreateOrUpdate(ctx, radiusPlaneName, resourceGroupName, resourceGroup, nil) + require.NoError(server.T(), err) +} diff --git a/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_emptylist_responsebody.json b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_emptylist_responsebody.json new file mode 100644 index 00000000000..bcd37241563 --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_emptylist_responsebody.json @@ -0,0 +1,3 @@ +{ + "value": [] +} \ No newline at end of file diff --git a/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_list_responsebody.json b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_list_responsebody.json new file mode 100644 index 00000000000..63f08b2e411 --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_list_responsebody.json @@ -0,0 +1,14 @@ +{ + "value": [ + { + "id": "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleResources/my-example", + "name": "my-example", + "type": "Applications.Test/exampleResources", + "location": "global", + "properties": { + "message": "this is a very cool user-defined-type", + "provisioningState": "Succeeded" + } + } + ] +} \ No newline at end of file diff --git a/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_requestbody.json b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_requestbody.json new file mode 100644 index 00000000000..3fbe33abe3c --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_requestbody.json @@ -0,0 +1,6 @@ +{ + "location": "global", + "properties": { + "message": "this is a very cool user-defined-type" + } +} \ No newline at end of file diff --git a/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_responsebody.json b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_responsebody.json new file mode 100644 index 00000000000..fcc99fdfa2d --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_responsebody.json @@ -0,0 +1,10 @@ +{ + "id": "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleResources/my-example", + "name": "my-example", + "type": "Applications.Test/exampleResources", + "location": "global", + "properties": { + "message": "this is a very cool user-defined-type", + "provisioningState": "Succeeded" + } +} \ No newline at end of file diff --git a/pkg/dynamicrp/integrationtest/dynamic/util_test.go b/pkg/dynamicrp/integrationtest/dynamic/util_test.go new file mode 100644 index 00000000000..15788c71361 --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/util_test.go @@ -0,0 +1,56 @@ +/* +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 dynamic + +import ( + "context" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" + "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/stretchr/testify/require" +) + +func PutPlane(t *testing.T, ts *testserver.TestServer) { + connection, err := sdk.NewDirectConnection(ts.BaseURL) + require.NoError(t, err) + + clientOptions := sdk.NewClientOptions(connection) + + client, err := v20231001preview.NewRadiusPlanesClient(&aztoken.AnonymousCredential{}, clientOptions) + require.NoError(t, err) + + plane := v20231001preview.RadiusPlaneResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.RadiusPlaneResourceProperties{ + // Note: this is a workaround. Properties is marked as a required field in + // the API. Without passing *something* here the body will be rejected. + ProvisioningState: to.Ptr(v20231001preview.ProvisioningStateSucceeded), + ResourceProviders: map[string]*string{}, + }, + } + + poller, err := client.BeginCreateOrUpdate(context.Background(), "local", plane, nil) + require.NoError(t, err) + + _, err = poller.PollUntilDone(context.Background(), nil) + require.NoError(t, err) +} diff --git a/pkg/dynamicrp/testhost/doc.go b/pkg/dynamicrp/testhost/doc.go new file mode 100644 index 00000000000..bdd65e049e9 --- /dev/null +++ b/pkg/dynamicrp/testhost/doc.go @@ -0,0 +1,18 @@ +/* +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. +*/ + +// testhost provides an implementation of a test server for the dynamic RP. +package testhost diff --git a/pkg/dynamicrp/testhost/host.go b/pkg/dynamicrp/testhost/host.go new file mode 100644 index 00000000000..9e9de4a2eff --- /dev/null +++ b/pkg/dynamicrp/testhost/host.go @@ -0,0 +1,173 @@ +/* +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 testhost + +import ( + "context" + "fmt" + "net" + "net/http" + "sync" + "testing" + + "github.com/google/uuid" + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/armrpc/hostoptions" + "github.com/radius-project/radius/pkg/dynamicrp" + "github.com/radius-project/radius/pkg/dynamicrp/server" + "github.com/radius-project/radius/pkg/ucp/config" + "github.com/radius-project/radius/pkg/ucp/dataprovider" + "github.com/radius-project/radius/pkg/ucp/frontend/api" + "github.com/radius-project/radius/pkg/ucp/frontend/modules" + "github.com/radius-project/radius/pkg/ucp/hosting" + "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + queueprovider "github.com/radius-project/radius/pkg/ucp/queue/provider" + secretprovider "github.com/radius-project/radius/pkg/ucp/secret/provider" + "github.com/radius-project/radius/test/testcontext" + "github.com/stretchr/testify/require" +) + +func Start(t *testing.T) (*TestHost, *testserver.TestServer) { + config := &dynamicrp.Config{ + Environment: hostoptions.EnvironmentOptions{ + Name: "test", + RoleLocation: v1.LocationGlobal, + }, + Queue: queueprovider.QueueProviderOptions{ + Provider: queueprovider.TypeInmemory, + }, + Secrets: secretprovider.SecretProviderOptions{ + Provider: secretprovider.TypeInMemorySecret, + }, + Server: hostoptions.ServerOptions{ + // Initialized dynamically when the server is started. + }, + Storage: dataprovider.StorageProviderOptions{ + Provider: dataprovider.TypeInMemory, + }, + UCP: config.UCPOptions{ + Kind: config.UCPConnectionKindDirect, + Direct: &config.UCPDirectConnectionOptions{ + Endpoint: "http://localhost:8080", + }, + }, + } + + options, err := dynamicrp.NewOptions(context.Background(), config) + require.NoError(t, err) + + return StartWithOptions(t, options) +} + +func StartWithOptions(t *testing.T, options *dynamicrp.Options) (*TestHost, *testserver.TestServer) { + // Allocate a random free port. + listener, err := net.Listen("tcp", ":0") + require.NoError(t, err, "failed to allocate port") + + options.Config.Server.Host = "localhost" + options.Config.Server.Port = listener.Addr().(*net.TCPAddr).Port + err = listener.Close() + require.NoError(t, err, "failed to close listener") + + options.Config.Server.PathBase = "/" + uuid.New().String() + baseURL := fmt.Sprintf( + "http://%s:%d%s", + options.Config.Server.Host, + options.Config.Server.Port) + + host, err := server.NewServer(options) + require.NoError(t, err, "failed to create server") + + ctx, cancel := context.WithCancel(testcontext.New(t)) + errs, messages := host.RunAsync(ctx) + + go func() { + for msg := range messages { + t.Logf("Message: %s", msg) + } + }() + + th := &TestHost{ + baseURL: baseURL, + host: host, + messages: messages, + cancel: cancel, + stoppedChan: errs, + t: t, + } + t.Cleanup(th.Close) + + return th, startUCP(t, baseURL) +} + +func startUCP(t *testing.T, url string) *testserver.TestServer { + return testserver.StartWithETCD(t, func(options modules.Options) []modules.Initializer { + options.DynamicRP.URL = url + return api.DefaultModules(options) + }) +} + +// TestHost is a test server for the dynamic RP. Do not construct this type directly, use one of the +// Start functions. +type TestHost struct { + // baseURL is the base URL of the server, including the path base. + baseURL string + + // host is the hosting process running the component. + host *hosting.Host + + // messages is the channel that will receive lifecycle messages from the host. + messages <-chan hosting.LifecycleMessage + + // cancel is the function to call to stop the server. + cancel context.CancelFunc + + // stoppedChan is the channel that will be closed when the server has stopped. + stoppedChan <-chan error + + // shutdown is used to ensure that Close is only called once. + shutdown sync.Once + + // t is the testing.T instance to use for assertions. + t *testing.T +} + +// Close shuts down the server and will block until shutdown completes. +func (th *TestHost) Close() { + // We're being picking about resource cleanup here, because unless we are picky we hit scalability + // problems in tests pretty quickly. + th.shutdown.Do(func() { + // Shut down the host. + th.cancel() + + if th.stoppedChan != nil { + <-th.stoppedChan // host stopped + } + }) +} + +// BaseURL returns the base URL of the server, including the path base. +// +// This should be used as a URL prefix for all requests to the server. +func (th *TestHost) BaseURL() string { + return th.baseURL +} + +// Client returns the HTTP client to use to make requests to the server. +func (th *TestHost) Client() *http.Client { + return http.DefaultClient +} diff --git a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go index 5623d88518d..3a628d12e1b 100644 --- a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go +++ b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go @@ -30,7 +30,6 @@ import ( "github.com/radius-project/radius/pkg/ucp/store" "github.com/radius-project/radius/pkg/ucp/trackedresource" "github.com/radius-project/radius/pkg/ucp/ucplog" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) var _ ctrl.Controller = (*TrackedResourceProcessController)(nil) @@ -48,9 +47,11 @@ type TrackedResourceProcessController struct { } // NewTrackedResourceProcessController creates a new TrackedResourceProcessController controller which is used to process resources asynchronously. -func NewTrackedResourceProcessController(opts ctrl.Options) (ctrl.Controller, error) { - transport := otelhttp.NewTransport(http.DefaultTransport) - return &TrackedResourceProcessController{ctrl.NewBaseAsyncController(opts), trackedresource.NewUpdater(opts.StorageClient, &http.Client{Transport: transport})}, nil +func NewTrackedResourceProcessController(opts ctrl.Options, transport http.RoundTripper) (ctrl.Controller, error) { + return &TrackedResourceProcessController{ + BaseController: ctrl.NewBaseAsyncController(opts), + updater: trackedresource.NewUpdater(opts.StorageClient, &http.Client{Transport: transport}), + }, nil } // Run retrieves a resource from storage, parses the resource ID, and updates our tracked resource entry in the background. @@ -67,7 +68,7 @@ func (c *TrackedResourceProcessController) Run(ctx context.Context, request *ctr return ctrl.Result{}, err } - downstreamURL, err := resourcegroups.ValidateDownstream(ctx, c.StorageClient(), originalID) + downstreamURL, err := resourcegroups.ValidateDownstream(ctx, c.StorageClient(), originalID, v1.LocationGlobal, resource.Properties.APIVersion) if errors.Is(err, &resourcegroups.NotFoundError{}) { return ctrl.NewFailedResult(v1.ErrorDetails{Code: v1.CodeNotFound, Message: err.Error(), Target: request.ResourceID}), nil } else if errors.Is(err, &resourcegroups.InvalidError{}) { @@ -78,6 +79,7 @@ func (c *TrackedResourceProcessController) Run(ctx context.Context, request *ctr logger := ucplog.FromContextOrDiscard(ctx) logger.Info("Processing tracked resource", "resourceID", originalID) + err = c.updater.Update(ctx, downstreamURL.String(), originalID, resource.Properties.APIVersion) if errors.Is(err, &trackedresource.InProgressErr{}) { // The resource is still being processed, so we can sleep for a while. diff --git a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go index 1958ace976d..5721af97bc6 100644 --- a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go +++ b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go @@ -32,12 +32,16 @@ import ( "go.uber.org/mock/gomock" ) +const ( + location = "global" +) + func Test_Run(t *testing.T) { setup := func(t *testing.T) (*TrackedResourceProcessController, *mockUpdater, *store.MockStorageClient) { ctrl := gomock.NewController(t) storageClient := store.NewMockStorageClient(ctrl) - pc, err := NewTrackedResourceProcessController(controller.Options{StorageClient: storageClient}) + pc, err := NewTrackedResourceProcessController(controller.Options{StorageClient: storageClient}, nil) require.NoError(t, err) updater := mockUpdater{} @@ -47,6 +51,10 @@ func Test_Run(t *testing.T) { id := resources.MustParse("/planes/test/local/resourceGroups/test-rg/providers/Applications.Test/testResources/my-resource") trackingID := trackedresource.IDFor(id) + providerID, err := datamodel.ResourceProviderIDFromResourceID(id) + require.NoError(t, err) + + locationID := providerID.Append(resources.TypeSegment{Type: "locations", Name: location}) plane := datamodel.RadiusPlane{ Properties: datamodel.RadiusPlaneProperties{ @@ -70,6 +78,10 @@ func Test_Run(t *testing.T) { Get(gomock.Any(), "/planes/"+trackingID.PlaneNamespace(), gomock.Any()). Return(&store.Object{Data: plane}, nil).Times(1) + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) + storageClient.EXPECT(). Get(gomock.Any(), trackingID.RootScope(), gomock.Any()). Return(&store.Object{Data: resourceGroup}, nil).Times(1) @@ -90,6 +102,10 @@ func Test_Run(t *testing.T) { Get(gomock.Any(), "/planes/"+trackingID.PlaneNamespace(), gomock.Any()). Return(&store.Object{Data: plane}, nil).Times(1) + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) + storageClient.EXPECT(). Get(gomock.Any(), trackingID.RootScope(), gomock.Any()). Return(&store.Object{Data: resourceGroup}, nil).Times(1) diff --git a/pkg/ucp/backend/service.go b/pkg/ucp/backend/service.go index b325a3bdcf1..d0420143192 100644 --- a/pkg/ucp/backend/service.go +++ b/pkg/ucp/backend/service.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "net/http" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" @@ -30,6 +31,7 @@ import ( "github.com/radius-project/radius/pkg/ucp/backend/controller/resourcegroups" "github.com/radius-project/radius/pkg/ucp/backend/controller/resourceproviders" "github.com/radius-project/radius/pkg/ucp/datamodel" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) const ( @@ -44,7 +46,7 @@ type Service struct { // NewService creates new service instance to run AsyncRequestProcessWorker. func NewService(options hostoptions.HostOptions) *Service { return &Service{ - worker.Service{ + Service: worker.Service{ ProviderName: UCPProviderName, Options: options, }, @@ -77,7 +79,8 @@ func (w *Service) Run(ctx context.Context) error { DataProvider: w.StorageProvider, } - err := RegisterControllers(ctx, w.Controllers, w.Options.UCPConnection, opts) + transport := otelhttp.NewTransport(http.DefaultTransport) + err := RegisterControllers(ctx, w.Controllers, w.Options.UCPConnection, transport, opts) if err != nil { return err } @@ -86,9 +89,11 @@ func (w *Service) Run(ctx context.Context) error { } // RegisterControllers registers the controllers for the UCP backend. -func RegisterControllers(ctx context.Context, registry *worker.ControllerRegistry, connection sdk.Connection, opts ctrl.Options) error { +func RegisterControllers(ctx context.Context, registry *worker.ControllerRegistry, connection sdk.Connection, transport http.RoundTripper, opts ctrl.Options) error { // Tracked resources - err := errors.Join(nil, registry.Register(ctx, v20231001preview.ResourceType, v1.OperationMethod(datamodel.OperationProcess), resourcegroups.NewTrackedResourceProcessController, opts)) + err := errors.Join(nil, registry.Register(ctx, v20231001preview.ResourceType, v1.OperationMethod(datamodel.OperationProcess), func(opts ctrl.Options) (ctrl.Controller, error) { + return resourcegroups.NewTrackedResourceProcessController(opts, transport) + }, opts)) // Resource providers and related types err = errors.Join(err, registry.Register(ctx, datamodel.ResourceProviderResourceType, v1.OperationPut, func(opts ctrl.Options) (ctrl.Controller, error) { diff --git a/pkg/ucp/datamodel/genericresource.go b/pkg/ucp/datamodel/genericresource.go index 1def4b001bc..3259d49a0cd 100644 --- a/pkg/ucp/datamodel/genericresource.go +++ b/pkg/ucp/datamodel/genericresource.go @@ -24,6 +24,7 @@ import ( const ( // OperationProcess is the operation type for processing a tracked resource. OperationProcess = "PROCESS" + // GenericResourceType is the resource type for a generic resource. GenericResourceType = "System.Resources/resources" ) diff --git a/pkg/ucp/datamodel/location.go b/pkg/ucp/datamodel/location.go index f4ea49999f3..c1473d6f9d4 100644 --- a/pkg/ucp/datamodel/location.go +++ b/pkg/ucp/datamodel/location.go @@ -21,6 +21,9 @@ import v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" const ( // LocationResourceType is the resource type for a resource provider location LocationResourceType = "System.Resources/resourceProviders/locations" + + // LocationUnqualifiedResourceType is the unqualified resource type for a resource provider location + LocationUnqualifiedResourceType = "locations" ) // Location represents a location. diff --git a/pkg/ucp/datamodel/resourceprovider_util.go b/pkg/ucp/datamodel/resourceprovider_util.go new file mode 100644 index 00000000000..846ae9c4ff6 --- /dev/null +++ b/pkg/ucp/datamodel/resourceprovider_util.go @@ -0,0 +1,32 @@ +/* +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 "github.com/radius-project/radius/pkg/ucp/resources" + +// ResourceProviderIDFromResourceID converts an inbound resource id to the resource ID +// of the resource provider. +func ResourceProviderIDFromResourceID(id resources.ID) (resources.ID, error) { + // Ex: + // /planes/radius/local/providers/Applications.Test/testResources/foo + // => /planes/radius/local/providers/System.Resources/resourceProviders/Applications.Test + return resources.ParseResource( + id.PlaneScope() + + resources.SegmentSeparator + resources.ProvidersSegment + + resources.SegmentSeparator + ResourceProviderResourceType + + resources.SegmentSeparator + id.ProviderNamespace()) +} diff --git a/pkg/ucp/frontend/controller/radius/proxy.go b/pkg/ucp/frontend/controller/radius/proxy.go index 8f2bbdfd3a5..e6f3121eb91 100644 --- a/pkg/ucp/frontend/controller/radius/proxy.go +++ b/pkg/ucp/frontend/controller/radius/proxy.go @@ -25,6 +25,7 @@ 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/asyncoperation/statusmanager" armrpc_controller "github.com/radius-project/radius/pkg/armrpc/frontend/controller" @@ -37,7 +38,6 @@ import ( "github.com/radius-project/radius/pkg/ucp/store" "github.com/radius-project/radius/pkg/ucp/trackedresource" "github.com/radius-project/radius/pkg/ucp/ucplog" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) const ( @@ -55,7 +55,7 @@ const ( ) type updater interface { - Update(ctx context.Context, downstream string, id resources.ID, version string) error + Update(ctx context.Context, downstreamURL string, originalID resources.ID, version string) error } var _ armrpc_controller.Controller = (*ProxyController)(nil) @@ -64,6 +64,9 @@ var _ armrpc_controller.Controller = (*ProxyController)(nil) type ProxyController struct { armrpc_controller.Operation[*datamodel.RadiusPlane, datamodel.RadiusPlane] + // defaultDownstrmmmm /// TODO SET ME + defaultDownstream *url.URL + // transport is the http.RoundTripper to use for proxying requests. Can be overridden for testing. transport http.RoundTripper @@ -71,17 +74,20 @@ type ProxyController struct { updater updater } -// # Function Explanation -// // NewProxyController creates a new ProxyPlane controller with the given options and returns it, or returns an error if the // controller cannot be created. -func NewProxyController(opts armrpc_controller.Options) (armrpc_controller.Controller, error) { - transport := otelhttp.NewTransport(http.DefaultTransport) +func NewProxyController(opts armrpc_controller.Options, transport http.RoundTripper, defaultDownstream string) (armrpc_controller.Controller, error) { + parsedDefaultDownstream, err := url.Parse(defaultDownstream) + if err != nil { + return nil, fmt.Errorf("failed to parse default downstream URL: %w", err) + } + updater := trackedresource.NewUpdater(opts.StorageClient, &http.Client{Transport: transport}) return &ProxyController{ - Operation: armrpc_controller.NewOperation(opts, armrpc_controller.ResourceOptions[datamodel.RadiusPlane]{}), - transport: transport, - updater: updater, + Operation: armrpc_controller.NewOperation(opts, armrpc_controller.ResourceOptions[datamodel.RadiusPlane]{}), + transport: transport, + defaultDownstream: parsedDefaultDownstream, + updater: updater, }, nil } @@ -100,7 +106,14 @@ func (p *ProxyController) Run(ctx context.Context, w http.ResponseWriter, req *h id := requestCtx.ResourceID relativePath := middleware.GetRelativePath(p.Options().PathBase, requestCtx.OriginalURL.Path) - downstreamURL, err := resourcegroups.ValidateDownstream(ctx, p.StorageClient(), id) + apiVersion := requestCtx.APIVersion + if apiVersion == "" { + message := "the api-version query parameter is required" + response := v1.ErrorResponse{Error: v1.ErrorDetails{Code: v1.CodeInvalid, Message: message, Target: id.String()}} + return armrpc_rest.NewBadRequestARMResponse(response), nil + } + + downstreamURL, err := resourcegroups.ValidateDownstream(ctx, p.StorageClient(), id, v1.LocationGlobal, apiVersion) if errors.Is(err, &resourcegroups.NotFoundError{}) { return armrpc_rest.NewNotFoundResponse(id), nil } else if errors.Is(err, &resourcegroups.InvalidError{}) { @@ -110,13 +123,16 @@ func (p *ProxyController) Run(ctx context.Context, w http.ResponseWriter, req *h return nil, fmt.Errorf("failed to validate downstream: %w", err) } + if downstreamURL == nil { + downstreamURL = p.defaultDownstream + } + proxyReq, err := p.PrepareProxyRequest(ctx, req, downstreamURL.String(), relativePath) if err != nil { return nil, err } interceptor := &responseInterceptor{Inner: p.transport} - sender := proxy.NewARMProxy(proxy.ReverseProxyOptions{RoundTripper: interceptor}, downstreamURL, nil) sender.ServeHTTP(w, proxyReq) @@ -192,6 +208,9 @@ func (p *ProxyController) PrepareProxyRequest(ctx context.Context, originalReq * proxyReq.Header.Set("X-Forwarded-Proto", refererURL.Scheme) proxyReq.Header.Set(v1.RefererHeader, refererURL.String()) + // Clear route context, we don't want to inherit any state from Chi. + proxyReq = proxyReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, nil)) + return proxyReq, nil } @@ -220,8 +239,8 @@ func (p *ProxyController) IsTerminalResponse(resp *http.Response) bool { } // UpdateTrackedResource updates the tracked resource synchronously. -func (p *ProxyController) UpdateTrackedResource(ctx context.Context, downstream string, id resources.ID, apiVersion string) error { - return p.updater.Update(ctx, downstream, id, apiVersion) +func (p *ProxyController) UpdateTrackedResource(ctx context.Context, downstreamURL string, originalID resources.ID, version string) error { + return p.updater.Update(ctx, downstreamURL, originalID, version) } // EnqueueTrackedResourceUpdate enqueues an async operation to update the tracked resource. diff --git a/pkg/ucp/frontend/controller/radius/proxy_test.go b/pkg/ucp/frontend/controller/radius/proxy_test.go index f596210c8a3..04f3a15d0f4 100644 --- a/pkg/ucp/frontend/controller/radius/proxy_test.go +++ b/pkg/ucp/frontend/controller/radius/proxy_test.go @@ -37,6 +37,11 @@ import ( "go.uber.org/mock/gomock" ) +const ( + apiVersion = "2025-01-01" + location = "global" +) + // The Run function is also tested by integration tests in the pkg/ucp/integrationtests/radius package. func createController(t *testing.T) (*ProxyController, *store.MockStorageClient, *mockUpdater, *mockRoundTripper, *statusmanager.MockStatusManager) { @@ -44,21 +49,28 @@ func createController(t *testing.T) (*ProxyController, *store.MockStorageClient, storageClient := store.NewMockStorageClient(ctrl) statusManager := statusmanager.NewMockStatusManager(ctrl) - p, err := NewProxyController(controller.Options{StorageClient: storageClient, StatusManager: statusManager}) + roundTripper := mockRoundTripper{} + + p, err := NewProxyController( + controller.Options{StorageClient: storageClient, StatusManager: statusManager}, + &roundTripper, + "http://localhost:1234") require.NoError(t, err) updater := mockUpdater{} - roundTripper := mockRoundTripper{} pc := p.(*ProxyController) pc.updater = &updater - pc.transport = &roundTripper return pc, storageClient, &updater, &roundTripper, statusManager } func Test_Run(t *testing.T) { id := resources.MustParse("/planes/test/local/resourceGroups/test-rg/providers/Applications.Test/testResources/my-resource") + providerID, err := datamodel.ResourceProviderIDFromResourceID(id) + require.NoError(t, err) + + locationID := providerID.Append(resources.TypeSegment{Type: "locations", Name: location}) plane := datamodel.RadiusPlane{ Properties: datamodel.RadiusPlaneProperties{ @@ -73,6 +85,7 @@ func Test_Run(t *testing.T) { p, storageClient, _, roundTripper, _ := createController(t) svcContext := &v1.ARMRequestContext{ + APIVersion: apiVersion, ResourceID: id, } ctx := testcontext.New(t) @@ -81,7 +94,11 @@ func Test_Run(t *testing.T) { w := httptest.NewRecorder() // Not a mutating request - req := httptest.NewRequest(http.MethodGet, id.String(), nil) + req := httptest.NewRequest(http.MethodGet, id.String()+"?api-version="+apiVersion, nil) + + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). @@ -104,6 +121,7 @@ func Test_Run(t *testing.T) { p, storageClient, updater, roundTripper, _ := createController(t) svcContext := &v1.ARMRequestContext{ + APIVersion: apiVersion, ResourceID: id, } ctx := testcontext.New(t) @@ -112,7 +130,11 @@ func Test_Run(t *testing.T) { w := httptest.NewRecorder() // Mutating request that will complete synchronously - req := httptest.NewRequest(http.MethodDelete, id.String(), nil) + req := httptest.NewRequest(http.MethodDelete, id.String()+"?api-version="+apiVersion, nil) + + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). @@ -138,6 +160,7 @@ func Test_Run(t *testing.T) { p, storageClient, updater, roundTripper, statusManager := createController(t) svcContext := &v1.ARMRequestContext{ + APIVersion: apiVersion, ResourceID: id, } ctx := testcontext.New(t) @@ -146,7 +169,11 @@ func Test_Run(t *testing.T) { w := httptest.NewRecorder() // Mutating request that will complete synchronously - req := httptest.NewRequest(http.MethodDelete, id.String(), nil) + req := httptest.NewRequest(http.MethodDelete, id.String()+"?api-version="+apiVersion, nil) + + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). @@ -184,6 +211,7 @@ func Test_Run(t *testing.T) { p, storageClient, updater, roundTripper, _ := createController(t) svcContext := &v1.ARMRequestContext{ + APIVersion: apiVersion, ResourceID: id, } ctx := testcontext.New(t) @@ -192,7 +220,11 @@ func Test_Run(t *testing.T) { w := httptest.NewRecorder() // Mutating request that will complete asynchronously - req := httptest.NewRequest(http.MethodDelete, id.String(), nil) + req := httptest.NewRequest(http.MethodDelete, id.String()+"?api-version="+apiVersion, nil) + + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). @@ -237,13 +269,14 @@ func Test_Run(t *testing.T) { p, storageClient, _, _, _ := createController(t) svcContext := &v1.ARMRequestContext{ + APIVersion: apiVersion, ResourceID: id, } ctx := testcontext.New(t) ctx = v1.WithARMRequestContext(ctx, svcContext) w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPut, id.String(), nil) + req := httptest.NewRequest(http.MethodPut, id.String()+"?api-version="+apiVersion, nil) storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). diff --git a/pkg/ucp/frontend/controller/resourcegroups/util.go b/pkg/ucp/frontend/controller/resourcegroups/util.go index ac8117e0eef..b2b267799ba 100644 --- a/pkg/ucp/frontend/controller/resourcegroups/util.go +++ b/pkg/ucp/frontend/controller/resourcegroups/util.go @@ -13,6 +13,7 @@ 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 resourcegroups import ( @@ -20,6 +21,7 @@ import ( "errors" "fmt" "net/url" + "strings" "github.com/radius-project/radius/pkg/ucp/datamodel" "github.com/radius-project/radius/pkg/ucp/resources" @@ -59,15 +61,14 @@ func (e *InvalidError) Is(err error) bool { return ok } -// ValidateDownstream can be used to find and validate the downstream URL for a resource. -// Returns NotFoundError for the case where the plane or resource group does not exist. -// Returns InvalidError for cases where the data is invalid, like when the resource provider is not configured. -func ValidateDownstream(ctx context.Context, client store.StorageClient, id resources.ID) (*url.URL, error) { +// ValidateRadiusPlane validates that the plane specified in the id exists. Returns NotFoundError if the plane does not exist. +func ValidateRadiusPlane(ctx context.Context, client store.StorageClient, id resources.ID) (*datamodel.RadiusPlane, error) { planeID, err := resources.ParseScope(id.PlaneScope()) if err != nil { // Not expected to happen. return nil, err } + plane, err := store.GetResource[datamodel.RadiusPlane](ctx, client, planeID.String()) if errors.Is(err, &store.ErrNotFound{}) { return nil, &NotFoundError{Message: fmt.Sprintf("plane %q not found", planeID.String())} @@ -75,22 +76,101 @@ func ValidateDownstream(ctx context.Context, client store.StorageClient, id reso return nil, fmt.Errorf("failed to find plane %q: %w", planeID.String(), err) } + return plane, nil +} + +// ValidateResourceGroup validates that the resource group specified in the id exists (if applicable). +// Returns NotFoundError if the resource group does not exist. +func ValidateResourceGroup(ctx context.Context, client store.StorageClient, id resources.ID) error { // If the ID contains a resource group, validate it now. - if id.FindScope(resources_radius.ScopeResourceGroups) != "" { - resourceGroupID, err := resources.ParseScope(id.RootScope()) - if err != nil { - // Not expected to happen. - return nil, err - } + if id.FindScope(resources_radius.ScopeResourceGroups) == "" { + return nil + } + + resourceGroupID, err := resources.ParseScope(id.RootScope()) + if err != nil { + // Not expected to happen. + return err + } + + _, err = store.GetResource[datamodel.ResourceGroup](ctx, client, resourceGroupID.String()) + if errors.Is(err, &store.ErrNotFound{}) { + return &NotFoundError{Message: fmt.Sprintf("resource group %q not found", resourceGroupID.String())} + } else if err != nil { + return fmt.Errorf("failed to find resource group %q: %w", resourceGroupID.String(), err) + } + + return nil +} + +// ValidateResourceType performs semantic validation of a proxy request against registered +// resource types. +// +// Returns NotFoundError if the resource type does not exist. +// Returns InvalidError if the request cannot be routed due to an invalid configuration. +func ValidateResourceType(ctx context.Context, client store.StorageClient, id resources.ID, locationName string, apiVersion string) (*url.URL, error) { + // The strategy is to try and look up the location resource, and validate that it supports + // the requested resource type and API version. + + providerID, err := datamodel.ResourceProviderIDFromResourceID(id) + if err != nil { + return nil, err + } - _, err = store.GetResource[datamodel.ResourceGroup](ctx, client, resourceGroupID.String()) - if errors.Is(err, &store.ErrNotFound{}) { - return nil, &NotFoundError{Message: fmt.Sprintf("resource group %q not found", resourceGroupID.String())} - } else if err != nil { - return nil, fmt.Errorf("failed to find resource group %q: %w", resourceGroupID.String(), err) + locationID := providerID.Append(resources.TypeSegment{Type: datamodel.LocationUnqualifiedResourceType, Name: locationName}) + location, err := store.GetResource[datamodel.Location](ctx, client, locationID.String()) + if errors.Is(err, &store.ErrNotFound{}) { + + // Return the error as-is to fallback to the legacy routing behavior. + return nil, err + + // Uncomment this when we remove the legacy routing behavior. + // return nil, &InvalidError{Message: fmt.Sprintf("location %q not found for resource provider %q", locationName, id.ProviderNamespace())} + } else if err != nil { + return nil, fmt.Errorf("failed to find location %q: %w", locationID.String(), err) + } + + // Check if the location supports the resource type. + // Resource types are case-intensitive so we have to iterate. + var resourceType *datamodel.LocationResourceTypeConfiguration + search := strings.TrimPrefix(strings.ToLower(id.Type()), strings.ToLower(id.ProviderNamespace())+resources.SegmentSeparator) + for name, rt := range location.Properties.ResourceTypes { + if strings.EqualFold(name, search) { + copy := rt + resourceType = © + break } } + if resourceType == nil { + return nil, &InvalidError{Message: fmt.Sprintf("resource type %q not supported by location %q", id.Type(), locationName)} + } + + // Now check if the location supports the resource type. If it does, we can return the downstream URL. + _, ok := resourceType.APIVersions[apiVersion] + if !ok { + return nil, &InvalidError{Message: fmt.Sprintf("api version %q is not supported for resource type %q by location %q", apiVersion, id.Type(), locationName)} + } + + // If we get here, the we're all good. + // + // The address might be nil which means that we're using the default address (dynamic RP) + if location.Properties.Address == nil { + return nil, nil + } + + // If the address was provided, then use that instead. + u, err := url.Parse(*location.Properties.Address) + if err != nil { + return nil, &InvalidError{Message: fmt.Sprintf("failed to parse location address: %v", err.Error())} + } + + return u, nil +} + +// ValidateLegacyResourceProvider validates that the resource provider specified in the id exists. Returns InvalidError if the plane +// contains invalid data. +func ValidateLegacyResourceProvider(ctx context.Context, client store.StorageClient, id resources.ID, plane *datamodel.RadiusPlane) (*url.URL, error) { downstream := plane.LookupResourceProvider(id.ProviderNamespace()) if downstream == "" { return nil, &InvalidError{Message: fmt.Sprintf("resource provider %s not configured", id.ProviderNamespace())} @@ -103,3 +183,44 @@ func ValidateDownstream(ctx context.Context, client store.StorageClient, id reso return downstreamURL, nil } + +// ValidateDownstream can be used to find and validate the downstream URL for a resource. +// Returns NotFoundError for the case where the plane or resource group does not exist. +// Returns InvalidError for cases where the data is invalid, like when the resource provider is not configured. +func ValidateDownstream(ctx context.Context, client store.StorageClient, id resources.ID, location string, apiVersion string) (*url.URL, error) { + // There are a few steps to validation: + // + // - The plane exists + // - The resource group exists + // - The resource provider is configured + // - As part of the plane (proxy routing) + // - As part of a resource provider manifest (internal or proxy routing) + // + + // The plane exists. + plane, err := ValidateRadiusPlane(ctx, client, id) + if err != nil { + return nil, err + } + + // The resource group exists (if applicable). + err = ValidateResourceGroup(ctx, client, id) + if err != nil { + return nil, err + } + + downstreamURL, err := ValidateResourceType(ctx, client, id, location, apiVersion) + if errors.Is(err, &store.ErrNotFound{}) { + // If the resource provider is not found, check if it is a legacy provider. + downstreamURL, err := ValidateLegacyResourceProvider(ctx, client, id, plane) + if err != nil { + return nil, err + } + + return downstreamURL, nil + } else if err != nil { + return nil, err + } + + return downstreamURL, nil +} diff --git a/pkg/ucp/frontend/controller/resourcegroups/util_test.go b/pkg/ucp/frontend/controller/resourcegroups/util_test.go index f2e530ea627..76b63ea67d6 100644 --- a/pkg/ucp/frontend/controller/resourcegroups/util_test.go +++ b/pkg/ucp/frontend/controller/resourcegroups/util_test.go @@ -22,6 +22,7 @@ import ( "testing" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/pkg/ucp/datamodel" "github.com/radius-project/radius/pkg/ucp/resources" "github.com/radius-project/radius/pkg/ucp/store" @@ -30,6 +31,11 @@ import ( "go.uber.org/mock/gomock" ) +const ( + location = "east" + apiVersion = "2025-01-01" +) + func Test_ValidateDownstream(t *testing.T) { id, err := resources.ParseResource("/planes/radius/local/resourceGroups/test-group/providers/System.TestRP/testResources/name") require.NoError(t, err) @@ -37,6 +43,282 @@ func Test_ValidateDownstream(t *testing.T) { idWithoutResourceGroup, err := resources.Parse("/planes/radius/local/providers/System.TestRP/testResources") require.NoError(t, err) + resourceProviderID, err := datamodel.ResourceProviderIDFromResourceID(id) + require.NoError(t, err) + + downstream := "http://localhost:7443" + + plane := &datamodel.RadiusPlane{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.PlaneScope(), + }, + }, + Properties: datamodel.RadiusPlaneProperties{ + ResourceProviders: map[string]string{ + "System.TestRP": downstream, + }, + }, + } + + locationResource := &datamodel.Location{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: location, + ID: resourceProviderID.Append(resources.TypeSegment{Type: "locations", Name: location}).String(), + }, + }, + Properties: datamodel.LocationProperties{ + Address: to.Ptr("http://localhost:7443"), + ResourceTypes: map[string]datamodel.LocationResourceTypeConfiguration{ + "testResources": { + APIVersions: map[string]datamodel.LocationAPIVersionConfiguration{ + apiVersion: {}, + }, + }, + }, + }, + } + + setup := func(t *testing.T) *store.MockStorageClient { + ctrl := gomock.NewController(t) + return store.NewMockStorageClient(ctrl) + } + + t.Run("success (resource group)", func(t *testing.T) { + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&store.Object{Data: locationResource}, nil).Times(1) + + expectedURL, err := url.Parse(downstream) + require.NoError(t, err) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.NoError(t, err) + require.Equal(t, expectedURL, downstreamURL) + }) + + t.Run("success (non resource group)", func(t *testing.T) { + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&store.Object{Data: locationResource}, nil).Times(1) + + expectedURL, err := url.Parse(downstream) + require.NoError(t, err) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, idWithoutResourceGroup, location, apiVersion) + require.NoError(t, err) + require.Equal(t, expectedURL, downstreamURL) + }) + + t.Run("plane not found", func(t *testing.T) { + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, &store.ErrNotFound{}).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, &NotFoundError{Message: "plane \"/planes/radius/local\" not found"}, err) + require.Nil(t, downstreamURL) + }) + + t.Run("plane retreival failure", func(t *testing.T) { + mock := setup(t) + + expected := fmt.Errorf("failed to find plane \"/planes/radius/local\": %w", errors.New("test error")) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, errors.New("test error")).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, expected, err) + require.Nil(t, downstreamURL) + }) + + t.Run("resource group not found", func(t *testing.T) { + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, &store.ErrNotFound{}).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, &NotFoundError{Message: "resource group \"/planes/radius/local/resourceGroups/test-group\" not found"}, err) + require.Nil(t, downstreamURL) + }) + + t.Run("resource group err", func(t *testing.T) { + mock := setup(t) + + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, errors.New("test error")).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, "failed to find resource group \"/planes/radius/local/resourceGroups/test-group\": test error", err.Error()) + require.Nil(t, downstreamURL) + }) + + t.Run("location error found", func(t *testing.T) { + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + + expected := fmt.Errorf("failed to find location %q: %w", locationResource.ID, errors.New("test error")) + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(nil, errors.New("test error")).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, expected, err) + require.Nil(t, downstreamURL) + }) + + t.Run("resource type not found", func(t *testing.T) { + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + + locationResource := &datamodel.Location{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: location, + ID: resourceProviderID.Append(resources.TypeSegment{Type: "locations", Name: location}).String(), + }, + }, + Properties: datamodel.LocationProperties{ + Address: to.Ptr("http://localhost:7443"), + ResourceTypes: map[string]datamodel.LocationResourceTypeConfiguration{ + "testResources2": { + APIVersions: map[string]datamodel.LocationAPIVersionConfiguration{ + apiVersion: {}, + }, + }, + }, + }, + } + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&store.Object{Data: locationResource}, nil).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, &InvalidError{Message: "resource type \"System.TestRP/testResources\" not supported by location \"east\""}, err) + require.Nil(t, downstreamURL) + }) + + t.Run("api-version not found", func(t *testing.T) { + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + + locationResource := &datamodel.Location{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: location, + ID: resourceProviderID.Append(resources.TypeSegment{Type: "locations", Name: location}).String(), + }, + }, + Properties: datamodel.LocationProperties{ + Address: to.Ptr("http://localhost:7443"), + ResourceTypes: map[string]datamodel.LocationResourceTypeConfiguration{ + "testResources": { + APIVersions: map[string]datamodel.LocationAPIVersionConfiguration{ + apiVersion + "-preview": {}, + }, + }, + }, + }, + } + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&store.Object{Data: locationResource}, nil).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, &InvalidError{Message: "api version \"2025-01-01\" is not supported for resource type \"System.TestRP/testResources\" by location \"east\""}, err) + require.Nil(t, downstreamURL) + }) + + t.Run("location invalid URL", func(t *testing.T) { + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + + locationResource := &datamodel.Location{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: location, + ID: resourceProviderID.Append(resources.TypeSegment{Type: "locations", Name: location}).String(), + }, + }, + Properties: datamodel.LocationProperties{ + Address: to.Ptr("\ninvalid"), + ResourceTypes: map[string]datamodel.LocationResourceTypeConfiguration{ + "testResources": { + APIVersions: map[string]datamodel.LocationAPIVersionConfiguration{ + apiVersion: {}, + }, + }, + }, + }, + } + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&store.Object{Data: locationResource}, nil).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, &InvalidError{Message: "failed to parse location address: parse \"\\ninvalid\": net/url: invalid control character in URL"}, err) + require.Nil(t, downstreamURL) + }) +} + +// This test validates the pre-UDT before where resource providers are registered as part of the plane. +// This can be deleted once the legacy routing behavior is removed. +func Test_ValidateDownstream_Legacy(t *testing.T) { + id, err := resources.ParseResource("/planes/radius/local/resourceGroups/test-group/providers/System.TestRP/testResources/name") + require.NoError(t, err) + + idWithoutResourceGroup, err := resources.Parse("/planes/radius/local/providers/System.TestRP/testResources") + require.NoError(t, err) + + resourceProviderID, err := datamodel.ResourceProviderIDFromResourceID(id) + require.NoError(t, err) + + locationResourceID := resourceProviderID.Append(resources.TypeSegment{Type: "locations", Name: location}) + downstream := "http://localhost:7443" plane := &datamodel.RadiusPlane{ @@ -69,11 +351,12 @@ func Test_ValidateDownstream(t *testing.T) { mock := setup(t) mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResourceID.String()).Return(nil, &store.ErrNotFound{}).Times(1) expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) @@ -81,11 +364,12 @@ func Test_ValidateDownstream(t *testing.T) { t.Run("success (non resource group)", func(t *testing.T) { mock := setup(t) mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResourceID.String()).Return(nil, &store.ErrNotFound{}).Times(1) expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, idWithoutResourceGroup) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, idWithoutResourceGroup, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) @@ -94,7 +378,7 @@ func Test_ValidateDownstream(t *testing.T) { mock := setup(t) mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, &store.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, &NotFoundError{Message: "plane \"/planes/radius/local\" not found"}, err) require.Nil(t, downstreamURL) @@ -106,7 +390,7 @@ func Test_ValidateDownstream(t *testing.T) { expected := fmt.Errorf("failed to find plane \"/planes/radius/local\": %w", errors.New("test error")) mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, errors.New("test error")).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, expected, err) require.Nil(t, downstreamURL) @@ -117,7 +401,7 @@ func Test_ValidateDownstream(t *testing.T) { mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, &store.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, &NotFoundError{Message: "resource group \"/planes/radius/local/resourceGroups/test-group\" not found"}, err) require.Nil(t, downstreamURL) @@ -129,7 +413,7 @@ func Test_ValidateDownstream(t *testing.T) { mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, errors.New("test error")).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, "failed to find resource group \"/planes/radius/local/resourceGroups/test-group\": test error", err.Error()) require.Nil(t, downstreamURL) @@ -158,8 +442,9 @@ func Test_ValidateDownstream(t *testing.T) { mock := setup(t) mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResourceID.String()).Return(nil, &store.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, &InvalidError{Message: "resource provider System.TestRP not configured"}, err) require.Nil(t, downstreamURL) @@ -190,8 +475,9 @@ func Test_ValidateDownstream(t *testing.T) { mock := setup(t) mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResourceID.String()).Return(nil, &store.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, &InvalidError{Message: "failed to parse downstream URL: parse \"\\ninvalid\": net/url: invalid control character in URL"}, err) require.Nil(t, downstreamURL) diff --git a/pkg/ucp/frontend/modules/types.go b/pkg/ucp/frontend/modules/types.go index 6b0a0d6e9a1..bc0853375b9 100644 --- a/pkg/ucp/frontend/modules/types.go +++ b/pkg/ucp/frontend/modules/types.go @@ -77,4 +77,13 @@ type Options struct { // UCPConnection is the connection used to communicate with UCP APIs. UCPConnection sdk.Connection + + // DynamicRP configures options for communicating with the DynamicRP. + DynamicRP DynamicRPOptions +} + +// DynamicRPOptions configures options for communicating with the DynamicRP. +type DynamicRPOptions struct { + // URL is the URL of the DynamicRP. + URL string } diff --git a/pkg/ucp/frontend/radius/module.go b/pkg/ucp/frontend/radius/module.go index 1dae8a56414..373a7df43b8 100644 --- a/pkg/ucp/frontend/radius/module.go +++ b/pkg/ucp/frontend/radius/module.go @@ -28,15 +28,16 @@ func NewModule(options modules.Options) *Module { router.NotFound(validator.APINotFoundHandler()) router.MethodNotAllowed(validator.APIMethodNotAllowedHandler()) - return &Module{options: options, router: router} + return &Module{options: options, router: router, defaultDownstream: options.DynamicRP.URL} } var _ modules.Initializer = &Module{} // Module defines the module for Radius functionality. type Module struct { - options modules.Options - router chi.Router + options modules.Options + router chi.Router + defaultDownstream string } // PlaneType returns the type of plane this module is for. diff --git a/pkg/ucp/frontend/radius/routes.go b/pkg/ucp/frontend/radius/routes.go index 22bf6288b63..cecf19df367 100644 --- a/pkg/ucp/frontend/radius/routes.go +++ b/pkg/ucp/frontend/radius/routes.go @@ -20,6 +20,7 @@ import ( "context" "errors" "net/http" + "time" "github.com/go-chi/chi/v5" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" @@ -34,11 +35,15 @@ import ( resourcegroups_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/resourcegroups" resourceproviders_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/resourceproviders" "github.com/radius-project/radius/pkg/validator" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) const ( // OperationTypeUCPRadiusProxy is the operation type for proxying Radius API calls. OperationTypeUCPRadiusProxy = "UCPRADIUSPROXY" + + // Tell clients to poll in 1 second intervals. Our operations are fast. + operationRetryAfter = time.Second * 1 ) func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { @@ -47,6 +52,8 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { ResourceTypeGetter: validator.UCPResourceTypeGetter, }) + transport := otelhttp.NewTransport(http.DefaultTransport) + // More convienent way to capture errors var err error capture := func(handler http.HandlerFunc, e error) http.HandlerFunc { @@ -124,7 +131,7 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { // Proxy to plane-scoped ResourceProvider APIs // // NOTE: DO NOT validate schema for proxy routes. - r.Handle("/*", capture(planeScopedProxyHandler(ctx, ctrlOptions))) + r.Handle("/*", capture(planeScopedProxyHandler(ctx, ctrlOptions, transport, m.defaultDownstream))) }) r.Route("/resourcegroups", func(r chi.Router) { @@ -141,7 +148,7 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { // Proxy to resource-group-scoped ResourceProvider APIs // // NOTE: DO NOT validate schema for proxy routes. - r.Handle("/*", capture(resourceGroupScopedProxyHandler(ctx, ctrlOptions))) + r.Handle("/*", capture(resourceGroupScopedProxyHandler(ctx, ctrlOptions, transport, m.defaultDownstream))) }) }) @@ -153,8 +160,9 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { } var planeResourceOptions = controller.ResourceOptions[datamodel.RadiusPlane]{ - RequestConverter: converter.RadiusPlaneDataModelFromVersioned, - ResponseConverter: converter.RadiusPlaneDataModelToVersioned, + RequestConverter: converter.RadiusPlaneDataModelFromVersioned, + ResponseConverter: converter.RadiusPlaneDataModelToVersioned, + AsyncOperationRetryAfter: operationRetryAfter, } var planeResourceType = "System.Radius/planes" @@ -231,8 +239,9 @@ func resourceProviderSummaryGetHandler(ctx context.Context, ctrlOptions controll } var resourceProviderResourceOptions = controller.ResourceOptions[datamodel.ResourceProvider]{ - RequestConverter: converter.ResourceProviderDataModelFromVersioned, - ResponseConverter: converter.ResourceProviderDataModelToVersioned, + RequestConverter: converter.ResourceProviderDataModelFromVersioned, + ResponseConverter: converter.ResourceProviderDataModelToVersioned, + AsyncOperationRetryAfter: operationRetryAfter, } func resourceProviderListHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { @@ -260,8 +269,9 @@ func resourceProviderDeleteHandler(ctx context.Context, ctrlOptions controller.O } var resourceTypeResourceOptions = controller.ResourceOptions[datamodel.ResourceType]{ - RequestConverter: converter.ResourceTypeDataModelFromVersioned, - ResponseConverter: converter.ResourceTypeDataModelToVersioned, + RequestConverter: converter.ResourceTypeDataModelFromVersioned, + ResponseConverter: converter.ResourceTypeDataModelToVersioned, + AsyncOperationRetryAfter: operationRetryAfter, } func resourceTypeListHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { @@ -289,8 +299,9 @@ func resourceTypeDeleteHandler(ctx context.Context, ctrlOptions controller.Optio } var apiVersionResourceOptions = controller.ResourceOptions[datamodel.APIVersion]{ - RequestConverter: converter.APIVersionDataModelFromVersioned, - ResponseConverter: converter.APIVersionDataModelToVersioned, + RequestConverter: converter.APIVersionDataModelFromVersioned, + ResponseConverter: converter.APIVersionDataModelToVersioned, + AsyncOperationRetryAfter: operationRetryAfter, } func apiVersionListHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { @@ -318,8 +329,9 @@ func apiVersionDeleteHandler(ctx context.Context, ctrlOptions controller.Options } var locationResourceOptions = controller.ResourceOptions[datamodel.Location]{ - RequestConverter: converter.LocationDataModelFromVersioned, - ResponseConverter: converter.LocationDataModelToVersioned, + RequestConverter: converter.LocationDataModelFromVersioned, + ResponseConverter: converter.LocationDataModelToVersioned, + AsyncOperationRetryAfter: operationRetryAfter, } func locationListHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { @@ -346,12 +358,16 @@ func locationDeleteHandler(ctx context.Context, ctrlOptions controller.Options) }) } -func planeScopedProxyHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { - return server.CreateHandler(ctx, OperationTypeUCPRadiusProxy, v1.OperationProxy, ctrlOptions, radius_ctrl.NewProxyController) +func planeScopedProxyHandler(ctx context.Context, ctrlOptions controller.Options, transport http.RoundTripper, defaultDownstream string) (http.HandlerFunc, error) { + return server.CreateHandler(ctx, OperationTypeUCPRadiusProxy, v1.OperationProxy, ctrlOptions, func(o controller.Options) (controller.Controller, error) { + return radius_ctrl.NewProxyController(o, transport, defaultDownstream) + }) } -func resourceGroupScopedProxyHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { - return server.CreateHandler(ctx, OperationTypeUCPRadiusProxy, v1.OperationProxy, ctrlOptions, radius_ctrl.NewProxyController) +func resourceGroupScopedProxyHandler(ctx context.Context, ctrlOptions controller.Options, transport http.RoundTripper, defaultDownstream string) (http.HandlerFunc, error) { + return server.CreateHandler(ctx, OperationTypeUCPRadiusProxy, v1.OperationProxy, ctrlOptions, func(o controller.Options) (controller.Controller, error) { + return radius_ctrl.NewProxyController(o, transport, defaultDownstream) + }) } func operationStatusGetHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { diff --git a/pkg/ucp/integrationtests/radius/proxy_test.go b/pkg/ucp/integrationtests/radius/proxy_test.go index 3d019e8ec45..64493caeee4 100644 --- a/pkg/ucp/integrationtests/radius/proxy_test.go +++ b/pkg/ucp/integrationtests/radius/proxy_test.go @@ -56,7 +56,7 @@ func Test_RadiusPlane_Proxy_ResourceGroupDoesNotExist(t *testing.T) { } createRadiusPlane(ucp, rps) - response := ucp.MakeRequest(http.MethodGet, testResourceID, nil) + response := ucp.MakeRequest(http.MethodGet, testResourceID+"?api-version="+testrp.Version, nil) response.EqualsErrorCode(http.StatusNotFound, "NotFound") require.Equal(t, "the resource with id '/planes/radius/test/resourceGroups/test-rg/providers/System.Test/testResources/test-resource' was not found", response.Error.Error.Message) } diff --git a/pkg/ucp/integrationtests/testserver/testserver.go b/pkg/ucp/integrationtests/testserver/testserver.go index c0134c0db69..538dede8b65 100644 --- a/pkg/ucp/integrationtests/testserver/testserver.go +++ b/pkg/ucp/integrationtests/testserver/testserver.go @@ -44,8 +44,10 @@ import ( "github.com/radius-project/radius/pkg/armrpc/asyncoperation/worker" "github.com/radius-project/radius/pkg/armrpc/rpctest" "github.com/radius-project/radius/pkg/armrpc/servicecontext" + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/middleware" "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/backend" "github.com/radius-project/radius/pkg/ucp/data" "github.com/radius-project/radius/pkg/ucp/dataprovider" @@ -94,6 +96,8 @@ type TestServer struct { t *testing.T stoppedChan <-chan struct{} shutdown sync.Once + + clientFactoryUCP *v20231001preview.ClientFactory } // TestServerClients provides access to the clients created by the TestServer. @@ -123,6 +127,24 @@ func (ts *TestServer) Client() *http.Client { return ts.Server.Client() } +// T provides access to the testing.T instance associated with this test. +func (ts *TestServer) T() *testing.T { + return ts.t +} + +// UCP provides access to the generated clients for the UCP API. +func (ts *TestServer) UCP() *v20231001preview.ClientFactory { + if ts.clientFactoryUCP == nil { + connection, err := sdk.NewDirectConnection(ts.BaseURL) + require.NoError(ts.t, err) + + ts.clientFactoryUCP, err = v20231001preview.NewClientFactory(&aztoken.AnonymousCredential{}, sdk.NewClientOptions(connection)) + require.NoError(ts.t, err) + } + + return ts.clientFactoryUCP +} + // Close shuts down the server and will block until shutdown completes. func (ts *TestServer) Close() { // We're being picking about resource cleanup here, because unless we are picky we hit scalability @@ -229,7 +251,7 @@ func StartWithETCD(t *testing.T, configureModules func(options modules.Options) etcd := data.NewEmbeddedETCDService(data.EmbeddedETCDServiceOptions{ ClientConfigSink: config, AssignRandomPorts: true, - Quiet: false, + Quiet: true, }) ctx, cancel := testcontext.NewWithCancel(t) @@ -299,7 +321,7 @@ func StartWithETCD(t *testing.T, configureModules func(options modules.Options) statusManager := statusmanager.New(dataProvider, queueClient, v1.LocationGlobal) registry := worker.NewControllerRegistry(dataProvider) - err = backend.RegisterControllers(ctx, registry, connection, backend_ctrl.Options{DataProvider: dataProvider}) + err = backend.RegisterControllers(ctx, registry, connection, http.DefaultTransport, backend_ctrl.Options{DataProvider: dataProvider}) require.NoError(t, err) w := worker.New(worker.Options{}, statusManager, queueClient, registry)