diff --git a/.github/scripts/release-verification.sh b/.github/scripts/release-verification.sh index ca0b404f65d..2b2248cb4e9 100755 --- a/.github/scripts/release-verification.sh +++ b/.github/scripts/release-verification.sh @@ -69,10 +69,12 @@ kind create cluster ./rad install kubernetes EXPECTED_APPCORE_RP_IMAGE="ghcr.io/radius-project/applications-rp:${EXPECTED_TAG_VERSION}" +EXPECTED_DYNAMIC_RP_IMAGE="ghcr.io/radius-project/dynamic-rp:${EXPECTED_TAG_VERSION}" EXPECTED_UCP_IMAGE="ghcr.io/radius-project/ucpd:${EXPECTED_TAG_VERSION}" EXPECTED_DE_IMAGE="ghcr.io/radius-project/deployment-engine:${EXPECTED_TAG_VERSION}" APPCORE_RP_IMAGE=$(kubectl describe pods -n radius-system -l control-plane=applications-rp | awk '/^.*Image:/ {print $2}') +DYNAMIC_RP_IMAGE=$(kubectl describe pods -n radius-system -l control-plane=dynamic-rp | awk '/^.*Image:/ {print $2}') UCP_IMAGE=$(kubectl describe pods -n radius-system -l control-plane=ucp | awk '/^.*Image:/ {print $2}') DE_IMAGE=$(kubectl describe pods -n radius-system -l control-plane=bicep-de | awk '/^.*Image:/ {print $2}') @@ -81,6 +83,11 @@ if [[ "${APPCORE_RP_IMAGE}" != "${EXPECTED_APPCORE_RP_IMAGE}" ]]; then exit 1 fi +if [[ "${DYNAMIC_RP_IMAGE}" != "${EXPECTED_DYNAMIC_RP_IMAGE}" ]]; then + echo "Error: Dynamic RP image: ${DYNAMIC_RP_IMAGE} does not match the desired image: ${EXPECTED_DYNAMIC_RP_IMAGE}." + exit 1 +fi + if [[ "${UCP_IMAGE}" != "${EXPECTED_UCP_IMAGE}" ]]; then echo "Error: UCP image: ${UCP_IMAGE} does not match the desired image: ${EXPECTED_UCP_IMAGE}." exit 1 diff --git a/.github/workflows/functional-test-cloud.yaml b/.github/workflows/functional-test-cloud.yaml index 1ba0e3b9990..c94d8a54496 100644 --- a/.github/workflows/functional-test-cloud.yaml +++ b/.github/workflows/functional-test-cloud.yaml @@ -276,6 +276,7 @@ jobs: * Bicep recipe location `${{ env.BICEP_RECIPE_REGISTRY }}/test/testrecipes/test-bicep-recipes/:${{ env.REL_VERSION }}` * Terraform recipe location `${{ env.TF_RECIPE_MODULE_SERVER_URL }}/.zip` (in cluster) * applications-rp test image location: `${{ env.CONTAINER_REGISTRY }}/applications-rp:${{ env.REL_VERSION }}` + * dynamic-rp test image location: `${{ env.CONTAINER_REGISTRY }}/dynamic-rp:${{ env.REL_VERSION }}` * controller test image location: `${{ env.CONTAINER_REGISTRY }}/controller:${{ env.REL_VERSION }}` * ucp test image location: `${{ env.CONTAINER_REGISTRY }}/ucpd:${{ env.REL_VERSION }}` * deployment-engine test image location: `${{ env.DE_IMAGE }}:${{ env.DE_TAG }}` @@ -650,7 +651,7 @@ jobs: echo "*** Installing Radius to Kubernetes ***" rad install kubernetes \ --chart ${{ env.RADIUS_CHART_LOCATION }} \ - --set rp.image=${{ env.CONTAINER_REGISTRY }}/applications-rp,rp.tag=${{ env.REL_VERSION }},controller.image=${{ env.CONTAINER_REGISTRY }}/controller,controller.tag=${{ env.REL_VERSION }},ucp.image=${{ env.CONTAINER_REGISTRY }}/ucpd,ucp.tag=${{ env.REL_VERSION }},de.image=${{ env.DE_IMAGE }},de.tag=${{ env.DE_TAG }},global.azureWorkloadIdentity.enabled=true + --set rp.image=${{ env.CONTAINER_REGISTRY }}/applications-rp,rp.tag=${{ env.REL_VERSION }},dynamicrp.image=${{ env.CONTAINER_REGISTRY }}/dynamic-rp,dynamicrp.tag=${{ env.REL_VERSION }}controller.image=${{ env.CONTAINER_REGISTRY }}/controller,controller.tag=${{ env.REL_VERSION }},ucp.image=${{ env.CONTAINER_REGISTRY }}/ucpd,ucp.tag=${{ env.REL_VERSION }},de.image=${{ env.DE_IMAGE }},de.tag=${{ env.DE_TAG }},global.azureWorkloadIdentity.enabled=true echo "*** Create workspace, group and environment for test ***" rad workspace create kubernetes diff --git a/.github/workflows/functional-test-noncloud.yaml b/.github/workflows/functional-test-noncloud.yaml index 06d482ca491..1cb80e9bc16 100644 --- a/.github/workflows/functional-test-noncloud.yaml +++ b/.github/workflows/functional-test-noncloud.yaml @@ -290,6 +290,7 @@ jobs: RAD_COMMAND="rad install kubernetes \ --chart ${{ env.RADIUS_CHART_LOCATION }} \ --set rp.image=${{ env.LOCAL_REGISTRY_NAME }}:${{ env.LOCAL_REGISTRY_PORT }}/applications-rp,rp.tag=${{ env.REL_VERSION }} \ + --set dynamicrp.image=${{ env.LOCAL_REGISTRY_NAME }}:${{ env.LOCAL_REGISTRY_PORT }}/dynamic-rp,dynamicrp.tag=${{ env.REL_VERSION }} \ --set controller.image=${{ env.LOCAL_REGISTRY_NAME }}:${{ env.LOCAL_REGISTRY_PORT }}/controller,controller.tag=${{ env.REL_VERSION }} \ --set ucp.image=${{ env.LOCAL_REGISTRY_NAME }}:${{ env.LOCAL_REGISTRY_PORT }}/ucpd,ucp.tag=${{ env.REL_VERSION }} \ --set de.image=${{ env.DE_IMAGE }},de.tag=${{ env.DE_TAG }}" diff --git a/.github/workflows/long-running-azure.yaml b/.github/workflows/long-running-azure.yaml index 93b61e4424e..ce30023e824 100644 --- a/.github/workflows/long-running-azure.yaml +++ b/.github/workflows/long-running-azure.yaml @@ -201,6 +201,7 @@ jobs: * Bicep recipe location `${{ env.BICEP_RECIPE_REGISTRY }}/test/testrecipes/test-bicep-recipes/:${{ steps.gen-id.outputs.REL_VERSION }}` * Terraform recipe location `${{ env.TF_RECIPE_MODULE_SERVER_URL }}/.zip` (in cluster) * applications-rp test image location: `${{ env.CONTAINER_REGISTRY }}/applications-rp:${{ steps.gen-id.outputs.REL_VERSION }}` + * dynamic-rp test image location: `${{ env.CONTAINER_REGISTRY }}/dynamic-rp:${{ steps.gen-id.outputs.REL_VERSION }}` * controller test image location: `${{ env.CONTAINER_REGISTRY }}/controller:${{ steps.gen-id.outputs.REL_VERSION }}` * ucp test image location: `${{ env.CONTAINER_REGISTRY }}/ucpd:${{ steps.gen-id.outputs.REL_VERSION }}` @@ -405,7 +406,7 @@ jobs: echo "*** Installing Radius to Kubernetes ***" rad install kubernetes --reinstall \ --chart ${{ env.RADIUS_CHART_LOCATION }} \ - --set rp.image=${{ env.CONTAINER_REGISTRY }}/applications-rp,rp.tag=${{ env.REL_VERSION }},controller.image=${{ env.CONTAINER_REGISTRY }}/controller,controller.tag=${{ env.REL_VERSION }},ucp.image=${{ env.CONTAINER_REGISTRY }}/ucpd,ucp.tag=${{ env.REL_VERSION }} + --set rp.image=${{ env.CONTAINER_REGISTRY }}/applications-rp,rp.tag=${{ env.REL_VERSION }},dynamicrp.image=${{ env.CONTAINER_REGISTRY }}/dynamic-rp,dynamicrp.tag=${{ env.REL_VERSION }},controller.image=${{ env.CONTAINER_REGISTRY }}/controller,controller.tag=${{ env.REL_VERSION }},ucp.image=${{ env.CONTAINER_REGISTRY }}/ucpd,ucp.tag=${{ env.REL_VERSION }} - name: Configure Radius test workspace run: | set -x diff --git a/.vscode/launch.json b/.vscode/launch.json index daf179be569..f19cd70f96f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -38,6 +38,13 @@ "env": { "RADIUS_ENV": "self-hosted" } + },{ + "name": "Launch Dynamic RP", + "type": "go", + "request": "launch", + "mode": "auto", + "preLaunchTask": "Build Radius (all)", + "program": "${workspaceFolder}/cmd/dynamic-rp/main.go", }, { "name": "Launch UCP", diff --git a/build/build.mk b/build/build.mk index 5f576047933..f173721c5b0 100644 --- a/build/build.mk +++ b/build/build.mk @@ -119,6 +119,7 @@ GOARCHES := amd64 arm arm64 BINARIES := docgen:./cmd/docgen \ rad:./cmd/rad \ applications-rp:./cmd/applications-rp \ + dynamic-rp:./cmd/dynamic-rp \ ucpd:./cmd/ucpd \ controller:./cmd/controller \ testrp:./test/testrp \ diff --git a/build/docker.mk b/build/docker.mk index 3b161fd653c..7adcdcaa6bb 100644 --- a/build/docker.mk +++ b/build/docker.mk @@ -101,6 +101,7 @@ configure-buildx: # Define a target for each image with name and Dockerfile location APPS_MAP := ucpd:./deploy/images/ucpd \ applications-rp:./deploy/images/applications-rp \ + dynamic-rp:./deploy/images/dynamic-rp \ controller:./deploy/images/controller \ testrp:./test/testrp \ magpiego:./test/magpiego diff --git a/cmd/dynamic-rp/cmd/root.go b/cmd/dynamic-rp/cmd/root.go new file mode 100644 index 00000000000..e83d40053bd --- /dev/null +++ b/cmd/dynamic-rp/cmd/root.go @@ -0,0 +1,82 @@ +/* +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 cmd + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/go-logr/logr" + "github.com/spf13/cobra" + runtimelog "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/radius-project/radius/pkg/armrpc/hostoptions" + "github.com/radius-project/radius/pkg/dynamicrp" + "github.com/radius-project/radius/pkg/dynamicrp/server" + "github.com/radius-project/radius/pkg/ucp/hosting" + "github.com/radius-project/radius/pkg/ucp/ucplog" +) + +var rootCmd = &cobra.Command{ + Use: "dynamic-rp", + Short: "Dyanamic Resource Provider server", + Long: `Server process for the Dynamic Resource Provider (UCP).`, + RunE: func(cmd *cobra.Command, args []string) error { + configFilePath := cmd.Flag("config-file").Value.String() + bs, err := os.ReadFile(configFilePath) + if err != nil { + return fmt.Errorf("failed to read config file %s: %w", configFilePath, err) + } + + config, err := dynamicrp.LoadConfig(bs) + if err != nil { + return fmt.Errorf("failed to parse config file %s: %w", configFilePath, err) + } + + options, err := dynamicrp.NewOptions(cmd.Context(), config) + if err != nil { + return err + } + + logger, flush, err := ucplog.NewLogger(ucplog.LoggerName, &options.Config.Logging) + if err != nil { + log.Fatal(err) //nolint:forbidigo // this is OK inside the main function. + } + defer flush() + + // Must set the logger before using controller-runtime. + runtimelog.SetLogger(logger) + + host, err := server.NewServer(options) + if err != nil { + return err + } + + ctx := logr.NewContext(cmd.Context(), logger) + + return hosting.RunWithInterrupts(ctx, host) + }, +} + +func Execute() { + // Let users override the configuration via `--config-file`. + rootCmd.Flags().String("config-file", fmt.Sprintf("dynamicrp-%s.yaml", hostoptions.Environment()), "The service configuration file.") + + cobra.CheckErr(rootCmd.ExecuteContext(context.Background())) +} diff --git a/cmd/dynamic-rp/dynamicrp-dev.yaml b/cmd/dynamic-rp/dynamicrp-dev.yaml new file mode 100644 index 00000000000..c13ad6ada41 --- /dev/null +++ b/cmd/dynamic-rp/dynamicrp-dev.yaml @@ -0,0 +1,43 @@ +# This is an example of configuration file. +environment: + name: Dev + roleLocation: "global" +storageProvider: + provider: "etcd" + etcd: + inmemory: true +queueProvider: + provider: inmemory + name: radius +profilerProvider: + enabled: true + port: 6060 +secretProvider: + provider: etcd + etcd: + inmemory: true +metricsProvider: + prometheus: + enabled: true + path: "/metrics" + port: 9090 +featureFlags: + - "PLACEHOLDER" +server: + host: "0.0.0.0" + port: 8080 + enableArmAuth: false +workerServer: + maxOperationConcurrency: 10 + maxOperationRetryCount: 2 +ucp: + kind: kubernetes + # Logging configuration +logging: + level: "info" + json: false +bicep: + deleteRetryCount: 20 + deleteRetryDelaySeconds: 60 +terraform: + path: "/terraform" \ No newline at end of file diff --git a/cmd/dynamic-rp/main.go b/cmd/dynamic-rp/main.go new file mode 100644 index 00000000000..2f95e79deb8 --- /dev/null +++ b/cmd/dynamic-rp/main.go @@ -0,0 +1,23 @@ +/* +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 main + +import "github.com/radius-project/radius/cmd/dynamic-rp/cmd" + +func main() { + cmd.Execute() +} diff --git a/deploy/Chart/values.yaml b/deploy/Chart/values.yaml index eb39f707ad5..d7502955702 100644 --- a/deploy/Chart/values.yaml +++ b/deploy/Chart/values.yaml @@ -64,6 +64,24 @@ ucp: limits: memory: "300Mi" +dynamicrp: + image: ghcr.io/radius-project/dynamic-rp + # Default tag uses Chart AppVersion. + # tag: latest + resources: + requests: + # request memory is the average memory usage + 10% buffer. + memory: "160Mi" + limits: + # limit is higher for dynamic-rp because the Terraform execution + # can spike memory usage. + memory: "500Mi" + bicep: + deleteRetryCount: 20 + deleteRetryDelaySeconds: 60 + terraform: + path: "/terraform" + rp: image: ghcr.io/radius-project/applications-rp # Default tag uses Chart AppVersion. diff --git a/pkg/armrpc/asyncoperation/worker/registry.go b/pkg/armrpc/asyncoperation/worker/registry.go index 814fc85b6d2..5fef1ce5d0f 100644 --- a/pkg/armrpc/asyncoperation/worker/registry.go +++ b/pkg/armrpc/asyncoperation/worker/registry.go @@ -25,6 +25,15 @@ import ( "github.com/radius-project/radius/pkg/ucp/dataprovider" ) +const ( + // ResourceTypeAny is a wildcard for any resource type. + ResourceTypeAny = "*" + + // OperationMethodAny is a wildcard for any operation method. + OperationMethodAny = "*" +) + +// ControllerFactoryFunc is a factory function to create a controller. type ControllerFactoryFunc func(opts ctrl.Options) (ctrl.Controller, error) // ControllerRegistry is an registry to register async controllers. @@ -32,6 +41,11 @@ type ControllerRegistry struct { ctrlMap map[string]ctrl.Controller ctrlMapMu sync.RWMutex sp dataprovider.DataStorageProvider + + // Fallback allows the registration of a controller that will be used + // for operations that don't match any other operation type. + fallbackFactory ControllerFactoryFunc + fallbackOpts ctrl.Options } // NewControllerRegistry creates an ControllerRegistry instance. @@ -48,6 +62,13 @@ func (h *ControllerRegistry) Register(ctx context.Context, resourceType string, defer h.ctrlMapMu.Unlock() ot := v1.OperationType{Type: resourceType, Method: method} + if resourceType == ResourceTypeAny && method == OperationMethodAny { + // This is a fallback controller. Skip registration for now so we can create instances + // dynamically when needed. + h.fallbackFactory = factoryFn + h.fallbackOpts = opts + return nil + } storageClient, err := h.sp.GetStorageClient(ctx, resourceType) if err != nil { @@ -66,13 +87,29 @@ func (h *ControllerRegistry) Register(ctx context.Context, resourceType string, } // Get gets the registered async controller instance. -func (h *ControllerRegistry) Get(operationType v1.OperationType) ctrl.Controller { +func (h *ControllerRegistry) Get(ctx context.Context, operationType v1.OperationType) (ctrl.Controller, error) { h.ctrlMapMu.RLock() defer h.ctrlMapMu.RUnlock() if h, ok := h.ctrlMap[operationType.String()]; ok { - return h + return h, nil } - return nil + // If no controller is found, then look for a default controller. + if h.fallbackFactory == nil { + return nil, nil + } + + storageClient, err := h.sp.GetStorageClient(ctx, operationType.Type) + if err != nil { + return nil, err + } + + // Copy the options so we can update it. + opts := h.fallbackOpts + + opts.StorageClient = storageClient + opts.ResourceType = operationType.Type + + return h.fallbackFactory(opts) } diff --git a/pkg/armrpc/asyncoperation/worker/registry_test.go b/pkg/armrpc/asyncoperation/worker/registry_test.go index 7fa94b271b2..b2f8689a7d9 100644 --- a/pkg/armrpc/asyncoperation/worker/registry_test.go +++ b/pkg/armrpc/asyncoperation/worker/registry_test.go @@ -63,8 +63,11 @@ func TestRegister_Get(t *testing.T) { }, ctrlOpts) require.NoError(t, err) - ctrl := registry.Get(opGet) + ctrl, err := registry.Get(context.Background(), opGet) + require.NoError(t, err) require.NotNil(t, ctrl) - ctrl = registry.Get(opPut) + + ctrl, err = registry.Get(context.Background(), opPut) + require.NoError(t, err) require.NotNil(t, ctrl) } diff --git a/pkg/armrpc/asyncoperation/worker/worker.go b/pkg/armrpc/asyncoperation/worker/worker.go index 1031a013cdb..e00c767170e 100644 --- a/pkg/armrpc/asyncoperation/worker/worker.go +++ b/pkg/armrpc/asyncoperation/worker/worker.go @@ -168,7 +168,15 @@ func (w *AsyncRequestProcessWorker) Start(ctx context.Context) error { } reqCtx = v1.WithARMRequestContext(reqCtx, armReqCtx) - asyncCtrl := w.registry.Get(armReqCtx.OperationType) + asyncCtrl, err := w.registry.Get(reqCtx, armReqCtx.OperationType) + if err != nil { + opLogger.Error(err, "failed to get async controller.") + if err := w.requestQueue.FinishMessage(reqCtx, msgreq); err != nil { + opLogger.Error(err, "failed to finish the message") + } + return + } + if asyncCtrl == nil { opLogger.Error(nil, "cannot process unknown operation: "+armReqCtx.OperationType.String()) if err := w.requestQueue.FinishMessage(reqCtx, msgreq); err != nil { diff --git a/pkg/armrpc/builder/builder_test.go b/pkg/armrpc/builder/builder_test.go index fee7ed8d1f8..c0c0f20654d 100644 --- a/pkg/armrpc/builder/builder_test.go +++ b/pkg/armrpc/builder/builder_test.go @@ -261,7 +261,8 @@ func TestApplyAsyncHandler(t *testing.T) { } for _, op := range expectedOperations { - jobCtrl := registry.Get(op) + jobCtrl, err := registry.Get(context.Background(), op) + require.NoError(t, err) require.NotNil(t, jobCtrl) } } diff --git a/pkg/dynamicrp/api/dynamicresource.go b/pkg/dynamicrp/api/dynamicresource.go new file mode 100644 index 00000000000..f0a602136af --- /dev/null +++ b/pkg/dynamicrp/api/dynamicresource.go @@ -0,0 +1,32 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +// DynamicResource is used as the versioned resource model for dynamic resources. +// +// A dynamic resource is implemented internally to dynamic-rp, and uses a user-provided +// OpenAPI specification to define the resource schema. Since the resource is internal +// and dynamically generated, this struct is used to represent all dynamic resources. +type DynamicResource struct { + ID *string `json:"id"` + Name *string `json:"name"` + Type *string `json:"type"` + Location *string `json:"location"` + Tags map[string]*string `json:"tags,omitempty"` + Properties map[string]any `json:"properties,omitempty"` + SystemData map[string]any `json:"systemData,omitempty"` +} diff --git a/pkg/dynamicrp/api/dynamicresource_conversion.go b/pkg/dynamicrp/api/dynamicresource_conversion.go new file mode 100644 index 00000000000..b7c7d89c603 --- /dev/null +++ b/pkg/dynamicrp/api/dynamicresource_conversion.go @@ -0,0 +1,96 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "encoding/json" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/dynamicrp/datamodel" + "github.com/radius-project/radius/pkg/to" +) + +const ( + // TODO + Version = "2023-01-01" +) + +func (d *DynamicResource) ConvertTo() (v1.DataModelInterface, error) { + dm := &datamodel.DynamicResource{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: to.String(d.ID), + Name: to.String(d.Name), + Type: to.String(d.Type), + Location: to.String(d.Location), + Tags: to.StringMap(d.Tags), + }, + InternalMetadata: v1.InternalMetadata{ + UpdatedAPIVersion: Version, + }, + }, + Properties: d.Properties, + } + + return dm, nil +} + +func (d *DynamicResource) ConvertFrom(src v1.DataModelInterface) error { + dm, ok := src.(*datamodel.DynamicResource) + if !ok { + return v1.ErrInvalidModelConversion + } + + d.ID = &dm.ID + d.Name = &dm.Name + d.Type = &dm.Type + d.Location = &dm.Location + d.Tags = *to.StringMapPtr(dm.Tags) + d.SystemData = fromSystemDataDataModel(dm.SystemData) + d.Properties = dm.Properties + if d.Properties == nil { + d.Properties = map[string]any{} + } + d.Properties["provisioningState"] = fromProvisioningStateDataModel(dm.AsyncProvisioningState) + + return nil +} + +func fromSystemDataDataModel(input v1.SystemData) map[string]any { + bs, err := json.Marshal(input) + if err != nil { + // This should never fail. We've designed the SystemData type to be serializable. + panic("marshalling system data failed: " + err.Error()) + } + + result := map[string]any{} + err = json.Unmarshal(bs, &result) + if err != nil { + // This should never fail. We've designed the SystemData type to be serializable. + panic("unmarshalling system data failed: " + err.Error()) + } + + return result +} + +func fromProvisioningStateDataModel(input v1.ProvisioningState) string { + if input == "" { + return string(v1.ProvisioningStateSucceeded) + } + + return string(input) +} diff --git a/pkg/dynamicrp/api/dynamicresource_conversion_test.go b/pkg/dynamicrp/api/dynamicresource_conversion_test.go new file mode 100644 index 00000000000..936448b6a1c --- /dev/null +++ b/pkg/dynamicrp/api/dynamicresource_conversion_test.go @@ -0,0 +1,126 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "encoding/json" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/dynamicrp/datamodel" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/test/testutil" + + "github.com/stretchr/testify/require" +) + +func Test_DynamicResource_ConvertVersionedToDataModel(t *testing.T) { + conversionTests := []struct { + filename string + expected *datamodel.DynamicResource + err error + }{ + { + filename: "dynamicresource-resource.json", + expected: &datamodel.DynamicResource{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: "/planes/radius/local/resourceGroups/test/providers/Applications.Test/testResources/testResource", + Name: "testResource", + Type: "Applications.Test/testResources", + Location: "global", + Tags: map[string]string{ + "env": "dev", + }, + }, + InternalMetadata: v1.InternalMetadata{ + UpdatedAPIVersion: Version, + }, + }, + Properties: map[string]any{ + "message": "Hello, world!", + }, + }, + }, + } + + for _, tt := range conversionTests { + t.Run(tt.filename, func(t *testing.T) { + rawPayload := testutil.ReadFixture(tt.filename) + r := &DynamicResource{} + err := json.Unmarshal(rawPayload, r) + require.NoError(t, err) + + dm, err := r.ConvertTo() + + if tt.err != nil { + require.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + ct := dm.(*datamodel.DynamicResource) + require.Equal(t, tt.expected, ct) + } + }) + } +} + +func Test_DynamicResource_ConvertDataModelToVersioned(t *testing.T) { + conversionTests := []struct { + filename string + expected *DynamicResource + err error + }{ + { + filename: "dynamicresource-datamodel.json", + expected: &DynamicResource{ + ID: to.Ptr("/planes/radius/local/resourceGroups/test/providers/Applications.Test/testResources/testResource"), + Name: to.Ptr("testResource"), + Type: to.Ptr("Applications.Test/testResources"), + Location: to.Ptr("global"), + Tags: map[string]*string{ + "env": to.Ptr("dev"), + }, + Properties: map[string]any{ + "provisioningState": fromProvisioningStateDataModel(v1.ProvisioningStateSucceeded), + "message": "Hello, world!", + }, + }, + }, + } + + for _, tt := range conversionTests { + t.Run(tt.filename, func(t *testing.T) { + rawPayload := testutil.ReadFixture(tt.filename) + dm := &datamodel.DynamicResource{} + err := json.Unmarshal(rawPayload, dm) + require.NoError(t, err) + + resource := &DynamicResource{} + err = resource.ConvertFrom(dm) + + // Avoid hardcoding the SystemData field in tests. + tt.expected.SystemData = fromSystemDataDataModel(dm.SystemData) + + if tt.err != nil { + require.ErrorIs(t, err, tt.err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, resource) + } + }) + } +} diff --git a/pkg/dynamicrp/api/testdata/dynamicresource-datamodel.json b/pkg/dynamicrp/api/testdata/dynamicresource-datamodel.json new file mode 100644 index 00000000000..0b5c40958f4 --- /dev/null +++ b/pkg/dynamicrp/api/testdata/dynamicresource-datamodel.json @@ -0,0 +1,20 @@ +{ + "id": "/planes/radius/local/resourceGroups/test/providers/Applications.Test/testResources/testResource", + "name": "testResource", + "type": "Applications.Test/testResources", + "location": "global", + "systemData": { + "createdBy": "fakeid@live.com", + "createdByType": "User", + "createdAt": "2021-09-24T19:09:54.2403864Z", + "lastModifiedBy": "fakeid@live.com", + "lastModifiedByType": "User", + "lastModifiedAt": "2021-09-24T20:09:54.2403864Z" + }, + "tags": { + "env": "dev" + }, + "properties": { + "message": "Hello, world!" + } +} diff --git a/pkg/dynamicrp/api/testdata/dynamicresource-resource.json b/pkg/dynamicrp/api/testdata/dynamicresource-resource.json new file mode 100644 index 00000000000..f2569eacb52 --- /dev/null +++ b/pkg/dynamicrp/api/testdata/dynamicresource-resource.json @@ -0,0 +1,12 @@ +{ + "id": "/planes/radius/local/resourceGroups/test/providers/Applications.Test/testResources/testResource", + "name": "testResource", + "type": "Applications.Test/testResources", + "location": "global", + "tags": { + "env": "dev" + }, + "properties": { + "message": "Hello, world!" + } +} \ No newline at end of file diff --git a/pkg/dynamicrp/backend/controller/dynamic/process.go b/pkg/dynamicrp/backend/controller/dynamic/process.go new file mode 100644 index 00000000000..63eba67e3b3 --- /dev/null +++ b/pkg/dynamicrp/backend/controller/dynamic/process.go @@ -0,0 +1,71 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dynamic + +import ( + "context" + "fmt" + "net/http" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" +) + +var _ ctrl.Controller = (*Controller)(nil) + +// Controller is the async operation controller to perform background processing on tracked resources. +type Controller struct { + ctrl.BaseController +} + +// NewController creates a new Controller controller which is used to process resources asynchronously. +func NewController(opts ctrl.Options) (ctrl.Controller, error) { + return &Controller{ + BaseController: ctrl.NewBaseAsyncController(opts), + }, nil +} + +// Run implements the async operation controller to process resources asynchronously. +func (c *Controller) Run(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + operationType, _ := v1.ParseOperationType(request.OperationType) + switch operationType.Method { + case http.MethodPut: + return c.processPut(ctx, request) + case http.MethodDelete: + return c.processDelete(ctx, request) + default: + e := v1.ErrorDetails{ + Code: v1.CodeInvalid, + Message: fmt.Sprintf("Invalid operation type: %q", operationType), + Target: request.ResourceID, + } + return ctrl.NewFailedResult(e), nil + } +} + +func (c *Controller) processDelete(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + err := c.StorageClient().Delete(ctx, request.ResourceID) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (c *Controller) processPut(ctx context.Context, request *ctrl.Request) (ctrl.Result, error) { + return ctrl.Result{}, nil +} diff --git a/pkg/dynamicrp/backend/controller/dynamic/process_test.go b/pkg/dynamicrp/backend/controller/dynamic/process_test.go new file mode 100644 index 00000000000..db95e2ea5b7 --- /dev/null +++ b/pkg/dynamicrp/backend/controller/dynamic/process_test.go @@ -0,0 +1,17 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dynamic diff --git a/pkg/dynamicrp/backend/service.go b/pkg/dynamicrp/backend/service.go new file mode 100644 index 00000000000..94e32aff459 --- /dev/null +++ b/pkg/dynamicrp/backend/service.go @@ -0,0 +1,95 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package backend + +import ( + "context" + "fmt" + + ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" + "github.com/radius-project/radius/pkg/armrpc/asyncoperation/worker" + "github.com/radius-project/radius/pkg/armrpc/hostoptions" + "github.com/radius-project/radius/pkg/dynamicrp" +) + +// Service runs the backend for the dynamic-rp. +type Service struct { + worker.Service +} + +// NewService creates a new service to run the dynamic-rp backend. +func NewService(options *dynamicrp.Options) *Service { + return &Service{ + Service: worker.Service{ + ProviderName: "dynamic-rp", + + // It's unfortunate that hostoptions.HostOptions is required here since that type is specific + // to applications-rp. + // + // We're limiting the spread of this by hiding that detail. + Options: hostoptions.HostOptions{ + Config: &hostoptions.ProviderConfig{ + Bicep: options.Config.Bicep, + Env: options.Config.Environment, + Logging: options.Config.Logging, + SecretProvider: options.Config.Secrets, + QueueProvider: options.Config.Queue, + StorageProvider: options.Config.Storage, + Terraform: options.Config.Terraform, + WorkerServer: &options.Config.Worker, + }, + }, + }, + } +} + +// Name returns the name of the service used for logging. +func (w *Service) Name() string { + return fmt.Sprintf("%s async worker", w.Service.ProviderName) +} + +// Run runs the service. +func (w *Service) Run(ctx context.Context) error { + err := w.Init(ctx) + if err != nil { + return err + } + + workerOptions := worker.Options{} + if w.Options.Config.WorkerServer != nil { + if w.Options.Config.WorkerServer.MaxOperationConcurrency != nil { + workerOptions.MaxOperationConcurrency = *w.Options.Config.WorkerServer.MaxOperationConcurrency + } + if w.Options.Config.WorkerServer.MaxOperationRetryCount != nil { + workerOptions.MaxOperationRetryCount = *w.Options.Config.WorkerServer.MaxOperationRetryCount + } + } + + controllerOptions := ctrl.Options{ + DataProvider: w.StorageProvider, + } + err = w.registerControllers(controllerOptions) + if err != nil { + return err + } + + return w.Start(ctx, workerOptions) +} + +func (w *Service) registerControllers(options ctrl.Options) error { + return nil +} diff --git a/pkg/dynamicrp/config.go b/pkg/dynamicrp/config.go new file mode 100644 index 00000000000..aaf821e6698 --- /dev/null +++ b/pkg/dynamicrp/config.go @@ -0,0 +1,116 @@ +/* +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 dynamicrp + +import ( + "bytes" + + "github.com/radius-project/radius/pkg/armrpc/hostoptions" + metricsprovider "github.com/radius-project/radius/pkg/metrics/provider" + profilerprovider "github.com/radius-project/radius/pkg/profiler/provider" + "github.com/radius-project/radius/pkg/trace" + ucpconfig "github.com/radius-project/radius/pkg/ucp/config" + "github.com/radius-project/radius/pkg/ucp/dataprovider" + queueprovider "github.com/radius-project/radius/pkg/ucp/queue/provider" + secretprovider "github.com/radius-project/radius/pkg/ucp/secret/provider" + "github.com/radius-project/radius/pkg/ucp/ucplog" + "gopkg.in/yaml.v3" +) + +// Config defines the configuration for the DynamicRP server. +type Config struct { + // Bicep configures properties for the Bicep recipe driver. + Bicep hostoptions.BicepOptions `yaml:"bicep"` + + // Environment is the configuration for the hosting environment. + Environment hostoptions.EnvironmentOptions `yaml:"environment"` + + // HTTP is the configuration for the HTTP server. + HTTP HTTPServerConfig `yaml:"http"` + + // Logging is the configuration for the logging system. + Logging ucplog.LoggingOptions `yaml:"logging"` + + // Metrics is the configuration for the metrics endpoint. + Metrics metricsprovider.MetricsProviderOptions `yaml:"metricsProvider"` + + // Profiler is the configuration for the profiler endpoint. + Profiler profilerprovider.ProfilerProviderOptions `yaml:"profilerProvider"` + + // Queue is the configuration for the message queue. + Queue queueprovider.QueueProviderOptions `yaml:"queueProvider"` + + // Secrets is the configuration for the secret storage system. + Secrets secretprovider.SecretProviderOptions `yaml:"secretProvider"` + + // Storage is the configuration for the database used for storage. + Storage dataprovider.StorageProviderOptions `yaml:"storageProvider"` + + // Terraform configures properties for the Terraform recipe driver. + Terraform hostoptions.TerraformOptions `yaml:"terraform"` + + // Tracing is the configuration for the tracing system. + Tracing trace.Options `yaml:"tracerProvider"` + + // UCPConfig is the configuration for the connection to UCP. + UCP ucpconfig.UCPOptions `yaml:"ucp"` + + // Worker is the configuration for the backend worker server. + Worker hostoptions.WorkerServerOptions `yaml:"workerServer"` +} + +// HTTPServerConfig defines the configuration for the HTTP server. +type HTTPServerConfig struct { + // Address is the address the HTTP server listens on. + // + // Address should be specified as a string in the format "host:port". The host may + // be omitted to listen on localhost. eg: ":8080" to listen on port 8080 on localhost. + // + // See: https://pkg.go.dev/net#Dial + // + Address string `yaml:"address"` + + // PathBase is the base path for the HTTP server. + // + // PathBase (when specified) is a mandatory prefix applied to all incoming HTTP requests. + // + // For example if PathBase is "/dynamic-rp", then the server will only respond to requests + // with a URL path that starts with "/dynamic-rp". + // + // For the purpose of parsing or interpreting the URL, the PathBase is removed from the URL path. + PathBase string `yaml:"pathBase"` + + // TLSCertificateDirectory is the directory containing the TLS certificate and key files. + // If this is set, the server will listen on HTTPS. + // + // The certificate file should be named "tls.crt" and the key file should be named "tls.key". + TLSCertificateDirectory string `yaml:"tlsCertificateDirectory"` +} + +// LoadConfig loads a Config from bytes. +func LoadConfig(bs []byte) (*Config, error) { + decoder := yaml.NewDecoder(bytes.NewBuffer(bs)) + decoder.KnownFields(true) + + config := Config{} + err := decoder.Decode(&config) + if err != nil { + return nil, err + } + + return &config, nil +} diff --git a/pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go b/pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go new file mode 100644 index 00000000000..776a6c9038a --- /dev/null +++ b/pkg/dynamicrp/datamodel/converter/dynamicresource_converter.go @@ -0,0 +1,48 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package converter + +import ( + "encoding/json" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/dynamicrp/api" + "github.com/radius-project/radius/pkg/dynamicrp/datamodel" +) + +// DynamicResourceDataModelFromVersioned converts version agnostic datamodel to versioned model. +func DynamicResourceDataModelToVersioned(model *datamodel.DynamicResource, version string) (v1.VersionedModelInterface, error) { + // NOTE: DynamicResource is used for all API versions. + versioned := &api.DynamicResource{} + if err := versioned.ConvertFrom(model); err != nil { + return nil, err + } + return versioned, nil +} + +// DynamicResourceDataModelFromVersioned converts versioned model to datamodel. +func DynamicResourceDataModelFromVersioned(content []byte, version string) (*datamodel.DynamicResource, error) { + // NOTE: DynamicResource is used for all API versions. + vm := &api.DynamicResource{} + if err := json.Unmarshal(content, vm); err != nil { + return nil, err + } + dm, err := vm.ConvertTo() + if err != nil { + return nil, err + } + return dm.(*datamodel.DynamicResource), nil +} diff --git a/pkg/dynamicrp/datamodel/dynamicresource.go b/pkg/dynamicrp/datamodel/dynamicresource.go new file mode 100644 index 00000000000..b99dbe2e113 --- /dev/null +++ b/pkg/dynamicrp/datamodel/dynamicresource.go @@ -0,0 +1,38 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package datamodel + +import ( + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" +) + +// DynamicResource is used as the data model for dynamic resources. +// +// A dynamic resource is implemented internally to UCP, and uses a user-provided +// OpenAPI specification to define the resource schema. Since the resource is internal +// to UCP and dynamically generated, this struct is used to represent all dynamic resources. +type DynamicResource struct { + v1.BaseResource + + // Properties stores the properties of the resource being tracked. + Properties map[string]any `json:"properties"` +} + +// ResourceTypeName gives the type of the resource. +func (r *DynamicResource) ResourceTypeName() string { + return r.Type +} diff --git a/pkg/dynamicrp/doc.go b/pkg/dynamicrp/doc.go new file mode 100644 index 00000000000..b491be29984 --- /dev/null +++ b/pkg/dynamicrp/doc.go @@ -0,0 +1,19 @@ +/* +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. +*/ + +// dynamicrp holds the implementation of the dynamic resource provider. The dynamicrp is responsible +// for managing the lifecycle of resources that are defined without their own resource provider implementation. +package dynamicrp diff --git a/pkg/dynamicrp/frontend/routes.go b/pkg/dynamicrp/frontend/routes.go new file mode 100644 index 00000000000..80517ede939 --- /dev/null +++ b/pkg/dynamicrp/frontend/routes.go @@ -0,0 +1,172 @@ +/* +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 frontend + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/armrpc/frontend/controller" + "github.com/radius-project/radius/pkg/armrpc/frontend/defaultoperation" + "github.com/radius-project/radius/pkg/armrpc/frontend/server" + "github.com/radius-project/radius/pkg/armrpc/rest" + "github.com/radius-project/radius/pkg/dynamicrp/api" + "github.com/radius-project/radius/pkg/dynamicrp/datamodel" + "github.com/radius-project/radius/pkg/ucp/resources" + "github.com/radius-project/radius/pkg/validator" +) + +const ( + operationBaseRoute = "/planes/radius/{planeName}/providers/{providerNamespace}/locations/{locationName}" + planeScopedResourceCollectionRoute = "/planes/radius/{planeName}/providers/{providerNamespace}/{resourceType}" + resourceCollectionRoute = "/planes/radius/{planeName}/{rg:resource[gG]roups}/{resourceGroupName}/providers/{providerNamespace}/{resourceType}" + resourceRoute = resourceCollectionRoute + "/{resourceName}" +) + +func (s *Service) registerRoutes(r *chi.Mux) error { + ctrlOpts := controller.Options{ + Address: s.options.Config.HTTP.Address, + PathBase: s.options.Config.HTTP.PathBase, + DataProvider: s.options.StorageProvider, + StatusManager: s.options.StatusManager, + + KubeClient: nil, // Unused by DynamicRP + StorageClient: nil, // Set dynamically + ResourceType: "", // Set dynamically + } + + // Return ARM errors for invalid requests. + r.NotFound(validator.APINotFoundHandler()) + r.MethodNotAllowed(validator.APIMethodNotAllowedHandler()) + + r.Route(s.options.Config.HTTP.PathBase, func(r chi.Router) { + register(r, "GET "+operationBaseRoute+"/operationResults/{operationID}", v1.OperationGet, ctrlOpts, func(opts controller.Options, ctrlOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewGetOperationResult(opts) + }) + + register(r, "GET "+operationBaseRoute+"/operationStatuses/{operationID}", v1.OperationGet, ctrlOpts, func(opts controller.Options, ctrlOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewGetOperationStatus(opts) + }) + + register(r, "GET "+planeScopedResourceCollectionRoute, v1.OperationPlaneScopeList, ctrlOpts, func(ctrlOpts controller.Options, resourceOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + resourceOpts.ListRecursiveQuery = true + return defaultoperation.NewListResources[*datamodel.DynamicResource, datamodel.DynamicResource](ctrlOpts, resourceOpts) + }) + + register(r, "GET "+resourceCollectionRoute, v1.OperationList, ctrlOpts, func(ctrlOpts controller.Options, resourceOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewListResources[*datamodel.DynamicResource, datamodel.DynamicResource](ctrlOpts, resourceOpts) + }) + + register(r, "GET "+resourceRoute, v1.OperationGet, ctrlOpts, func(ctrlOpts controller.Options, resourceOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewGetResource[*datamodel.DynamicResource, datamodel.DynamicResource](ctrlOpts, resourceOpts) + }) + + register(r, "PUT "+resourceRoute, v1.OperationPut, ctrlOpts, func(ctrlOpts controller.Options, resourceOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewDefaultSyncPut[*datamodel.DynamicResource, datamodel.DynamicResource](ctrlOpts, resourceOpts) + }) + + register(r, "DELETE "+resourceRoute, v1.OperationDelete, ctrlOpts, func(ctrlOpts controller.Options, resourceOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) { + return defaultoperation.NewDefaultSyncDelete[*datamodel.DynamicResource, datamodel.DynamicResource](ctrlOpts, resourceOpts) + }) + }) + + return nil +} + +type controllerFactory = func(opts controller.Options, ctrlOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error) + +func register(r chi.Router, pattern string, method v1.OperationMethod, opts controller.Options, factory controllerFactory) { + r.Handle(pattern, dynamicOperationType(method, opts, factory)) +} + +func dynamicOperationType(method v1.OperationMethod, opts controller.Options, factory func(opts controller.Options, ctrlOpts controller.ResourceOptions[datamodel.DynamicResource]) (controller.Controller, error)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, err := resources.Parse(r.URL.Path) + if err != nil { + result := rest.NewBadRequestResponse(err.Error()) + err = result.Apply(r.Context(), w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + return + } + + operationType := v1.OperationType{Type: strings.ToUpper(id.Type()), Method: method} + + // Copy the options and initalize them dynamically for this type. + opts := opts + opts.ResourceType = id.Type() + + client, err := opts.DataProvider.GetStorageClient(r.Context(), id.Type()) + if err != nil { + result := rest.NewBadRequestResponse(err.Error()) + err = result.Apply(r.Context(), w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + return + } + + opts.StorageClient = client + + ctrlOpts := controller.ResourceOptions[datamodel.DynamicResource]{ + RequestConverter: func(content []byte, version string) (*datamodel.DynamicResource, error) { + api := &api.DynamicResource{} + + err := json.Unmarshal(content, api) + if err != nil { + return nil, err + } + + dm, err := api.ConvertTo() + if err != nil { + return nil, err + } + + return dm.(*datamodel.DynamicResource), nil + }, + ResponseConverter: func(resource *datamodel.DynamicResource, version string) (v1.VersionedModelInterface, error) { + api := &api.DynamicResource{} + err = api.ConvertFrom(resource) + if err != nil { + return nil, err + } + + return api, nil + }, + } + + ctrl, err := factory(opts, ctrlOpts) + if err != nil { + result := rest.NewBadRequestResponse(err.Error()) + err = result.Apply(r.Context(), w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + return + } + + handler := server.HandlerForController(ctrl, operationType) + handler.ServeHTTP(w, r) + }) +} diff --git a/pkg/dynamicrp/frontend/server.go b/pkg/dynamicrp/frontend/server.go new file mode 100644 index 00000000000..8d435efc3b0 --- /dev/null +++ b/pkg/dynamicrp/frontend/server.go @@ -0,0 +1,122 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package frontend + +import ( + "context" + "fmt" + "net" + "net/http" + + "github.com/radius-project/radius/pkg/armrpc/servicecontext" + "github.com/radius-project/radius/pkg/dynamicrp" + "github.com/radius-project/radius/pkg/middleware" + "github.com/radius-project/radius/pkg/ucp/ucplog" + + "github.com/go-chi/chi/v5" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel" +) + +func NewService(options *dynamicrp.Options) *Service { + return &Service{ + options: options, + } +} + +// Service implements the hosting.Service interface for the UCP frontend API. +type Service struct { + options *dynamicrp.Options +} + +// Name gets this service name. +func (s *Service) Name() string { + return "dyanmic-rp api" +} + +// Initialize sets up the router, storage provider, secret provider, status manager, AWS config, AWS clients, +// registers the routes, configures the default planes, and sets up the http server with the appropriate middleware. It +// returns an http server and an error if one occurs. +func (s *Service) initialize(ctx context.Context) (*http.Server, error) { + r := chi.NewRouter() + + err := s.registerRoutes(r) + if err != nil { + return nil, fmt.Errorf("failed to register routes: %w", err) + } + + app := http.Handler(r) + + // Autodetect pathbase + app = servicecontext.ARMRequestCtx("", s.options.Config.Environment.RoleLocation)(app) + app = middleware.WithLogger(app) + + app = otelhttp.NewHandler( + middleware.NormalizePath(app), + "dynamic-rp", + otelhttp.WithMeterProvider(otel.GetMeterProvider()), + otelhttp.WithTracerProvider(otel.GetTracerProvider())) + + // TODO: This is the workaround to fix the high cardinality of otelhttp. + // Remove this once otelhttp middleware is fixed - https://github.com/open-telemetry/opentelemetry-go-contrib/issues/3765 + app = middleware.RemoveRemoteAddr(app) + + server := &http.Server{ + Addr: s.options.Config.HTTP.Address, + Handler: app, + BaseContext: func(ln net.Listener) context.Context { + return ctx + }, + } + + return server, nil +} + +// Run sets up a server to listen on a given address, and shuts it down when the context is done. It returns an +// error if the server fails to start or stops unexpectedly. +func (s *Service) Run(ctx context.Context) error { + logger := ucplog.FromContextOrDiscard(ctx) + server, err := s.initialize(ctx) + if err != nil { + return err + } + + // Handle shutdown based on the context + go func() { + <-ctx.Done() + // We don't care about shutdown errors + _ = server.Shutdown(ctx) + }() + + logger.Info(fmt.Sprintf("listening on: '%s'...", s.options.Config.HTTP.Address)) + if s.options.Config.HTTP.TLSCertificateDirectory == "" { + err = server.ListenAndServe() + } else { + err = server.ListenAndServeTLS(s.options.Config.HTTP.TLSCertificateDirectory+"/tls.crt", s.options.Config.HTTP.TLSCertificateDirectory+"/tls.key") + } + + if err == http.ErrServerClosed { + // We expect this, safe to ignore. + logger.Info("Server stopped...") + return nil + } else if err != nil { + return err + } + + logger.Info("Server stopped...") + return nil +} diff --git a/pkg/dynamicrp/integrationtest/dynamic/providers_test.go b/pkg/dynamicrp/integrationtest/dynamic/providers_test.go new file mode 100644 index 00000000000..873899fcb5e --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/providers_test.go @@ -0,0 +1,207 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dynamic + +import ( + "context" + "net/http" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/dynamicrp/testhost" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/stretchr/testify/require" +) + +const ( + radiusPlaneName = "testing" + resourceProviderNamespace = "Applications.Test" + resourceTypeName = "exampleResources" + locationName = v1.LocationGlobal + apiVersion = "2024-01-01" + + resourceGroupName = "test-group" + exampleResourceName = "my-example" + + exampleResourcePlaneID = "/planes/radius/" + radiusPlaneName + exampleResourceGroupID = exampleResourcePlaneID + "/resourceGroups/test-group" + exampleResourcePlaneCollectionURL = exampleResourcePlaneID + "/providers/Applications.Test/exampleResources?api-version=" + apiVersion + exampleResourceCollectionURL = exampleResourceGroupID + "/providers/Applications.Test/exampleResources?api-version=" + apiVersion + + exampleResourceID = exampleResourceGroupID + "/providers/Applications.Test/exampleResources/" + exampleResourceName + exampleResourceURL = exampleResourceID + "?api-version=" + apiVersion + + exampleResourceEmptyListResponseFixture = "testdata/exampleresource_v20240101preview_emptylist_responsebody.json" + exampleResourceListResponseFixture = "testdata/exampleresource_v20240101preview_list_responsebody.json" + + exampleResourceRequestFixture = "testdata/exampleresource_v20240101preview_requestbody.json" + exampleResourceResponseFixture = "testdata/exampleresource_v20240101preview_responsebody.json" +) + +// This test covers the lifecycle of a dynamic resource. +func Test_Dynamic_Resource_Lifecycle(t *testing.T) { + _, server := testhost.Start(t) + + // Setup a resource provider (Applications.Test/exampleResources) + createRadiusPlane(server) + createResourceProvider(server) + createResourceType(server) + createAPIVersion(server) + createLocation(server) + + // Setup a resource group where we can interact with the new resource type. + createResourceGroup(server) + + // List should start empty + response := server.MakeRequest(http.MethodGet, exampleResourceCollectionURL, nil) + response.EqualsFixture(200, exampleResourceEmptyListResponseFixture) + + // Getting a specific resource should return 404. + response = server.MakeRequest(http.MethodGet, exampleResourceURL, nil) + response.EqualsErrorCode(404, "NotFound") + + // Create a resource + response = server.MakeFixtureRequest(http.MethodPut, exampleResourceURL, exampleResourceRequestFixture) + response.EqualsFixture(200, exampleResourceResponseFixture) + + // List should now contain the resource + response = server.MakeRequest(http.MethodGet, exampleResourceCollectionURL, nil) + response.EqualsFixture(200, exampleResourceListResponseFixture) + + // Getting the resource should return 200 + response = server.MakeRequest(http.MethodGet, exampleResourceURL, nil) + response.EqualsFixture(200, exampleResourceResponseFixture) + + // Deleting a resource should return 200 + response = server.MakeRequest(http.MethodDelete, exampleResourceURL, nil) + response.EqualsStatusCode(200) + + // Now the resource is gone + response = server.MakeRequest(http.MethodGet, exampleResourceCollectionURL, nil) + response.EqualsFixture(200, exampleResourceEmptyListResponseFixture) + response = server.MakeRequest(http.MethodGet, exampleResourceURL, nil) + response.EqualsErrorCode(404, "NotFound") +} + +func createRadiusPlane(server *testserver.TestServer) v20231001preview.RadiusPlanesClientCreateOrUpdateResponse { + ctx := context.Background() + + plane := v20231001preview.RadiusPlaneResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.RadiusPlaneResourceProperties{ + // Note: this is a workaround. Properties is marked as a required field in + // the API. Without passing *something* here the body will be rejected. + ProvisioningState: to.Ptr(v20231001preview.ProvisioningStateSucceeded), + ResourceProviders: map[string]*string{}, + }, + } + + client := server.UCP().NewRadiusPlanesClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, plane, nil) + require.NoError(server.T(), err) + + response, err := poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) + + return response +} + +func createResourceProvider(server *testserver.TestServer) { + ctx := context.Background() + + resourceProvider := v20231001preview.ResourceProviderResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.ResourceProviderProperties{}, + } + + client := server.UCP().NewResourceProvidersClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, resourceProvider, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createResourceType(server *testserver.TestServer) { + ctx := context.Background() + + resourceType := v20231001preview.ResourceTypeResource{ + Properties: &v20231001preview.ResourceTypeProperties{ + DefaultAPIVersion: to.Ptr(apiVersion), + }, + } + + client := server.UCP().NewResourceTypesClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, resourceTypeName, resourceType, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createAPIVersion(server *testserver.TestServer) { + ctx := context.Background() + + apiVersionResource := v20231001preview.APIVersionResource{ + Properties: &v20231001preview.APIVersionProperties{}, + } + + client := server.UCP().NewAPIVersionsClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, resourceTypeName, apiVersion, apiVersionResource, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createLocation(server *testserver.TestServer) { + ctx := context.Background() + + location := v20231001preview.LocationResource{ + Properties: &v20231001preview.LocationProperties{ + ResourceTypes: map[string]*v20231001preview.LocationResourceType{ + resourceTypeName: { + APIVersions: map[string]map[string]any{ + apiVersion: map[string]any{}, + }, + }, + }, + }, + } + + client := server.UCP().NewLocationsClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, locationName, location, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createResourceGroup(server *testserver.TestServer) { + ctx := context.Background() + + resourceGroup := v20231001preview.ResourceGroupResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.ResourceGroupProperties{}, + } + + client := server.UCP().NewResourceGroupsClient() + _, err := client.CreateOrUpdate(ctx, radiusPlaneName, resourceGroupName, resourceGroup, nil) + require.NoError(server.T(), err) +} diff --git a/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_emptylist_responsebody.json b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_emptylist_responsebody.json new file mode 100644 index 00000000000..bcd37241563 --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_emptylist_responsebody.json @@ -0,0 +1,3 @@ +{ + "value": [] +} \ No newline at end of file diff --git a/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_list_responsebody.json b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_list_responsebody.json new file mode 100644 index 00000000000..63f08b2e411 --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_list_responsebody.json @@ -0,0 +1,14 @@ +{ + "value": [ + { + "id": "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleResources/my-example", + "name": "my-example", + "type": "Applications.Test/exampleResources", + "location": "global", + "properties": { + "message": "this is a very cool user-defined-type", + "provisioningState": "Succeeded" + } + } + ] +} \ No newline at end of file diff --git a/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_requestbody.json b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_requestbody.json new file mode 100644 index 00000000000..3fbe33abe3c --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_requestbody.json @@ -0,0 +1,6 @@ +{ + "location": "global", + "properties": { + "message": "this is a very cool user-defined-type" + } +} \ No newline at end of file diff --git a/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_responsebody.json b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_responsebody.json new file mode 100644 index 00000000000..fcc99fdfa2d --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/testdata/exampleresource_v20240101preview_responsebody.json @@ -0,0 +1,10 @@ +{ + "id": "/planes/radius/testing/resourcegroups/test-group/providers/Applications.Test/exampleResources/my-example", + "name": "my-example", + "type": "Applications.Test/exampleResources", + "location": "global", + "properties": { + "message": "this is a very cool user-defined-type", + "provisioningState": "Succeeded" + } +} \ No newline at end of file diff --git a/pkg/dynamicrp/integrationtest/dynamic/util_test.go b/pkg/dynamicrp/integrationtest/dynamic/util_test.go new file mode 100644 index 00000000000..15788c71361 --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/util_test.go @@ -0,0 +1,56 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dynamic + +import ( + "context" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" + "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/stretchr/testify/require" +) + +func PutPlane(t *testing.T, ts *testserver.TestServer) { + connection, err := sdk.NewDirectConnection(ts.BaseURL) + require.NoError(t, err) + + clientOptions := sdk.NewClientOptions(connection) + + client, err := v20231001preview.NewRadiusPlanesClient(&aztoken.AnonymousCredential{}, clientOptions) + require.NoError(t, err) + + plane := v20231001preview.RadiusPlaneResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.RadiusPlaneResourceProperties{ + // Note: this is a workaround. Properties is marked as a required field in + // the API. Without passing *something* here the body will be rejected. + ProvisioningState: to.Ptr(v20231001preview.ProvisioningStateSucceeded), + ResourceProviders: map[string]*string{}, + }, + } + + poller, err := client.BeginCreateOrUpdate(context.Background(), "local", plane, nil) + require.NoError(t, err) + + _, err = poller.PollUntilDone(context.Background(), nil) + require.NoError(t, err) +} diff --git a/pkg/dynamicrp/options.go b/pkg/dynamicrp/options.go new file mode 100644 index 00000000000..59d2d692429 --- /dev/null +++ b/pkg/dynamicrp/options.go @@ -0,0 +1,91 @@ +/* +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 dynamicrp + +import ( + "context" + "fmt" + + "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" + "github.com/radius-project/radius/pkg/kubeutil" + "github.com/radius-project/radius/pkg/sdk" + ucpconfig "github.com/radius-project/radius/pkg/ucp/config" + "github.com/radius-project/radius/pkg/ucp/dataprovider" + queueprovider "github.com/radius-project/radius/pkg/ucp/queue/provider" + secretprovider "github.com/radius-project/radius/pkg/ucp/secret/provider" + kube_rest "k8s.io/client-go/rest" +) + +// Options holds the configuration options and shared services for the server. +type Options struct { + // Config is the configuration for the server. + Config *Config + + // QueueProvider provides access to the message queue client. + QueueProvider *queueprovider.QueueProvider + + // SecretProvider provides access to the secret storage system. + SecretProvider *secretprovider.SecretProvider + + // StatusManager implements operations on async operation statuses. + StatusManager statusmanager.StatusManager + + // StorageProvider provides access to the data storage system. + StorageProvider dataprovider.DataStorageProvider + + // UCP is the connection to UCP + UCP sdk.Connection +} + +// NewOptions creates a new Options instance from the given configuration. +func NewOptions(ctx context.Context, config *Config) (*Options, error) { + var err error + options := Options{ + Config: config, + } + + options.QueueProvider = queueprovider.New(config.Queue) + options.SecretProvider = secretprovider.NewSecretProvider(config.Secrets) + options.StorageProvider = dataprovider.NewStorageProvider(config.Storage) + + queueClient, err := options.QueueProvider.GetClient(ctx) + if err != nil { + return nil, err + } + + options.StatusManager = statusmanager.New(options.StorageProvider, queueClient, config.Environment.RoleLocation) + + var cfg *kube_rest.Config + if config.UCP.Kind == ucpconfig.UCPConnectionKindKubernetes { + cfg, err = kubeutil.NewClientConfig(&kubeutil.ConfigOptions{ + // TODO: Allow to use custom context via configuration. - https://github.com/radius-project/radius/issues/5433 + ContextName: "", + QPS: kubeutil.DefaultServerQPS, + Burst: kubeutil.DefaultServerBurst, + }) + if err != nil { + return nil, fmt.Errorf("failed to get kubernetes config: %w", err) + } + } + + options.UCP, err = ucpconfig.NewConnectionFromUCPConfig(&config.UCP, cfg) + if err != nil { + return nil, err + } + + return &options, nil +} diff --git a/pkg/dynamicrp/server/doc.go b/pkg/dynamicrp/server/doc.go new file mode 100644 index 00000000000..7d0951df618 --- /dev/null +++ b/pkg/dynamicrp/server/doc.go @@ -0,0 +1,19 @@ +/* +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. +*/ + +// server holds the server implementation for the dynamicrp. +// This package is the entry-point for interacting with the server. +package server diff --git a/pkg/dynamicrp/server/server.go b/pkg/dynamicrp/server/server.go new file mode 100644 index 00000000000..6a3f09bf23b --- /dev/null +++ b/pkg/dynamicrp/server/server.go @@ -0,0 +1,79 @@ +/* +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 server + +import ( + "time" + + "github.com/radius-project/radius/pkg/dynamicrp" + "github.com/radius-project/radius/pkg/dynamicrp/backend" + "github.com/radius-project/radius/pkg/dynamicrp/frontend" + metricsservice "github.com/radius-project/radius/pkg/metrics/service" + profilerservice "github.com/radius-project/radius/pkg/profiler/service" + "github.com/radius-project/radius/pkg/trace" + "github.com/radius-project/radius/pkg/ucp/data" + "github.com/radius-project/radius/pkg/ucp/dataprovider" + "github.com/radius-project/radius/pkg/ucp/hosting" +) + +const ( + HTTPServerStopTimeout = time.Second * 10 + ServiceName = "dynamic-rp" +) + +const UCPProviderName = "System.Resources" + +// NewServer creates a new hosting.Host instance with services for API, EmbeddedETCD, Metrics, Profiler and Backend (if +// enabled) based on the given Options. +func NewServer(options *dynamicrp.Options) (*hosting.Host, error) { + services := []hosting.Service{} + + // In-memory ETCD requires a service running in the process. + if options.Config.Storage.Provider == dataprovider.TypeETCD && + options.Config.Storage.ETCD.InMemory { + services = append(services, data.NewEmbeddedETCDService(data.EmbeddedETCDServiceOptions{ClientConfigSink: options.Config.Storage.ETCD.Client})) + } + + // Metrics is provided via a service. + if options.Config.Metrics.Prometheus.Enabled { + services = append(services, metricsservice.NewService(metricsservice.HostOptions{ + Config: &options.Config.Metrics, + })) + } + + // Profiling is provided via a service. + if options.Config.Profiler.Enabled { + services = append(services, profilerservice.NewService(profilerservice.HostOptions{ + Config: &options.Config.Profiler, + })) + } + + // Tracing is provided via a service. + if options.Config.Tracing.ServiceName != "" { + services = append(services, &trace.Service{Options: options.Config.Tracing}) + } + + // TODO: configure frontend + services = append(services, frontend.NewService(options)) + + // TODO: configure backend + services = append(services, backend.NewService(options)) + + return &hosting.Host{ + Services: services, + }, nil +} diff --git a/pkg/dynamicrp/testhost/doc.go b/pkg/dynamicrp/testhost/doc.go new file mode 100644 index 00000000000..bdd65e049e9 --- /dev/null +++ b/pkg/dynamicrp/testhost/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// testhost provides an implementation of a test server for the dynamic RP. +package testhost diff --git a/pkg/dynamicrp/testhost/host.go b/pkg/dynamicrp/testhost/host.go new file mode 100644 index 00000000000..0ee54ed9271 --- /dev/null +++ b/pkg/dynamicrp/testhost/host.go @@ -0,0 +1,170 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package testhost + +import ( + "context" + "fmt" + "net" + "net/http" + "sync" + "testing" + + "github.com/google/uuid" + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/armrpc/hostoptions" + "github.com/radius-project/radius/pkg/dynamicrp" + "github.com/radius-project/radius/pkg/dynamicrp/server" + "github.com/radius-project/radius/pkg/ucp/config" + "github.com/radius-project/radius/pkg/ucp/dataprovider" + "github.com/radius-project/radius/pkg/ucp/frontend/api" + "github.com/radius-project/radius/pkg/ucp/frontend/modules" + "github.com/radius-project/radius/pkg/ucp/hosting" + "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + queueprovider "github.com/radius-project/radius/pkg/ucp/queue/provider" + secretprovider "github.com/radius-project/radius/pkg/ucp/secret/provider" + "github.com/radius-project/radius/test/testcontext" + "github.com/stretchr/testify/require" +) + +func Start(t *testing.T) (*TestHost, *testserver.TestServer) { + config := &dynamicrp.Config{ + Environment: hostoptions.EnvironmentOptions{ + Name: "test", + RoleLocation: v1.LocationGlobal, + }, + HTTP: dynamicrp.HTTPServerConfig{ + // Initialized dynamically when the server is started. + }, + Queue: queueprovider.QueueProviderOptions{ + Provider: queueprovider.TypeInmemory, + }, + Secrets: secretprovider.SecretProviderOptions{ + Provider: secretprovider.TypeInMemorySecret, + }, + Storage: dataprovider.StorageProviderOptions{ + Provider: dataprovider.TypeInMemory, + }, + UCP: config.UCPOptions{ + Kind: config.UCPConnectionKindDirect, + Direct: &config.UCPDirectConnectionOptions{ + Endpoint: "http://localhost:8080", + }, + }, + } + + options, err := dynamicrp.NewOptions(context.Background(), config) + require.NoError(t, err) + + return StartWithOptions(t, options) +} + +func StartWithOptions(t *testing.T, options *dynamicrp.Options) (*TestHost, *testserver.TestServer) { + // Allocate a random free port. + listener, err := net.Listen("tcp", ":0") + require.NoError(t, err, "failed to allocate port") + + address := fmt.Sprintf("localhost:%d", listener.Addr().(*net.TCPAddr).Port) + err = listener.Close() + require.NoError(t, err, "failed to close listener") + + options.Config.HTTP.Address = address + options.Config.HTTP.PathBase = "/" + uuid.New().String() + baseURL := fmt.Sprintf("http://%s%s", options.Config.HTTP.Address, options.Config.HTTP.PathBase) + + host, err := server.NewServer(options) + require.NoError(t, err, "failed to create server") + + ctx, cancel := context.WithCancel(testcontext.New(t)) + errs, messages := host.RunAsync(ctx) + + go func() { + for msg := range messages { + t.Logf("Message: %s", msg) + } + }() + + th := &TestHost{ + baseURL: baseURL, + host: host, + messages: messages, + cancel: cancel, + stoppedChan: errs, + t: t, + } + t.Cleanup(th.Close) + + return th, startUCP(t, baseURL) +} + +func startUCP(t *testing.T, url string) *testserver.TestServer { + return testserver.StartWithETCD(t, func(options modules.Options) []modules.Initializer { + options.DynamicRP.URL = url + return api.DefaultModules(options) + }) +} + +// TestHost is a test server for the dynamic RP. Do not construct this type directly, use one of the +// Start functions. +type TestHost struct { + // baseURL is the base URL of the server, including the path base. + baseURL string + + // host is the hosting process running the component. + host *hosting.Host + + // messages is the channel that will receive lifecycle messages from the host. + messages <-chan hosting.LifecycleMessage + + // cancel is the function to call to stop the server. + cancel context.CancelFunc + + // stoppedChan is the channel that will be closed when the server has stopped. + stoppedChan <-chan error + + // shutdown is used to ensure that Close is only called once. + shutdown sync.Once + + // t is the testing.T instance to use for assertions. + t *testing.T +} + +// Close shuts down the server and will block until shutdown completes. +func (th *TestHost) Close() { + // We're being picking about resource cleanup here, because unless we are picky we hit scalability + // problems in tests pretty quickly. + th.shutdown.Do(func() { + // Shut down the host. + th.cancel() + + if th.stoppedChan != nil { + <-th.stoppedChan // host stopped + } + }) +} + +// BaseURL returns the base URL of the server, including the path base. +// +// This should be used as a URL prefix for all requests to the server. +func (th *TestHost) BaseURL() string { + return th.baseURL +} + +// Client returns the HTTP client to use to make requests to the server. +func (th *TestHost) Client() *http.Client { + return http.DefaultClient +} diff --git a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go index 5623d88518d..3a628d12e1b 100644 --- a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go +++ b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess.go @@ -30,7 +30,6 @@ import ( "github.com/radius-project/radius/pkg/ucp/store" "github.com/radius-project/radius/pkg/ucp/trackedresource" "github.com/radius-project/radius/pkg/ucp/ucplog" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) var _ ctrl.Controller = (*TrackedResourceProcessController)(nil) @@ -48,9 +47,11 @@ type TrackedResourceProcessController struct { } // NewTrackedResourceProcessController creates a new TrackedResourceProcessController controller which is used to process resources asynchronously. -func NewTrackedResourceProcessController(opts ctrl.Options) (ctrl.Controller, error) { - transport := otelhttp.NewTransport(http.DefaultTransport) - return &TrackedResourceProcessController{ctrl.NewBaseAsyncController(opts), trackedresource.NewUpdater(opts.StorageClient, &http.Client{Transport: transport})}, nil +func NewTrackedResourceProcessController(opts ctrl.Options, transport http.RoundTripper) (ctrl.Controller, error) { + return &TrackedResourceProcessController{ + BaseController: ctrl.NewBaseAsyncController(opts), + updater: trackedresource.NewUpdater(opts.StorageClient, &http.Client{Transport: transport}), + }, nil } // Run retrieves a resource from storage, parses the resource ID, and updates our tracked resource entry in the background. @@ -67,7 +68,7 @@ func (c *TrackedResourceProcessController) Run(ctx context.Context, request *ctr return ctrl.Result{}, err } - downstreamURL, err := resourcegroups.ValidateDownstream(ctx, c.StorageClient(), originalID) + downstreamURL, err := resourcegroups.ValidateDownstream(ctx, c.StorageClient(), originalID, v1.LocationGlobal, resource.Properties.APIVersion) if errors.Is(err, &resourcegroups.NotFoundError{}) { return ctrl.NewFailedResult(v1.ErrorDetails{Code: v1.CodeNotFound, Message: err.Error(), Target: request.ResourceID}), nil } else if errors.Is(err, &resourcegroups.InvalidError{}) { @@ -78,6 +79,7 @@ func (c *TrackedResourceProcessController) Run(ctx context.Context, request *ctr logger := ucplog.FromContextOrDiscard(ctx) logger.Info("Processing tracked resource", "resourceID", originalID) + err = c.updater.Update(ctx, downstreamURL.String(), originalID, resource.Properties.APIVersion) if errors.Is(err, &trackedresource.InProgressErr{}) { // The resource is still being processed, so we can sleep for a while. diff --git a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go index 1958ace976d..5721af97bc6 100644 --- a/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go +++ b/pkg/ucp/backend/controller/resourcegroups/trackedresourceprocess_test.go @@ -32,12 +32,16 @@ import ( "go.uber.org/mock/gomock" ) +const ( + location = "global" +) + func Test_Run(t *testing.T) { setup := func(t *testing.T) (*TrackedResourceProcessController, *mockUpdater, *store.MockStorageClient) { ctrl := gomock.NewController(t) storageClient := store.NewMockStorageClient(ctrl) - pc, err := NewTrackedResourceProcessController(controller.Options{StorageClient: storageClient}) + pc, err := NewTrackedResourceProcessController(controller.Options{StorageClient: storageClient}, nil) require.NoError(t, err) updater := mockUpdater{} @@ -47,6 +51,10 @@ func Test_Run(t *testing.T) { id := resources.MustParse("/planes/test/local/resourceGroups/test-rg/providers/Applications.Test/testResources/my-resource") trackingID := trackedresource.IDFor(id) + providerID, err := datamodel.ResourceProviderIDFromResourceID(id) + require.NoError(t, err) + + locationID := providerID.Append(resources.TypeSegment{Type: "locations", Name: location}) plane := datamodel.RadiusPlane{ Properties: datamodel.RadiusPlaneProperties{ @@ -70,6 +78,10 @@ func Test_Run(t *testing.T) { Get(gomock.Any(), "/planes/"+trackingID.PlaneNamespace(), gomock.Any()). Return(&store.Object{Data: plane}, nil).Times(1) + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) + storageClient.EXPECT(). Get(gomock.Any(), trackingID.RootScope(), gomock.Any()). Return(&store.Object{Data: resourceGroup}, nil).Times(1) @@ -90,6 +102,10 @@ func Test_Run(t *testing.T) { Get(gomock.Any(), "/planes/"+trackingID.PlaneNamespace(), gomock.Any()). Return(&store.Object{Data: plane}, nil).Times(1) + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) + storageClient.EXPECT(). Get(gomock.Any(), trackingID.RootScope(), gomock.Any()). Return(&store.Object{Data: resourceGroup}, nil).Times(1) diff --git a/pkg/ucp/backend/service.go b/pkg/ucp/backend/service.go index b325a3bdcf1..d0420143192 100644 --- a/pkg/ucp/backend/service.go +++ b/pkg/ucp/backend/service.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "net/http" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" @@ -30,6 +31,7 @@ import ( "github.com/radius-project/radius/pkg/ucp/backend/controller/resourcegroups" "github.com/radius-project/radius/pkg/ucp/backend/controller/resourceproviders" "github.com/radius-project/radius/pkg/ucp/datamodel" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) const ( @@ -44,7 +46,7 @@ type Service struct { // NewService creates new service instance to run AsyncRequestProcessWorker. func NewService(options hostoptions.HostOptions) *Service { return &Service{ - worker.Service{ + Service: worker.Service{ ProviderName: UCPProviderName, Options: options, }, @@ -77,7 +79,8 @@ func (w *Service) Run(ctx context.Context) error { DataProvider: w.StorageProvider, } - err := RegisterControllers(ctx, w.Controllers, w.Options.UCPConnection, opts) + transport := otelhttp.NewTransport(http.DefaultTransport) + err := RegisterControllers(ctx, w.Controllers, w.Options.UCPConnection, transport, opts) if err != nil { return err } @@ -86,9 +89,11 @@ func (w *Service) Run(ctx context.Context) error { } // RegisterControllers registers the controllers for the UCP backend. -func RegisterControllers(ctx context.Context, registry *worker.ControllerRegistry, connection sdk.Connection, opts ctrl.Options) error { +func RegisterControllers(ctx context.Context, registry *worker.ControllerRegistry, connection sdk.Connection, transport http.RoundTripper, opts ctrl.Options) error { // Tracked resources - err := errors.Join(nil, registry.Register(ctx, v20231001preview.ResourceType, v1.OperationMethod(datamodel.OperationProcess), resourcegroups.NewTrackedResourceProcessController, opts)) + err := errors.Join(nil, registry.Register(ctx, v20231001preview.ResourceType, v1.OperationMethod(datamodel.OperationProcess), func(opts ctrl.Options) (ctrl.Controller, error) { + return resourcegroups.NewTrackedResourceProcessController(opts, transport) + }, opts)) // Resource providers and related types err = errors.Join(err, registry.Register(ctx, datamodel.ResourceProviderResourceType, v1.OperationPut, func(opts ctrl.Options) (ctrl.Controller, error) { diff --git a/pkg/ucp/datamodel/genericresource.go b/pkg/ucp/datamodel/genericresource.go index 1def4b001bc..3259d49a0cd 100644 --- a/pkg/ucp/datamodel/genericresource.go +++ b/pkg/ucp/datamodel/genericresource.go @@ -24,6 +24,7 @@ import ( const ( // OperationProcess is the operation type for processing a tracked resource. OperationProcess = "PROCESS" + // GenericResourceType is the resource type for a generic resource. GenericResourceType = "System.Resources/resources" ) diff --git a/pkg/ucp/datamodel/location.go b/pkg/ucp/datamodel/location.go index f4ea49999f3..c1473d6f9d4 100644 --- a/pkg/ucp/datamodel/location.go +++ b/pkg/ucp/datamodel/location.go @@ -21,6 +21,9 @@ import v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" const ( // LocationResourceType is the resource type for a resource provider location LocationResourceType = "System.Resources/resourceProviders/locations" + + // LocationUnqualifiedResourceType is the unqualified resource type for a resource provider location + LocationUnqualifiedResourceType = "locations" ) // Location represents a location. diff --git a/pkg/ucp/datamodel/resourceprovider_util.go b/pkg/ucp/datamodel/resourceprovider_util.go new file mode 100644 index 00000000000..846ae9c4ff6 --- /dev/null +++ b/pkg/ucp/datamodel/resourceprovider_util.go @@ -0,0 +1,32 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package datamodel + +import "github.com/radius-project/radius/pkg/ucp/resources" + +// ResourceProviderIDFromResourceID converts an inbound resource id to the resource ID +// of the resource provider. +func ResourceProviderIDFromResourceID(id resources.ID) (resources.ID, error) { + // Ex: + // /planes/radius/local/providers/Applications.Test/testResources/foo + // => /planes/radius/local/providers/System.Resources/resourceProviders/Applications.Test + return resources.ParseResource( + id.PlaneScope() + + resources.SegmentSeparator + resources.ProvidersSegment + + resources.SegmentSeparator + ResourceProviderResourceType + + resources.SegmentSeparator + id.ProviderNamespace()) +} diff --git a/pkg/ucp/frontend/controller/radius/proxy.go b/pkg/ucp/frontend/controller/radius/proxy.go index 8f2bbdfd3a5..e6f3121eb91 100644 --- a/pkg/ucp/frontend/controller/radius/proxy.go +++ b/pkg/ucp/frontend/controller/radius/proxy.go @@ -25,6 +25,7 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" armrpc_controller "github.com/radius-project/radius/pkg/armrpc/frontend/controller" @@ -37,7 +38,6 @@ import ( "github.com/radius-project/radius/pkg/ucp/store" "github.com/radius-project/radius/pkg/ucp/trackedresource" "github.com/radius-project/radius/pkg/ucp/ucplog" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) const ( @@ -55,7 +55,7 @@ const ( ) type updater interface { - Update(ctx context.Context, downstream string, id resources.ID, version string) error + Update(ctx context.Context, downstreamURL string, originalID resources.ID, version string) error } var _ armrpc_controller.Controller = (*ProxyController)(nil) @@ -64,6 +64,9 @@ var _ armrpc_controller.Controller = (*ProxyController)(nil) type ProxyController struct { armrpc_controller.Operation[*datamodel.RadiusPlane, datamodel.RadiusPlane] + // defaultDownstrmmmm /// TODO SET ME + defaultDownstream *url.URL + // transport is the http.RoundTripper to use for proxying requests. Can be overridden for testing. transport http.RoundTripper @@ -71,17 +74,20 @@ type ProxyController struct { updater updater } -// # Function Explanation -// // NewProxyController creates a new ProxyPlane controller with the given options and returns it, or returns an error if the // controller cannot be created. -func NewProxyController(opts armrpc_controller.Options) (armrpc_controller.Controller, error) { - transport := otelhttp.NewTransport(http.DefaultTransport) +func NewProxyController(opts armrpc_controller.Options, transport http.RoundTripper, defaultDownstream string) (armrpc_controller.Controller, error) { + parsedDefaultDownstream, err := url.Parse(defaultDownstream) + if err != nil { + return nil, fmt.Errorf("failed to parse default downstream URL: %w", err) + } + updater := trackedresource.NewUpdater(opts.StorageClient, &http.Client{Transport: transport}) return &ProxyController{ - Operation: armrpc_controller.NewOperation(opts, armrpc_controller.ResourceOptions[datamodel.RadiusPlane]{}), - transport: transport, - updater: updater, + Operation: armrpc_controller.NewOperation(opts, armrpc_controller.ResourceOptions[datamodel.RadiusPlane]{}), + transport: transport, + defaultDownstream: parsedDefaultDownstream, + updater: updater, }, nil } @@ -100,7 +106,14 @@ func (p *ProxyController) Run(ctx context.Context, w http.ResponseWriter, req *h id := requestCtx.ResourceID relativePath := middleware.GetRelativePath(p.Options().PathBase, requestCtx.OriginalURL.Path) - downstreamURL, err := resourcegroups.ValidateDownstream(ctx, p.StorageClient(), id) + apiVersion := requestCtx.APIVersion + if apiVersion == "" { + message := "the api-version query parameter is required" + response := v1.ErrorResponse{Error: v1.ErrorDetails{Code: v1.CodeInvalid, Message: message, Target: id.String()}} + return armrpc_rest.NewBadRequestARMResponse(response), nil + } + + downstreamURL, err := resourcegroups.ValidateDownstream(ctx, p.StorageClient(), id, v1.LocationGlobal, apiVersion) if errors.Is(err, &resourcegroups.NotFoundError{}) { return armrpc_rest.NewNotFoundResponse(id), nil } else if errors.Is(err, &resourcegroups.InvalidError{}) { @@ -110,13 +123,16 @@ func (p *ProxyController) Run(ctx context.Context, w http.ResponseWriter, req *h return nil, fmt.Errorf("failed to validate downstream: %w", err) } + if downstreamURL == nil { + downstreamURL = p.defaultDownstream + } + proxyReq, err := p.PrepareProxyRequest(ctx, req, downstreamURL.String(), relativePath) if err != nil { return nil, err } interceptor := &responseInterceptor{Inner: p.transport} - sender := proxy.NewARMProxy(proxy.ReverseProxyOptions{RoundTripper: interceptor}, downstreamURL, nil) sender.ServeHTTP(w, proxyReq) @@ -192,6 +208,9 @@ func (p *ProxyController) PrepareProxyRequest(ctx context.Context, originalReq * proxyReq.Header.Set("X-Forwarded-Proto", refererURL.Scheme) proxyReq.Header.Set(v1.RefererHeader, refererURL.String()) + // Clear route context, we don't want to inherit any state from Chi. + proxyReq = proxyReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, nil)) + return proxyReq, nil } @@ -220,8 +239,8 @@ func (p *ProxyController) IsTerminalResponse(resp *http.Response) bool { } // UpdateTrackedResource updates the tracked resource synchronously. -func (p *ProxyController) UpdateTrackedResource(ctx context.Context, downstream string, id resources.ID, apiVersion string) error { - return p.updater.Update(ctx, downstream, id, apiVersion) +func (p *ProxyController) UpdateTrackedResource(ctx context.Context, downstreamURL string, originalID resources.ID, version string) error { + return p.updater.Update(ctx, downstreamURL, originalID, version) } // EnqueueTrackedResourceUpdate enqueues an async operation to update the tracked resource. diff --git a/pkg/ucp/frontend/controller/radius/proxy_test.go b/pkg/ucp/frontend/controller/radius/proxy_test.go index f596210c8a3..04f3a15d0f4 100644 --- a/pkg/ucp/frontend/controller/radius/proxy_test.go +++ b/pkg/ucp/frontend/controller/radius/proxy_test.go @@ -37,6 +37,11 @@ import ( "go.uber.org/mock/gomock" ) +const ( + apiVersion = "2025-01-01" + location = "global" +) + // The Run function is also tested by integration tests in the pkg/ucp/integrationtests/radius package. func createController(t *testing.T) (*ProxyController, *store.MockStorageClient, *mockUpdater, *mockRoundTripper, *statusmanager.MockStatusManager) { @@ -44,21 +49,28 @@ func createController(t *testing.T) (*ProxyController, *store.MockStorageClient, storageClient := store.NewMockStorageClient(ctrl) statusManager := statusmanager.NewMockStatusManager(ctrl) - p, err := NewProxyController(controller.Options{StorageClient: storageClient, StatusManager: statusManager}) + roundTripper := mockRoundTripper{} + + p, err := NewProxyController( + controller.Options{StorageClient: storageClient, StatusManager: statusManager}, + &roundTripper, + "http://localhost:1234") require.NoError(t, err) updater := mockUpdater{} - roundTripper := mockRoundTripper{} pc := p.(*ProxyController) pc.updater = &updater - pc.transport = &roundTripper return pc, storageClient, &updater, &roundTripper, statusManager } func Test_Run(t *testing.T) { id := resources.MustParse("/planes/test/local/resourceGroups/test-rg/providers/Applications.Test/testResources/my-resource") + providerID, err := datamodel.ResourceProviderIDFromResourceID(id) + require.NoError(t, err) + + locationID := providerID.Append(resources.TypeSegment{Type: "locations", Name: location}) plane := datamodel.RadiusPlane{ Properties: datamodel.RadiusPlaneProperties{ @@ -73,6 +85,7 @@ func Test_Run(t *testing.T) { p, storageClient, _, roundTripper, _ := createController(t) svcContext := &v1.ARMRequestContext{ + APIVersion: apiVersion, ResourceID: id, } ctx := testcontext.New(t) @@ -81,7 +94,11 @@ func Test_Run(t *testing.T) { w := httptest.NewRecorder() // Not a mutating request - req := httptest.NewRequest(http.MethodGet, id.String(), nil) + req := httptest.NewRequest(http.MethodGet, id.String()+"?api-version="+apiVersion, nil) + + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). @@ -104,6 +121,7 @@ func Test_Run(t *testing.T) { p, storageClient, updater, roundTripper, _ := createController(t) svcContext := &v1.ARMRequestContext{ + APIVersion: apiVersion, ResourceID: id, } ctx := testcontext.New(t) @@ -112,7 +130,11 @@ func Test_Run(t *testing.T) { w := httptest.NewRecorder() // Mutating request that will complete synchronously - req := httptest.NewRequest(http.MethodDelete, id.String(), nil) + req := httptest.NewRequest(http.MethodDelete, id.String()+"?api-version="+apiVersion, nil) + + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). @@ -138,6 +160,7 @@ func Test_Run(t *testing.T) { p, storageClient, updater, roundTripper, statusManager := createController(t) svcContext := &v1.ARMRequestContext{ + APIVersion: apiVersion, ResourceID: id, } ctx := testcontext.New(t) @@ -146,7 +169,11 @@ func Test_Run(t *testing.T) { w := httptest.NewRecorder() // Mutating request that will complete synchronously - req := httptest.NewRequest(http.MethodDelete, id.String(), nil) + req := httptest.NewRequest(http.MethodDelete, id.String()+"?api-version="+apiVersion, nil) + + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). @@ -184,6 +211,7 @@ func Test_Run(t *testing.T) { p, storageClient, updater, roundTripper, _ := createController(t) svcContext := &v1.ARMRequestContext{ + APIVersion: apiVersion, ResourceID: id, } ctx := testcontext.New(t) @@ -192,7 +220,11 @@ func Test_Run(t *testing.T) { w := httptest.NewRecorder() // Mutating request that will complete asynchronously - req := httptest.NewRequest(http.MethodDelete, id.String(), nil) + req := httptest.NewRequest(http.MethodDelete, id.String()+"?api-version="+apiVersion, nil) + + storageClient.EXPECT(). + Get(gomock.Any(), locationID.String(), gomock.Any()). + Return(nil, &store.ErrNotFound{}).Times(1) storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). @@ -237,13 +269,14 @@ func Test_Run(t *testing.T) { p, storageClient, _, _, _ := createController(t) svcContext := &v1.ARMRequestContext{ + APIVersion: apiVersion, ResourceID: id, } ctx := testcontext.New(t) ctx = v1.WithARMRequestContext(ctx, svcContext) w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPut, id.String(), nil) + req := httptest.NewRequest(http.MethodPut, id.String()+"?api-version="+apiVersion, nil) storageClient.EXPECT(). Get(gomock.Any(), "/planes/"+id.PlaneNamespace(), gomock.Any()). diff --git a/pkg/ucp/frontend/controller/resourcegroups/util.go b/pkg/ucp/frontend/controller/resourcegroups/util.go index ac8117e0eef..b2b267799ba 100644 --- a/pkg/ucp/frontend/controller/resourcegroups/util.go +++ b/pkg/ucp/frontend/controller/resourcegroups/util.go @@ -13,6 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + package resourcegroups import ( @@ -20,6 +21,7 @@ import ( "errors" "fmt" "net/url" + "strings" "github.com/radius-project/radius/pkg/ucp/datamodel" "github.com/radius-project/radius/pkg/ucp/resources" @@ -59,15 +61,14 @@ func (e *InvalidError) Is(err error) bool { return ok } -// ValidateDownstream can be used to find and validate the downstream URL for a resource. -// Returns NotFoundError for the case where the plane or resource group does not exist. -// Returns InvalidError for cases where the data is invalid, like when the resource provider is not configured. -func ValidateDownstream(ctx context.Context, client store.StorageClient, id resources.ID) (*url.URL, error) { +// ValidateRadiusPlane validates that the plane specified in the id exists. Returns NotFoundError if the plane does not exist. +func ValidateRadiusPlane(ctx context.Context, client store.StorageClient, id resources.ID) (*datamodel.RadiusPlane, error) { planeID, err := resources.ParseScope(id.PlaneScope()) if err != nil { // Not expected to happen. return nil, err } + plane, err := store.GetResource[datamodel.RadiusPlane](ctx, client, planeID.String()) if errors.Is(err, &store.ErrNotFound{}) { return nil, &NotFoundError{Message: fmt.Sprintf("plane %q not found", planeID.String())} @@ -75,22 +76,101 @@ func ValidateDownstream(ctx context.Context, client store.StorageClient, id reso return nil, fmt.Errorf("failed to find plane %q: %w", planeID.String(), err) } + return plane, nil +} + +// ValidateResourceGroup validates that the resource group specified in the id exists (if applicable). +// Returns NotFoundError if the resource group does not exist. +func ValidateResourceGroup(ctx context.Context, client store.StorageClient, id resources.ID) error { // If the ID contains a resource group, validate it now. - if id.FindScope(resources_radius.ScopeResourceGroups) != "" { - resourceGroupID, err := resources.ParseScope(id.RootScope()) - if err != nil { - // Not expected to happen. - return nil, err - } + if id.FindScope(resources_radius.ScopeResourceGroups) == "" { + return nil + } + + resourceGroupID, err := resources.ParseScope(id.RootScope()) + if err != nil { + // Not expected to happen. + return err + } + + _, err = store.GetResource[datamodel.ResourceGroup](ctx, client, resourceGroupID.String()) + if errors.Is(err, &store.ErrNotFound{}) { + return &NotFoundError{Message: fmt.Sprintf("resource group %q not found", resourceGroupID.String())} + } else if err != nil { + return fmt.Errorf("failed to find resource group %q: %w", resourceGroupID.String(), err) + } + + return nil +} + +// ValidateResourceType performs semantic validation of a proxy request against registered +// resource types. +// +// Returns NotFoundError if the resource type does not exist. +// Returns InvalidError if the request cannot be routed due to an invalid configuration. +func ValidateResourceType(ctx context.Context, client store.StorageClient, id resources.ID, locationName string, apiVersion string) (*url.URL, error) { + // The strategy is to try and look up the location resource, and validate that it supports + // the requested resource type and API version. + + providerID, err := datamodel.ResourceProviderIDFromResourceID(id) + if err != nil { + return nil, err + } - _, err = store.GetResource[datamodel.ResourceGroup](ctx, client, resourceGroupID.String()) - if errors.Is(err, &store.ErrNotFound{}) { - return nil, &NotFoundError{Message: fmt.Sprintf("resource group %q not found", resourceGroupID.String())} - } else if err != nil { - return nil, fmt.Errorf("failed to find resource group %q: %w", resourceGroupID.String(), err) + locationID := providerID.Append(resources.TypeSegment{Type: datamodel.LocationUnqualifiedResourceType, Name: locationName}) + location, err := store.GetResource[datamodel.Location](ctx, client, locationID.String()) + if errors.Is(err, &store.ErrNotFound{}) { + + // Return the error as-is to fallback to the legacy routing behavior. + return nil, err + + // Uncomment this when we remove the legacy routing behavior. + // return nil, &InvalidError{Message: fmt.Sprintf("location %q not found for resource provider %q", locationName, id.ProviderNamespace())} + } else if err != nil { + return nil, fmt.Errorf("failed to find location %q: %w", locationID.String(), err) + } + + // Check if the location supports the resource type. + // Resource types are case-intensitive so we have to iterate. + var resourceType *datamodel.LocationResourceTypeConfiguration + search := strings.TrimPrefix(strings.ToLower(id.Type()), strings.ToLower(id.ProviderNamespace())+resources.SegmentSeparator) + for name, rt := range location.Properties.ResourceTypes { + if strings.EqualFold(name, search) { + copy := rt + resourceType = © + break } } + if resourceType == nil { + return nil, &InvalidError{Message: fmt.Sprintf("resource type %q not supported by location %q", id.Type(), locationName)} + } + + // Now check if the location supports the resource type. If it does, we can return the downstream URL. + _, ok := resourceType.APIVersions[apiVersion] + if !ok { + return nil, &InvalidError{Message: fmt.Sprintf("api version %q is not supported for resource type %q by location %q", apiVersion, id.Type(), locationName)} + } + + // If we get here, the we're all good. + // + // The address might be nil which means that we're using the default address (dynamic RP) + if location.Properties.Address == nil { + return nil, nil + } + + // If the address was provided, then use that instead. + u, err := url.Parse(*location.Properties.Address) + if err != nil { + return nil, &InvalidError{Message: fmt.Sprintf("failed to parse location address: %v", err.Error())} + } + + return u, nil +} + +// ValidateLegacyResourceProvider validates that the resource provider specified in the id exists. Returns InvalidError if the plane +// contains invalid data. +func ValidateLegacyResourceProvider(ctx context.Context, client store.StorageClient, id resources.ID, plane *datamodel.RadiusPlane) (*url.URL, error) { downstream := plane.LookupResourceProvider(id.ProviderNamespace()) if downstream == "" { return nil, &InvalidError{Message: fmt.Sprintf("resource provider %s not configured", id.ProviderNamespace())} @@ -103,3 +183,44 @@ func ValidateDownstream(ctx context.Context, client store.StorageClient, id reso return downstreamURL, nil } + +// ValidateDownstream can be used to find and validate the downstream URL for a resource. +// Returns NotFoundError for the case where the plane or resource group does not exist. +// Returns InvalidError for cases where the data is invalid, like when the resource provider is not configured. +func ValidateDownstream(ctx context.Context, client store.StorageClient, id resources.ID, location string, apiVersion string) (*url.URL, error) { + // There are a few steps to validation: + // + // - The plane exists + // - The resource group exists + // - The resource provider is configured + // - As part of the plane (proxy routing) + // - As part of a resource provider manifest (internal or proxy routing) + // + + // The plane exists. + plane, err := ValidateRadiusPlane(ctx, client, id) + if err != nil { + return nil, err + } + + // The resource group exists (if applicable). + err = ValidateResourceGroup(ctx, client, id) + if err != nil { + return nil, err + } + + downstreamURL, err := ValidateResourceType(ctx, client, id, location, apiVersion) + if errors.Is(err, &store.ErrNotFound{}) { + // If the resource provider is not found, check if it is a legacy provider. + downstreamURL, err := ValidateLegacyResourceProvider(ctx, client, id, plane) + if err != nil { + return nil, err + } + + return downstreamURL, nil + } else if err != nil { + return nil, err + } + + return downstreamURL, nil +} diff --git a/pkg/ucp/frontend/controller/resourcegroups/util_test.go b/pkg/ucp/frontend/controller/resourcegroups/util_test.go index f2e530ea627..76b63ea67d6 100644 --- a/pkg/ucp/frontend/controller/resourcegroups/util_test.go +++ b/pkg/ucp/frontend/controller/resourcegroups/util_test.go @@ -22,6 +22,7 @@ import ( "testing" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/pkg/ucp/datamodel" "github.com/radius-project/radius/pkg/ucp/resources" "github.com/radius-project/radius/pkg/ucp/store" @@ -30,6 +31,11 @@ import ( "go.uber.org/mock/gomock" ) +const ( + location = "east" + apiVersion = "2025-01-01" +) + func Test_ValidateDownstream(t *testing.T) { id, err := resources.ParseResource("/planes/radius/local/resourceGroups/test-group/providers/System.TestRP/testResources/name") require.NoError(t, err) @@ -37,6 +43,282 @@ func Test_ValidateDownstream(t *testing.T) { idWithoutResourceGroup, err := resources.Parse("/planes/radius/local/providers/System.TestRP/testResources") require.NoError(t, err) + resourceProviderID, err := datamodel.ResourceProviderIDFromResourceID(id) + require.NoError(t, err) + + downstream := "http://localhost:7443" + + plane := &datamodel.RadiusPlane{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.PlaneScope(), + }, + }, + Properties: datamodel.RadiusPlaneProperties{ + ResourceProviders: map[string]string{ + "System.TestRP": downstream, + }, + }, + } + + locationResource := &datamodel.Location{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: location, + ID: resourceProviderID.Append(resources.TypeSegment{Type: "locations", Name: location}).String(), + }, + }, + Properties: datamodel.LocationProperties{ + Address: to.Ptr("http://localhost:7443"), + ResourceTypes: map[string]datamodel.LocationResourceTypeConfiguration{ + "testResources": { + APIVersions: map[string]datamodel.LocationAPIVersionConfiguration{ + apiVersion: {}, + }, + }, + }, + }, + } + + setup := func(t *testing.T) *store.MockStorageClient { + ctrl := gomock.NewController(t) + return store.NewMockStorageClient(ctrl) + } + + t.Run("success (resource group)", func(t *testing.T) { + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&store.Object{Data: locationResource}, nil).Times(1) + + expectedURL, err := url.Parse(downstream) + require.NoError(t, err) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.NoError(t, err) + require.Equal(t, expectedURL, downstreamURL) + }) + + t.Run("success (non resource group)", func(t *testing.T) { + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&store.Object{Data: locationResource}, nil).Times(1) + + expectedURL, err := url.Parse(downstream) + require.NoError(t, err) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, idWithoutResourceGroup, location, apiVersion) + require.NoError(t, err) + require.Equal(t, expectedURL, downstreamURL) + }) + + t.Run("plane not found", func(t *testing.T) { + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, &store.ErrNotFound{}).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, &NotFoundError{Message: "plane \"/planes/radius/local\" not found"}, err) + require.Nil(t, downstreamURL) + }) + + t.Run("plane retreival failure", func(t *testing.T) { + mock := setup(t) + + expected := fmt.Errorf("failed to find plane \"/planes/radius/local\": %w", errors.New("test error")) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, errors.New("test error")).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, expected, err) + require.Nil(t, downstreamURL) + }) + + t.Run("resource group not found", func(t *testing.T) { + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, &store.ErrNotFound{}).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, &NotFoundError{Message: "resource group \"/planes/radius/local/resourceGroups/test-group\" not found"}, err) + require.Nil(t, downstreamURL) + }) + + t.Run("resource group err", func(t *testing.T) { + mock := setup(t) + + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, errors.New("test error")).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, "failed to find resource group \"/planes/radius/local/resourceGroups/test-group\": test error", err.Error()) + require.Nil(t, downstreamURL) + }) + + t.Run("location error found", func(t *testing.T) { + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + + expected := fmt.Errorf("failed to find location %q: %w", locationResource.ID, errors.New("test error")) + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(nil, errors.New("test error")).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, expected, err) + require.Nil(t, downstreamURL) + }) + + t.Run("resource type not found", func(t *testing.T) { + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + + locationResource := &datamodel.Location{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: location, + ID: resourceProviderID.Append(resources.TypeSegment{Type: "locations", Name: location}).String(), + }, + }, + Properties: datamodel.LocationProperties{ + Address: to.Ptr("http://localhost:7443"), + ResourceTypes: map[string]datamodel.LocationResourceTypeConfiguration{ + "testResources2": { + APIVersions: map[string]datamodel.LocationAPIVersionConfiguration{ + apiVersion: {}, + }, + }, + }, + }, + } + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&store.Object{Data: locationResource}, nil).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, &InvalidError{Message: "resource type \"System.TestRP/testResources\" not supported by location \"east\""}, err) + require.Nil(t, downstreamURL) + }) + + t.Run("api-version not found", func(t *testing.T) { + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + + locationResource := &datamodel.Location{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: location, + ID: resourceProviderID.Append(resources.TypeSegment{Type: "locations", Name: location}).String(), + }, + }, + Properties: datamodel.LocationProperties{ + Address: to.Ptr("http://localhost:7443"), + ResourceTypes: map[string]datamodel.LocationResourceTypeConfiguration{ + "testResources": { + APIVersions: map[string]datamodel.LocationAPIVersionConfiguration{ + apiVersion + "-preview": {}, + }, + }, + }, + }, + } + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&store.Object{Data: locationResource}, nil).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, &InvalidError{Message: "api version \"2025-01-01\" is not supported for resource type \"System.TestRP/testResources\" by location \"east\""}, err) + require.Nil(t, downstreamURL) + }) + + t.Run("location invalid URL", func(t *testing.T) { + resourceGroup := &datamodel.ResourceGroup{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + ID: id.RootScope(), + }, + }, + } + + locationResource := &datamodel.Location{ + BaseResource: v1.BaseResource{ + TrackedResource: v1.TrackedResource{ + Name: location, + ID: resourceProviderID.Append(resources.TypeSegment{Type: "locations", Name: location}).String(), + }, + }, + Properties: datamodel.LocationProperties{ + Address: to.Ptr("\ninvalid"), + ResourceTypes: map[string]datamodel.LocationResourceTypeConfiguration{ + "testResources": { + APIVersions: map[string]datamodel.LocationAPIVersionConfiguration{ + apiVersion: {}, + }, + }, + }, + }, + } + + mock := setup(t) + mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&store.Object{Data: locationResource}, nil).Times(1) + + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + require.Error(t, err) + require.Equal(t, &InvalidError{Message: "failed to parse location address: parse \"\\ninvalid\": net/url: invalid control character in URL"}, err) + require.Nil(t, downstreamURL) + }) +} + +// This test validates the pre-UDT before where resource providers are registered as part of the plane. +// This can be deleted once the legacy routing behavior is removed. +func Test_ValidateDownstream_Legacy(t *testing.T) { + id, err := resources.ParseResource("/planes/radius/local/resourceGroups/test-group/providers/System.TestRP/testResources/name") + require.NoError(t, err) + + idWithoutResourceGroup, err := resources.Parse("/planes/radius/local/providers/System.TestRP/testResources") + require.NoError(t, err) + + resourceProviderID, err := datamodel.ResourceProviderIDFromResourceID(id) + require.NoError(t, err) + + locationResourceID := resourceProviderID.Append(resources.TypeSegment{Type: "locations", Name: location}) + downstream := "http://localhost:7443" plane := &datamodel.RadiusPlane{ @@ -69,11 +351,12 @@ func Test_ValidateDownstream(t *testing.T) { mock := setup(t) mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResourceID.String()).Return(nil, &store.ErrNotFound{}).Times(1) expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) @@ -81,11 +364,12 @@ func Test_ValidateDownstream(t *testing.T) { t.Run("success (non resource group)", func(t *testing.T) { mock := setup(t) mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResourceID.String()).Return(nil, &store.ErrNotFound{}).Times(1) expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, idWithoutResourceGroup) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, idWithoutResourceGroup, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) @@ -94,7 +378,7 @@ func Test_ValidateDownstream(t *testing.T) { mock := setup(t) mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, &store.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, &NotFoundError{Message: "plane \"/planes/radius/local\" not found"}, err) require.Nil(t, downstreamURL) @@ -106,7 +390,7 @@ func Test_ValidateDownstream(t *testing.T) { expected := fmt.Errorf("failed to find plane \"/planes/radius/local\": %w", errors.New("test error")) mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, errors.New("test error")).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, expected, err) require.Nil(t, downstreamURL) @@ -117,7 +401,7 @@ func Test_ValidateDownstream(t *testing.T) { mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, &store.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, &NotFoundError{Message: "resource group \"/planes/radius/local/resourceGroups/test-group\" not found"}, err) require.Nil(t, downstreamURL) @@ -129,7 +413,7 @@ func Test_ValidateDownstream(t *testing.T) { mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, errors.New("test error")).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, "failed to find resource group \"/planes/radius/local/resourceGroups/test-group\": test error", err.Error()) require.Nil(t, downstreamURL) @@ -158,8 +442,9 @@ func Test_ValidateDownstream(t *testing.T) { mock := setup(t) mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResourceID.String()).Return(nil, &store.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, &InvalidError{Message: "resource provider System.TestRP not configured"}, err) require.Nil(t, downstreamURL) @@ -190,8 +475,9 @@ func Test_ValidateDownstream(t *testing.T) { mock := setup(t) mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&store.Object{Data: plane}, nil).Times(1) mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&store.Object{Data: resourceGroup}, nil).Times(1) + mock.EXPECT().Get(gomock.Any(), locationResourceID.String()).Return(nil, &store.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id) + downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) require.Error(t, err) require.Equal(t, &InvalidError{Message: "failed to parse downstream URL: parse \"\\ninvalid\": net/url: invalid control character in URL"}, err) require.Nil(t, downstreamURL) diff --git a/pkg/ucp/frontend/modules/types.go b/pkg/ucp/frontend/modules/types.go index 6b0a0d6e9a1..bc0853375b9 100644 --- a/pkg/ucp/frontend/modules/types.go +++ b/pkg/ucp/frontend/modules/types.go @@ -77,4 +77,13 @@ type Options struct { // UCPConnection is the connection used to communicate with UCP APIs. UCPConnection sdk.Connection + + // DynamicRP configures options for communicating with the DynamicRP. + DynamicRP DynamicRPOptions +} + +// DynamicRPOptions configures options for communicating with the DynamicRP. +type DynamicRPOptions struct { + // URL is the URL of the DynamicRP. + URL string } diff --git a/pkg/ucp/frontend/radius/module.go b/pkg/ucp/frontend/radius/module.go index 1dae8a56414..373a7df43b8 100644 --- a/pkg/ucp/frontend/radius/module.go +++ b/pkg/ucp/frontend/radius/module.go @@ -28,15 +28,16 @@ func NewModule(options modules.Options) *Module { router.NotFound(validator.APINotFoundHandler()) router.MethodNotAllowed(validator.APIMethodNotAllowedHandler()) - return &Module{options: options, router: router} + return &Module{options: options, router: router, defaultDownstream: options.DynamicRP.URL} } var _ modules.Initializer = &Module{} // Module defines the module for Radius functionality. type Module struct { - options modules.Options - router chi.Router + options modules.Options + router chi.Router + defaultDownstream string } // PlaneType returns the type of plane this module is for. diff --git a/pkg/ucp/frontend/radius/routes.go b/pkg/ucp/frontend/radius/routes.go index 22bf6288b63..cecf19df367 100644 --- a/pkg/ucp/frontend/radius/routes.go +++ b/pkg/ucp/frontend/radius/routes.go @@ -20,6 +20,7 @@ import ( "context" "errors" "net/http" + "time" "github.com/go-chi/chi/v5" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" @@ -34,11 +35,15 @@ import ( resourcegroups_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/resourcegroups" resourceproviders_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/resourceproviders" "github.com/radius-project/radius/pkg/validator" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) const ( // OperationTypeUCPRadiusProxy is the operation type for proxying Radius API calls. OperationTypeUCPRadiusProxy = "UCPRADIUSPROXY" + + // Tell clients to poll in 1 second intervals. Our operations are fast. + operationRetryAfter = time.Second * 1 ) func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { @@ -47,6 +52,8 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { ResourceTypeGetter: validator.UCPResourceTypeGetter, }) + transport := otelhttp.NewTransport(http.DefaultTransport) + // More convienent way to capture errors var err error capture := func(handler http.HandlerFunc, e error) http.HandlerFunc { @@ -124,7 +131,7 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { // Proxy to plane-scoped ResourceProvider APIs // // NOTE: DO NOT validate schema for proxy routes. - r.Handle("/*", capture(planeScopedProxyHandler(ctx, ctrlOptions))) + r.Handle("/*", capture(planeScopedProxyHandler(ctx, ctrlOptions, transport, m.defaultDownstream))) }) r.Route("/resourcegroups", func(r chi.Router) { @@ -141,7 +148,7 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { // Proxy to resource-group-scoped ResourceProvider APIs // // NOTE: DO NOT validate schema for proxy routes. - r.Handle("/*", capture(resourceGroupScopedProxyHandler(ctx, ctrlOptions))) + r.Handle("/*", capture(resourceGroupScopedProxyHandler(ctx, ctrlOptions, transport, m.defaultDownstream))) }) }) @@ -153,8 +160,9 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { } var planeResourceOptions = controller.ResourceOptions[datamodel.RadiusPlane]{ - RequestConverter: converter.RadiusPlaneDataModelFromVersioned, - ResponseConverter: converter.RadiusPlaneDataModelToVersioned, + RequestConverter: converter.RadiusPlaneDataModelFromVersioned, + ResponseConverter: converter.RadiusPlaneDataModelToVersioned, + AsyncOperationRetryAfter: operationRetryAfter, } var planeResourceType = "System.Radius/planes" @@ -231,8 +239,9 @@ func resourceProviderSummaryGetHandler(ctx context.Context, ctrlOptions controll } var resourceProviderResourceOptions = controller.ResourceOptions[datamodel.ResourceProvider]{ - RequestConverter: converter.ResourceProviderDataModelFromVersioned, - ResponseConverter: converter.ResourceProviderDataModelToVersioned, + RequestConverter: converter.ResourceProviderDataModelFromVersioned, + ResponseConverter: converter.ResourceProviderDataModelToVersioned, + AsyncOperationRetryAfter: operationRetryAfter, } func resourceProviderListHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { @@ -260,8 +269,9 @@ func resourceProviderDeleteHandler(ctx context.Context, ctrlOptions controller.O } var resourceTypeResourceOptions = controller.ResourceOptions[datamodel.ResourceType]{ - RequestConverter: converter.ResourceTypeDataModelFromVersioned, - ResponseConverter: converter.ResourceTypeDataModelToVersioned, + RequestConverter: converter.ResourceTypeDataModelFromVersioned, + ResponseConverter: converter.ResourceTypeDataModelToVersioned, + AsyncOperationRetryAfter: operationRetryAfter, } func resourceTypeListHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { @@ -289,8 +299,9 @@ func resourceTypeDeleteHandler(ctx context.Context, ctrlOptions controller.Optio } var apiVersionResourceOptions = controller.ResourceOptions[datamodel.APIVersion]{ - RequestConverter: converter.APIVersionDataModelFromVersioned, - ResponseConverter: converter.APIVersionDataModelToVersioned, + RequestConverter: converter.APIVersionDataModelFromVersioned, + ResponseConverter: converter.APIVersionDataModelToVersioned, + AsyncOperationRetryAfter: operationRetryAfter, } func apiVersionListHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { @@ -318,8 +329,9 @@ func apiVersionDeleteHandler(ctx context.Context, ctrlOptions controller.Options } var locationResourceOptions = controller.ResourceOptions[datamodel.Location]{ - RequestConverter: converter.LocationDataModelFromVersioned, - ResponseConverter: converter.LocationDataModelToVersioned, + RequestConverter: converter.LocationDataModelFromVersioned, + ResponseConverter: converter.LocationDataModelToVersioned, + AsyncOperationRetryAfter: operationRetryAfter, } func locationListHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { @@ -346,12 +358,16 @@ func locationDeleteHandler(ctx context.Context, ctrlOptions controller.Options) }) } -func planeScopedProxyHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { - return server.CreateHandler(ctx, OperationTypeUCPRadiusProxy, v1.OperationProxy, ctrlOptions, radius_ctrl.NewProxyController) +func planeScopedProxyHandler(ctx context.Context, ctrlOptions controller.Options, transport http.RoundTripper, defaultDownstream string) (http.HandlerFunc, error) { + return server.CreateHandler(ctx, OperationTypeUCPRadiusProxy, v1.OperationProxy, ctrlOptions, func(o controller.Options) (controller.Controller, error) { + return radius_ctrl.NewProxyController(o, transport, defaultDownstream) + }) } -func resourceGroupScopedProxyHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { - return server.CreateHandler(ctx, OperationTypeUCPRadiusProxy, v1.OperationProxy, ctrlOptions, radius_ctrl.NewProxyController) +func resourceGroupScopedProxyHandler(ctx context.Context, ctrlOptions controller.Options, transport http.RoundTripper, defaultDownstream string) (http.HandlerFunc, error) { + return server.CreateHandler(ctx, OperationTypeUCPRadiusProxy, v1.OperationProxy, ctrlOptions, func(o controller.Options) (controller.Controller, error) { + return radius_ctrl.NewProxyController(o, transport, defaultDownstream) + }) } func operationStatusGetHandler(ctx context.Context, ctrlOptions controller.Options) (http.HandlerFunc, error) { diff --git a/pkg/ucp/integrationtests/radius/proxy_test.go b/pkg/ucp/integrationtests/radius/proxy_test.go index 3d019e8ec45..64493caeee4 100644 --- a/pkg/ucp/integrationtests/radius/proxy_test.go +++ b/pkg/ucp/integrationtests/radius/proxy_test.go @@ -56,7 +56,7 @@ func Test_RadiusPlane_Proxy_ResourceGroupDoesNotExist(t *testing.T) { } createRadiusPlane(ucp, rps) - response := ucp.MakeRequest(http.MethodGet, testResourceID, nil) + response := ucp.MakeRequest(http.MethodGet, testResourceID+"?api-version="+testrp.Version, nil) response.EqualsErrorCode(http.StatusNotFound, "NotFound") require.Equal(t, "the resource with id '/planes/radius/test/resourceGroups/test-rg/providers/System.Test/testResources/test-resource' was not found", response.Error.Error.Message) } diff --git a/pkg/ucp/integrationtests/testserver/testserver.go b/pkg/ucp/integrationtests/testserver/testserver.go index c0134c0db69..538dede8b65 100644 --- a/pkg/ucp/integrationtests/testserver/testserver.go +++ b/pkg/ucp/integrationtests/testserver/testserver.go @@ -44,8 +44,10 @@ import ( "github.com/radius-project/radius/pkg/armrpc/asyncoperation/worker" "github.com/radius-project/radius/pkg/armrpc/rpctest" "github.com/radius-project/radius/pkg/armrpc/servicecontext" + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/middleware" "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/backend" "github.com/radius-project/radius/pkg/ucp/data" "github.com/radius-project/radius/pkg/ucp/dataprovider" @@ -94,6 +96,8 @@ type TestServer struct { t *testing.T stoppedChan <-chan struct{} shutdown sync.Once + + clientFactoryUCP *v20231001preview.ClientFactory } // TestServerClients provides access to the clients created by the TestServer. @@ -123,6 +127,24 @@ func (ts *TestServer) Client() *http.Client { return ts.Server.Client() } +// T provides access to the testing.T instance associated with this test. +func (ts *TestServer) T() *testing.T { + return ts.t +} + +// UCP provides access to the generated clients for the UCP API. +func (ts *TestServer) UCP() *v20231001preview.ClientFactory { + if ts.clientFactoryUCP == nil { + connection, err := sdk.NewDirectConnection(ts.BaseURL) + require.NoError(ts.t, err) + + ts.clientFactoryUCP, err = v20231001preview.NewClientFactory(&aztoken.AnonymousCredential{}, sdk.NewClientOptions(connection)) + require.NoError(ts.t, err) + } + + return ts.clientFactoryUCP +} + // Close shuts down the server and will block until shutdown completes. func (ts *TestServer) Close() { // We're being picking about resource cleanup here, because unless we are picky we hit scalability @@ -229,7 +251,7 @@ func StartWithETCD(t *testing.T, configureModules func(options modules.Options) etcd := data.NewEmbeddedETCDService(data.EmbeddedETCDServiceOptions{ ClientConfigSink: config, AssignRandomPorts: true, - Quiet: false, + Quiet: true, }) ctx, cancel := testcontext.NewWithCancel(t) @@ -299,7 +321,7 @@ func StartWithETCD(t *testing.T, configureModules func(options modules.Options) statusManager := statusmanager.New(dataProvider, queueClient, v1.LocationGlobal) registry := worker.NewControllerRegistry(dataProvider) - err = backend.RegisterControllers(ctx, registry, connection, backend_ctrl.Options{DataProvider: dataProvider}) + err = backend.RegisterControllers(ctx, registry, connection, http.DefaultTransport, backend_ctrl.Options{DataProvider: dataProvider}) require.NoError(t, err) w := worker.New(worker.Options{}, statusManager, queueClient, registry) diff --git a/pkg/ucp/secret/inmemory/client.go b/pkg/ucp/secret/inmemory/client.go new file mode 100644 index 00000000000..ec6493d5a44 --- /dev/null +++ b/pkg/ucp/secret/inmemory/client.go @@ -0,0 +1,113 @@ +/* +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 inmemory + +import ( + "context" + "sync" + + "github.com/radius-project/radius/pkg/kubernetes" + "github.com/radius-project/radius/pkg/ucp/secret" +) + +const ( + SecretKey = "ucp_secret" + RadiusNamespace = "radius-system" +) + +var _ secret.Client = (*Client)(nil) + +// Client implements secret storage for k8s. +type Client struct { + lock sync.Mutex + data map[string][]byte +} + +// Save saves the secret data. +func (c *Client) Save(ctx context.Context, name string, value []byte) error { + if name == "" { + return &secret.ErrInvalid{Message: "invalid argument. 'name' is required"} + } + + if value == nil { + return &secret.ErrInvalid{Message: "invalid argument. 'value' is required"} + } + + if !kubernetes.IsValidObjectName(name) { + return &secret.ErrInvalid{Message: "invalid name: " + name} + } + + c.lock.Lock() + defer c.lock.Unlock() + + if c.data == nil { + c.data = map[string][]byte{} + } + + c.data[name] = value + + return nil +} + +// Delete deletes the secret data. +func (c *Client) Delete(ctx context.Context, name string) error { + if name == "" { + return &secret.ErrInvalid{Message: "invalid argument. 'name' is required"} + } + + if !kubernetes.IsValidObjectName(name) { + return &secret.ErrInvalid{Message: "invalid name: " + name} + } + + c.lock.Lock() + defer c.lock.Unlock() + + if c.data == nil { + c.data = map[string][]byte{} + } + + _, ok := c.data[name] + if !ok { + return &secret.ErrNotFound{} + } + + delete(c.data, name) + + return nil +} + +// Get checks if the provided name is valid and if it exists in the RadiusNamespace, and returns the data associated with +// the secret if found, otherwise it returns an error. +func (c *Client) Get(ctx context.Context, name string) ([]byte, error) { + if name == "" { + return nil, &secret.ErrInvalid{Message: "invalid argument. 'name' is required"} + } + + if !kubernetes.IsValidObjectName(name) { + return nil, &secret.ErrInvalid{Message: "invalid name: " + name} + } + + c.lock.Lock() + defer c.lock.Unlock() + + data, ok := c.data[name] + if !ok { + return nil, &secret.ErrNotFound{} + } + + return data, nil +} diff --git a/pkg/ucp/secret/inmemory/client_test.go b/pkg/ucp/secret/inmemory/client_test.go new file mode 100644 index 00000000000..49a925e01ab --- /dev/null +++ b/pkg/ucp/secret/inmemory/client_test.go @@ -0,0 +1,157 @@ +/* +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 inmemory + +import ( + "context" + "encoding/json" + "testing" + + "github.com/radius-project/radius/pkg/ucp/secret" + + "github.com/stretchr/testify/require" +) + +const ( + SecretName = "test-secret-name" +) + +func Test_Save(t *testing.T) { + ctx := context.Background() + + secretValue, err := json.Marshal("test_secret_value") + require.NoError(t, err) + + updatedSecretValue, err := json.Marshal("updated_secret_value") + require.NoError(t, err) + + tests := []struct { + testName string + secretName string + secretValue []byte + update bool + err error + }{ + {"save-new-secret", SecretName, secretValue, false, nil}, + {"update-secret", SecretName, secretValue, true, nil}, + {"save-with-invalid-name", "", secretValue, false, &secret.ErrInvalid{Message: "invalid argument. 'name' is required"}}, + {"save-with-empty-secret", SecretName, nil, false, &secret.ErrInvalid{Message: "invalid argument. 'value' is required"}}, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + client := Client{} + + err := client.Save(ctx, tt.secretName, tt.secretValue) + require.Equal(t, err, tt.err) + + if tt.update { + err := client.Save(ctx, tt.secretName, updatedSecretValue) + require.Equal(t, err, tt.err) + } + + if tt.err != nil { + return + } + + // If save is expected to succeed, then compare saved secret and delete after test + res, err := client.Get(ctx, tt.secretName) + require.NoError(t, err) + if tt.update { + require.Equal(t, res, updatedSecretValue) + } else { + require.Equal(t, res, secretValue) + } + + err = client.Delete(ctx, tt.secretName) + require.NoError(t, err) + }) + } +} + +func Test_Get(t *testing.T) { + ctx := context.Background() + + secretValue, err := json.Marshal("test_secret_value") + require.NoError(t, err) + + tests := []struct { + testName string + secretName string + save bool + err error + }{ + {"get-secret", SecretName, true, nil}, + {"get-non-existent-secret", SecretName, false, &secret.ErrNotFound{}}, + {"get-with-invalid-name", "", false, &secret.ErrInvalid{Message: "invalid argument. 'name' is required"}}, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + client := Client{} + if tt.save { + err := client.Save(ctx, tt.secretName, secretValue) + require.NoError(t, err) + } + + res, err := client.Get(ctx, tt.secretName) + require.Equal(t, err, tt.err) + + // If the get is successful then compare for values + if tt.err == nil { + require.Equal(t, res, secretValue) + } + + // If secret is saved, cleanup secret at the end + if tt.save { + err = client.Delete(ctx, tt.secretName) + require.NoError(t, err) + } + }) + } +} + +func Test_Delete(t *testing.T) { + ctx := context.Background() + + secretValue, err := json.Marshal("test_secret_value") + require.NoError(t, err) + + tests := []struct { + testName string + secretName string + save bool + err error + }{ + {"delete-secret", SecretName, true, nil}, + {"delete-non-existent-secret", SecretName, false, &secret.ErrNotFound{}}, + {"delete-with-invalid-name", "", false, &secret.ErrInvalid{Message: "invalid argument. 'name' is required"}}, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + client := Client{} + if tt.save { + err := client.Save(ctx, tt.secretName, secretValue) + require.NoError(t, err) + } + + err = client.Delete(ctx, tt.secretName) + require.Equal(t, err, tt.err) + }) + } +} diff --git a/pkg/ucp/secret/provider/factory.go b/pkg/ucp/secret/provider/factory.go index 396b1995c62..5608171b520 100644 --- a/pkg/ucp/secret/provider/factory.go +++ b/pkg/ucp/secret/provider/factory.go @@ -24,6 +24,7 @@ import ( "github.com/radius-project/radius/pkg/ucp/dataprovider" "github.com/radius-project/radius/pkg/ucp/secret" "github.com/radius-project/radius/pkg/ucp/secret/etcd" + "github.com/radius-project/radius/pkg/ucp/secret/inmemory" kubernetes_client "github.com/radius-project/radius/pkg/ucp/secret/kubernetes" "github.com/radius-project/radius/pkg/ucp/store/etcdstore" "k8s.io/kubectl/pkg/scheme" @@ -35,6 +36,7 @@ type secretFactoryFunc func(context.Context, SecretProviderOptions) (secret.Clie var secretClientFactory = map[SecretProviderType]secretFactoryFunc{ TypeETCDSecret: initETCDSecretClient, TypeKubernetesSecret: initKubernetesSecretClient, + TypeInMemorySecret: initInMemorySecretClient, } func initETCDSecretClient(ctx context.Context, opts SecretProviderOptions) (secret.Client, error) { @@ -70,3 +72,7 @@ func initKubernetesSecretClient(ctx context.Context, opt SecretProviderOptions) } return &kubernetes_client.Client{K8sClient: client}, nil } + +func initInMemorySecretClient(ctx context.Context, opt SecretProviderOptions) (secret.Client, error) { + return &inmemory.Client{}, nil +} diff --git a/pkg/ucp/secret/provider/types.go b/pkg/ucp/secret/provider/types.go index a2e9bfe34d3..72abadf5da0 100644 --- a/pkg/ucp/secret/provider/types.go +++ b/pkg/ucp/secret/provider/types.go @@ -25,4 +25,7 @@ const ( // TypeKubernetesSecret represents the Kubernetes secret provider. TypeKubernetesSecret SecretProviderType = "kubernetes" + + // TypeInMemorySecret represents the in-memory secret provider. + TypeInMemorySecret SecretProviderType = "inmemory" )