Skip to content

Commit

Permalink
Initial implementation of user-defined-types (#7686)
Browse files Browse the repository at this point in the history
This change implements the skeleton of user-defined types. The changes
here enable the following:

- Users can author a resource of type
`System.Resources/resourceProviders` to create a user-defined-type.
- Users can use the UCP API to register and query `resourceProviders`.
- Users can use the UCP API to execute the full lifecycle of a
user-defined-type.

Right now the user-defined-type RP will use our default operation
(synchronous) controllers to implement the resource lifecycle. There is
no background processing.

The next step will include the ability to execute asynchronous
operations like recipes.

- This pull request fixes a bug in Radius and has an approved issue
(issue link required).
- This pull request adds or changes features of Radius and has an
approved issue (issue link required).

Part of: #6688

**note: This change is going into a feature-branch where we can iterate
on the user-defined-type design before integrating it with main. The PR
is an FYI 😆.**

---------

Signed-off-by: ytimocin <ytimocin@microsoft.com>
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: willdavsmith <willdavsmith@gmail.com>
Signed-off-by: Ryan Nowak <nowakra@gmail.com>
Co-authored-by: Yetkin Timocin <ytimocin@microsoft.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Will Smith <willdavsmith@gmail.com>
Signed-off-by: Ryan Nowak <nowakra@gmail.com>
  • Loading branch information
4 people committed Jun 24, 2024
1 parent 13d50f7 commit 9b2f41a
Show file tree
Hide file tree
Showing 62 changed files with 3,702 additions and 159 deletions.
43 changes: 40 additions & 3 deletions pkg/armrpc/asyncoperation/worker/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,27 @@ import (
"github.com/radius-project/radius/pkg/ucp/dataprovider"
)

const (
// ResourceTypeAny is a wildcard for any resource type.
ResourceTypeAny = "*"

// OperationMethodAny is a wildcard for any operation method.
OperationMethodAny = "*"
)

// ControllerFactoryFunc is a factory function to create a controller.
type ControllerFactoryFunc func(opts ctrl.Options) (ctrl.Controller, error)

// ControllerRegistry is an registry to register async controllers.
type ControllerRegistry struct {
ctrlMap map[string]ctrl.Controller
ctrlMapMu sync.RWMutex
sp dataprovider.DataStorageProvider

// Fallback allows the registration of a controller that will be used
// for operations that don't match any other operation type.
fallbackFactory ControllerFactoryFunc
fallbackOpts ctrl.Options
}

// NewControllerRegistry creates an ControllerRegistry instance.
Expand All @@ -48,6 +62,13 @@ func (h *ControllerRegistry) Register(ctx context.Context, resourceType string,
defer h.ctrlMapMu.Unlock()

ot := v1.OperationType{Type: resourceType, Method: method}
if resourceType == ResourceTypeAny && method == OperationMethodAny {
// This is a fallback controller. Skip registration for now so we can create instances
// dynamically when needed.
h.fallbackFactory = factoryFn
h.fallbackOpts = opts
return nil
}

storageClient, err := h.sp.GetStorageClient(ctx, resourceType)
if err != nil {
Expand All @@ -66,13 +87,29 @@ func (h *ControllerRegistry) Register(ctx context.Context, resourceType string,
}

// Get gets the registered async controller instance.
func (h *ControllerRegistry) Get(operationType v1.OperationType) ctrl.Controller {
func (h *ControllerRegistry) Get(ctx context.Context, operationType v1.OperationType) (ctrl.Controller, error) {
h.ctrlMapMu.RLock()
defer h.ctrlMapMu.RUnlock()

if h, ok := h.ctrlMap[operationType.String()]; ok {
return h
return h, nil
}

return nil
// If no controller is found, then look for a default controller.
if h.fallbackFactory == nil {
return nil, nil
}

storageClient, err := h.sp.GetStorageClient(ctx, operationType.Type)
if err != nil {
return nil, err
}

// Copy the options so we can update it.
opts := h.fallbackOpts

opts.StorageClient = storageClient
opts.ResourceType = operationType.Type

return h.fallbackFactory(opts)
}
7 changes: 5 additions & 2 deletions pkg/armrpc/asyncoperation/worker/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ func TestRegister_Get(t *testing.T) {
}, ctrlOpts)
require.NoError(t, err)

ctrl := registry.Get(opGet)
ctrl, err := registry.Get(context.Background(), opGet)
require.NoError(t, err)
require.NotNil(t, ctrl)
ctrl = registry.Get(opPut)

ctrl, err = registry.Get(context.Background(), opPut)
require.NoError(t, err)
require.NotNil(t, ctrl)
}
10 changes: 9 additions & 1 deletion pkg/armrpc/asyncoperation/worker/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,15 @@ func (w *AsyncRequestProcessWorker) Start(ctx context.Context) error {
}
reqCtx = v1.WithARMRequestContext(reqCtx, armReqCtx)

asyncCtrl := w.registry.Get(armReqCtx.OperationType)
asyncCtrl, err := w.registry.Get(reqCtx, armReqCtx.OperationType)
if err != nil {
opLogger.Error(err, "failed to get async controller.")
if err := w.requestQueue.FinishMessage(reqCtx, msgreq); err != nil {
opLogger.Error(err, "failed to finish the message")
}
return
}

if asyncCtrl == nil {
opLogger.Error(nil, "cannot process unknown operation: "+armReqCtx.OperationType.String())
if err := w.requestQueue.FinishMessage(reqCtx, msgreq); err != nil {
Expand Down
3 changes: 2 additions & 1 deletion pkg/armrpc/builder/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,8 @@ func TestApplyAsyncHandler(t *testing.T) {
}

for _, op := range expectedOperations {
jobCtrl := registry.Get(op)
jobCtrl, err := registry.Get(context.Background(), op)
require.NoError(t, err)
require.NotNil(t, jobCtrl)
}
}
57 changes: 57 additions & 0 deletions pkg/armrpc/frontend/middleware/resourceidoverride.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
54 changes: 54 additions & 0 deletions pkg/armrpc/frontend/middleware/resourceidoverride_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
32 changes: 32 additions & 0 deletions pkg/ucp/api/v20231001preview/dynamicresource.go
Original file line number Diff line number Diff line change
@@ -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"`
}
64 changes: 64 additions & 0 deletions pkg/ucp/api/v20231001preview/dynamicresource_conversion.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 9b2f41a

Please sign in to comment.