From 4b428bf74407c8a050e6b9e6ad6ceb72e462377c 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 --- .../frontend/middleware/resourceidoverride.go | 57 +++ .../middleware/resourceidoverride_test.go | 54 +++ .../api/v20231001preview/dynamicresource.go | 32 ++ .../dynamicresource_conversion.go | 64 +++ .../dynamicresource_conversion_test.go | 126 ++++++ .../genericresource_conversion.go | 2 +- .../resourceprovider_conversion.go | 146 +++++++ .../resourceprovider_conversion_test.go | 191 +++++++++ .../testdata/dynamicresource-datamodel.json | 20 + .../testdata/dynamicresource-resource.json | 12 + .../testdata/resourceprovider_datamodel.json | 52 +++ .../testdata/resourceprovider_resource.json | 52 +++ .../zz_generated_client_factory.go | 5 + .../zz_generated_constants.go | 18 + .../v20231001preview/zz_generated_models.go | 79 ++++ .../zz_generated_models_serde.go | 218 ++++++++++ .../v20231001preview/zz_generated_options.go | 24 ++ .../zz_generated_resourceproviders_client.go | 294 +++++++++++++ .../zz_generated_response_types.go | 23 + .../resourcegroups/trackedresourceprocess.go | 30 +- .../trackedresourceprocess_test.go | 11 +- pkg/ucp/backend/service.go | 2 +- .../converter/dynamicresource_converter.go | 58 +++ .../converter/resourceprovider_converter.go | 59 +++ pkg/ucp/datamodel/dynamicresource.go | 38 ++ pkg/ucp/datamodel/genericresource.go | 4 +- pkg/ucp/datamodel/resourceprovider.go | 80 ++++ .../frontend/controller/radius/provider.go | 17 + pkg/ucp/frontend/controller/radius/proxy.go | 51 ++- .../frontend/controller/radius/proxy_test.go | 29 +- .../resourcegroups/listresources.go | 2 +- .../resourcegroups/listresources_test.go | 4 +- .../controller/resourcegroups/util.go | 188 ++++++++- .../controller/resourcegroups/util_test.go | 204 ++++++++- pkg/ucp/frontend/radius/internal.go | 179 ++++++++ pkg/ucp/frontend/radius/module.go | 19 +- pkg/ucp/frontend/radius/routes.go | 138 +++++- .../providers/providers_test.go | 156 +++++++ ...0240101preview_emptylist_responsebody.json | 3 + ...ce_v20240101preview_list_responsebody.json | 14 + ...resource_v20240101preview_requestbody.json | 6 + ...esource_v20240101preview_responsebody.json | 10 + ...0231001preview_emptylist_responsebody.json | 3 + ...er_v20231001preview_list_responsebody.json | 42 ++ ...provider_v20231001preview_requestbody.json | 38 ++ ...rovider_v20231001preview_responsebody.json | 38 ++ .../providers/testdata/v20240101.json | 0 .../integrationtests/testserver/testserver.go | 16 + pkg/ucp/trackedresource/name.go | 2 +- pkg/ucp/trackedresource/update.go | 48 ++- pkg/ucp/trackedresource/update_test.go | 61 ++- .../preview/2023-10-01-preview/openapi.json | 399 ++++++++++++++++++ typespec/UCP/main.tsp | 1 + typespec/UCP/resourceproviders.tsp | 150 +++++++ 54 files changed, 3454 insertions(+), 115 deletions(-) create mode 100644 pkg/armrpc/frontend/middleware/resourceidoverride.go create mode 100644 pkg/armrpc/frontend/middleware/resourceidoverride_test.go create mode 100644 pkg/ucp/api/v20231001preview/dynamicresource.go create mode 100644 pkg/ucp/api/v20231001preview/dynamicresource_conversion.go create mode 100644 pkg/ucp/api/v20231001preview/dynamicresource_conversion_test.go create mode 100644 pkg/ucp/api/v20231001preview/resourceprovider_conversion.go create mode 100644 pkg/ucp/api/v20231001preview/resourceprovider_conversion_test.go create mode 100644 pkg/ucp/api/v20231001preview/testdata/dynamicresource-datamodel.json create mode 100644 pkg/ucp/api/v20231001preview/testdata/dynamicresource-resource.json create mode 100644 pkg/ucp/api/v20231001preview/testdata/resourceprovider_datamodel.json create mode 100644 pkg/ucp/api/v20231001preview/testdata/resourceprovider_resource.json create mode 100644 pkg/ucp/api/v20231001preview/zz_generated_resourceproviders_client.go create mode 100644 pkg/ucp/datamodel/converter/dynamicresource_converter.go create mode 100644 pkg/ucp/datamodel/converter/resourceprovider_converter.go create mode 100644 pkg/ucp/datamodel/dynamicresource.go create mode 100644 pkg/ucp/datamodel/resourceprovider.go create mode 100644 pkg/ucp/frontend/controller/radius/provider.go create mode 100644 pkg/ucp/frontend/radius/internal.go create mode 100644 pkg/ucp/integrationtests/providers/providers_test.go create mode 100644 pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_emptylist_responsebody.json create mode 100644 pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_list_responsebody.json create mode 100644 pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_requestbody.json create mode 100644 pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_responsebody.json create mode 100644 pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_emptylist_responsebody.json create mode 100644 pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_list_responsebody.json create mode 100644 pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_requestbody.json create mode 100644 pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_responsebody.json create mode 100644 pkg/ucp/integrationtests/providers/testdata/v20240101.json create mode 100644 typespec/UCP/resourceproviders.tsp diff --git a/pkg/armrpc/frontend/middleware/resourceidoverride.go b/pkg/armrpc/frontend/middleware/resourceidoverride.go new file mode 100644 index 0000000000..d2eb730b8b --- /dev/null +++ b/pkg/armrpc/frontend/middleware/resourceidoverride.go @@ -0,0 +1,57 @@ +/* +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 middleware + +import ( + "net/http" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/ucp/resources" + "github.com/radius-project/radius/pkg/ucp/ucplog" +) + +// OverrideResourceIDMiddleware is a middleware that tweaks the resource ID of the request. +// +// This is useful for URLs that don't follow the usual ResourceID pattern. We still want these +// URLs to be handled by our data storage and telemetry systems in the same way. +// +// For example a request like: +// +// GET /planes/radius/local/providers -> ResourceID: /planes/radius/local/providers/System.Resources/resourceProviders +func OverrideResourceID(override func(req *http.Request) (resources.ID, error)) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + // This handler will get the resource ID and update the stored request to refer to it. + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + id, err := override(req) + if err != nil { + logger := ucplog.FromContextOrDiscard(req.Context()) + logger.Error(err, "failed to override resource ID") + next.ServeHTTP(w, req) + return + } + + // Update the request context with the new resource ID. + armCtx := v1.ARMRequestContextFromContext(req.Context()) + if armCtx != nil { + armCtx.ResourceID = id + *req = *req.WithContext(v1.WithARMRequestContext(req.Context(), armCtx)) + } + + next.ServeHTTP(w, req) + }) + } +} diff --git a/pkg/armrpc/frontend/middleware/resourceidoverride_test.go b/pkg/armrpc/frontend/middleware/resourceidoverride_test.go new file mode 100644 index 0000000000..1dbed1e158 --- /dev/null +++ b/pkg/armrpc/frontend/middleware/resourceidoverride_test.go @@ -0,0 +1,54 @@ +/* +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 middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/ucp/resources" + "github.com/stretchr/testify/require" +) + +func Test_OverrideResourceID(t *testing.T) { + override := func(req *http.Request) (resources.ID, error) { + return resources.MustParse("/planes/radius/local"), nil + } + + actualID := resources.ID{} + handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + armCtx := v1.ARMRequestContextFromContext(req.Context()) + actualID = armCtx.ResourceID + }) + + h := OverrideResourceID(override)(handler) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + ctx := v1.WithARMRequestContext(context.Background(), &v1.ARMRequestContext{ + ResourceID: resources.MustParse("/planes/radius/anotherone/"), + }) + req = req.WithContext(ctx) + + h.ServeHTTP(w, req) + + require.Equal(t, resources.MustParse("/planes/radius/local"), actualID) +} diff --git a/pkg/ucp/api/v20231001preview/dynamicresource.go b/pkg/ucp/api/v20231001preview/dynamicresource.go new file mode 100644 index 0000000000..bf167aa86d --- /dev/null +++ b/pkg/ucp/api/v20231001preview/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 v20231001preview + +// DynamicResource is used as the versioned resource 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 { + 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 *SystemData `json:"systemData,omitempty"` +} diff --git a/pkg/ucp/api/v20231001preview/dynamicresource_conversion.go b/pkg/ucp/api/v20231001preview/dynamicresource_conversion.go new file mode 100644 index 0000000000..46c5739c56 --- /dev/null +++ b/pkg/ucp/api/v20231001preview/dynamicresource_conversion.go @@ -0,0 +1,64 @@ +/* +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 v20231001preview + +import ( + 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" +) + +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 = fromSystemDataModel(dm.SystemData) + d.Properties = dm.Properties + if d.Properties == nil { + d.Properties = map[string]any{} + } + d.Properties["provisioningState"] = fromProvisioningStateDataModel(dm.AsyncProvisioningState) + + return nil +} diff --git a/pkg/ucp/api/v20231001preview/dynamicresource_conversion_test.go b/pkg/ucp/api/v20231001preview/dynamicresource_conversion_test.go new file mode 100644 index 0000000000..331f3b134c --- /dev/null +++ b/pkg/ucp/api/v20231001preview/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 v20231001preview + +import ( + "encoding/json" + "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/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 = fromSystemDataModel(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/ucp/api/v20231001preview/genericresource_conversion.go b/pkg/ucp/api/v20231001preview/genericresource_conversion.go index fb8c7f97b9..987535414b 100644 --- a/pkg/ucp/api/v20231001preview/genericresource_conversion.go +++ b/pkg/ucp/api/v20231001preview/genericresource_conversion.go @@ -25,7 +25,7 @@ import ( ) const ( - ResourceType = "System.Resources/resources" + GenericResourceType = "System.Resources/resources" ) // ConvertTo converts from the versioned GenericResource resource to version-agnostic datamodel. diff --git a/pkg/ucp/api/v20231001preview/resourceprovider_conversion.go b/pkg/ucp/api/v20231001preview/resourceprovider_conversion.go new file mode 100644 index 0000000000..ac5ec3a6a1 --- /dev/null +++ b/pkg/ucp/api/v20231001preview/resourceprovider_conversion.go @@ -0,0 +1,146 @@ +/* +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 v20231001preview + +import ( + 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" +) + +// ConvertTo converts from the versioned ResourceProviderResource resource to version-agnostic datamodel. +func (src *ResourceProviderResource) ConvertTo() (v1.DataModelInterface, error) { + dst := &datamodel.ResourceProvider{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: to.String(src.ID), + Name: to.String(src.Name), + Type: to.String(src.Type), + Location: to.String(src.Location), + Tags: to.StringMap(src.Tags), + }, + }, + } + + // Note: we omit SystemData and Tags for this type. They cannot be specified by the user. + + dst.Properties = datamodel.ResourceProviderProperties{ + Locations: map[string]datamodel.ResourceProviderLocation{}, + } + + for name, location := range src.Properties.Locations { + dst.Properties.Locations[name] = fromResourceProviderLocation(location) + } + + for _, rt := range src.Properties.ResourceTypes { + dst.Properties.ResourceTypes = append(dst.Properties.ResourceTypes, fromResourceType(rt)) + } + + return dst, nil +} + +// ConvertFrom converts from version-agnostic datamodel to the versioned ResourceProviderResource resource. +func (dst *ResourceProviderResource) ConvertFrom(src v1.DataModelInterface) error { + dm, ok := src.(*datamodel.ResourceProvider) + if !ok { + return v1.ErrInvalidModelConversion + } + + dst.ID = to.Ptr(dm.ID) + dst.Name = to.Ptr(dm.Name) + dst.Type = to.Ptr(datamodel.ResourceProviderResourceType) + dst.Location = to.Ptr(dm.Location) + + // Note: we omit SystemData and Tags for this type. They cannot be specified by the user. + + dst.Properties = &ResourceProviderProperties{ + ProvisioningState: to.Ptr(ProvisioningState(dm.InternalMetadata.AsyncProvisioningState)), + Locations: map[string]*ResourceProviderLocation{}, + } + + for name, location := range dm.Properties.Locations { + dst.Properties.Locations[name] = toResourceProviderLocation(location) + } + + for _, rt := range dm.Properties.ResourceTypes { + dst.Properties.ResourceTypes = append(dst.Properties.ResourceTypes, toResourceType(rt)) + } + + return nil +} + +func fromResourceType(rt *ResourceType) datamodel.ResourceType { + dm := datamodel.ResourceType{ + ResourceType: to.String(rt.ResourceType), + DefaultAPIVersion: to.String(rt.DefaultAPIVersion), + APIVersions: map[string]datamodel.ResourceTypeAPIVersion{}, + } + + for name, apiVersion := range rt.APIVersions { + dm.APIVersions[name] = fromResourceTypeAPIVersion(apiVersion) + } + + for _, capability := range rt.Capabilities { + dm.Capabilities = append(dm.Capabilities, to.String(capability)) + } + + for _, location := range rt.Locations { + dm.Locations = append(dm.Locations, to.String(location)) + } + + return dm +} + +func toResourceType(dm datamodel.ResourceType) *ResourceType { + rt := &ResourceType{ + ResourceType: to.Ptr(dm.ResourceType), + APIVersions: map[string]*ResourceTypeAPIVersion{}, + Capabilities: to.SliceOfPtrs(dm.Capabilities...), + DefaultAPIVersion: to.Ptr(dm.DefaultAPIVersion), + Locations: to.SliceOfPtrs(dm.Locations...), + } + + for name, apiVersion := range dm.APIVersions { + rt.APIVersions[name] = toResourceTypeAPIVersion(apiVersion) + } + + return rt +} + +func fromResourceProviderLocation(location *ResourceProviderLocation) datamodel.ResourceProviderLocation { + return datamodel.ResourceProviderLocation{ + Address: to.String(location.Address), + } +} + +func toResourceProviderLocation(d datamodel.ResourceProviderLocation) *ResourceProviderLocation { + return &ResourceProviderLocation{ + Address: to.Ptr(d.Address), + } +} + +func fromResourceTypeAPIVersion(version *ResourceTypeAPIVersion) datamodel.ResourceTypeAPIVersion { + return datamodel.ResourceTypeAPIVersion{ + Schema: version.Schema, + } +} + +func toResourceTypeAPIVersion(d datamodel.ResourceTypeAPIVersion) *ResourceTypeAPIVersion { + return &ResourceTypeAPIVersion{ + Schema: d.Schema, + } +} diff --git a/pkg/ucp/api/v20231001preview/resourceprovider_conversion_test.go b/pkg/ucp/api/v20231001preview/resourceprovider_conversion_test.go new file mode 100644 index 0000000000..02a0fb2918 --- /dev/null +++ b/pkg/ucp/api/v20231001preview/resourceprovider_conversion_test.go @@ -0,0 +1,191 @@ +/* +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 v20231001preview + +import ( + "encoding/json" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/ucp/datamodel" + "github.com/radius-project/radius/test/testutil" + + "github.com/stretchr/testify/require" +) + +func Test_ResourceProvider_VersionedToDataModel(t *testing.T) { + conversionTests := []struct { + filename string + expected *datamodel.ResourceProvider + err error + }{ + { + filename: "resourceprovider_resource.json", + expected: &datamodel.ResourceProvider{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/providers/System.Resources/resourceProviders/Applications.Core", + Name: "Applications.Core", + Type: datamodel.ResourceProviderResourceType, + Location: "global", + Tags: map[string]string{}, + }, + }, + Properties: datamodel.ResourceProviderProperties{ + Locations: map[string]datamodel.ResourceProviderLocation{ + "global": { + Address: "https://localhost:8080", + }, + }, + ResourceTypes: []datamodel.ResourceType{ + { + ResourceType: "testType", + Locations: []string{"global"}, + APIVersions: map[string]datamodel.ResourceTypeAPIVersion{ + "2023-10-01-preview": { + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + }, + "location": map[string]any{ + "type": "string", + }, + "tags": map[string]any{ + "type": "object", + }, + "properties": map[string]any{ + "type": "object", + "properties": map[string]any{ + "coolness": map[string]any{ + "type": "string", + }, + }, + }, + }, + }, + }, + }, + Capabilities: []string{"coolness"}, + DefaultAPIVersion: "2023-10-01-preview", + }, + }, + }, + }, + }, + } + + for _, tt := range conversionTests { + t.Run(tt.filename, func(t *testing.T) { + rawPayload := testutil.ReadFixture(tt.filename) + versioned := &ResourceProviderResource{} + err := json.Unmarshal(rawPayload, versioned) + require.NoError(t, err) + + dm, err := versioned.ConvertTo() + + if tt.err != nil { + require.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, dm) + } + }) + } +} + +func Test_ResourceProvider_DataModelToVersioned(t *testing.T) { + conversionTests := []struct { + filename string + expected *ResourceProviderResource + err error + }{ + { + filename: "resourceprovider_datamodel.json", + expected: &ResourceProviderResource{ + ID: to.Ptr("/planes/radius/local/providers/System.Resources/resourceProviders/Applications.Core"), + Type: to.Ptr(datamodel.ResourceProviderResourceType), + Name: to.Ptr("Applications.Core"), + Location: to.Ptr("global"), + Properties: &ResourceProviderProperties{ + ProvisioningState: to.Ptr(ProvisioningStateSucceeded), + Locations: map[string]*ResourceProviderLocation{ + "global": { + Address: to.Ptr("https://localhost:8080"), + }, + }, + ResourceTypes: []*ResourceType{ + { + ResourceType: to.Ptr("testType"), + Locations: to.SliceOfPtrs("global"), + APIVersions: map[string]*ResourceTypeAPIVersion{ + "2023-10-01-preview": { + Schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + }, + "location": map[string]any{ + "type": "string", + }, + "tags": map[string]any{ + "type": "object", + }, + "properties": map[string]any{ + "type": "object", + "properties": map[string]any{ + "coolness": map[string]any{ + "type": "string", + }, + }, + }, + }, + }, + }, + }, + Capabilities: to.SliceOfPtrs("coolness"), + DefaultAPIVersion: to.Ptr("2023-10-01-preview"), + }, + }, + }, + }, + }, + } + + for _, tt := range conversionTests { + t.Run(tt.filename, func(t *testing.T) { + rawPayload := testutil.ReadFixture(tt.filename) + data := &datamodel.ResourceProvider{} + err := json.Unmarshal(rawPayload, data) + require.NoError(t, err) + + versioned := &ResourceProviderResource{} + + err = versioned.ConvertFrom(data) + + if tt.err != nil { + require.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, versioned) + } + }) + } +} diff --git a/pkg/ucp/api/v20231001preview/testdata/dynamicresource-datamodel.json b/pkg/ucp/api/v20231001preview/testdata/dynamicresource-datamodel.json new file mode 100644 index 0000000000..0b5c40958f --- /dev/null +++ b/pkg/ucp/api/v20231001preview/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/ucp/api/v20231001preview/testdata/dynamicresource-resource.json b/pkg/ucp/api/v20231001preview/testdata/dynamicresource-resource.json new file mode 100644 index 0000000000..f2569eacb5 --- /dev/null +++ b/pkg/ucp/api/v20231001preview/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/ucp/api/v20231001preview/testdata/resourceprovider_datamodel.json b/pkg/ucp/api/v20231001preview/testdata/resourceprovider_datamodel.json new file mode 100644 index 0000000000..0a80d3b426 --- /dev/null +++ b/pkg/ucp/api/v20231001preview/testdata/resourceprovider_datamodel.json @@ -0,0 +1,52 @@ +{ + "id": "/planes/radius/local/providers/System.Resources/resourceProviders/Applications.Core", + "name": "Applications.Core", + "type": "System.Resources/resourceProviders", + "location": "global", + "provisioningState": "Succeeded", + "properties": { + "locations": { + "global": { + "address": "https://localhost:8080" + } + }, + "resourceTypes": [ + { + "resourceType": "testType", + "locations": [ + "global" + ], + "apiVersions": { + "2023-10-01-preview": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "properties": { + "type": "object", + "properties": { + "coolness": { + "type": "string" + } + } + } + } + } + } + }, + "capabilities": [ + "coolness" + ], + "defaultApiVersion": "2023-10-01-preview" + } + ] + } +} \ No newline at end of file diff --git a/pkg/ucp/api/v20231001preview/testdata/resourceprovider_resource.json b/pkg/ucp/api/v20231001preview/testdata/resourceprovider_resource.json new file mode 100644 index 0000000000..0a80d3b426 --- /dev/null +++ b/pkg/ucp/api/v20231001preview/testdata/resourceprovider_resource.json @@ -0,0 +1,52 @@ +{ + "id": "/planes/radius/local/providers/System.Resources/resourceProviders/Applications.Core", + "name": "Applications.Core", + "type": "System.Resources/resourceProviders", + "location": "global", + "provisioningState": "Succeeded", + "properties": { + "locations": { + "global": { + "address": "https://localhost:8080" + } + }, + "resourceTypes": [ + { + "resourceType": "testType", + "locations": [ + "global" + ], + "apiVersions": { + "2023-10-01-preview": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "properties": { + "type": "object", + "properties": { + "coolness": { + "type": "string" + } + } + } + } + } + } + }, + "capabilities": [ + "coolness" + ], + "defaultApiVersion": "2023-10-01-preview" + } + ] + } +} \ No newline at end of file diff --git a/pkg/ucp/api/v20231001preview/zz_generated_client_factory.go b/pkg/ucp/api/v20231001preview/zz_generated_client_factory.go index cce33657b4..03db65dec9 100644 --- a/pkg/ucp/api/v20231001preview/zz_generated_client_factory.go +++ b/pkg/ucp/api/v20231001preview/zz_generated_client_factory.go @@ -69,6 +69,11 @@ func (c *ClientFactory) NewResourceGroupsClient() *ResourceGroupsClient { return subClient } +func (c *ClientFactory) NewResourceProvidersClient() *ResourceProvidersClient { + subClient, _ := NewResourceProvidersClient(c.credential, c.options) + return subClient +} + func (c *ClientFactory) NewResourcesClient() *ResourcesClient { subClient, _ := NewResourcesClient(c.credential, c.options) return subClient diff --git a/pkg/ucp/api/v20231001preview/zz_generated_constants.go b/pkg/ucp/api/v20231001preview/zz_generated_constants.go index 3818bcaa71..6448389095 100644 --- a/pkg/ucp/api/v20231001preview/zz_generated_constants.go +++ b/pkg/ucp/api/v20231001preview/zz_generated_constants.go @@ -113,6 +113,24 @@ func PossibleProvisioningStateValues() []ProvisioningState { } } +// ResourceTypeRoutingBehavior - The routing behavior for a resource type. +type ResourceTypeRoutingBehavior string + +const ( + // ResourceTypeRoutingBehaviorInternal - The resource type is implemented inside UCP. + ResourceTypeRoutingBehaviorInternal ResourceTypeRoutingBehavior = "Internal" + // ResourceTypeRoutingBehaviorProvider - The resource type is routed to a separate resource provider implementation. + ResourceTypeRoutingBehaviorProvider ResourceTypeRoutingBehavior = "Provider" +) + +// PossibleResourceTypeRoutingBehaviorValues returns the possible values for the ResourceTypeRoutingBehavior const type. +func PossibleResourceTypeRoutingBehaviorValues() []ResourceTypeRoutingBehavior { + return []ResourceTypeRoutingBehavior{ + ResourceTypeRoutingBehaviorInternal, + ResourceTypeRoutingBehaviorProvider, + } +} + // Versions - Supported API versions for Universal Control Plane resource provider. type Versions string diff --git a/pkg/ucp/api/v20231001preview/zz_generated_models.go b/pkg/ucp/api/v20231001preview/zz_generated_models.go index 9a57b377e7..c03bd3c37e 100644 --- a/pkg/ucp/api/v20231001preview/zz_generated_models.go +++ b/pkg/ucp/api/v20231001preview/zz_generated_models.go @@ -560,6 +560,85 @@ type ResourceGroupResourceTagsUpdate struct { Tags map[string]*string } +// ResourceProviderLocation - The configuration of a resource provider in a specific location. +type ResourceProviderLocation struct { + // REQUIRED; The address of the resource provider implementation. + Address *string +} + +// ResourceProviderProperties - Resource provider properties +type ResourceProviderProperties struct { + // REQUIRED; The configuration of the resource provider in each supported location. + Locations map[string]*ResourceProviderLocation + + // READ-ONLY; The resource types supported by the provider. + ResourceTypes []*ResourceType + + // READ-ONLY; The status of the asynchronous operation. + ProvisioningState *ProvisioningState +} + +// ResourceProviderResource - Concrete tracked resource types can be created by aliasing this type using a specific property +// type. +type ResourceProviderResource struct { + // REQUIRED; The geo-location where the resource lives + Location *string + + // The resource-specific properties for this resource. + Properties *ResourceProviderProperties + + // Resource tags. + Tags map[string]*string + + // READ-ONLY; Fully qualified resource ID for the resource. Ex - /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName} + ID *string + + // READ-ONLY; The name of the resource + Name *string + + // READ-ONLY; Azure Resource Manager metadata containing createdBy and modifiedBy information. + SystemData *SystemData + + // READ-ONLY; The type of the resource. E.g. "Microsoft.Compute/virtualMachines" or "Microsoft.Storage/storageAccounts" + Type *string +} + +// ResourceProviderResourceListResult - The response of a ResourceProviderResource list operation. +type ResourceProviderResourceListResult struct { + // REQUIRED; The ResourceProviderResource items on this page + Value []*ResourceProviderResource + + // The link to the next page of items + NextLink *string +} + +// ResourceType - A resource type supported by the resource provider. +type ResourceType struct { + // REQUIRED; The supported resource type api versions. + APIVersions map[string]*ResourceTypeAPIVersion + + // REQUIRED; The additional capabilities offered by this resource type. + Capabilities []*string + + // REQUIRED; The default api version for the resource type. + DefaultAPIVersion *string + + // REQUIRED; The locations that are supported by this resource type. + Locations []*string + + // REQUIRED; The resource type name. + ResourceType *string + + // REQUIRED; The routing behavior for the resource type. + RoutingType *ResourceTypeRoutingBehavior +} + +// ResourceTypeAPIVersion - The supported api versions for a resource type. +type ResourceTypeAPIVersion struct { + // REQUIRED; The OpenAPI v3 schema for the resource types. + Schema map[string]any +} + // SystemData - Metadata pertaining to creation and last modification of the resource. type SystemData struct { // The timestamp of resource creation (UTC). diff --git a/pkg/ucp/api/v20231001preview/zz_generated_models_serde.go b/pkg/ucp/api/v20231001preview/zz_generated_models_serde.go index 28932179ca..fa342d0a75 100644 --- a/pkg/ucp/api/v20231001preview/zz_generated_models_serde.go +++ b/pkg/ucp/api/v20231001preview/zz_generated_models_serde.go @@ -1469,6 +1469,224 @@ func (r *ResourceGroupResourceTagsUpdate) UnmarshalJSON(data []byte) error { return nil } +// MarshalJSON implements the json.Marshaller interface for type ResourceProviderLocation. +func (r ResourceProviderLocation) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "address", r.Address) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type ResourceProviderLocation. +func (r *ResourceProviderLocation) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "address": + err = unpopulate(val, "Address", &r.Address) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type ResourceProviderProperties. +func (r ResourceProviderProperties) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "locations", r.Locations) + populate(objectMap, "provisioningState", r.ProvisioningState) + populate(objectMap, "resourceTypes", r.ResourceTypes) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type ResourceProviderProperties. +func (r *ResourceProviderProperties) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "locations": + err = unpopulate(val, "Locations", &r.Locations) + delete(rawMsg, key) + case "provisioningState": + err = unpopulate(val, "ProvisioningState", &r.ProvisioningState) + delete(rawMsg, key) + case "resourceTypes": + err = unpopulate(val, "ResourceTypes", &r.ResourceTypes) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type ResourceProviderResource. +func (r ResourceProviderResource) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "id", r.ID) + populate(objectMap, "location", r.Location) + populate(objectMap, "name", r.Name) + populate(objectMap, "properties", r.Properties) + populate(objectMap, "systemData", r.SystemData) + populate(objectMap, "tags", r.Tags) + populate(objectMap, "type", r.Type) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type ResourceProviderResource. +func (r *ResourceProviderResource) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "id": + err = unpopulate(val, "ID", &r.ID) + delete(rawMsg, key) + case "location": + err = unpopulate(val, "Location", &r.Location) + delete(rawMsg, key) + case "name": + err = unpopulate(val, "Name", &r.Name) + delete(rawMsg, key) + case "properties": + err = unpopulate(val, "Properties", &r.Properties) + delete(rawMsg, key) + case "systemData": + err = unpopulate(val, "SystemData", &r.SystemData) + delete(rawMsg, key) + case "tags": + err = unpopulate(val, "Tags", &r.Tags) + delete(rawMsg, key) + case "type": + err = unpopulate(val, "Type", &r.Type) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type ResourceProviderResourceListResult. +func (r ResourceProviderResourceListResult) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "nextLink", r.NextLink) + populate(objectMap, "value", r.Value) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type ResourceProviderResourceListResult. +func (r *ResourceProviderResourceListResult) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "nextLink": + err = unpopulate(val, "NextLink", &r.NextLink) + delete(rawMsg, key) + case "value": + err = unpopulate(val, "Value", &r.Value) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type ResourceType. +func (r ResourceType) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "apiVersions", r.APIVersions) + populate(objectMap, "capabilities", r.Capabilities) + populate(objectMap, "defaultApiVersion", r.DefaultAPIVersion) + populate(objectMap, "locations", r.Locations) + populate(objectMap, "resourceType", r.ResourceType) + populate(objectMap, "routingType", r.RoutingType) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type ResourceType. +func (r *ResourceType) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "apiVersions": + err = unpopulate(val, "APIVersions", &r.APIVersions) + delete(rawMsg, key) + case "capabilities": + err = unpopulate(val, "Capabilities", &r.Capabilities) + delete(rawMsg, key) + case "defaultApiVersion": + err = unpopulate(val, "DefaultAPIVersion", &r.DefaultAPIVersion) + delete(rawMsg, key) + case "locations": + err = unpopulate(val, "Locations", &r.Locations) + delete(rawMsg, key) + case "resourceType": + err = unpopulate(val, "ResourceType", &r.ResourceType) + delete(rawMsg, key) + case "routingType": + err = unpopulate(val, "RoutingType", &r.RoutingType) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + } + return nil +} + +// MarshalJSON implements the json.Marshaller interface for type ResourceTypeAPIVersion. +func (r ResourceTypeAPIVersion) MarshalJSON() ([]byte, error) { + objectMap := make(map[string]any) + populate(objectMap, "schema", r.Schema) + return json.Marshal(objectMap) +} + +// UnmarshalJSON implements the json.Unmarshaller interface for type ResourceTypeAPIVersion. +func (r *ResourceTypeAPIVersion) UnmarshalJSON(data []byte) error { + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(data, &rawMsg); err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + for key, val := range rawMsg { + var err error + switch key { + case "schema": + err = unpopulate(val, "Schema", &r.Schema) + delete(rawMsg, key) + } + if err != nil { + return fmt.Errorf("unmarshalling type %T: %v", r, err) + } + } + return nil +} + // MarshalJSON implements the json.Marshaller interface for type SystemData. func (s SystemData) MarshalJSON() ([]byte, error) { objectMap := make(map[string]any) diff --git a/pkg/ucp/api/v20231001preview/zz_generated_options.go b/pkg/ucp/api/v20231001preview/zz_generated_options.go index f42bbe5e13..1cc3524676 100644 --- a/pkg/ucp/api/v20231001preview/zz_generated_options.go +++ b/pkg/ucp/api/v20231001preview/zz_generated_options.go @@ -177,6 +177,30 @@ type ResourceGroupsClientUpdateOptions struct { // placeholder for future optional parameters } +// ResourceProvidersClientBeginCreateOrUpdateOptions contains the optional parameters for the ResourceProvidersClient.BeginCreateOrUpdate +// method. +type ResourceProvidersClientBeginCreateOrUpdateOptions struct { + // Resumes the LRO from the provided token. + ResumeToken string +} + +// ResourceProvidersClientBeginDeleteOptions contains the optional parameters for the ResourceProvidersClient.BeginDelete +// method. +type ResourceProvidersClientBeginDeleteOptions struct { + // Resumes the LRO from the provided token. + ResumeToken string +} + +// ResourceProvidersClientGetOptions contains the optional parameters for the ResourceProvidersClient.Get method. +type ResourceProvidersClientGetOptions struct { + // placeholder for future optional parameters +} + +// ResourceProvidersClientListOptions contains the optional parameters for the ResourceProvidersClient.NewListPager method. +type ResourceProvidersClientListOptions struct { + // placeholder for future optional parameters +} + // ResourcesClientListOptions contains the optional parameters for the ResourcesClient.NewListPager method. type ResourcesClientListOptions struct { // placeholder for future optional parameters diff --git a/pkg/ucp/api/v20231001preview/zz_generated_resourceproviders_client.go b/pkg/ucp/api/v20231001preview/zz_generated_resourceproviders_client.go new file mode 100644 index 0000000000..edff042129 --- /dev/null +++ b/pkg/ucp/api/v20231001preview/zz_generated_resourceproviders_client.go @@ -0,0 +1,294 @@ +//go:build go1.18 +// +build go1.18 + +// Licensed under the Apache License, Version 2.0 . See LICENSE in the repository root for license information. +// Code generated by Microsoft (R) AutoRest Code Generator. DO NOT EDIT. +// Changes may cause incorrect behavior and will be lost if the code is regenerated. + +package v20231001preview + +import ( + "context" + "errors" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "net/http" + "net/url" + "strings" +) + +// ResourceProvidersClient contains the methods for the ResourceProviders group. +// Don't use this type directly, use NewResourceProvidersClient() instead. +type ResourceProvidersClient struct { + internal *arm.Client +} + +// NewResourceProvidersClient creates a new instance of ResourceProvidersClient with the specified values. +// - credential - used to authorize requests. Usually a credential from azidentity. +// - options - pass nil to accept the default values. +func NewResourceProvidersClient(credential azcore.TokenCredential, options *arm.ClientOptions) (*ResourceProvidersClient, error) { + cl, err := arm.NewClient(moduleName+".ResourceProvidersClient", moduleVersion, credential, options) + if err != nil { + return nil, err + } + client := &ResourceProvidersClient{ + internal: cl, + } + return client, nil +} + +// BeginCreateOrUpdate - Create or update a resource provider +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2023-10-01-preview +// - planeName - The plane name. +// - resourceProviderName - The resource provider name. +// - resource - Resource create parameters. +// - options - ResourceProvidersClientBeginCreateOrUpdateOptions contains the optional parameters for the ResourceProvidersClient.BeginCreateOrUpdate +// method. +func (client *ResourceProvidersClient) BeginCreateOrUpdate(ctx context.Context, planeName string, resourceProviderName string, resource ResourceProviderResource, options *ResourceProvidersClientBeginCreateOrUpdateOptions) (*runtime.Poller[ResourceProvidersClientCreateOrUpdateResponse], error) { + if options == nil || options.ResumeToken == "" { + resp, err := client.createOrUpdate(ctx, planeName, resourceProviderName, resource, options) + if err != nil { + return nil, err + } + poller, err := runtime.NewPoller(resp, client.internal.Pipeline(), &runtime.NewPollerOptions[ResourceProvidersClientCreateOrUpdateResponse]{ + FinalStateVia: runtime.FinalStateViaAzureAsyncOp, + }) + return poller, err + } else { + return runtime.NewPollerFromResumeToken[ResourceProvidersClientCreateOrUpdateResponse](options.ResumeToken, client.internal.Pipeline(), nil) + } +} + +// CreateOrUpdate - Create or update a resource provider +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2023-10-01-preview +func (client *ResourceProvidersClient) createOrUpdate(ctx context.Context, planeName string, resourceProviderName string, resource ResourceProviderResource, options *ResourceProvidersClientBeginCreateOrUpdateOptions) (*http.Response, error) { + var err error + req, err := client.createOrUpdateCreateRequest(ctx, planeName, resourceProviderName, resource, options) + if err != nil { + return nil, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return nil, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK, http.StatusCreated) { + err = runtime.NewResponseError(httpResp) + return nil, err + } + return httpResp, nil +} + +// createOrUpdateCreateRequest creates the CreateOrUpdate request. +func (client *ResourceProvidersClient) createOrUpdateCreateRequest(ctx context.Context, planeName string, resourceProviderName string, resource ResourceProviderResource, options *ResourceProvidersClientBeginCreateOrUpdateOptions) (*policy.Request, error) { + urlPath := "/planes/radius/{planeName}/providers/{resourceProviderName}" + if planeName == "" { + return nil, errors.New("parameter planeName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{planeName}", url.PathEscape(planeName)) + if resourceProviderName == "" { + return nil, errors.New("parameter resourceProviderName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{resourceProviderName}", url.PathEscape(resourceProviderName)) + req, err := runtime.NewRequest(ctx, http.MethodPut, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2023-10-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + if err := runtime.MarshalAsJSON(req, resource); err != nil { + return nil, err +} + return req, nil +} + +// BeginDelete - Delete a resource provider +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2023-10-01-preview +// - planeName - The plane name. +// - resourceProviderName - The resource provider name. +// - options - ResourceProvidersClientBeginDeleteOptions contains the optional parameters for the ResourceProvidersClient.BeginDelete +// method. +func (client *ResourceProvidersClient) BeginDelete(ctx context.Context, planeName string, resourceProviderName string, options *ResourceProvidersClientBeginDeleteOptions) (*runtime.Poller[ResourceProvidersClientDeleteResponse], error) { + if options == nil || options.ResumeToken == "" { + resp, err := client.deleteOperation(ctx, planeName, resourceProviderName, options) + if err != nil { + return nil, err + } + poller, err := runtime.NewPoller(resp, client.internal.Pipeline(), &runtime.NewPollerOptions[ResourceProvidersClientDeleteResponse]{ + FinalStateVia: runtime.FinalStateViaLocation, + }) + return poller, err + } else { + return runtime.NewPollerFromResumeToken[ResourceProvidersClientDeleteResponse](options.ResumeToken, client.internal.Pipeline(), nil) + } +} + +// Delete - Delete a resource provider +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2023-10-01-preview +func (client *ResourceProvidersClient) deleteOperation(ctx context.Context, planeName string, resourceProviderName string, options *ResourceProvidersClientBeginDeleteOptions) (*http.Response, error) { + var err error + req, err := client.deleteCreateRequest(ctx, planeName, resourceProviderName, options) + if err != nil { + return nil, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return nil, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK, http.StatusAccepted, http.StatusNoContent) { + err = runtime.NewResponseError(httpResp) + return nil, err + } + return httpResp, nil +} + +// deleteCreateRequest creates the Delete request. +func (client *ResourceProvidersClient) deleteCreateRequest(ctx context.Context, planeName string, resourceProviderName string, options *ResourceProvidersClientBeginDeleteOptions) (*policy.Request, error) { + urlPath := "/planes/radius/{planeName}/providers/{resourceProviderName}" + if planeName == "" { + return nil, errors.New("parameter planeName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{planeName}", url.PathEscape(planeName)) + if resourceProviderName == "" { + return nil, errors.New("parameter resourceProviderName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{resourceProviderName}", url.PathEscape(resourceProviderName)) + req, err := runtime.NewRequest(ctx, http.MethodDelete, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2023-10-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + return req, nil +} + +// Get - Get the specified resource provider. +// If the operation fails it returns an *azcore.ResponseError type. +// +// Generated from API version 2023-10-01-preview +// - planeName - The plane name. +// - resourceProviderName - The resource provider name. +// - options - ResourceProvidersClientGetOptions contains the optional parameters for the ResourceProvidersClient.Get method. +func (client *ResourceProvidersClient) Get(ctx context.Context, planeName string, resourceProviderName string, options *ResourceProvidersClientGetOptions) (ResourceProvidersClientGetResponse, error) { + var err error + req, err := client.getCreateRequest(ctx, planeName, resourceProviderName, options) + if err != nil { + return ResourceProvidersClientGetResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return ResourceProvidersClientGetResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusOK) { + err = runtime.NewResponseError(httpResp) + return ResourceProvidersClientGetResponse{}, err + } + resp, err := client.getHandleResponse(httpResp) + return resp, err +} + +// getCreateRequest creates the Get request. +func (client *ResourceProvidersClient) getCreateRequest(ctx context.Context, planeName string, resourceProviderName string, options *ResourceProvidersClientGetOptions) (*policy.Request, error) { + urlPath := "/planes/radius/{planeName}/providers/{resourceProviderName}" + if planeName == "" { + return nil, errors.New("parameter planeName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{planeName}", url.PathEscape(planeName)) + if resourceProviderName == "" { + return nil, errors.New("parameter resourceProviderName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{resourceProviderName}", url.PathEscape(resourceProviderName)) + req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2023-10-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + return req, nil +} + +// getHandleResponse handles the Get response. +func (client *ResourceProvidersClient) getHandleResponse(resp *http.Response) (ResourceProvidersClientGetResponse, error) { + result := ResourceProvidersClientGetResponse{} + if err := runtime.UnmarshalAsJSON(resp, &result.ResourceProviderResource); err != nil { + return ResourceProvidersClientGetResponse{}, err + } + return result, nil +} + +// NewListPager - List resource providers. +// +// Generated from API version 2023-10-01-preview +// - planeName - The plane name. +// - options - ResourceProvidersClientListOptions contains the optional parameters for the ResourceProvidersClient.NewListPager +// method. +func (client *ResourceProvidersClient) NewListPager(planeName string, options *ResourceProvidersClientListOptions) (*runtime.Pager[ResourceProvidersClientListResponse]) { + return runtime.NewPager(runtime.PagingHandler[ResourceProvidersClientListResponse]{ + More: func(page ResourceProvidersClientListResponse) bool { + return page.NextLink != nil && len(*page.NextLink) > 0 + }, + Fetcher: func(ctx context.Context, page *ResourceProvidersClientListResponse) (ResourceProvidersClientListResponse, error) { + var req *policy.Request + var err error + if page == nil { + req, err = client.listCreateRequest(ctx, planeName, options) + } else { + req, err = runtime.NewRequest(ctx, http.MethodGet, *page.NextLink) + } + if err != nil { + return ResourceProvidersClientListResponse{}, err + } + resp, err := client.internal.Pipeline().Do(req) + if err != nil { + return ResourceProvidersClientListResponse{}, err + } + if !runtime.HasStatusCode(resp, http.StatusOK) { + return ResourceProvidersClientListResponse{}, runtime.NewResponseError(resp) + } + return client.listHandleResponse(resp) + }, + }) +} + +// listCreateRequest creates the List request. +func (client *ResourceProvidersClient) listCreateRequest(ctx context.Context, planeName string, options *ResourceProvidersClientListOptions) (*policy.Request, error) { + urlPath := "/planes/radius/{planeName}/providers" + if planeName == "" { + return nil, errors.New("parameter planeName cannot be empty") + } + urlPath = strings.ReplaceAll(urlPath, "{planeName}", url.PathEscape(planeName)) + req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", "2023-10-01-preview") + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + return req, nil +} + +// listHandleResponse handles the List response. +func (client *ResourceProvidersClient) listHandleResponse(resp *http.Response) (ResourceProvidersClientListResponse, error) { + result := ResourceProvidersClientListResponse{} + if err := runtime.UnmarshalAsJSON(resp, &result.ResourceProviderResourceListResult); err != nil { + return ResourceProvidersClientListResponse{}, err + } + return result, nil +} + diff --git a/pkg/ucp/api/v20231001preview/zz_generated_response_types.go b/pkg/ucp/api/v20231001preview/zz_generated_response_types.go index e93fae78e7..69826f5dd7 100644 --- a/pkg/ucp/api/v20231001preview/zz_generated_response_types.go +++ b/pkg/ucp/api/v20231001preview/zz_generated_response_types.go @@ -187,6 +187,29 @@ type ResourceGroupsClientUpdateResponse struct { ResourceGroupResource } +// ResourceProvidersClientCreateOrUpdateResponse contains the response from method ResourceProvidersClient.BeginCreateOrUpdate. +type ResourceProvidersClientCreateOrUpdateResponse struct { + // Concrete tracked resource types can be created by aliasing this type using a specific property type. + ResourceProviderResource +} + +// ResourceProvidersClientDeleteResponse contains the response from method ResourceProvidersClient.BeginDelete. +type ResourceProvidersClientDeleteResponse struct { + // placeholder for future response values +} + +// ResourceProvidersClientGetResponse contains the response from method ResourceProvidersClient.Get. +type ResourceProvidersClientGetResponse struct { + // Concrete tracked resource types can be created by aliasing this type using a specific property type. + ResourceProviderResource +} + +// ResourceProvidersClientListResponse contains the response from method ResourceProvidersClient.NewListPager. +type ResourceProvidersClientListResponse struct { + // The response of a ResourceProviderResource list operation. + ResourceProviderResourceListResult +} + // ResourcesClientListResponse contains the response from method ResourcesClient.NewListPager. type ResourcesClientListResponse struct { // The response of a GenericResource list operation. diff --git a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go index 5623d88518..e1da2a6c88 100644 --- a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go +++ b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go @@ -36,7 +36,7 @@ import ( var _ ctrl.Controller = (*TrackedResourceProcessController)(nil) type updater interface { - Update(ctx context.Context, downstreamURL string, originalID resources.ID, version string) error + Update(ctx context.Context, opts trackedresource.UpdateOptions) error } // TrackedResourceProcessController is the async operation controller to perform background processing on tracked resources. @@ -45,12 +45,21 @@ type TrackedResourceProcessController struct { // Updater is the utility struct that can perform updates on tracked resources. This can be modified for testing. updater updater + + // transport is the transport used for requests that are proxied to other resource providers. + transport http.RoundTripper + + // internalTransport is the transport used for requests that are internal to the UCP (user-defined-types). + internalTransport http.RoundTripper } // 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 + return &TrackedResourceProcessController{ + BaseController: ctrl.NewBaseAsyncController(opts), + updater: trackedresource.NewUpdater(opts.StorageClient), + transport: otelhttp.NewTransport(http.DefaultTransport), + }, nil } // Run retrieves a resource from storage, parses the resource ID, and updates our tracked resource entry in the background. @@ -67,7 +76,7 @@ func (c *TrackedResourceProcessController) Run(ctx context.Context, request *ctr return ctrl.Result{}, err } - downstreamURL, err := resourcegroups.ValidateDownstream(ctx, c.StorageClient(), originalID) + downstreamURL, routingType, err := resourcegroups.ValidateDownstream(ctx, c.StorageClient(), originalID, "location") 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{}) { @@ -76,9 +85,20 @@ func (c *TrackedResourceProcessController) Run(ctx context.Context, request *ctr return ctrl.Result{}, fmt.Errorf("failed to validate downstream: %w", err) } + transport := c.transport + if routingType == resourcegroups.RoutingTypeInternal { + transport = c.internalTransport + } + logger := ucplog.FromContextOrDiscard(ctx) logger.Info("Processing tracked resource", "resourceID", originalID) - err = c.updater.Update(ctx, downstreamURL.String(), originalID, resource.Properties.APIVersion) + opts := trackedresource.UpdateOptions{ + Downstream: downstreamURL.String(), + Transport: transport, + ID: originalID, + APIVersion: resource.Properties.APIVersion, + } + err = c.updater.Update(ctx, opts) if errors.Is(err, &trackedresource.InProgressErr{}) { // The resource is still being processed, so we can sleep for a while. result := ctrl.Result{} diff --git a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go index 1958ace976..f6d0c82b3a 100644 --- a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go +++ b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go @@ -47,6 +47,7 @@ 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 := resources.MustParse("/planes/test/local/providers/System.Resources/resourceProviders/Applications.Test") plane := datamodel.RadiusPlane{ Properties: datamodel.RadiusPlaneProperties{ @@ -70,6 +71,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(), providerID.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 +95,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(), providerID.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) @@ -150,6 +159,6 @@ type mockUpdater struct { Result error } -func (u *mockUpdater) Update(ctx context.Context, downstreamURL string, originalID resources.ID, version string) error { +func (u *mockUpdater) Update(ctx context.Context, opts trackedresource.UpdateOptions) error { return u.Result } diff --git a/pkg/ucp/backend/service.go b/pkg/ucp/backend/service.go index 66c902fac7..fd2dcc5ae9 100644 --- a/pkg/ucp/backend/service.go +++ b/pkg/ucp/backend/service.go @@ -84,7 +84,7 @@ func (w *Service) Run(ctx context.Context) error { // RegisterControllers registers the controllers for the UCP backend. func RegisterControllers(ctx context.Context, registry *worker.ControllerRegistry, opts ctrl.Options) error { - err := registry.Register(ctx, v20231001preview.ResourceType, v1.OperationMethod(datamodel.OperationProcess), resourcegroups.NewTrackedResourceProcessController, opts) + err := registry.Register(ctx, v20231001preview.GenericResourceType, v1.OperationMethod(datamodel.OperationProcess), resourcegroups.NewTrackedResourceProcessController, opts) if err != nil { return err } diff --git a/pkg/ucp/datamodel/converter/dynamicresource_converter.go b/pkg/ucp/datamodel/converter/dynamicresource_converter.go new file mode 100644 index 0000000000..875a572187 --- /dev/null +++ b/pkg/ucp/datamodel/converter/dynamicresource_converter.go @@ -0,0 +1,58 @@ +/* +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/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/datamodel" +) + +// DynamicResourceDataModelFromVersioned converts version agnostic datamodel to versioned model. +func DynamicResourceDataModelToVersioned(model *datamodel.DynamicResource, version string) (v1.VersionedModelInterface, error) { + switch version { + case v20231001preview.Version: + versioned := &v20231001preview.DynamicResource{} + if err := versioned.ConvertFrom(model); err != nil { + return nil, err + } + return versioned, nil + + default: + return nil, v1.ErrUnsupportedAPIVersion + } +} + +// DynamicResourceDataModelFromVersioned converts versioned model to datamodel. +func DynamicResourceDataModelFromVersioned(content []byte, version string) (*datamodel.DynamicResource, error) { + switch version { + case v20231001preview.Version: + vm := &v20231001preview.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 + + default: + return nil, v1.ErrUnsupportedAPIVersion + } +} diff --git a/pkg/ucp/datamodel/converter/resourceprovider_converter.go b/pkg/ucp/datamodel/converter/resourceprovider_converter.go new file mode 100644 index 0000000000..572650168f --- /dev/null +++ b/pkg/ucp/datamodel/converter/resourceprovider_converter.go @@ -0,0 +1,59 @@ +/* +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/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/datamodel" +) + +// ResourceProviderDataModelToVersioned converts version agnostic plane datamodel to versioned model. +func ResourceProviderDataModelToVersioned(model *datamodel.ResourceProvider, version string) (v1.VersionedModelInterface, error) { + switch version { + case v20231001preview.Version: + versioned := &v20231001preview.ResourceProviderResource{} + if err := versioned.ConvertFrom(model); err != nil { + return nil, err + } + return versioned, nil + + default: + return nil, v1.ErrUnsupportedAPIVersion + } +} + +// ResourceProviderDataModelFromVersioned converts versioned plane model to datamodel. +func ResourceProviderDataModelFromVersioned(content []byte, version string) (*datamodel.ResourceProvider, error) { + switch version { + case v20231001preview.Version: + vm := &v20231001preview.ResourceProviderResource{} + if err := json.Unmarshal(content, vm); err != nil { + return nil, err + } + dm, err := vm.ConvertTo() + if err != nil { + return nil, err + } + return dm.(*datamodel.ResourceProvider), nil + + default: + return nil, v1.ErrUnsupportedAPIVersion + } +} diff --git a/pkg/ucp/datamodel/dynamicresource.go b/pkg/ucp/datamodel/dynamicresource.go new file mode 100644 index 0000000000..b99dbe2e11 --- /dev/null +++ b/pkg/ucp/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/ucp/datamodel/genericresource.go b/pkg/ucp/datamodel/genericresource.go index f561569a54..e50d5f3016 100644 --- a/pkg/ucp/datamodel/genericresource.go +++ b/pkg/ucp/datamodel/genericresource.go @@ -25,7 +25,7 @@ const ( // OperationProcess is the operation type for processing a tracked resource. OperationProcess = "PROCESS" // ResourceType is the resource type for a generic resource. - ResourceType = "System.Resources/resources" + GenericResourceType = "System.Resources/resources" ) // GenericResource represents a stored "tracked resource" within a UCP resource group. @@ -47,7 +47,7 @@ type GenericResource struct { // ResourceTypeName gives the type of ucp resource. func (r *GenericResource) ResourceTypeName() string { - return ResourceType + return GenericResourceType } // GenericResourceProperties stores the properties of the resource being tracked. diff --git a/pkg/ucp/datamodel/resourceprovider.go b/pkg/ucp/datamodel/resourceprovider.go new file mode 100644 index 0000000000..443260d386 --- /dev/null +++ b/pkg/ucp/datamodel/resourceprovider.go @@ -0,0 +1,80 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package datamodel + +import v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + +const ( + // ResourceType is the resource type for a resource provider. + ResourceProviderResourceType = "System.Resources/resourceProviders" +) + +// ResourceProvider represents a resource provider (namespace + set of types). +type ResourceProvider struct { + v1.BaseResource + + // Properties stores the properties of the resource provider. + Properties ResourceProviderProperties `json:"properties"` +} + +// ResourceTypeName gives the type of the resource. +func (r *ResourceProvider) ResourceTypeName() string { + return ResourceProviderResourceType +} + +// ReosurceProviderID returns the resource ID of the resource provider. +func ResourceProviderID(scope string, namespace string) string { + return scope + "/providers/" + (&ResourceProvider{}).ResourceTypeName() + "/" + namespace +} + +// ResourceProviderProperties stores the properties of a resource provider. +type ResourceProviderProperties struct { + // Locations is the list of locations supported by this resource provider. + Locations map[string]ResourceProviderLocation `json:"locations"` + + // ResourceTypes stores the properties of the resource types. + ResourceTypes []ResourceType `json:"resourceTypes"` +} + +// ResourceProviderLocation stores the configuration for each instance of the resource provider. +type ResourceProviderLocation struct { + // Address is the address of the resource provider for this location. + Address string `json:"address"` +} + +type ResourceTypeAPIVersion struct { + // Schema is the OpenAPI v3 schema of the resource type. + Schema map[string]any `json:"schema"` +} + +// ResourceType stores the properties of a resource type. +type ResourceType struct { + // ResourceType is the name of the resource type. + ResourceType string `json:"resourceType"` + + // APIVersions is the list of API versions supported by this resource type. + APIVersions map[string]ResourceTypeAPIVersion `json:"apiVersions"` + + // Capabilities is the list of capabilities of this resource type. + Capabilities []string `json:"capabilities"` + + // DefaultAPIVersion is the default API version for this resource type. + DefaultAPIVersion string `json:"defaultApiVersion"` + + // Locations is the list of locations supported by this resource type. + Locations []string `json:"locations"` +} diff --git a/pkg/ucp/frontend/controller/radius/provider.go b/pkg/ucp/frontend/controller/radius/provider.go new file mode 100644 index 0000000000..dee9f81176 --- /dev/null +++ b/pkg/ucp/frontend/controller/radius/provider.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 radius diff --git a/pkg/ucp/frontend/controller/radius/proxy.go b/pkg/ucp/frontend/controller/radius/proxy.go index 8f2bbdfd3a..14335b2100 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, opts trackedresource.UpdateOptions) error } var _ armrpc_controller.Controller = (*ProxyController)(nil) @@ -67,6 +67,9 @@ type ProxyController struct { // transport is the http.RoundTripper to use for proxying requests. Can be overridden for testing. transport http.RoundTripper + // internalTransport is the http.RoundTripper to use for internal requests. Can be overridden for testing. + internalTransport http.RoundTripper + // updater is used to process tracked resources. Can be overridden for testing. updater updater } @@ -75,13 +78,13 @@ type ProxyController struct { // // 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) - updater := trackedresource.NewUpdater(opts.StorageClient, &http.Client{Transport: transport}) +func NewProxyController(opts armrpc_controller.Options, transport http.RoundTripper, internalTransport http.RoundTripper) (armrpc_controller.Controller, error) { + updater := trackedresource.NewUpdater(opts.StorageClient) 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, + internalTransport: internalTransport, + updater: updater, }, nil } @@ -100,7 +103,7 @@ 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) + downstreamURL, routingType, err := resourcegroups.ValidateDownstream(ctx, p.StorageClient(), id, requestCtx.Location) if errors.Is(err, &resourcegroups.NotFoundError{}) { return armrpc_rest.NewNotFoundResponse(id), nil } else if errors.Is(err, &resourcegroups.InvalidError{}) { @@ -110,13 +113,24 @@ func (p *ProxyController) Run(ctx context.Context, w http.ResponseWriter, req *h return nil, fmt.Errorf("failed to validate downstream: %w", err) } + transport := p.transport + if routingType == resourcegroups.RoutingTypeInternal { + transport = p.internalTransport + + // For internal requests, the downstream URL doesn't need to change. + // We only need the scheme and hostname. + downstreamURL = &url.URL{ + Scheme: requestCtx.OriginalURL.Scheme, + Host: requestCtx.OriginalURL.Host, + } + } + proxyReq, err := p.PrepareProxyRequest(ctx, req, downstreamURL.String(), relativePath) if err != nil { return nil, err } - interceptor := &responseInterceptor{Inner: p.transport} - + interceptor := &responseInterceptor{Inner: transport} sender := proxy.NewARMProxy(proxy.ReverseProxyOptions{RoundTripper: interceptor}, downstreamURL, nil) sender.ServeHTTP(w, proxyReq) @@ -138,7 +152,13 @@ func (p *ProxyController) Run(ctx context.Context, w http.ResponseWriter, req *h if p.IsTerminalResponse(interceptor.Response) { logger.V(ucplog.LevelDebug).Info("response is terminal, updating tracked resource synchronously") - err = p.UpdateTrackedResource(ctx, downstreamURL.String(), id, requestCtx.APIVersion) + opts := trackedresource.UpdateOptions{ + Downstream: downstreamURL.String(), + Transport: transport, + ID: id, + APIVersion: requestCtx.APIVersion, + } + err = p.UpdateTrackedResource(ctx, opts) if errors.Is(err, &trackedresource.InProgressErr{}) { logger.V(ucplog.LevelDebug).Info("synchronous update failed, updating tracked resource asynchronously") // Continue executing @@ -192,6 +212,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 +243,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, opts trackedresource.UpdateOptions) error { + return p.updater.Update(ctx, opts) } // 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 f596210c8a..9983f9a112 100644 --- a/pkg/ucp/frontend/controller/radius/proxy_test.go +++ b/pkg/ucp/frontend/controller/radius/proxy_test.go @@ -44,22 +44,25 @@ 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, + &roundTripper) 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 := resources.MustParse("/planes/test/local/providers/System.Resources/resourceProviders/Applications.Test") plane := datamodel.RadiusPlane{ Properties: datamodel.RadiusPlaneProperties{ ResourceProviders: map[string]string{ @@ -83,6 +86,10 @@ func Test_Run(t *testing.T) { // Not a mutating request req := httptest.NewRequest(http.MethodGet, id.String(), nil) + storageClient.EXPECT(). + Get(gomock.Any(), providerID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) + storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). Return(&store.Object{Data: plane}, nil).Times(1) @@ -114,6 +121,10 @@ func Test_Run(t *testing.T) { // Mutating request that will complete synchronously req := httptest.NewRequest(http.MethodDelete, id.String(), nil) + storageClient.EXPECT(). + Get(gomock.Any(), providerID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) + storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). Return(&store.Object{Data: plane}, nil).Times(1) @@ -148,6 +159,10 @@ func Test_Run(t *testing.T) { // Mutating request that will complete synchronously req := httptest.NewRequest(http.MethodDelete, id.String(), nil) + storageClient.EXPECT(). + Get(gomock.Any(), providerID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) + storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). Return(&store.Object{Data: plane}, nil).Times(1) @@ -194,6 +209,10 @@ func Test_Run(t *testing.T) { // Mutating request that will complete asynchronously req := httptest.NewRequest(http.MethodDelete, id.String(), nil) + storageClient.EXPECT(). + Get(gomock.Any(), providerID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) + storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). Return(&store.Object{Data: plane}, nil).Times(1) @@ -315,7 +334,7 @@ type mockUpdater struct { Result error } -func (u *mockUpdater) Update(ctx context.Context, downstreamURL string, originalID resources.ID, version string) error { +func (u *mockUpdater) Update(ctx context.Context, opts trackedresource.UpdateOptions) error { return u.Result } diff --git a/pkg/ucp/frontend/controller/resourcegroups/listresources.go b/pkg/ucp/frontend/controller/resourcegroups/listresources.go index 4cfc82a53c..0e6dc2bc82 100644 --- a/pkg/ucp/frontend/controller/resourcegroups/listresources.go +++ b/pkg/ucp/frontend/controller/resourcegroups/listresources.go @@ -71,7 +71,7 @@ func (r *ListResources) Run(ctx context.Context, w http.ResponseWriter, req *htt query := store.Query{ RootScope: resourceGroupID.String(), - ResourceType: v20231001preview.ResourceType, + ResourceType: v20231001preview.GenericResourceType, } result, err := r.StorageClient().Query(ctx, query) diff --git a/pkg/ucp/frontend/controller/resourcegroups/listresources_test.go b/pkg/ucp/frontend/controller/resourcegroups/listresources_test.go index c7f77c9934..fb332f3138 100644 --- a/pkg/ucp/frontend/controller/resourcegroups/listresources_test.go +++ b/pkg/ucp/frontend/controller/resourcegroups/listresources_test.go @@ -69,7 +69,7 @@ func Test_ListResources(t *testing.T) { Return(&store.Object{Data: resourceGroupDatamodel}, nil). Times(1) - expectedQuery := store.Query{RootScope: resourceGroupID, ResourceType: v20231001preview.ResourceType} + expectedQuery := store.Query{RootScope: resourceGroupID, ResourceType: v20231001preview.GenericResourceType} storage.EXPECT(). Query(gomock.Any(), expectedQuery). Return(&store.ObjectQueryResult{Items: []store.Object{{Data: entryDatamodel}}}, nil). @@ -95,7 +95,7 @@ func Test_ListResources(t *testing.T) { Return(&store.Object{Data: resourceGroupDatamodel}, nil). Times(1) - expectedQuery := store.Query{RootScope: resourceGroupID, ResourceType: v20231001preview.ResourceType} + expectedQuery := store.Query{RootScope: resourceGroupID, ResourceType: v20231001preview.GenericResourceType} storage.EXPECT(). Query(gomock.Any(), expectedQuery). Return(&store.ObjectQueryResult{Items: []store.Object{}}, nil). diff --git a/pkg/ucp/frontend/controller/resourcegroups/util.go b/pkg/ucp/frontend/controller/resourcegroups/util.go index ac8117e0ee..050e4d661f 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,30 @@ 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) { +// RoutingType specifies the type of routing to apply to the request. +type RoutingType string + +const ( + // RoutingTypeInvalid is used when the routing type cannot be determined due to an error. + RoutingTypeInvalid RoutingType = "invalid" + + // RoutingTypeProxy is used when the request should be proxied to the downstream URL. This + // is used for services that implement the resource provider interface. + RoutingTypeProxy RoutingType = "proxy" + + // RoutingTypeInternal is used when the request should be handled internally by the UCP. This + // is used for user-defined-types. + RoutingTypeInternal RoutingType = "internal" +) + +// 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 +92,124 @@ 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 +} + +// ValidateResourceProvider validates that the resource provider specified in the id exists (if applicable). +// Returns NotFoundError if the resource provider does not exist. +func ValidateResourceProvider(ctx context.Context, client store.StorageClient, id resources.ID) (*datamodel.ResourceProvider, error) { + providerId := makeResourceProviderID(id) + obj, err := client.Get(ctx, providerId.String()) + if errors.Is(err, &store.ErrNotFound{}) { + return nil, &NotFoundError{Message: fmt.Sprintf("resource provider %q not found", providerId.String())} + } else if err != nil { + return nil, err + } + + provider := &datamodel.ResourceProvider{} + err = obj.As(provider) + if err != nil { + return nil, err + } + + return provider, nil +} + +func makeResourceProviderID(id resources.ID) resources.ID { + return resources.MustParse(resources.MakeUCPID( + // /planes/radius/{planeName} + id.ScopeSegments()[0:1], + + // /providers/ + []resources.TypeSegment{ + { + Type: datamodel.ResourceProviderResourceType, + Name: id.ProviderNamespace(), + }, + }, + nil)) +} + +// ValidateResourceType validates that the resource type specified in the id exists. +// Returns NotFoundError if the resource type does not exist. +// Returns InvalidError if the data is invalid or the resource type is not supported at the provided location. +// +// This function does not validate the API version. API version validation is handled by the dynamic RP. +func ValidateResourceType(id resources.ID, location string, provider *datamodel.ResourceProvider) (*url.URL, RoutingType, error) { + // First let's validate that the resource type exists. + found := false + for _, resourceType := range provider.Properties.ResourceTypes { + // Look for matching resource type + if strings.EqualFold(id.Type(), provider.Name+"/"+resourceType.ResourceType) { + found = true + break + } + + // Support special cases for built-in operation types. We don't require the RP to register these with + // UCP. + if strings.EqualFold(id.Type(), provider.Name+"/locations/operationStatuses") { + found = true + break + } + if strings.EqualFold(id.Type(), provider.Name+"/locations/operationResults") { + found = true + break + } + } + + if !found { + return nil, RoutingTypeInvalid, &NotFoundError{Message: fmt.Sprintf("resource type %q not found", id.Type())} + } + + // Look for matching location + for name, loc := range provider.Properties.Locations { + if !strings.EqualFold(name, location) { + continue } - _, 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) + if strings.EqualFold(loc.Address, "internal") { + return nil, RoutingTypeInternal, nil + } + + downstreamURL, err := url.Parse(loc.Address) + if err != nil { + return nil, RoutingTypeInvalid, &InvalidError{Message: fmt.Sprintf("failed to parse downstream URL: %v", err.Error())} } + + return downstreamURL, RoutingTypeProxy, nil } + // If we get here, the specific location is not supported. + return nil, RoutingTypeInvalid, &InvalidError{Message: fmt.Sprintf("resource type %q not supported at location %q", id.Type(), location)} +} + +// 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 +222,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) (*url.URL, RoutingType, 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, RoutingTypeInvalid, err + } + + // The resource group exists (if applicable). + err = ValidateResourceGroup(ctx, client, id) + if err != nil { + return nil, RoutingTypeInvalid, err + } + + provider, err := ValidateResourceProvider(ctx, client, id) + if errors.Is(err, &NotFoundError{}) { + // 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, RoutingTypeInvalid, err + } + + return downstreamURL, RoutingTypeProxy, nil + } else if err != nil { + return nil, RoutingTypeInvalid, err + } + + return ValidateResourceType(id, location, provider) +} diff --git a/pkg/ucp/frontend/controller/resourcegroups/util_test.go b/pkg/ucp/frontend/controller/resourcegroups/util_test.go index f2e530ea62..4847477a94 100644 --- a/pkg/ucp/frontend/controller/resourcegroups/util_test.go +++ b/pkg/ucp/frontend/controller/resourcegroups/util_test.go @@ -34,6 +34,8 @@ 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) + providerID := makeResourceProviderID(id) + idWithoutResourceGroup, err := resources.Parse("/planes/radius/local/providers/System.TestRP/testResources") require.NoError(t, err) @@ -52,52 +54,132 @@ func Test_ValidateDownstream(t *testing.T) { }, } + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + 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(), providerID.String()).Return(nil, &store.ErrNotFound{}).Times(1) 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) expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, routingType, err := ValidateDownstream(testcontext.New(t), mock, id, "global") require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) + require.Equal(t, RoutingTypeProxy, routingType) }) t.Run("success (non resource group)", func(t *testing.T) { mock := setup(t) + mock.EXPECT().Get(gomock.Any(), providerID.String()).Return(nil, &store.ErrNotFound{}).Times(1) mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, idWithoutResourceGroup) + downstreamURL, routingType, err := ValidateDownstream(testcontext.New(t), mock, idWithoutResourceGroup, "global") require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) + require.Equal(t, RoutingTypeProxy, routingType) + }) + + t.Run("success (resource provider: internal)", func(t *testing.T) { + resourceProvider := &datamodel.ResourceProvider{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: "System.TestRP", + ID: providerID.String(), + }, + }, + Properties: datamodel.ResourceProviderProperties{ + Locations: map[string]datamodel.ResourceProviderLocation{ + "global": { + Address: "internal", + }, + }, + ResourceTypes: []datamodel.ResourceType{ + { + ResourceType: "testResources", + Locations: []string{ + "global", + }, + }, + }, + }, + } + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), providerID.String()).Return(&store.Object{Data: resourceProvider}, nil).Times(1) + 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) + + downstreamURL, routingType, err := ValidateDownstream(testcontext.New(t), mock, id, "global") + require.NoError(t, err) + require.Nil(t, downstreamURL) + require.Equal(t, RoutingTypeInternal, routingType) + }) + + t.Run("success (resource provider: proxy)", func(t *testing.T) { + resourceProvider := &datamodel.ResourceProvider{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: "System.TestRP", + ID: providerID.String(), + }, + }, + Properties: datamodel.ResourceProviderProperties{ + Locations: map[string]datamodel.ResourceProviderLocation{ + "global": { + Address: "http://localhost:7443", + }, + }, + ResourceTypes: []datamodel.ResourceType{ + { + ResourceType: "testResources", + Locations: []string{ + "global", + }, + }, + }, + }, + } + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), providerID.String()).Return(&store.Object{Data: resourceProvider}, nil).Times(1) + 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) + + expectedURL, err := url.Parse("http://localhost:7443") + require.NoError(t, err) + + downstreamURL, routingType, err := ValidateDownstream(testcontext.New(t), mock, id, "global") + require.NoError(t, err) + require.Equal(t, expectedURL, downstreamURL) + require.Equal(t, RoutingTypeProxy, routingType) }) 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) + downstreamURL, routingType, err := ValidateDownstream(testcontext.New(t), mock, id, "global") require.Error(t, err) require.Equal(t, &NotFoundError{Message: "plane \"/planes/radius/local\" not found"}, err) require.Nil(t, downstreamURL) + require.Equal(t, RoutingTypeInvalid, routingType) }) t.Run("plane retreival failure", func(t *testing.T) { @@ -106,10 +188,11 @@ 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, routingType, err := ValidateDownstream(testcontext.New(t), mock, id, "global") require.Error(t, err) require.Equal(t, expected, err) require.Nil(t, downstreamURL) + require.Equal(t, RoutingTypeInvalid, routingType) }) t.Run("resource group not found", func(t *testing.T) { @@ -117,10 +200,11 @@ 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, routingType, err := ValidateDownstream(testcontext.New(t), mock, id, "global") require.Error(t, err) require.Equal(t, &NotFoundError{Message: "resource group \"/planes/radius/local/resourceGroups/test-group\" not found"}, err) require.Nil(t, downstreamURL) + require.Equal(t, RoutingTypeInvalid, routingType) }) t.Run("resource group err", func(t *testing.T) { @@ -129,10 +213,11 @@ 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, routingType, err := ValidateDownstream(testcontext.New(t), mock, id, "global") 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) + require.Equal(t, RoutingTypeInvalid, routingType) }) t.Run("resource provider not found", func(t *testing.T) { @@ -156,13 +241,15 @@ func Test_ValidateDownstream(t *testing.T) { } mock := setup(t) + mock.EXPECT().Get(gomock.Any(), providerID.String()).Return(nil, &store.ErrNotFound{}).Times(1) 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) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, routingType, err := ValidateDownstream(testcontext.New(t), mock, id, "global") require.Error(t, err) require.Equal(t, &InvalidError{Message: "resource provider System.TestRP not configured"}, err) require.Nil(t, downstreamURL) + require.Equal(t, RoutingTypeInvalid, routingType) }) t.Run("resource provider invalid URL", func(t *testing.T) { @@ -188,12 +275,97 @@ func Test_ValidateDownstream(t *testing.T) { } mock := setup(t) + mock.EXPECT().Get(gomock.Any(), providerID.String()).Return(nil, &store.ErrNotFound{}).Times(1) 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) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, routingType, err := ValidateDownstream(testcontext.New(t), mock, id, "global") 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) + require.Equal(t, RoutingTypeInvalid, routingType) + }) +} + +func Test_ValidateResourceType(t *testing.T) { + id := resources.MustParse("/planes/radius/local/resourceGroups/test-group/providers/Applications.Test/testResources/testResource") + + provider := &datamodel.ResourceProvider{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: "Applications.Test", + }, + }, + Properties: datamodel.ResourceProviderProperties{ + Locations: map[string]datamodel.ResourceProviderLocation{ + "proxy": { + Address: "http://localhost:7443", // Proxy + }, + "internal": { + Address: "internal", // Internal + }, + }, + ResourceTypes: []datamodel.ResourceType{ + { + ResourceType: "testResources", + Locations: []string{ + "proxy", + "internal", + }, + }, + }, + }, + } + + t.Run("Success: proxy", func(t *testing.T) { + parsed, err := url.Parse("http://localhost:7443") + require.NoError(t, err) + + downstream, routingType, err := ValidateResourceType(id, "proxy", provider) + require.Equal(t, downstream, parsed) + require.Equal(t, RoutingTypeProxy, routingType) + require.NoError(t, err) + }) + + t.Run("Success: internal", func(t *testing.T) { + downstream, routingType, err := ValidateResourceType(id, "internal", provider) + require.Nil(t, downstream) + require.Equal(t, RoutingTypeInternal, routingType) + require.NoError(t, err) + }) + + t.Run("Success: operationStatuses", func(t *testing.T) { + id := resources.MustParse("/planes/radius/local/providers/Applications.Test/locations/internal/operationStatuses/abcd") + downstream, routingType, err := ValidateResourceType(id, "internal", provider) + require.Nil(t, downstream) + require.Equal(t, RoutingTypeInternal, routingType) + require.NoError(t, err) + }) + + t.Run("Success: operationResults", func(t *testing.T) { + id := resources.MustParse("/planes/radius/local/providers/Applications.Test/locations/internal/operationResults/abcd") + downstream, routingType, err := ValidateResourceType(id, "internal", provider) + require.Nil(t, downstream) + require.Equal(t, RoutingTypeInternal, routingType) + require.NoError(t, err) + }) + + t.Run("ResourceType not found", func(t *testing.T) { + id := resources.MustParse("/planes/radius/local/resourceGroups/test-group/providers/Applications.Test/anotherType/testResource") + downstream, routingType, err := ValidateResourceType(id, "internal", provider) + require.Nil(t, downstream) + require.Equal(t, RoutingTypeInvalid, routingType) + require.Error(t, err) + require.ErrorIs(t, err, &NotFoundError{Message: "resource type \"Applications.Test/anotherType\" not found"}) + require.Equal(t, "resource type \"Applications.Test/anotherType\" not found", err.Error()) + }) + + t.Run("Location not supported", func(t *testing.T) { + downstream, routingType, err := ValidateResourceType(id, "another-one", provider) + require.Nil(t, downstream) + require.Equal(t, RoutingTypeInvalid, routingType) + require.Error(t, err) + require.ErrorIs(t, err, &InvalidError{Message: "resource type \"Applications.Test/testResources\" not supported at location \"another-one\""}) + require.Equal(t, "resource type \"Applications.Test/testResources\" not supported at location \"another-one\"", err.Error()) }) } diff --git a/pkg/ucp/frontend/radius/internal.go b/pkg/ucp/frontend/radius/internal.go new file mode 100644 index 0000000000..6a3ae447c4 --- /dev/null +++ b/pkg/ucp/frontend/radius/internal.go @@ -0,0 +1,179 @@ +/* +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 radius + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "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/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/datamodel" + "github.com/radius-project/radius/pkg/ucp/frontend/modules" + "github.com/radius-project/radius/pkg/ucp/resources" + "github.com/radius-project/radius/pkg/validator" +) + +const ( + planeScopedResourceCollectionRoute = "/planes/radius/{planeName}/providers/{providerNamespace}/{resourceType}" + resourceCollectionRoute = "/planes/radius/{planeName}/{rg:resource[gG]roups}/{resourceGroupName}/providers/{providerNamespace}/{resourceType}" + resourceRoute = resourceCollectionRoute + "/{resourceName}" +) + +func createInternalTransport(opts modules.Options) http.RoundTripper { + r := chi.NewRouter() + + ctrlOpts := controller.Options{ + Address: opts.Address, + PathBase: "", // Ignore PathBase because the proxy will remove it. + DataProvider: opts.DataProvider, + KubeClient: nil, // Unused by internal transport. + StatusManager: opts.StatusManager, + } + + // Return ARM errors for invalid requests. + r.NotFound(validator.APINotFoundHandler()) + r.MethodNotAllowed(validator.APIMethodNotAllowedHandler()) + + // TODO: add default operations from: pkg/armrpc/builder/builder.go + + 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 &handlerRoundTripper{handler: r} +} + +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.PathBase = "" // The proxy will blank out any base path + 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 := &v20231001preview.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 := &v20231001preview.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) + }) +} + +type handlerRoundTripper struct { + handler http.Handler +} + +// RoundTrip implements http.RoundTripper by executing the request in-memory. +func (r *handlerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + w := httptest.NewRecorder() + r.handler.ServeHTTP(w, req) + + response := w.Result() + response.Request = req + return response, nil +} diff --git a/pkg/ucp/frontend/radius/module.go b/pkg/ucp/frontend/radius/module.go index 1dae8a5641..622c40375d 100644 --- a/pkg/ucp/frontend/radius/module.go +++ b/pkg/ucp/frontend/radius/module.go @@ -17,9 +17,12 @@ limitations under the License. package radius import ( + "net/http" + "github.com/go-chi/chi/v5" "github.com/radius-project/radius/pkg/ucp/frontend/modules" "github.com/radius-project/radius/pkg/validator" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) // NewModule creates a new Radius module. @@ -28,7 +31,15 @@ func NewModule(options modules.Options) *Module { router.NotFound(validator.APINotFoundHandler()) router.MethodNotAllowed(validator.APIMethodNotAllowedHandler()) - return &Module{options: options, router: router} + transport := otelhttp.NewTransport(http.DefaultTransport) + internalTransport := createInternalTransport(options) + + return &Module{ + options: options, + router: router, + transport: transport, + internalTransport: internalTransport, + } } var _ modules.Initializer = &Module{} @@ -37,6 +48,12 @@ var _ modules.Initializer = &Module{} type Module struct { options modules.Options router chi.Router + + // transport is the transport used for requests that are proxied to other resource providers. + transport http.RoundTripper + + // internalTransport is the transport used for requests that are internal to the UCP (user-defined-types). + internalTransport http.RoundTripper } // 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 7063bc4cd6..5191c2c134 100644 --- a/pkg/ucp/frontend/radius/routes.go +++ b/pkg/ucp/frontend/radius/routes.go @@ -18,11 +18,14 @@ package radius import ( "context" + "fmt" "net/http" + "strings" 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/middleware" "github.com/radius-project/radius/pkg/armrpc/frontend/server" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/datamodel" @@ -30,20 +33,30 @@ import ( planes_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/planes" radius_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/radius" resourcegroups_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/resourcegroups" + "github.com/radius-project/radius/pkg/ucp/resources" "github.com/radius-project/radius/pkg/validator" ) const ( - planeCollectionPath = "/planes/radius" - planeResourcePath = "/planes/radius/{planeName}" - resourceGroupCollectionPath = planeResourcePath + "/resourcegroups" - resourceGroupResourcePath = planeResourcePath + "/resourcegroups/{resourceGroupName}" + planeCollectionPath = "/planes/radius" + planeResourcePath = "/planes/radius/{planeName}" + resourceProviderCollectionPath = planeResourcePath + "/providers" + resourceProviderResourcePath = planeResourcePath + "/providers/{resourceProviderName}" + resourceGroupCollectionPath = planeResourcePath + "/resourcegroups" + resourceGroupResourcePath = planeResourcePath + "/resourcegroups/{resourceGroupName}" // OperationTypeUCPRadiusProxy is the operation type for proxying Radius API calls. OperationTypeUCPRadiusProxy = "UCPRADIUSPROXY" ) func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { + ctrlOptions := controller.Options{ + Address: m.options.Address, + PathBase: m.options.PathBase, + DataProvider: m.options.DataProvider, + StatusManager: m.options.StatusManager, + } + baseRouter := server.NewSubrouter(m.router, m.options.PathBase) apiValidator := validator.APIValidator(validator.Options{ @@ -70,6 +83,10 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { resourceGroupCollectionRouter := server.NewSubrouter(baseRouter, resourceGroupCollectionPath, apiValidator) resourceGroupResourceRouter := server.NewSubrouter(baseRouter, resourceGroupResourcePath, apiValidator) + // URLs for lifecycle of resource providers + resourceProviderCollectionRouter := server.NewSubrouter(baseRouter, resourceProviderCollectionPath, apiValidator) + resourceProviderResourceRouter := server.NewSubrouter(baseRouter, resourceProviderResourcePath, apiValidator) + handlerOptions := []server.HandlerOptions{ { // This is a scope query so we can't use the default operation. @@ -106,6 +123,61 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { return defaultoperation.NewDefaultSyncDelete(opts, planeResourceOptions) }, }, + { + ParentRouter: resourceProviderCollectionRouter, + ResourceType: datamodel.ResourceProviderResourceType, + Method: v1.OperationList, + ControllerFactory: func(opt controller.Options) (controller.Controller, error) { + return defaultoperation.NewListResources(opt, + controller.ResourceOptions[datamodel.ResourceProvider]{ + RequestConverter: converter.ResourceProviderDataModelFromVersioned, + ResponseConverter: converter.ResourceProviderDataModelToVersioned, + }) + }, + Middlewares: []func(http.Handler) http.Handler{middleware.OverrideResourceID(resourceIDForResourceProviderCollection(m.options.PathBase))}, + }, + { + ParentRouter: resourceProviderResourceRouter, + ResourceType: datamodel.ResourceProviderResourceType, + Method: v1.OperationGet, + ControllerFactory: func(opt controller.Options) (controller.Controller, error) { + return defaultoperation.NewGetResource(opt, + controller.ResourceOptions[datamodel.ResourceProvider]{ + RequestConverter: converter.ResourceProviderDataModelFromVersioned, + ResponseConverter: converter.ResourceProviderDataModelToVersioned, + }, + ) + }, + Middlewares: []func(http.Handler) http.Handler{middleware.OverrideResourceID(resourceIDForResourceProviderResource(m.options.PathBase))}, + }, + { + ParentRouter: resourceProviderResourceRouter, + ResourceType: datamodel.ResourceProviderResourceType, + Method: v1.OperationPut, + ControllerFactory: func(opt controller.Options) (controller.Controller, error) { + return defaultoperation.NewDefaultSyncPut(opt, + controller.ResourceOptions[datamodel.ResourceProvider]{ + RequestConverter: converter.ResourceProviderDataModelFromVersioned, + ResponseConverter: converter.ResourceProviderDataModelToVersioned, + }, + ) + }, + Middlewares: []func(http.Handler) http.Handler{middleware.OverrideResourceID(resourceIDForResourceProviderResource(m.options.PathBase))}, + }, + { + ParentRouter: resourceProviderResourceRouter, + ResourceType: datamodel.ResourceProviderResourceType, + Method: v1.OperationDelete, + ControllerFactory: func(opt controller.Options) (controller.Controller, error) { + return defaultoperation.NewDefaultSyncDelete(opt, + controller.ResourceOptions[datamodel.ResourceProvider]{ + RequestConverter: converter.ResourceProviderDataModelFromVersioned, + ResponseConverter: converter.ResourceProviderDataModelToVersioned, + }, + ) + }, + Middlewares: []func(http.Handler) http.Handler{middleware.OverrideResourceID(resourceIDForResourceProviderResource(m.options.PathBase))}, + }, { ParentRouter: resourceGroupCollectionRouter, ResourceType: v20231001preview.ResourceGroupType, @@ -138,7 +210,7 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { }, { ParentRouter: resourceGroupResourceRouter, - ResourceType: v20231001preview.ResourceType, + ResourceType: v20231001preview.GenericResourceType, Path: "/resources", Method: v1.OperationList, ControllerFactory: func(opt controller.Options) (controller.Controller, error) { @@ -151,27 +223,33 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { // Note that the API validation is not applied for CatchAllPath(/*). { // Proxy request should use CatchAllPath(/*) to process all requests under /planes/radius/{planeName}/resourcegroups/{resourceGroupName}. - ParentRouter: resourceGroupResourceRouter, - Path: server.CatchAllPath, - OperationType: &v1.OperationType{Type: OperationTypeUCPRadiusProxy, Method: v1.OperationProxy}, - ControllerFactory: radius_ctrl.NewProxyController, + ParentRouter: resourceGroupResourceRouter, + Path: server.CatchAllPath, + OperationType: &v1.OperationType{Type: OperationTypeUCPRadiusProxy, Method: v1.OperationProxy}, + ControllerFactory: func(o controller.Options) (controller.Controller, error) { + return radius_ctrl.NewProxyController(o, m.transport, m.internalTransport) + }, + }, + { + // Proxy request should use CatchAllPath(/*) to process all requests under /planes/radius/{planeName}/resourcegroups/{resourceGroupName}/providers/{resourceNamespace}/{resourceType}. + ParentRouter: resourceProviderResourceRouter, + Path: server.CatchAllPath, + OperationType: &v1.OperationType{Type: OperationTypeUCPRadiusProxy, Method: v1.OperationProxy}, + ControllerFactory: func(o controller.Options) (controller.Controller, error) { + return radius_ctrl.NewProxyController(o, m.transport, m.internalTransport) + }, }, { // Proxy request should use CatchAllPath(/*) to process all requests under /planes/radius/{planeName}/. - ParentRouter: planeResourceRouter, - Path: server.CatchAllPath, - OperationType: &v1.OperationType{Type: OperationTypeUCPRadiusProxy, Method: v1.OperationProxy}, - ControllerFactory: radius_ctrl.NewProxyController, + ParentRouter: planeResourceRouter, + Path: server.CatchAllPath, + OperationType: &v1.OperationType{Type: OperationTypeUCPRadiusProxy, Method: v1.OperationProxy}, + ControllerFactory: func(o controller.Options) (controller.Controller, error) { + return radius_ctrl.NewProxyController(o, m.transport, m.internalTransport) + }, }, } - ctrlOptions := controller.Options{ - Address: m.options.Address, - PathBase: m.options.PathBase, - DataProvider: m.options.DataProvider, - StatusManager: m.options.StatusManager, - } - for _, h := range handlerOptions { if err := server.RegisterHandler(ctx, h, ctrlOptions); err != nil { return nil, err @@ -180,3 +258,23 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { return m.router, nil } + +func resourceIDForResourceProviderCollection(pathBase string) func(req *http.Request) (resources.ID, error) { + return func(req *http.Request) (resources.ID, error) { + // URL should look like: /planes/radius/local/providers + scope := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, pathBase), "/providers") + return resources.Parse(scope + "/providers/System.Resources/resourceProviders") + } +} + +func resourceIDForResourceProviderResource(pathBase string) func(req *http.Request) (resources.ID, error) { + return func(req *http.Request) (resources.ID, error) { + // URL should look like: /planes/radius/local/providers/My.Namespaces + scope, namespace, found := strings.Cut(strings.TrimPrefix(req.URL.Path, pathBase), "/providers/") + if !found { + return resources.ID{}, fmt.Errorf("unexpected resource provider URL: %s", req.URL.Path) + } + + return resources.Parse(scope + "/providers/System.Resources/resourceProviders/" + namespace) + } +} diff --git a/pkg/ucp/integrationtests/providers/providers_test.go b/pkg/ucp/integrationtests/providers/providers_test.go new file mode 100644 index 0000000000..4b02ee3e60 --- /dev/null +++ b/pkg/ucp/integrationtests/providers/providers_test.go @@ -0,0 +1,156 @@ +/* +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 resourcegroups + +import ( + "net/http" + "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/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/frontend/api" + "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/stretchr/testify/require" +) + +const ( + radiusAPIVersion = "?api-version=2023-10-01-preview" + radiusPlaneResourceURL = "/planes/radius/local" + radiusAPIVersion + radiusPlaneRequestFixture = "../planes/testdata/radiusplane_v20231001preview_requestbody.json" + radiusPlaneResponseFixture = "../planes/testdata/radiusplane_v20231001preview_responsebody.json" + + resourceProviderCollectionURL = "/planes/radius/local/providers" + radiusAPIVersion + + resourceProviderNamespace = "Applications.Test" + resourceProviderID = "/planes/radius/local/providers/System.Resources/resourceProviders/Applications.Test" + resourceProviderURL = "/planes/radius/local/providers/" + resourceProviderNamespace + radiusAPIVersion + + exampleResourceGroupID = "/planes/radius/local/resourceGroups/test-group" + exampleResourceCollectionURL = exampleResourceGroupID + "/providers/Applications.Test/exampleResources" + exampleResourceAPIVersion + + exampleResourceName = "my-example" + exampleResourceID = exampleResourceGroupID + "/providers/Applications.Test/exampleResources/" + exampleResourceName + exampleResourceAPIVersion = "?api-version=2024-01-01" + exampleResourceURL = exampleResourceID + exampleResourceAPIVersion + + resourceProviderEmptyListResponseFixture = "testdata/resourceprovider_v20231001preview_emptylist_responsebody.json" + resourceProviderListResponseFixture = "testdata/resourceprovider_v20231001preview_list_responsebody.json" + + resourceProviderRequestFixture = "testdata/resourceprovider_v20231001preview_requestbody.json" + resourceProviderResponseFixture = "testdata/resourceprovider_v20231001preview_responsebody.json" + + 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" +) + +func createRadiusPlane(server *testserver.TestServer) { + response := server.MakeFixtureRequest("PUT", radiusPlaneResourceURL, radiusPlaneRequestFixture) + response.EqualsFixture(200, radiusPlaneResponseFixture) +} + +func createResourceProvider(server *testserver.TestServer) { + response := server.MakeFixtureRequest("PUT", resourceProviderURL, resourceProviderRequestFixture) + response.EqualsFixture(200, resourceProviderResponseFixture) +} + +func createResourceGroup(server *testserver.TestServer) { + body := v20231001preview.ResourceGroupResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.ResourceGroupProperties{}, + } + response := server.MakeTypedRequest(http.MethodPut, exampleResourceGroupID+radiusAPIVersion, body) + response.EqualsStatusCode(http.StatusOK) +} + +func Test_ResourceProvider_Lifecycle(t *testing.T) { + server := testserver.StartWithETCD(t, api.DefaultModules) + defer server.Close() + + createRadiusPlane(server) + + // We don't use t.Run() here because we want the test to fail if *any* of these steps fail. + + // List should start empty + response := server.MakeRequest(http.MethodGet, resourceProviderCollectionURL, nil) + response.EqualsFixture(200, resourceProviderEmptyListResponseFixture) + + // Getting a specific resource provider should return 404 with the correct resource ID. + response = server.MakeRequest(http.MethodGet, resourceProviderURL, nil) + response.EqualsErrorCode(404, "NotFound") + require.Equal(t, resourceProviderID, response.Error.Error.Target) + + // Create a resource provider + createResourceProvider(server) + + // List should now contain the resource provider + response = server.MakeRequest(http.MethodGet, resourceProviderCollectionURL, nil) + response.EqualsFixture(200, resourceProviderListResponseFixture) + + // Getting the resource provider should return 200 + response = server.MakeRequest(http.MethodGet, resourceProviderURL, nil) + response.EqualsFixture(200, resourceProviderResponseFixture) + + // Deleting a resource provider should return 200 + response = server.MakeRequest(http.MethodDelete, resourceProviderURL, nil) + response.EqualsStatusCode(200) +} + +func Test_ResourceProvider_Resource_Lifecycle(t *testing.T) { + server := testserver.StartWithETCD(t, api.DefaultModules) + defer server.Close() + + // We don't use t.Run() here because we want the test to fail if *any* of these steps fail. + + // Setup a resource provider (Applications.Test/exampleResources) + createRadiusPlane(server) + createResourceProvider(server) + 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") +} diff --git a/pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_emptylist_responsebody.json b/pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_emptylist_responsebody.json new file mode 100644 index 0000000000..bcd3724156 --- /dev/null +++ b/pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_emptylist_responsebody.json @@ -0,0 +1,3 @@ +{ + "value": [] +} \ No newline at end of file diff --git a/pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_list_responsebody.json b/pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_list_responsebody.json new file mode 100644 index 0000000000..be276eee3d --- /dev/null +++ b/pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_list_responsebody.json @@ -0,0 +1,14 @@ +{ + "value": [ + { + "id": "/planes/radius/local/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/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_requestbody.json b/pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_requestbody.json new file mode 100644 index 0000000000..3fbe33abe3 --- /dev/null +++ b/pkg/ucp/integrationtests/providers/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/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_responsebody.json b/pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_responsebody.json new file mode 100644 index 0000000000..bb2574397d --- /dev/null +++ b/pkg/ucp/integrationtests/providers/testdata/exampleresource_v20240101preview_responsebody.json @@ -0,0 +1,10 @@ +{ + "id": "/planes/radius/local/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/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_emptylist_responsebody.json b/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_emptylist_responsebody.json new file mode 100644 index 0000000000..bcd3724156 --- /dev/null +++ b/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_emptylist_responsebody.json @@ -0,0 +1,3 @@ +{ + "value": [] +} \ No newline at end of file diff --git a/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_list_responsebody.json b/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_list_responsebody.json new file mode 100644 index 0000000000..3c72c3d315 --- /dev/null +++ b/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_list_responsebody.json @@ -0,0 +1,42 @@ +{ + "value": [ + { + "id": "/planes/radius/local/providers/System.Resources/resourceProviders/Applications.Test", + "location": "global", + "name": "Applications.Test", + "properties": { + "locations": { + "global": { + "address": "internal" + } + }, + "provisioningState": "Succeeded", + "resourceTypes": [ + { + "apiVersions": { + "2024-01-01": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + }, + "capabilities": [ + "awesomeness" + ], + "defaultApiVersion": "2024-01-01", + "locations": [ + "global" + ], + "resourceType": "exampleResources" + } + ] + }, + "type": "System.Resources/resourceProviders" + } + ] +} \ No newline at end of file diff --git a/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_requestbody.json b/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_requestbody.json new file mode 100644 index 0000000000..346db9f88d --- /dev/null +++ b/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_requestbody.json @@ -0,0 +1,38 @@ +{ + "location": "global", + "tags": { + "test": "my-test" + }, + "properties": { + "locations": { + "global": { + "address": "internal" + } + }, + "resourceTypes": [ + { + "resourceType": "exampleResources", + "routingType": "Internal", + "apiVersions": { + "2024-01-01": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + }, + "capabilities": [ + "awesomeness" + ], + "defaultApiVersion": "2024-01-01", + "locations": [ + "global" + ] + } + ] + } +} \ No newline at end of file diff --git a/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_responsebody.json b/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_responsebody.json new file mode 100644 index 0000000000..97c7f78624 --- /dev/null +++ b/pkg/ucp/integrationtests/providers/testdata/resourceprovider_v20231001preview_responsebody.json @@ -0,0 +1,38 @@ +{ + "id": "/planes/radius/local/providers/System.Resources/resourceProviders/Applications.Test", + "location": "global", + "name": "Applications.Test", + "properties": { + "locations": { + "global": { + "address": "internal" + } + }, + "provisioningState": "Succeeded", + "resourceTypes": [ + { + "apiVersions": { + "2024-01-01": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + }, + "capabilities": [ + "awesomeness" + ], + "defaultApiVersion": "2024-01-01", + "locations": [ + "global" + ], + "resourceType": "exampleResources" + } + ] + }, + "type": "System.Resources/resourceProviders" +} \ No newline at end of file diff --git a/pkg/ucp/integrationtests/providers/testdata/v20240101.json b/pkg/ucp/integrationtests/providers/testdata/v20240101.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/ucp/integrationtests/testserver/testserver.go b/pkg/ucp/integrationtests/testserver/testserver.go index d3d2e0ad97..1c63904638 100644 --- a/pkg/ucp/integrationtests/testserver/testserver.go +++ b/pkg/ucp/integrationtests/testserver/testserver.go @@ -140,6 +140,8 @@ func (ts *TestServer) Close() { // StartWithMocks creates and starts a new TestServer that used an mocks for storage. func StartWithMocks(t *testing.T, configureModules func(options modules.Options) []modules.Initializer) *TestServer { + t.Helper() + ctx, cancel := testcontext.NewWithCancel(t) // Generate a random base path to ensure we're handling it correctly. @@ -367,6 +369,8 @@ type TestResponse struct { // MakeFixtureRequest sends a request to the server using a file on disk as the payload (body). Use the fixture // parameter to specify the path to a file. func (ts *TestServer) MakeFixtureRequest(method string, pathAndQuery string, fixture string) *TestResponse { + ts.t.Helper() + body, err := os.ReadFile(fixture) require.NoError(ts.t, err, "reading fixture failed") return ts.MakeRequest(method, pathAndQuery, body) @@ -374,6 +378,8 @@ func (ts *TestServer) MakeFixtureRequest(method string, pathAndQuery string, fix // MakeTypedRequest sends a request to the server by marshalling the provided object to JSON. func (ts *TestServer) MakeTypedRequest(method string, pathAndQuery string, body any) *TestResponse { + ts.t.Helper() + if body == nil { return ts.MakeRequest(method, pathAndQuery, nil) } @@ -385,6 +391,8 @@ func (ts *TestServer) MakeTypedRequest(method string, pathAndQuery string, body // MakeRequest sends a request to the server. func (ts *TestServer) MakeRequest(method string, pathAndQuery string, body []byte) *TestResponse { + ts.t.Helper() + client := ts.Server.Client() request, err := rpctest.NewHTTPRequestWithContent(context.Background(), method, ts.BaseURL+pathAndQuery, body) require.NoError(ts.t, err, "creating request failed") @@ -428,6 +436,8 @@ func (ts *TestServer) MakeRequest(method string, pathAndQuery string, body []byt // EqualsErrorCode compares a TestResponse against an expected status code and error code. EqualsErrorCode assumes the response // uses the ARM error format (required for our APIs). func (tr *TestResponse) EqualsErrorCode(statusCode int, code string) { + tr.t.Helper() + require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") require.NotNil(tr.t, tr.Error, "expected an error but actual response did not contain one") require.Equal(tr.t, code, tr.Error.Error.Code, "actual error code was different from expected") @@ -436,6 +446,8 @@ func (tr *TestResponse) EqualsErrorCode(statusCode int, code string) { // EqualsFixture compares a TestResponse against an expected status code and body payload. Use the fixture parameter to specify // the path to a file. func (tr *TestResponse) EqualsFixture(statusCode int, fixture string) { + tr.t.Helper() + body, err := os.ReadFile(fixture) require.NoError(tr.t, err, "reading fixture failed") tr.EqualsResponse(statusCode, body) @@ -443,11 +455,15 @@ func (tr *TestResponse) EqualsFixture(statusCode int, fixture string) { // EqualsStatusCode compares a TestResponse against an expected status code (ingnores the body payload). func (tr *TestResponse) EqualsStatusCode(statusCode int) { + tr.t.Helper() + require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") } // EqualsFixture compares a TestResponse against an expected status code and body payload. func (tr *TestResponse) EqualsResponse(statusCode int, body []byte) { + tr.t.Helper() + if len(body) == 0 { require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") require.Empty(tr.t, tr.Body.Bytes(), "expected an empty response but actual response had a body") diff --git a/pkg/ucp/trackedresource/name.go b/pkg/ucp/trackedresource/name.go index 4e94796a7b..c0f688c8b4 100644 --- a/pkg/ucp/trackedresource/name.go +++ b/pkg/ucp/trackedresource/name.go @@ -78,7 +78,7 @@ func IDFor(id resources.ID) resources.ID { id.ScopeSegments(), []resources.TypeSegment{ { - Type: v20231001preview.ResourceType, + Type: v20231001preview.GenericResourceType, Name: NameFor(id), }, }, nil)) diff --git a/pkg/ucp/trackedresource/update.go b/pkg/ucp/trackedresource/update.go index 49f9de3c8c..835fde5bd0 100644 --- a/pkg/ucp/trackedresource/update.go +++ b/pkg/ucp/trackedresource/update.go @@ -42,10 +42,9 @@ const ( ) // NewUpdater creates a new Updater. -func NewUpdater(storeClient store.StorageClient, httpClient *http.Client) *Updater { +func NewUpdater(storeClient store.StorageClient) *Updater { return &Updater{ Store: storeClient, - Client: httpClient, AttemptCount: retryCount, RetryDelay: retryDelay, RequestTimeout: requestTimeout, @@ -57,9 +56,6 @@ type Updater struct { // Store is the storage client used to access the database. Store store.StorageClient - // Client is the HTTP client used to make requests to the downstream API. - Client *http.Client - // AttemptCount is the number of times to attempt a request and database update. AttemptCount int @@ -70,6 +66,21 @@ type Updater struct { RequestTimeout time.Duration } +// UpdateOptions are the options for updating a tracked resource. +type UpdateOptions struct { + // Downstring is the downstream URL of the destination resource provider. + Downstream string + + // Transport is an http.RoundTripper that can be used to invoke the destination resource provider. + Transport http.RoundTripper + + // ID is the ID of the resource to update. + ID resources.ID + + // APIVersion is the API version to use when querying the downstream API. + APIVersion string +} + // InProgressErr signifies that the resource is currently in a non-terminal state. type InProgressErr struct { } @@ -107,17 +118,17 @@ type trackedResourceStateProperties struct { // - Database failure // - Optimistic concurrency failure // - Resource is still being provisioned (provisioning state is non-terminal) -func (u *Updater) Update(ctx context.Context, downstream string, id resources.ID, apiVersion string) error { +func (u *Updater) Update(ctx context.Context, opts UpdateOptions) error { logger := ucplog.FromContextOrDiscard(ctx) - destination, err := url.Parse(downstream) + destination, err := url.Parse(opts.Downstream) if err != nil { return err } - destination = destination.JoinPath(id.String()) + destination = destination.JoinPath(opts.ID.String()) query := destination.Query() - query.Set("api-version", apiVersion) + query.Set("api-version", opts.APIVersion) destination.RawQuery = query.Encode() // Tracking ID is the ID of the TrackedResourceEntry that will store the data. @@ -125,16 +136,16 @@ func (u *Updater) Update(ctx context.Context, downstream string, id resources.ID // Example: // id: /planes/radius/local/resourceGroups/test-group/providers/Applications.Core/applications/test-app // trackingID: /planes/radius/local/resourceGroups/test-group/providers/System.Resources/trackingResourceEntries/test-app-ec291e26078b7ea8a74abfac82530005a0ecbf15 - trackingID := IDFor(id) + trackingID := IDFor(opts.ID) - logger = logger.WithValues("id", id, "trackingID", trackingID, "destination", destination.String()) + logger = logger.WithValues("id", opts.ID, "trackingID", trackingID, "destination", destination.String()) logger.V(ucplog.LevelDebug).Info("updating tracked resource") for attempt := 1; attempt <= u.AttemptCount; attempt++ { logger.WithValues("attempt", attempt) ctx := logr.NewContext(ctx, logger) logger.V(ucplog.LevelDebug).Info("beginning attempt") - err := u.run(ctx, id, trackingID, destination, apiVersion) + err := u.run(ctx, opts.ID, trackingID, destination, opts.Transport, opts.APIVersion) if errors.Is(err, &InProgressErr{}) && attempt == u.AttemptCount { // Preserve the InprogressErr for the last attempt. return err @@ -151,7 +162,7 @@ func (u *Updater) Update(ctx context.Context, downstream string, id resources.ID return fmt.Errorf("failed to update tracked resource after %d attempts", u.AttemptCount) } -func (u *Updater) run(ctx context.Context, id resources.ID, trackingID resources.ID, destination *url.URL, apiVersion string) error { +func (u *Updater) run(ctx context.Context, id resources.ID, trackingID resources.ID, destination *url.URL, transport http.RoundTripper, apiVersion string) error { logger := ucplog.FromContextOrDiscard(ctx) obj, err := u.Store.Get(ctx, trackingID.String()) if errors.Is(err, &store.ErrNotFound{}) { @@ -171,7 +182,7 @@ func (u *Updater) run(ctx context.Context, id resources.ID, trackingID resources } } - data, err := u.fetch(ctx, destination) + data, err := u.fetch(ctx, destination, transport) if err != nil { return err } @@ -219,7 +230,7 @@ func (u *Updater) run(ctx context.Context, id resources.ID, trackingID resources return nil } -func (u *Updater) fetch(ctx context.Context, destination *url.URL) (*trackedResourceState, error) { +func (u *Updater) fetch(ctx context.Context, destination *url.URL, transport http.RoundTripper) (*trackedResourceState, error) { logger := ucplog.FromContextOrDiscard(ctx) ctx, cancel := context.WithTimeout(ctx, requestTimeout) @@ -230,7 +241,12 @@ func (u *Updater) fetch(ctx context.Context, destination *url.URL) (*trackedReso if err != nil { return nil, err } - response, err := u.Client.Do(request) + + client := &http.Client{ + Transport: transport, + } + + response, err := client.Do(request) if err != nil { return nil, err } diff --git a/pkg/ucp/trackedresource/update_test.go b/pkg/ucp/trackedresource/update_test.go index 98d3ba173b..fc8e1188e6 100644 --- a/pkg/ucp/trackedresource/update_test.go +++ b/pkg/ucp/trackedresource/update_test.go @@ -50,7 +50,8 @@ func setupUpdater(t *testing.T) (*Updater, *store.MockStorageClient, *mockRoundT storeClient := store.NewMockStorageClient(ctrl) roundTripper := &mockRoundTripper{} - updater := NewUpdater(storeClient, &http.Client{Transport: roundTripper}) + + updater := NewUpdater(storeClient) // Optimize these values for testability. We don't want to wait for retries or timeouts unless // the test is specifically testing that behavior. @@ -94,7 +95,13 @@ func Test_Update(t *testing.T) { }). Times(1) - err := updater.Update(testcontext.New(t), testURL.String(), testID, apiVersion) + opts := UpdateOptions{ + Downstream: testURL.String(), + Transport: roundTripper, + ID: testID, + APIVersion: apiVersion, + } + err := updater.Update(testcontext.New(t), opts) require.NoError(t, err) }) @@ -136,7 +143,13 @@ func Test_Update(t *testing.T) { }). Times(1) - err := updater.Update(testcontext.New(t), testURL.String(), testID, apiVersion) + opts := UpdateOptions{ + Downstream: testURL.String(), + Transport: roundTripper, + ID: testID, + APIVersion: apiVersion, + } + err := updater.Update(testcontext.New(t), opts) require.NoError(t, err) }) @@ -161,7 +174,13 @@ func Test_Update(t *testing.T) { // Mock a successful (non-terminal) response from the downstream API. roundTripper.RespondWithJSON(t, http.StatusOK, resource) - err := updater.Update(testcontext.New(t), testURL.String(), testID, apiVersion) + opts := UpdateOptions{ + Downstream: testURL.String(), + Transport: roundTripper, + ID: testID, + APIVersion: apiVersion, + } + err := updater.Update(testcontext.New(t), opts) require.Error(t, err) require.ErrorIs(t, err, &InProgressErr{}) }) @@ -190,13 +209,19 @@ func Test_Update(t *testing.T) { // Mock a successful (non-terminal) response from the downstream API. roundTripper.RespondWithJSON(t, http.StatusOK, resource) - err := updater.Update(testcontext.New(t), testURL.String(), testID, apiVersion) + opts := UpdateOptions{ + Downstream: testURL.String(), + Transport: roundTripper, + ID: testID, + APIVersion: apiVersion, + } + err := updater.Update(testcontext.New(t), opts) require.Error(t, err) require.ErrorIs(t, err, &InProgressErr{}) }) t.Run("retries exhausted", func(t *testing.T) { - updater, storeClient, _ := setupUpdater(t) + updater, storeClient, roundTripper := setupUpdater(t) updater.AttemptCount = 3 apiVersion := "1234" @@ -207,7 +232,13 @@ func Test_Update(t *testing.T) { Return(nil, errors.New("this will be retried")). Times(3) - err := updater.Update(testcontext.New(t), testURL.String(), testID, apiVersion) + opts := UpdateOptions{ + Downstream: testURL.String(), + Transport: roundTripper, + ID: testID, + APIVersion: apiVersion, + } + err := updater.Update(testcontext.New(t), opts) require.Error(t, err) require.Equal(t, "failed to update tracked resource after 3 attempts", err.Error()) }) @@ -247,7 +278,7 @@ func Test_run(t *testing.T) { }). Times(1) - err := updater.run(testcontext.New(t), testID, IDFor(testID), testURL, apiVersion) + err := updater.run(testcontext.New(t), testID, IDFor(testID), testURL, roundTripper, apiVersion) require.NoError(t, err) }) @@ -286,7 +317,7 @@ func Test_run(t *testing.T) { }). Times(1) - err := updater.run(testcontext.New(t), testID, IDFor(testID), testURL, apiVersion) + err := updater.run(testcontext.New(t), testID, IDFor(testID), testURL, roundTripper, apiVersion) require.NoError(t, err) }) @@ -310,7 +341,7 @@ func Test_run(t *testing.T) { Return(nil). Times(1) - err := updater.run(testcontext.New(t), testID, IDFor(testID), testURL, apiVersion) + err := updater.run(testcontext.New(t), testID, IDFor(testID), testURL, roundTripper, apiVersion) require.NoError(t, err) }) @@ -334,7 +365,7 @@ func Test_run(t *testing.T) { // Mock a successful (terminal) response from the downstream API. roundTripper.RespondWithJSON(t, http.StatusOK, resource) - err := updater.run(testcontext.New(t), testID, IDFor(testID), testURL, apiVersion) + err := updater.run(testcontext.New(t), testID, IDFor(testID), testURL, roundTripper, apiVersion) require.Error(t, err) require.ErrorIs(t, err, &InProgressErr{}) }) @@ -375,7 +406,7 @@ func Test_fetch(t *testing.T) { }, } - state, err := updater.fetch(testcontext.New(t), testURL) + state, err := updater.fetch(testcontext.New(t), testURL, roundTripper) require.NoError(t, err) require.Equal(t, expected, state) }) @@ -386,7 +417,7 @@ func Test_fetch(t *testing.T) { // We consider 404 a success case. roundTripper.RespondWithJSON(t, http.StatusNotFound, errorResponse) - state, err := updater.fetch(testcontext.New(t), testURL) + state, err := updater.fetch(testcontext.New(t), testURL, roundTripper) require.NoError(t, err) require.Nil(t, state) }) @@ -400,7 +431,7 @@ func Test_fetch(t *testing.T) { _, _ = w.Write([]byte("LOL here's some not-JSON")) roundTripper.Response = w.Result() - state, err := updater.fetch(testcontext.New(t), testURL) + state, err := updater.fetch(testcontext.New(t), testURL, roundTripper) require.Error(t, err) require.Equal(t, "response is not JSON. Content-Type: \"text/plain\"", err.Error()) require.Nil(t, state) @@ -411,7 +442,7 @@ func Test_fetch(t *testing.T) { roundTripper.RespondWithJSON(t, http.StatusBadRequest, errorResponse) - state, err := updater.fetch(testcontext.New(t), testURL) + state, err := updater.fetch(testcontext.New(t), testURL, roundTripper) require.Error(t, err) require.Equal(t, "request failed with status code 400 Bad Request:\n"+errorResponseText, err.Error()) require.Nil(t, state) diff --git a/swagger/specification/ucp/resource-manager/UCP/preview/2023-10-01-preview/openapi.json b/swagger/specification/ucp/resource-manager/UCP/preview/2023-10-01-preview/openapi.json index 7dca6e2eb9..f9c1c5c7d8 100644 --- a/swagger/specification/ucp/resource-manager/UCP/preview/2023-10-01-preview/openapi.json +++ b/swagger/specification/ucp/resource-manager/UCP/preview/2023-10-01-preview/openapi.json @@ -60,6 +60,9 @@ { "name": "Resources" }, + { + "name": "ResourceProviders" + }, { "name": "RadiusPlanes" } @@ -1322,6 +1325,224 @@ "x-ms-long-running-operation": true } }, + "/planes/radius/{planeName}/providers": { + "get": { + "operationId": "ResourceProviders_List", + "tags": [ + "ResourceProviders" + ], + "description": "List resource providers.", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "name": "planeName", + "in": "path", + "description": "The plane name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + } + ], + "responses": { + "200": { + "description": "ARM operation completed successfully.", + "schema": { + "$ref": "#/definitions/ResourceProviderResourceListResult" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + }, + "x-ms-pageable": { + "nextLinkName": "nextLink" + } + } + }, + "/planes/radius/{planeName}/providers/{resourceProviderName}": { + "get": { + "operationId": "ResourceProviders_Get", + "tags": [ + "ResourceProviders" + ], + "description": "Get the specified resource provider.", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "name": "planeName", + "in": "path", + "description": "The plane name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + }, + { + "name": "resourceProviderName", + "in": "path", + "description": "The resource provider name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^([A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9]))\\.([A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9]))?$" + } + ], + "responses": { + "200": { + "description": "ARM operation completed successfully.", + "schema": { + "$ref": "#/definitions/ResourceProviderResource" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + } + }, + "put": { + "operationId": "ResourceProviders_CreateOrUpdate", + "tags": [ + "ResourceProviders" + ], + "description": "Create or update a resource provider", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "name": "planeName", + "in": "path", + "description": "The plane name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + }, + { + "name": "resourceProviderName", + "in": "path", + "description": "The resource provider name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^([A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9]))\\.([A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9]))?$" + }, + { + "name": "resource", + "in": "body", + "description": "Resource create parameters.", + "required": true, + "schema": { + "$ref": "#/definitions/ResourceProviderResource" + } + } + ], + "responses": { + "200": { + "description": "Resource 'ResourceProviderResource' update operation succeeded", + "schema": { + "$ref": "#/definitions/ResourceProviderResource" + } + }, + "201": { + "description": "Resource 'ResourceProviderResource' create operation succeeded", + "schema": { + "$ref": "#/definitions/ResourceProviderResource" + }, + "headers": { + "Retry-After": { + "type": "integer", + "format": "int32", + "description": "The Retry-After header can indicate how long the client should wait before polling the operation status." + } + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + }, + "x-ms-long-running-operation-options": { + "final-state-via": "azure-async-operation" + }, + "x-ms-long-running-operation": true + }, + "delete": { + "operationId": "ResourceProviders_Delete", + "tags": [ + "ResourceProviders" + ], + "description": "Delete a resource provider", + "parameters": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/parameters/ApiVersionParameter" + }, + { + "name": "planeName", + "in": "path", + "description": "The plane name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^[A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9])?$" + }, + { + "name": "resourceProviderName", + "in": "path", + "description": "The resource provider name.", + "required": true, + "type": "string", + "maxLength": 63, + "pattern": "^([A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9]))\\.([A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9]))?$" + } + ], + "responses": { + "200": { + "description": "Resource deleted successfully." + }, + "202": { + "description": "Resource deletion accepted.", + "headers": { + "Retry-After": { + "type": "integer", + "format": "int32", + "description": "The Retry-After header can indicate how long the client should wait before polling the operation status." + }, + "Location": { + "type": "string", + "description": "The Location header contains the URL where the status of the long running operation can be checked." + } + } + }, + "204": { + "description": "Resource deleted successfully." + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/ErrorResponse" + } + } + }, + "x-ms-long-running-operation-options": { + "final-state-via": "location" + }, + "x-ms-long-running-operation": true + } + }, "/planes/radius/{planeName}/resourcegroups": { "get": { "operationId": "ResourceGroups_List", @@ -2467,6 +2688,184 @@ "description": "The resource properties", "properties": {} }, + "ResourceProviderLocation": { + "type": "object", + "description": "The configuration of a resource provider in a specific location.", + "properties": { + "address": { + "type": "string", + "description": "The address of the resource provider implementation." + } + }, + "required": [ + "address" + ] + }, + "ResourceProviderNamespaceString": { + "type": "string", + "description": "The resource provider namespace", + "maxLength": 63, + "pattern": "^([A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9]))\\.([A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9]))?$" + }, + "ResourceProviderProperties": { + "type": "object", + "description": "Resource provider properties", + "properties": { + "provisioningState": { + "$ref": "#/definitions/ProvisioningState", + "description": "The status of the asynchronous operation.", + "readOnly": true + }, + "locations": { + "type": "object", + "description": "The configuration of the resource provider in each supported location.", + "additionalProperties": { + "$ref": "#/definitions/ResourceProviderLocation" + } + }, + "resourceTypes": { + "type": "array", + "description": "The resource types supported by the provider.", + "items": { + "$ref": "#/definitions/ResourceType" + }, + "readOnly": true, + "x-ms-identifiers": [ + "resourceType" + ] + } + }, + "required": [ + "locations", + "resourceTypes" + ] + }, + "ResourceProviderResource": { + "type": "object", + "description": "Concrete tracked resource types can be created by aliasing this type using a specific property type.", + "properties": { + "properties": { + "$ref": "#/definitions/ResourceProviderProperties", + "description": "The resource-specific properties for this resource.", + "x-ms-client-flatten": true, + "x-ms-mutability": [ + "read", + "create" + ] + } + }, + "allOf": [ + { + "$ref": "../../../../../common-types/resource-management/v3/types.json#/definitions/TrackedResource" + } + ] + }, + "ResourceProviderResourceListResult": { + "type": "object", + "description": "The response of a ResourceProviderResource list operation.", + "properties": { + "value": { + "type": "array", + "description": "The ResourceProviderResource items on this page", + "items": { + "$ref": "#/definitions/ResourceProviderResource" + } + }, + "nextLink": { + "type": "string", + "format": "uri", + "description": "The link to the next page of items" + } + }, + "required": [ + "value" + ] + }, + "ResourceType": { + "type": "object", + "description": "A resource type supported by the resource provider.", + "properties": { + "resourceType": { + "type": "string", + "description": "The resource type name." + }, + "routingType": { + "$ref": "#/definitions/ResourceTypeRoutingBehavior", + "description": "The routing behavior for the resource type." + }, + "apiVersions": { + "type": "object", + "description": "The supported resource type api versions.", + "additionalProperties": { + "$ref": "#/definitions/ResourceTypeApiVersion" + } + }, + "capabilities": { + "type": "array", + "description": "The additional capabilities offered by this resource type.", + "items": { + "type": "string" + } + }, + "defaultApiVersion": { + "type": "string", + "description": "The default api version for the resource type." + }, + "locations": { + "type": "array", + "description": "The locations that are supported by this resource type.", + "items": { + "type": "string" + } + } + }, + "required": [ + "resourceType", + "routingType", + "apiVersions", + "capabilities", + "defaultApiVersion", + "locations" + ] + }, + "ResourceTypeApiVersion": { + "type": "object", + "description": "The supported api versions for a resource type.", + "properties": { + "schema": { + "type": "object", + "description": "The OpenAPI v3 schema for the resource types.", + "additionalProperties": true + } + }, + "required": [ + "schema" + ] + }, + "ResourceTypeRoutingBehavior": { + "type": "string", + "description": "The routing behavior for a resource type.", + "enum": [ + "Provider", + "Internal" + ], + "x-ms-enum": { + "name": "ResourceTypeRoutingBehavior", + "modelAsString": true, + "values": [ + { + "name": "Provider", + "value": "Provider", + "description": "The resource type is routed to a separate resource provider implementation." + }, + { + "name": "Internal", + "value": "Internal", + "description": "The resource type is implemented inside UCP." + } + ] + } + }, "Versions": { "type": "string", "description": "Supported API versions for Universal Control Plane resource provider.", diff --git a/typespec/UCP/main.tsp b/typespec/UCP/main.tsp index a1f33d06fe..97e2761142 100644 --- a/typespec/UCP/main.tsp +++ b/typespec/UCP/main.tsp @@ -27,6 +27,7 @@ import "./azure-credentials.tsp"; import "./azure-plane.tsp"; import "./resourcegroups.tsp"; +import "./resourceproviders.tsp"; import "./radius-plane.tsp"; using TypeSpec.Versioning; diff --git a/typespec/UCP/resourceproviders.tsp b/typespec/UCP/resourceproviders.tsp new file mode 100644 index 0000000000..c09ce250e4 --- /dev/null +++ b/typespec/UCP/resourceproviders.tsp @@ -0,0 +1,150 @@ +/* +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. +*/ + +import "@typespec/rest"; +import "@typespec/versioning"; +import "@typespec/openapi"; +import "@azure-tools/typespec-autorest"; +import "@azure-tools/typespec-azure-core"; +import "@azure-tools/typespec-azure-resource-manager"; +import "@azure-tools/typespec-providerhub"; + +import "../radius/v1/ucprootscope.tsp"; +import "../radius/v1/resources.tsp"; +import "./common.tsp"; +import "./ucp-operations.tsp"; + +using TypeSpec.Http; +using TypeSpec.Rest; +using TypeSpec.Versioning; +using Autorest; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.Foundations; +using OpenAPI; + +namespace Ucp; + +@doc("The resource provider namespace") +@maxLength(63) +@pattern("^([A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9]))\\.([A-Za-z]([-A-Za-z0-9]*[A-Za-z0-9]))?$") +scalar ResourceProviderNamespaceString extends string; + +#suppress "@azure-tools/typespec-azure-resource-manager/arm-resource-path-segment-invalid-chars" +model ResourceProviderResource is TrackedResource { + @key("resourceProviderName") + @doc("The resource provider name.") + @path + @segment("providers") + name: ResourceProviderNamespaceString; +} + + +@doc("Resource provider properties") +model ResourceProviderProperties { + @doc("The status of the asynchronous operation.") + @visibility("read") + provisioningState?: ProvisioningState; + + // NOTE: we don't implement regional routing yet, this is here to avoid a breaking change + // in the future. + @doc("The configuration of the resource provider in each supported location.") + locations: Record; + + @extension("x-ms-identifiers", ["resourceType"]) + @doc("The resource types supported by the provider.") + @visibility("read") + resourceTypes: ResourceType[]; +} + +@doc("The configuration of a resource provider in a specific location.") +model ResourceProviderLocation { + @doc("The address of the resource provider implementation.") + address: string; +} + +@doc("A resource type supported by the resource provider.") +model ResourceType { + #suppress "@azure-tools/typespec-azure-core/property-name-conflict" + @doc("The resource type name.") + resourceType: string; + + @doc("The routing behavior for the resource type.") + routingType: ResourceTypeRoutingBehavior; + + @doc("The supported resource type api versions.") + apiVersions: Record; + + @doc("The additional capabilities offered by this resource type.") + capabilities: string[]; + + @doc("The default api version for the resource type.") + defaultApiVersion: string; + + @doc("The locations that are supported by this resource type.") + locations: string[]; +} + +// Note: we don't implement other behaviors yet, this is here to avoid a breaking change +// in the future. +@doc("The routing behavior for a resource type.") +enum ResourceTypeRoutingBehavior { + @doc("The resource type is routed to a separate resource provider implementation.") + Provider, + + @doc("The resource type is implemented inside UCP.") + Internal, +} + +@doc("The supported api versions for a resource type.") +model ResourceTypeApiVersion { + @doc("The OpenAPI v3 schema for the resource types.") + schema: Record; +} + +@doc("The UCP HTTP request base parameters.") +model ResourceProviderBaseParameters { + ...PlaneBaseParameters; + ...KeysOf; +} + +@route("/planes") +@armResourceOperations +interface ResourceProviders { + @doc("List resource providers.") + list is UcpResourceList< + ResourceProviderResource, + PlaneBaseParameters + >; + + @doc("Get the specified resource provider.") + get is UcpResourceRead< + ResourceProviderResource, + ResourceProviderBaseParameters + >; + + @doc("Create or update a resource provider") + createOrUpdate is UcpResourceCreateOrUpdateAsync< + ResourceProviderResource, + ResourceProviderBaseParameters + >; + + @doc("Delete a resource provider") + delete is UcpResourceDeleteAsync< + ResourceProviderResource, + ResourceProviderBaseParameters + >; +}