diff --git a/pkg/armrpc/frontend/middleware/resourceidoverride.go b/pkg/armrpc/frontend/middleware/resourceidoverride.go new file mode 100644 index 00000000000..d2eb730b8b7 --- /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 00000000000..1dbed1e1580 --- /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 00000000000..bf167aa86dc --- /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 00000000000..46c5739c56c --- /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 00000000000..331f3b134c8 --- /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 fb8c7f97b91..987535414b5 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 00000000000..ac5ec3a6a19 --- /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 00000000000..02a0fb29186 --- /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 00000000000..0b5c40958f4 --- /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 00000000000..f2569eacb52 --- /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 00000000000..0a80d3b426a --- /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 00000000000..0a80d3b426a --- /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 cce33657b40..03db65dec9b 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 3818bcaa716..64483890957 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 9a57b377e78..c03bd3c37e9 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 28932179ca2..fa342d0a75d 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 f42bbe5e13a..1cc3524676a 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 00000000000..edff0421299 --- /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 e93fae78e7b..69826f5dd72 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 5623d88518d..e1da2a6c881 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 1958ace976d..f6d0c82b3ac 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 66c902fac74..fd2dcc5ae99 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 00000000000..875a572187f --- /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 00000000000..572650168fb --- /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 00000000000..b99dbe2e113 --- /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 f561569a543..e50d5f30166 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 00000000000..443260d386a --- /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 00000000000..dee9f81176a --- /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 8f2bbdfd3a5..14335b21004 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 f596210c8a3..9983f9a1122 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 4cfc82a53cd..0e6dc2bc821 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 c7f77c99342..fb332f31382 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 ac8117e0eef..050e4d661f0 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 f2e530ea627..4847477a940 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 00000000000..6a3ae447c45 --- /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 1dae8a56414..622c40375d2 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 7063bc4cd60..5191c2c1347 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 00000000000..4b02ee3e603 --- /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 00000000000..bcd37241563 --- /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 00000000000..be276eee3de --- /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 00000000000..3fbe33abe3c --- /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 00000000000..bb2574397dd --- /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 00000000000..bcd37241563 --- /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 00000000000..3c72c3d3150 --- /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 00000000000..346db9f88dd --- /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 00000000000..97c7f78624f --- /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 00000000000..e69de29bb2d diff --git a/pkg/ucp/integrationtests/testserver/testserver.go b/pkg/ucp/integrationtests/testserver/testserver.go index d3d2e0ad97b..1c639046383 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 4e94796a7b6..c0f688c8b4b 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 49f9de3c8cd..835fde5bd07 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 98d3ba173b8..fc8e1188e63 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 7dca6e2eb9f..f9c1c5c7d8c 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 a1f33d06fe0..97e2761142a 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 00000000000..c09ce250e4e --- /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 + >; +}